329 lines
8.5 KiB
TypeScript
329 lines
8.5 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const { scenes } = vi.hoisted(() => ({
|
|
scenes: [
|
|
{
|
|
id: 'scene-1',
|
|
name: 'Camp',
|
|
description: 'A quiet camp.',
|
|
imageSrc: '/camp.png',
|
|
forwardSceneId: 'scene-2',
|
|
connectedSceneIds: ['scene-2', 'scene-3'],
|
|
connections: [],
|
|
npcs: [],
|
|
treasureHints: [],
|
|
},
|
|
{
|
|
id: 'scene-2',
|
|
name: 'Trail',
|
|
description: 'A mountain trail.',
|
|
imageSrc: '/trail.png',
|
|
forwardSceneId: 'scene-3',
|
|
connectedSceneIds: ['scene-3'],
|
|
connections: [],
|
|
npcs: [],
|
|
treasureHints: [],
|
|
},
|
|
{
|
|
id: 'scene-3',
|
|
name: 'Ruin',
|
|
description: 'A ruined gate.',
|
|
imageSrc: '/ruin.png',
|
|
connectedSceneIds: [],
|
|
connections: [],
|
|
npcs: [],
|
|
treasureHints: [],
|
|
},
|
|
],
|
|
}));
|
|
|
|
vi.mock('../../data/scenePresets', () => ({
|
|
getForwardScenePreset: () => scenes[1],
|
|
getScenePresetById: (_worldType: unknown, sceneId: string) =>
|
|
scenes.find((scene) => scene.id === sceneId) ?? null,
|
|
getScenePresetsByWorld: () => scenes,
|
|
getSceneFriendlyNpcs: () => [],
|
|
getSceneHostileNpcs: () => [],
|
|
getSceneHostileNpcPresetIds: () => [],
|
|
getTravelScenePreset: () => scenes[1],
|
|
getWorldCampScenePreset: () => null,
|
|
}));
|
|
|
|
vi.mock('../../data/stateFunctions', () => ({
|
|
getFunctionById: (functionId: string) => ({
|
|
id: functionId,
|
|
state: functionId.startsWith('idle_') ? 'idle' : 'battle',
|
|
category: functionId === 'battle_escape_breakout' ? 'escape' : functionId.startsWith('idle_') ? 'idle' : 'battle',
|
|
}),
|
|
getFunctionEffect: () => ({
|
|
sceneShift: 0,
|
|
escapeDistance: 5,
|
|
escapeDurationMs: 5000,
|
|
}),
|
|
}));
|
|
|
|
import {
|
|
AnimationState,
|
|
type Character,
|
|
type GameState,
|
|
type StoryOption,
|
|
WorldType,
|
|
} from '../../types';
|
|
import type { BattlePlan } from './battlePlan';
|
|
import { buildResolvedChoiceState } from './resolvedChoice';
|
|
|
|
function createTestCharacter(): Character {
|
|
return {
|
|
id: 'test-hero',
|
|
name: 'Test Hero',
|
|
title: 'Hero',
|
|
description: 'A test character',
|
|
backstory: 'A test backstory',
|
|
avatar: '/hero.png',
|
|
portrait: '/hero-portrait.png',
|
|
assetFolder: 'hero',
|
|
assetVariant: 'default',
|
|
attributes: {
|
|
strength: 10,
|
|
agility: 10,
|
|
intelligence: 10,
|
|
spirit: 10,
|
|
},
|
|
personality: 'calm',
|
|
skills: [
|
|
{
|
|
id: 'skill-basic',
|
|
name: 'Basic Strike',
|
|
animation: AnimationState.ATTACK,
|
|
damage: 10,
|
|
manaCost: 0,
|
|
cooldownTurns: 1,
|
|
range: 1,
|
|
style: 'steady',
|
|
},
|
|
],
|
|
adventureOpenings: {},
|
|
};
|
|
}
|
|
|
|
function createBaseState(): GameState {
|
|
return {
|
|
worldType: WorldType.WUXIA,
|
|
customWorldProfile: null,
|
|
playerCharacter: createTestCharacter(),
|
|
runtimeStats: {
|
|
playTimeMs: 0,
|
|
lastPlayTickAt: null,
|
|
hostileNpcsDefeated: 0,
|
|
questsAccepted: 0,
|
|
itemsUsed: 0,
|
|
scenesTraveled: 0,
|
|
},
|
|
currentScene: 'Story',
|
|
storyHistory: [],
|
|
characterChats: {},
|
|
animationState: AnimationState.IDLE,
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
currentScenePreset: null,
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
playerActionMode: 'idle',
|
|
scrollWorld: false,
|
|
inBattle: true,
|
|
playerHp: 100,
|
|
playerMaxHp: 100,
|
|
playerMana: 20,
|
|
playerMaxMana: 20,
|
|
playerSkillCooldowns: {},
|
|
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 createOption(functionId: string): StoryOption {
|
|
return {
|
|
functionId,
|
|
actionText: functionId,
|
|
text: functionId,
|
|
visuals: {
|
|
playerAnimation: AnimationState.ATTACK,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('buildResolvedChoiceState', () => {
|
|
it('keeps battle planning in the pure resolution layer', () => {
|
|
const state = createBaseState();
|
|
const character = createTestCharacter();
|
|
const battlePlan: BattlePlan = {
|
|
preparedState: state,
|
|
turns: [],
|
|
finalState: {
|
|
...state,
|
|
inBattle: false,
|
|
},
|
|
};
|
|
const buildBattlePlan = vi.fn(() => battlePlan);
|
|
|
|
const resolved = buildResolvedChoiceState({
|
|
state,
|
|
option: createOption('battle_all_in_crush'),
|
|
character,
|
|
buildBattlePlan,
|
|
});
|
|
|
|
expect(buildBattlePlan).toHaveBeenCalledWith(state, expect.objectContaining({ functionId: 'battle_all_in_crush' }), character);
|
|
expect(resolved.optionKind).toBe('battle');
|
|
expect(resolved.battlePlan).toBe(battlePlan);
|
|
expect(resolved.afterSequence.inBattle).toBe(false);
|
|
});
|
|
|
|
it('builds escape results without playback timing concerns', () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
sceneHostileNpcs: [
|
|
{
|
|
id: 'monster-1',
|
|
name: 'Wolf',
|
|
action: 'growls',
|
|
description: 'A wolf',
|
|
animation: 'idle' as const,
|
|
xMeters: 3,
|
|
yOffset: 0,
|
|
facing: 'left' as const,
|
|
attackRange: 1,
|
|
speed: 1,
|
|
hp: 10,
|
|
maxHp: 10,
|
|
renderKind: 'npc' as const,
|
|
},
|
|
],
|
|
};
|
|
|
|
const resolved = buildResolvedChoiceState({
|
|
state,
|
|
option: createOption('battle_escape_breakout'),
|
|
character: createTestCharacter(),
|
|
buildBattlePlan: vi.fn(),
|
|
});
|
|
|
|
expect(resolved.optionKind).toBe('escape');
|
|
expect(resolved.battlePlan).toBeNull();
|
|
expect(resolved.afterSequence.inBattle).toBe(false);
|
|
expect(resolved.afterSequence.currentEncounter).toBeNull();
|
|
expect(resolved.afterSequence.sceneHostileNpcs).toEqual([]);
|
|
expect(resolved.afterSequence.playerFacing).toBe('right');
|
|
});
|
|
|
|
it('moves escape result to explicit target scene and resets player to scene start', () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
currentScenePreset: scenes[0] as GameState['currentScenePreset'],
|
|
sceneHostileNpcs: [
|
|
{
|
|
id: 'monster-1',
|
|
name: 'Wolf',
|
|
action: 'growls',
|
|
description: 'A wolf',
|
|
animation: 'idle' as const,
|
|
xMeters: 3,
|
|
yOffset: 0,
|
|
facing: 'left' as const,
|
|
attackRange: 1,
|
|
speed: 1,
|
|
hp: 10,
|
|
maxHp: 10,
|
|
renderKind: 'npc' as const,
|
|
},
|
|
],
|
|
};
|
|
const option = {
|
|
...createOption('battle_escape_breakout'),
|
|
runtimePayload: {
|
|
escapeTargetSceneId: 'scene-3',
|
|
escapeEntry: 'from_left',
|
|
},
|
|
};
|
|
|
|
const resolved = buildResolvedChoiceState({
|
|
state,
|
|
option,
|
|
character: createTestCharacter(),
|
|
buildBattlePlan: vi.fn(),
|
|
});
|
|
|
|
expect(resolved.optionKind).toBe('escape');
|
|
expect(resolved.afterSequence.currentScenePreset?.id).toBe('scene-3');
|
|
expect(resolved.afterSequence.playerX).toBe(0);
|
|
expect(resolved.afterSequence.playerFacing).toBe('right');
|
|
expect(resolved.afterSequence.inBattle).toBe(false);
|
|
});
|
|
|
|
it('keeps idle follow-up generation separate from combat planning', () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
inBattle: false,
|
|
};
|
|
|
|
const resolved = buildResolvedChoiceState({
|
|
state,
|
|
option: createOption('idle_observe_signs'),
|
|
character: createTestCharacter(),
|
|
buildBattlePlan: vi.fn(),
|
|
});
|
|
|
|
expect(resolved.optionKind).toBe('idle');
|
|
expect(resolved.battlePlan).toBeNull();
|
|
expect(resolved.afterSequence.inBattle).toBe(false);
|
|
expect(resolved.afterSequence.currentEncounter).toBeNull();
|
|
});
|
|
|
|
it('uses explicit target scene id from travel option payload', () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
currentScenePreset: scenes[0] as GameState['currentScenePreset'],
|
|
inBattle: false,
|
|
};
|
|
const option = {
|
|
...createOption('idle_travel_next_scene'),
|
|
runtimePayload: {
|
|
targetSceneId: 'scene-3',
|
|
},
|
|
};
|
|
|
|
const resolved = buildResolvedChoiceState({
|
|
state,
|
|
option,
|
|
character: createTestCharacter(),
|
|
buildBattlePlan: vi.fn(),
|
|
});
|
|
|
|
expect(resolved.afterSequence.currentScenePreset?.id).toBe('scene-3');
|
|
});
|
|
});
|