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',
|
|
connectedSceneIds: ['scene-2'],
|
|
monsterIds: [],
|
|
npcs: [],
|
|
treasureHints: [],
|
|
},
|
|
{
|
|
id: 'scene-2',
|
|
name: 'Trail',
|
|
description: 'A mountain trail.',
|
|
imageSrc: '/trail.png',
|
|
connectedSceneIds: ['scene-1'],
|
|
monsterIds: [],
|
|
npcs: [],
|
|
treasureHints: [],
|
|
},
|
|
],
|
|
}));
|
|
|
|
vi.mock('../../data/scenePresets', () => ({
|
|
getScenePresetById: (_worldType: unknown, sceneId: string) =>
|
|
scenes.find(scene => scene.id === sceneId) ?? null,
|
|
getSceneFriendlyNpcs: (scene: { npcs?: unknown[] } | null | undefined) => scene?.npcs ?? [],
|
|
getSceneHostileNpcs: () => [],
|
|
getScenePresetsByWorld: () => scenes,
|
|
getWorldCampScenePreset: () => scenes[0] ?? null,
|
|
}));
|
|
|
|
import {
|
|
buildInitialNpcState,
|
|
MAX_COMPANIONS,
|
|
} from '../../data/npcInteractions';
|
|
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
|
import {
|
|
AnimationState,
|
|
type Character,
|
|
type CompanionState,
|
|
type Encounter,
|
|
type GameState,
|
|
type InventoryItem,
|
|
type StoryOption,
|
|
WorldType,
|
|
} from '../../types';
|
|
import {
|
|
buildMapTravelResolution,
|
|
resolveNpcInteractionDecision,
|
|
} from './storyGenerationState';
|
|
|
|
function createCharacter(): Character {
|
|
return {
|
|
id: 'hero',
|
|
name: 'Hero',
|
|
title: 'Wanderer',
|
|
description: 'A reliable test hero.',
|
|
backstory: 'Travels the land.',
|
|
avatar: '/hero.png',
|
|
portrait: '/hero-portrait.png',
|
|
assetFolder: 'hero',
|
|
assetVariant: 'default',
|
|
attributes: {
|
|
strength: 10,
|
|
agility: 9,
|
|
intelligence: 8,
|
|
spirit: 7,
|
|
},
|
|
personality: 'steady',
|
|
skills: [],
|
|
adventureOpenings: {},
|
|
};
|
|
}
|
|
|
|
function createInventoryItem(
|
|
id: string,
|
|
name: string,
|
|
overrides: Partial<InventoryItem> = {},
|
|
): InventoryItem {
|
|
return {
|
|
id,
|
|
name,
|
|
description: `${name} description`,
|
|
quantity: 1,
|
|
category: 'misc',
|
|
rarity: 'common',
|
|
tags: [],
|
|
value: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createEncounter(): Encounter {
|
|
return {
|
|
id: 'npc-trader',
|
|
kind: 'npc',
|
|
npcName: 'Trader Lin',
|
|
npcDescription: 'A traveling merchant.',
|
|
npcAvatar: 'T',
|
|
context: 'merchant',
|
|
};
|
|
}
|
|
|
|
function createCompanion(npcId: string): CompanionState {
|
|
return {
|
|
npcId,
|
|
characterId: `character-${npcId}`,
|
|
joinedAtAffinity: 10,
|
|
hp: 10,
|
|
maxHp: 10,
|
|
mana: 5,
|
|
maxMana: 5,
|
|
skillCooldowns: {},
|
|
};
|
|
}
|
|
|
|
function createBaseState(): GameState {
|
|
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
|
|
|
return {
|
|
worldType: WorldType.WUXIA,
|
|
customWorldProfile: null,
|
|
playerCharacter: createCharacter(),
|
|
runtimeStats: {
|
|
playTimeMs: 0,
|
|
lastPlayTickAt: null,
|
|
hostileNpcsDefeated: 0,
|
|
questsAccepted: 0,
|
|
itemsUsed: 0,
|
|
scenesTraveled: 0,
|
|
},
|
|
currentScene: 'Story',
|
|
storyHistory: [],
|
|
characterChats: {},
|
|
animationState: AnimationState.IDLE,
|
|
currentEncounter: createEncounter(),
|
|
npcInteractionActive: false,
|
|
currentScenePreset: scenes[0] ?? null,
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
playerActionMode: 'idle',
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
playerHp: 100,
|
|
playerMaxHp: 100,
|
|
playerMana: 20,
|
|
playerMaxMana: 20,
|
|
playerSkillCooldowns: {},
|
|
activeCombatEffects: [],
|
|
playerCurrency: 10,
|
|
playerInventory: [createInventoryItem('player-potion', 'Potion')],
|
|
playerEquipment: {
|
|
weapon: null,
|
|
armor: null,
|
|
relic: null,
|
|
},
|
|
npcStates: {
|
|
'npc-trader': {
|
|
...buildInitialNpcState(createEncounter(), WorldType.WUXIA),
|
|
inventory: [createInventoryItem('npc-herb', 'Herb')],
|
|
},
|
|
},
|
|
quests: [],
|
|
roster: [],
|
|
companions: [],
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
}
|
|
|
|
function createInteractionOption(action: Extract<NonNullable<StoryOption['interaction']>, { kind: 'npc' }>['action']): StoryOption {
|
|
return {
|
|
functionId: `npc_${action}`,
|
|
actionText: action,
|
|
text: action,
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
interaction: {
|
|
kind: 'npc',
|
|
npcId: 'npc-trader',
|
|
action,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('storyGenerationState', () => {
|
|
it('opens the trade modal with the first npc and player inventory items selected', () => {
|
|
const decision = resolveNpcInteractionDecision(
|
|
createBaseState(),
|
|
createInteractionOption('trade'),
|
|
);
|
|
|
|
expect(decision.kind).toBe('trade_modal');
|
|
if (decision.kind !== 'trade_modal') {
|
|
throw new Error('Expected trade modal decision');
|
|
}
|
|
|
|
expect(decision.modal.selectedNpcItemId).toBe('npc-herb');
|
|
expect(decision.modal.selectedPlayerItemId).toBe('player-potion');
|
|
expect(decision.modal.selectedQuantity).toBe(1);
|
|
});
|
|
|
|
it('skips zero-quantity player items when opening the trade modal', () => {
|
|
const decision = resolveNpcInteractionDecision(
|
|
{
|
|
...createBaseState(),
|
|
playerInventory: [
|
|
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
|
createInventoryItem('player-herb', 'Herb'),
|
|
],
|
|
},
|
|
createInteractionOption('trade'),
|
|
);
|
|
|
|
expect(decision.kind).toBe('trade_modal');
|
|
if (decision.kind !== 'trade_modal') {
|
|
throw new Error('Expected trade modal decision');
|
|
}
|
|
|
|
expect(decision.modal.selectedPlayerItemId).toBe('player-herb');
|
|
});
|
|
|
|
it('forces a recruit replacement modal when the active party is full', () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
companions: Array.from({ length: MAX_COMPANIONS }, (_, index) => createCompanion(`npc-${index + 1}`)),
|
|
};
|
|
|
|
const decision = resolveNpcInteractionDecision(
|
|
state,
|
|
createInteractionOption('recruit'),
|
|
);
|
|
|
|
expect(decision.kind).toBe('recruit_modal');
|
|
if (decision.kind !== 'recruit_modal') {
|
|
throw new Error('Expected recruit modal decision');
|
|
}
|
|
|
|
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
|
|
});
|
|
|
|
it('opens the gift modal with the preferred gift candidate selected', () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
playerInventory: [
|
|
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
|
createInventoryItem('jade-token', 'Jade Token', {
|
|
rarity: 'rare',
|
|
category: '专属',
|
|
tags: ['merchant'],
|
|
}),
|
|
],
|
|
};
|
|
|
|
const decision = resolveNpcInteractionDecision(
|
|
state,
|
|
createInteractionOption('gift'),
|
|
);
|
|
|
|
expect(decision.kind).toBe('gift_modal');
|
|
if (decision.kind !== 'gift_modal') {
|
|
throw new Error('Expected gift modal decision');
|
|
}
|
|
|
|
expect(decision.modal.selectedItemId).toBe('jade-token');
|
|
});
|
|
|
|
it('does not open the gift modal when there are no gift candidates', () => {
|
|
const state = {
|
|
...createBaseState(),
|
|
playerInventory: [],
|
|
};
|
|
|
|
const decision = resolveNpcInteractionDecision(
|
|
state,
|
|
createInteractionOption('gift'),
|
|
);
|
|
|
|
expect(decision.kind).toBe('none');
|
|
});
|
|
|
|
it('builds a map travel transition that increments runtime stats and clears battle state', () => {
|
|
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
|
const sourceScene = scenes[0];
|
|
const targetScene = scenes[1]!;
|
|
const state = {
|
|
...createBaseState(),
|
|
currentScenePreset: sourceScene ?? null,
|
|
inBattle: true,
|
|
currentBattleNpcId: 'battle-npc',
|
|
currentNpcBattleMode: 'fight' as const,
|
|
currentNpcBattleOutcome: 'fight_victory' as const,
|
|
sparReturnEncounter: createEncounter(),
|
|
};
|
|
|
|
const resolution = buildMapTravelResolution(state, targetScene.id);
|
|
|
|
expect(resolution).not.toBeNull();
|
|
if (!resolution) {
|
|
throw new Error('Expected map travel resolution');
|
|
}
|
|
|
|
expect(resolution.nextState.currentScenePreset?.id).toBe(targetScene.id);
|
|
expect(resolution.nextState.npcInteractionActive).toBe(false);
|
|
expect(resolution.nextState.inBattle).toBe(false);
|
|
expect(resolution.nextState.currentBattleNpcId).toBeNull();
|
|
expect(resolution.nextState.currentNpcBattleMode).toBeNull();
|
|
expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1);
|
|
});
|
|
});
|