This commit is contained in:
2026-04-28 20:25:37 +08:00
parent f0471a4f8d
commit 0f013b6eee
45 changed files with 1117 additions and 1047 deletions

View File

@@ -21,7 +21,6 @@ import { createStoryChoiceActions } from './choiceActions';
vi.mock('./storyChoiceRuntime', async () => {
return {
runCampTravelHomeChoice: vi.fn(),
runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock,
shouldOpenLocalRuntimeNpcModal: (option: StoryOption) =>
(
@@ -811,4 +810,97 @@ describe('createStoryChoiceActions', () => {
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
expect(playResolvedChoice).not.toHaveBeenCalled();
});
it('routes camp_travel_home_scene to the backend resolver instead of the legacy local travel branch', async () => {
const state = {
...createBaseState(),
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentEncounter: {
kind: 'npc' as const,
id: 'npc-camp',
npcName: '营地同伴',
npcDescription: '准备一起出发的同伴',
npcAvatar: '伴',
context: '营地',
hostile: false,
},
npcInteractionActive: false,
sceneHostileNpcs: [],
currentScenePreset: {
id: 'wuxia-border-camp',
name: '边关营地',
description: '营火未熄。',
imageSrc: '',
connectedSceneIds: ['wuxia-palace-court'],
connections: [],
forwardSceneId: 'wuxia-palace-court',
treasureHints: [],
npcs: [],
},
} satisfies GameState;
const option: StoryOption = {
...createBattleOption('camp_travel_home_scene'),
actionText: '前往宫苑内庭',
text: '前往宫苑内庭',
};
const buildResolvedChoiceState = vi.fn();
const playResolvedChoice = vi.fn();
const commitGeneratedStateWithEncounterEntry = vi.fn();
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory('营地对话已经收束。'),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => [option]),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn(() => []),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => {
throw new Error('legacy camp travel resolver should not run');
}),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry,
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => true),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: state,
option,
character: state.playerCharacter,
}),
);
expect(commitGeneratedStateWithEncounterEntry).not.toHaveBeenCalled();
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
expect(playResolvedChoice).not.toHaveBeenCalled();
});
});

View File

@@ -20,7 +20,6 @@ import type {
} from './progressionActions';
import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation';
import {
runCampTravelHomeChoice,
runServerRuntimeChoiceAction,
shouldOpenLocalRuntimeNpcModal,
} from './storyChoiceRuntime';
@@ -99,18 +98,13 @@ export function createStoryChoiceActions({
handleNpcBattleConversationContinuation,
updateQuestLog,
incrementRuntimeStats,
getCampCompanionTravelScene,
enterNpcInteraction,
handleNpcInteraction,
handleTreasureInteraction,
commitGeneratedStateWithEncounterEntry,
finalizeNpcBattleResult,
isContinueAdventureOption,
isCampTravelHomeOption,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId,
fallbackCompanionName,
turnVisualMs,
}: {
gameState: GameState;
@@ -140,13 +134,13 @@ export function createStoryChoiceActions({
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
getCampCompanionTravelScene?: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: (
option: StoryOption,
) => void | Promise<void> | boolean | Promise<boolean>;
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
commitGeneratedStateWithEncounterEntry?: CommitGeneratedStateWithEncounterEntry;
finalizeNpcBattleResult: (
state: GameState,
character: Character,
@@ -154,11 +148,11 @@ export function createStoryChoiceActions({
battleOutcome: GameState['currentNpcBattleOutcome'],
) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isCampTravelHomeOption?: (option: StoryOption) => boolean;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter?: (encounter: GameState['currentEncounter']) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName: string;
fallbackCompanionName?: string;
turnVisualMs: number;
}) {
const handleChoice = async (option: StoryOption) => {
@@ -191,25 +185,6 @@ export function createStoryChoiceActions({
return;
}
if (isCampTravelHomeOption(option)) {
await runCampTravelHomeChoice({
gameState,
option,
character,
setBattleReward,
setAiError,
setIsLoading,
setGameState,
incrementRuntimeStats,
getCampCompanionTravelScene,
commitGeneratedStateWithEncounterEntry,
isNpcEncounter,
fallbackCompanionName,
turnVisualMs,
});
return;
}
if (shouldOpenLocalRuntimeNpcModal(option)) {
setAiError(null);
await handleNpcInteraction(option);

View File

@@ -1,16 +1,6 @@
import {
buildEncounterEntryState,
hasEncounterEntity,
} from '../../data/encounterTransition';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import {
AnimationState,
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
@@ -18,24 +8,12 @@ import {
import { resolveRpgRuntimeChoice } from '.';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<
Pick<
GameState['runtimeStats'],
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
>
>;
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
type IncrementRuntimeStats = (
state: GameState,
increments: RuntimeStatsIncrements,
) => GameState;
function sleep(ms: number) {
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
}
@@ -54,126 +32,6 @@ export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
);
}
export async function runCampTravelHomeChoice(params: {
gameState: GameState;
option: StoryOption;
character: Character;
setBattleReward: (reward: BattleRewardSummary | null) => void;
setAiError: (message: string | null) => void;
setIsLoading: (loading: boolean) => void;
setGameState: (state: GameState) => void;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (
state: GameState,
character: Character,
) => GameState['currentScenePreset'] | null;
commitGeneratedStateWithEncounterEntry: (
entryState: GameState,
resolvedState: GameState,
character: Character,
actionText: string,
resultText: string,
lastFunctionId?: string,
) => Promise<void> | void;
isNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
fallbackCompanionName: string;
turnVisualMs: number;
}) {
const targetScene = params.getCampCompanionTravelScene(
params.gameState,
params.character,
);
if (!targetScene) {
return false;
}
params.setBattleReward(null);
params.setAiError(null);
const companionName = params.isNpcEncounter(params.gameState.currentEncounter)
? params.gameState.currentEncounter.npcName
: params.fallbackCompanionName;
const travelRunState: GameState = {
...params.gameState,
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
const travelBaseState: GameState = params.incrementRuntimeStats(
{
...params.gameState,
ambientIdleMode: undefined,
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
{
scenesTraveled: 1,
},
);
const travelPreviewState: GameState = {
...travelBaseState,
...createSceneEncounterPreview(travelBaseState),
};
const resolvedState = hasEncounterEntity(travelPreviewState)
? resolveSceneEncounterPreview(travelPreviewState)
: travelBaseState;
const entryState = buildEncounterEntryState(
resolvedState,
CALL_OUT_ENTRY_X_METERS,
);
params.setIsLoading(true);
params.setGameState(travelRunState);
await sleep(params.turnVisualMs);
await params.commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
params.character,
params.option.actionText,
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
params.option.functionId,
);
return true;
}
export async function runServerRuntimeChoiceAction(params: {
gameState: GameState;
currentStory: StoryMoment | null;