This commit is contained in:
2026-04-28 02:05:12 +08:00
parent 271db02e4a
commit 1eb090e4a5
39 changed files with 2671 additions and 165 deletions

View File

@@ -15,6 +15,7 @@ vi.mock('../../services/rpg-runtime', () => ({
}));
import { generateNextStep } from '../../services/aiService';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryChoiceActions } from './choiceActions';
@@ -138,6 +139,60 @@ function createFallbackStory(text = 'fallback'): StoryMoment {
};
}
function createCustomWorldProfileForSceneAct(sceneId: string) {
return {
id: 'custom-world-test',
name: '场景幕重置测试',
summary: '用于验证战败后回到首幕。',
playableNpcs: [],
storyNpcs: [],
sceneChapterBlueprints: [
{
id: `${sceneId}-chapter`,
sceneId,
title: '测试章节',
summary: '测试章节摘要',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: `${sceneId}-act-1`,
sceneId,
title: '第一幕',
summary: '开场第一幕',
stageCoverage: ['opening'],
backgroundImageSrc: '/act-1.png',
encounterNpcIds: [],
primaryNpcId: null,
oppositeNpcId: null,
eventDescription: '第一幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '完成第一幕目标',
transitionHook: '第一幕过渡',
},
{
id: `${sceneId}-act-2`,
sceneId,
title: '第二幕',
summary: '推进第二幕',
stageCoverage: ['expansion'],
backgroundImageSrc: '/act-2.png',
encounterNpcIds: [],
primaryNpcId: null,
oppositeNpcId: null,
eventDescription: '第二幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '完成第二幕目标',
transitionHook: '第二幕过渡',
},
],
},
],
} as NonNullable<GameState['customWorldProfile']>;
}
const neverNpcEncounter = (
encounter: GameState['currentEncounter'],
): encounter is Encounter => false;
@@ -634,6 +689,144 @@ describe('createStoryChoiceActions', () => {
);
});
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
vi.useFakeTimers();
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id);
const state = {
...createBaseState(),
customWorldProfile,
currentScenePreset: firstScene,
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: firstScene.id,
chapterId: `${firstScene.id}-chapter`,
currentActId: `${firstScene.id}-act-2`,
currentActIndex: 1,
completedActIds: [`${firstScene.id}-act-1`],
visitedActIds: [`${firstScene.id}-act-1`, `${firstScene.id}-act-2`],
},
},
currentEncounter: {
id: 'npc-opponent',
kind: 'npc' as const,
npcName: '山道客',
npcDescription: '拦路旧敌',
npcAvatar: '/npc.png',
context: '山道旧案',
},
npcInteractionActive: true,
};
const option = createBattleOption();
const afterSequence = {
...state,
playerHp: 0,
inBattle: false,
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_defeat' as const,
};
const finalizeNpcBattleResult = vi.fn(() => ({
nextState: afterSequence,
resultText: '不应该进入胜利结算',
}));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 0,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult,
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
const choicePromise = handleChoice(option);
await vi.advanceTimersByTimeAsync(3000);
await choicePromise;
vi.useRealTimers();
expect(finalizeNpcBattleResult).not.toHaveBeenCalled();
expect(setGameState).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
playerHp: 0,
inBattle: false,
currentNpcBattleOutcome: 'fight_defeat',
animationState: AnimationState.DIE,
}),
);
expect(setGameState).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
currentScenePreset: expect.objectContaining({
id: firstScene.id,
}),
playerHp: 100,
inBattle: false,
currentNpcBattleOutcome: null,
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
sceneId: firstScene.id,
currentActId: `${firstScene.id}-act-1`,
currentActIndex: 0,
}),
}),
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('重新醒来'),
}),
);
});
it('settles escape locally without ai continuation', async () => {
const mockedGenerateNextStep = vi.mocked(generateNextStep);