Files
Genarrative/src/hooks/rpg-runtime-story/storyGenerationState.test.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +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',
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);
});
});