196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { buildChapterQuestForScene } from '../../data/questFlow';
|
|
import { AnimationState, type GameState, WorldType } from '../../types';
|
|
import { advanceChapterState, resolveCurrentChapterState } from './chapterDirector';
|
|
|
|
function createState(signalCount: number): GameState {
|
|
return {
|
|
worldType: null,
|
|
customWorldProfile: null,
|
|
playerCharacter: null,
|
|
runtimeStats: {
|
|
playTimeMs: 0,
|
|
lastPlayTickAt: null,
|
|
hostileNpcsDefeated: 0,
|
|
questsAccepted: 0,
|
|
itemsUsed: 0,
|
|
scenesTraveled: 0,
|
|
},
|
|
currentScene: 'Story',
|
|
storyHistory: [],
|
|
storyEngineMemory: {
|
|
discoveredFactIds: [],
|
|
inferredFactIds: [],
|
|
activeThreadIds: ['thread-1'],
|
|
resolvedScarIds: [],
|
|
recentCarrierIds: [],
|
|
recentSignalIds: Array.from({ length: signalCount }, (_, index) => `signal-${index + 1}`),
|
|
recentCompanionReactions: [],
|
|
currentChapter: null,
|
|
currentJourneyBeatId: null,
|
|
companionArcStates: [],
|
|
worldMutations: [],
|
|
chronicle: [],
|
|
factionTensionStates: [],
|
|
currentCampEvent: null,
|
|
currentSetpieceDirective: null,
|
|
continueGameDigest: null,
|
|
},
|
|
chapterState: null,
|
|
characterChats: {},
|
|
animationState: AnimationState.IDLE,
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
currentScenePreset: null,
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
playerActionMode: 'idle',
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
playerHp: 0,
|
|
playerMaxHp: 0,
|
|
playerMana: 0,
|
|
playerMaxMana: 0,
|
|
playerSkillCooldowns: {},
|
|
activeBuildBuffs: [],
|
|
activeCombatEffects: [],
|
|
playerCurrency: 0,
|
|
playerInventory: [],
|
|
playerEquipment: { weapon: null, armor: null, relic: null },
|
|
npcStates: {},
|
|
quests: [],
|
|
roster: [],
|
|
companions: [],
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
}
|
|
|
|
function createSceneChapterState() {
|
|
const quest = buildChapterQuestForScene({
|
|
scene: {
|
|
id: 'scene-court',
|
|
name: '宫苑内庭',
|
|
description: '回廊深处静得过分。',
|
|
npcs: [
|
|
{
|
|
id: 'npc-maid',
|
|
name: '旧宫侍女',
|
|
description: '她总知道哪条回廊最近不该过去。',
|
|
avatar: '侍',
|
|
role: '宫人',
|
|
hostile: false,
|
|
},
|
|
{
|
|
id: 'hostile-shadow',
|
|
name: '旧宫戍影',
|
|
description: '巡行在回廊里的敌影。',
|
|
avatar: '戍',
|
|
role: '敌对角色',
|
|
monsterPresetId: 'monster-11',
|
|
hostile: true,
|
|
},
|
|
],
|
|
treasureHints: ['回廊暗格里的香囊'],
|
|
},
|
|
worldType: WorldType.WUXIA,
|
|
});
|
|
|
|
if (!quest) {
|
|
throw new Error('Expected chapter quest');
|
|
}
|
|
|
|
return {
|
|
...createState(0),
|
|
currentScenePreset: {
|
|
id: 'scene-court',
|
|
name: '宫苑内庭',
|
|
description: '回廊深处静得过分。',
|
|
imageSrc: '/scene.png',
|
|
treasureHints: ['回廊暗格里的香囊'],
|
|
npcs: [],
|
|
},
|
|
quests: [quest],
|
|
} satisfies GameState;
|
|
}
|
|
|
|
describe('chapterDirector', () => {
|
|
it('resolves chapter stages from signal intensity', () => {
|
|
expect(resolveCurrentChapterState({ state: createState(1) }).stage).toBe('opening');
|
|
expect(resolveCurrentChapterState({ state: createState(4) }).stage).toBe('expansion');
|
|
expect(resolveCurrentChapterState({ state: createState(10) }).stage).toBe('climax');
|
|
});
|
|
|
|
it('keeps chapter id stable when stage and theme do not change', () => {
|
|
const previous = resolveCurrentChapterState({ state: createState(4) });
|
|
const next = advanceChapterState({
|
|
previousChapter: previous,
|
|
nextChapter: resolveCurrentChapterState({ state: createState(4) }),
|
|
});
|
|
|
|
expect(next.id).toBe(previous.id);
|
|
});
|
|
|
|
it('binds the current chapter to the current scene chapter quest', () => {
|
|
const openingState = createSceneChapterState();
|
|
const openingChapter = resolveCurrentChapterState({ state: openingState });
|
|
expect(openingChapter.id).toBe('chapter:scene:scene-court');
|
|
expect(openingChapter.sceneId).toBe('scene-court');
|
|
expect(openingChapter.chapterQuestId).toBe('quest:chapter:scene-court');
|
|
expect(openingChapter.stage).toBe('opening');
|
|
|
|
const turningState: GameState = {
|
|
...openingState,
|
|
quests: [
|
|
{
|
|
...openingState.quests[0]!,
|
|
steps: openingState.quests[0]!.steps?.map((step) =>
|
|
step.id === 'step_scene_opening'
|
|
? { ...step, progress: step.requiredCount }
|
|
: step.id === 'step_scene_pressure'
|
|
? { ...step, progress: step.requiredCount }
|
|
: step,
|
|
),
|
|
activeStepId: 'step_scene_turning',
|
|
},
|
|
],
|
|
};
|
|
expect(resolveCurrentChapterState({ state: turningState }).stage).toBe('turning_point');
|
|
|
|
const climaxState: GameState = {
|
|
...turningState,
|
|
quests: [
|
|
{
|
|
...turningState.quests[0]!,
|
|
steps: turningState.quests[0]!.steps?.map((step) => ({
|
|
...step,
|
|
progress: step.requiredCount,
|
|
})),
|
|
activeStepId: null,
|
|
status: 'ready_to_turn_in',
|
|
},
|
|
],
|
|
};
|
|
expect(resolveCurrentChapterState({ state: climaxState }).stage).toBe('climax');
|
|
|
|
const aftermathState: GameState = {
|
|
...climaxState,
|
|
quests: [
|
|
{
|
|
...climaxState.quests[0]!,
|
|
status: 'turned_in',
|
|
},
|
|
],
|
|
};
|
|
expect(resolveCurrentChapterState({ state: aftermathState }).stage).toBe('aftermath');
|
|
});
|
|
});
|