Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -253,8 +253,22 @@ describe('createStoryChoiceActions', () => {
const afterSequence = {
...state,
inBattle: false,
sceneHostileNpcs: [],
playerX: -1.2,
};
const setBattleReward = vi.fn();
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
const buildStoryContextFromState = vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: -1.2,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
}));
const { handleChoice } = createStoryChoiceActions({
gameState: state,
@@ -264,24 +278,14 @@ describe('createStoryChoiceActions', () => {
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
setBattleReward,
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'escape' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: -1.2,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryContextFromState,
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
@@ -290,7 +294,7 @@ describe('createStoryChoiceActions', () => {
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
@@ -314,7 +318,23 @@ describe('createStoryChoiceActions', () => {
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:你已经摆脱与山狼的交战,暂时把对方甩在身后,当前不再处于战斗状态。',
'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
]);
expect(buildStoryContextFromState).toHaveBeenCalledWith(
expect.objectContaining({
inBattle: false,
sceneHostileNpcs: [],
}),
expect.objectContaining({
lastFunctionId: 'battle_escape_breakout',
recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
}),
);
expect(setBattleReward).toHaveBeenCalledTimes(1);
expect(setBattleReward).toHaveBeenCalledWith(null);
expect(incrementRuntimeStats).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ hostileNpcsDefeated: 0 }),
);
});
});

View File

