Files
Genarrative/src/hooks/combat/resolvedChoice.test.ts
T
2026-04-27 22:50:18 +08:00

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');
});
});