307 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|