@@ -61,7 +61,11 @@ type BuildNpcStory = (
type BuildStoryContextFromState = (
state: GameState,
extras?: { lastFunctionId?: string | null; observeSignsRequested?: boolean },
extras?: {
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
},
) => StoryGenerationContext;
type UpdateQuestLog = (
@@ -111,8 +115,8 @@ function buildCombatResolutionContextText(params: {
.map((hostileNpc) => hostileNpc.name)
.join('、');
return hostileNames
? `你已经摆脱${hostileNames}的交战,暂时把对方甩在身后,当前不再处于战斗状态。`
: '你已成功脱刚才的交战,当前不再处于战斗状态。';
? `你已成功逃脱,${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
: '你已成功脱刚才的交战,当前不再处于战斗状态。';
}
if (
@@ -136,12 +140,19 @@ function buildCombatResolutionContextText(params: {
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
}
function buildHostileNpcBattleReward(
async function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
optionKind: ResolvedChoiceState['optionKind'],
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
): BattleRewardSummary | null {
if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) {
): Promise<BattleRewardSummary | null> {
if (
optionKind === 'escape'
|| !state.worldType
|| state.currentBattleNpcId
|| !state.inBattle
|| afterSequence.inBattle
) {
return null;
}
@@ -155,7 +166,7 @@ function buildHostileNpcBattleReward(
return null;
}
const rolledItems = rollHostileNpcLoot(
const rolledItems = await rollHostileNpcLoot(
state,
defeatedHostileNpcs.map(hostileNpc => ({
id: hostileNpc.id,
@@ -424,7 +435,12 @@ export function createStoryChoiceActions({
);
const projectedBattleReward = shouldUseLocalNpcVictory
? null
: buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs);
: await buildHostileNpcBattleReward(
baseChoiceState,
projectedState,
resolvedChoice.optionKind,
getResolvedSceneHostileNpcs,
);
const projectedStateWithBattleReward = projectedBattleReward
? appendStoryEngineCarrierMemory({
...projectedState,
@@ -462,6 +478,7 @@ export function createStoryChoiceActions({
buildStoryContextFromState(projectedStateWithBattleReward, {
lastFunctionId: option.functionId,
observeSignsRequested: option.functionId === 'idle_observe_signs',
recentActionResult: combatResolutionContextText,
}),
projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined,
);
@@ -548,7 +565,7 @@ export function createStoryChoiceActions({
}
const response = responseResult.value!;
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId || resolvedChoice.optionKind === 'escape'
? []
: getResolvedSceneHostileNpcs(baseChoiceState)
.map(hostileNpc => hostileNpc.id)

View File

@@ -161,16 +161,17 @@ describe('sessionActions', () => {
});
it('applies quest rewards to currency, inventory, and issuer affinity in one state transition', () => {
const nextState = applyQuestRewardClaim(createBaseState(), 'quest-1');
const rewardClaim = applyQuestRewardClaim(createBaseState(), 'quest-1');
expect(nextState).not.toBeNull();
if (!nextState) {
expect(rewardClaim).not.toBeNull();
if (!rewardClaim) {
throw new Error('Expected quest reward claim state');
}
expect(nextState.quests[0]?.status).toBe('turned_in');
expect(nextState.playerCurrency).toBe(17);
expect(nextState.playerInventory.find(item => item.id === 'reward-herb')?.quantity).toBe(2);
expect(nextState.npcStates['npc-trader']?.affinity).toBe(7);
expect(rewardClaim.nextState.quests[0]?.status).toBe('turned_in');
expect(rewardClaim.nextState.playerCurrency).toBe(17);
expect(rewardClaim.nextState.playerInventory.find((item) => item.id === 'reward-herb')?.quantity).toBe(2);
expect(rewardClaim.nextState.npcStates['npc-trader']?.affinity).toBe(7);
expect(rewardClaim).toHaveProperty('handoff');
});
});

View File

@@ -10,6 +10,7 @@ import {
markQuestTurnedIn,
} from '../../data/questFlow';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
import type {
GameState,
StoryMoment,
@@ -36,15 +37,18 @@ export function acknowledgeQuestCompletionState(
export function applyQuestRewardClaim(
state: GameState,
questId: string,
): GameState | null {
): {
nextState: GameState;
handoff: ReturnType<typeof buildGoalHandoffFromState>;
} | null {
const quest = findQuestById(state.quests, questId);
if (!quest || quest.status !== 'completed') {
if (!quest || (quest.status !== 'completed' && quest.status !== 'ready_to_turn_in')) {
return null;
}
const issuerNpcState = state.npcStates[quest.issuerNpcId];
return appendStoryEngineCarrierMemory({
const nextState = appendStoryEngineCarrierMemory({
...state,
quests: markQuestTurnedIn(state.quests, questId),
playerCurrency: state.playerCurrency + quest.reward.currency,
@@ -59,6 +63,11 @@ export function applyQuestRewardClaim(
}
: state.npcStates,
}, quest.reward.items);
return {
nextState,
handoff: buildGoalHandoffFromState(nextState),
};
}
export function createStorySessionActions({
@@ -83,13 +92,16 @@ export function createStorySessionActions({
};
const claimQuestReward = (questId: string) => {
const nextState = applyQuestRewardClaim(gameState, questId);
if (!nextState) {
return false;
const rewardClaim = applyQuestRewardClaim(gameState, questId);
if (!rewardClaim) {
return null;
}
setGameState(nextState);
return true;
setGameState(rewardClaim.nextState);
return {
questId,
handoff: rewardClaim.handoff,
};
};
const resetStoryState = () => {

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type StoryOption } from '../../types';
import { resolveStoryResponseOptions } from './storyResponseOptions';
function createOption(
functionId: string,
actionText: string,
priority = 0,
): StoryOption {
return {
functionId,
actionText,
text: actionText,
priority,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
describe('storyResponseOptions', () => {
it('keeps rewritten actionText when camp companion follow-up uses available options', () => {
const availableOptions = [
createOption('npc_chat', '先聊聊营地安排', 3),
createOption('npc_gift', '把旧礼物递给你', 2),
createOption('camp_travel_home_scene', '前往旧地点', 1),
];
const responseOptions = [
createOption('npc_chat', '顺着你刚才的话继续问下去', 3),
createOption('npc_gift', '把刚挑好的礼物正式交给你', 2),
createOption('camp_travel_home_scene', '前往云河渡', 1),
];
const resolved = resolveStoryResponseOptions({
responseOptions,
availableOptions,
getSanitizedOptions: () => {
throw new Error('available options branch should not sanitize');
},
});
expect(resolved.map((option) => option.actionText)).toEqual([
'顺着你刚才的话继续问下去',
'把刚挑好的礼物正式交给你',
'前往云河渡',
]);
});
it('falls back to available options when the response omits them entirely', () => {
const availableOptions = [
createOption('npc_chat', '继续交谈', 2),
createOption('camp_travel_home_scene', '前往山门', 1),
];
const resolved = resolveStoryResponseOptions({
responseOptions: [],
availableOptions,
getSanitizedOptions: () => {
throw new Error('available options fallback should not sanitize');
},
});
expect(resolved.map((option) => option.actionText)).toEqual([
'继续交谈',
'前往山门',
]);
});
});

View File

@@ -0,0 +1,30 @@
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import type { StoryOption } from '../../types';
type ResolveStoryResponseOptionsParams = {
responseOptions: StoryOption[];
availableOptions?: StoryOption[] | null;
optionCatalog?: StoryOption[] | null;
getSanitizedOptions: () => StoryOption[];
};
export function resolveStoryResponseOptions({
responseOptions,
availableOptions = null,
optionCatalog = null,
getSanitizedOptions,
}: ResolveStoryResponseOptionsParams) {
if (availableOptions) {
return sortStoryOptionsByPriority(
responseOptions.length > 0 ? responseOptions : availableOptions,
);
}
if (optionCatalog) {
return sortStoryOptionsByPriority(
responseOptions.length > 0 ? responseOptions : optionCatalog,
);
}
return sortStoryOptionsByPriority(getSanitizedOptions());
}

View File

@@ -1,5 +1,8 @@
import type {
Encounter,
GoalHandoff,
GoalPulseEvent,
GoalStackState,
InventoryItem,
} from '../../types';
@@ -72,7 +75,16 @@ export interface InventoryFlowUi {
export interface QuestFlowUi {
acknowledgeQuestCompletion: (questId: string) => void;
claimQuestReward: (questId: string) => boolean;
claimQuestReward: (questId: string) => {
questId: string;
handoff: GoalHandoff | null;
} | null;
}
export interface GoalFlowUi {
goalStack: GoalStackState;
pulse: GoalPulseEvent | null;
dismissPulse: () => void;
}
export interface BattleRewardSummary {