Files
Genarrative/src/data/npcInteractions.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

307 lines
8.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { Character, Encounter, GameState, InventoryItem } from '../types';
import { AnimationState, WorldType } from '../types';
import {
buildGiftCandidateSummary,
buildInitialNpcState,
buildNpcEncounterStoryMoment,
buildNpcHelpReward,
buildNpcTradeTransactionActionText,
syncNpcTradeInventory,
} from './npcInteractions';
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 createEncounter(): Encounter {
return {
id: 'npc-trader',
kind: 'npc',
npcName: 'Trader Lin',
npcDescription: 'A traveling merchant.',
npcAvatar: 'T',
context: 'merchant',
};
}
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 createGameState(
encounter: Encounter,
overrides: Partial<GameState> = {},
): GameState {
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: encounter,
npcInteractionActive: true,
currentScenePreset: {
id: 'scene-camp',
name: 'Camp',
description: 'A temporary camp.',
imageSrc: '/camp.png',
npcs: [],
treasureHints: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 80,
playerMaxHp: 100,
playerMana: 40,
playerMaxMana: 60,
playerSkillCooldowns: {},
activeBuildBuffs: [],
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,
...overrides,
};
}
describe('npcInteractions', () => {
it('builds a readable fallback summary for empty gift candidates', () => {
expect(buildGiftCandidateSummary([])).toBe('暂无合适礼物');
});
it('includes gift candidate context in the npc gift option detail text', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [
createInventoryItem('jade-token', 'Jade Token', {
rarity: 'rare',
category: '专属',
tags: ['merchant'],
}),
createInventoryItem('tea-brick', 'Tea Brick'),
],
activeQuests: [],
scene: {
id: 'scene-1',
name: 'Camp',
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
const giftOption = story.options.find((option) => option.functionId === 'npc_gift');
expect(giftOption).toBeTruthy();
expect(giftOption?.detailText).toContain('Jade Token');
expect(giftOption?.detailText).toContain('Tea Brick');
});
it('omits the npc gift option when the player has no gift candidates', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-1',
name: 'Camp',
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false);
});
it('uses ai-first copy for quest offers instead of prebuilding a fallback quest preview', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-ruins',
name: '遗迹外缘',
npcs: [],
treasureHints: ['半截封泥'],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
const questOption = story.options.find((option) => option.functionId === 'npc_quest_accept');
expect(questOption).toBeTruthy();
expect(questOption?.detailText).toContain('AI 剧情引擎');
expect(questOption?.detailText).not.toContain('完成后可获得');
});
it('builds hostile npc encounters as a direct declaration dialogue with only escape and fight', () => {
const encounter = createEncounter();
const hostileState = {
...buildInitialNpcState(encounter, WorldType.WUXIA),
affinity: -12,
};
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: hostileState,
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-pass',
name: '断桥口',
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
expect(story.displayMode).toBe('dialogue');
expect(story.dialogue).toEqual([
expect.objectContaining({
speaker: 'npc',
speakerName: 'Trader Lin',
}),
]);
expect(story.options.map((option) => option.functionId)).toEqual([
'battle_escape_breakout',
'npc_fight',
]);
expect(story.options.map((option) => option.actionText)).toEqual([
'逃跑',
'与他对战',
]);
});
it('builds concrete trade action text for story continuation', () => {
const encounter = createEncounter();
expect(
buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: createInventoryItem('jade-token', 'Jade Token'),
quantity: 2,
}),
).toBe('从Trader Lin手里买下Jade Token x2');
expect(
buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: createInventoryItem('tea-brick', 'Tea Brick'),
quantity: 1,
}),
).toBe('把Tea Brick卖给Trader Lin');
});
it('syncs generic trade stock to the current build while preserving sold-in items', () => {
const encounter: Encounter = {
...createEncounter(),
context: '商贩',
};
const state = createGameState(encounter);
const syncedState = syncNpcTradeInventory(state, encounter, {
...buildInitialNpcState(encounter, WorldType.WUXIA),
inventory: [createInventoryItem('sold-tea', 'Tea Brick')],
tradeStockSignature: 'stale-build',
});
expect(syncedState.tradeStockSignature).not.toBe('stale-build');
expect(syncedState.inventory.some(item => item.id === 'sold-tea')).toBe(true);
expect(
syncedState.inventory.some(
item => item.runtimeMetadata?.generationChannel === 'npc_trade',
),
).toBe(true);
});
it('builds npc help rewards from the runtime director', () => {
const encounter: Encounter = {
...createEncounter(),
context: '商贩',
};
const reward = buildNpcHelpReward(encounter, createGameState(encounter));
expect(reward.items.length).toBeGreaterThan(0);
expect(reward.items[0]?.runtimeMetadata?.generationChannel).toBe('npc_reward');
expect(reward.storyHint).toBeTruthy();
});
});