This commit is contained in:
328
src/hooks/rpg-runtime-story/storyGenerationState.test.ts
Normal file
328
src/hooks/rpg-runtime-story/storyGenerationState.test.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user