Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
74
src/hooks/story/storyResponseOptions.test.ts
Normal file
74
src/hooks/story/storyResponseOptions.test.ts
Normal 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([
|
||||
'继续交谈',
|
||||
'前往山门',
|
||||
]);
|
||||
});
|
||||
});
|
||||
30
src/hooks/story/storyResponseOptions.ts
Normal file
30
src/hooks/story/storyResponseOptions.ts
Normal 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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user