Integrate role asset studio into custom world agent flow
This commit is contained in:
157
src/data/functionCatalog/functionCatalog.test.ts
Normal file
157
src/data/functionCatalog/functionCatalog.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { SERVER_RUNTIME_FUNCTION_IDS } from '../../../packages/shared/src/contracts/story';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ALL_FUNCTION_DOCUMENTATION,
|
||||
buildCampTravelHomeOption,
|
||||
buildContinueAdventureOption,
|
||||
buildNpcGiftModalState,
|
||||
buildNpcPreviewTalkOption,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
CONTINUE_ADVENTURE_FUNCTION,
|
||||
getFunctionDocumentationById,
|
||||
isNpcPreviewTalkOption,
|
||||
NPC_PREVIEW_TALK_FUNCTION,
|
||||
shouldNpcRecruitOpenModal,
|
||||
} from './index';
|
||||
import type { Encounter, GameState, InventoryItem } from '../../types';
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc',
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿路摆摊的商人。',
|
||||
npcAvatar: '梁',
|
||||
context: '商贩',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<InventoryItem> = {},
|
||||
): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} 的测试描述`,
|
||||
quantity: 1,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createModalState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
playerInventory: [
|
||||
createInventoryItem('player-potion', '疗伤药'),
|
||||
createInventoryItem('player-charm', '护符'),
|
||||
],
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-ally-1',
|
||||
characterId: 'ally-1',
|
||||
name: '阿青',
|
||||
role: '同伴',
|
||||
joinedAtAffinity: 12,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('functionCatalog', () => {
|
||||
it('keeps function documentation ids unique and source files resolvable', () => {
|
||||
const documentationIds = ALL_FUNCTION_DOCUMENTATION.map((entry) => entry.id);
|
||||
|
||||
expect(new Set(documentationIds).size).toBe(documentationIds.length);
|
||||
ALL_FUNCTION_DOCUMENTATION.forEach((entry) => {
|
||||
expect(existsSync(entry.source), `${entry.id} -> ${entry.source}`).toBe(
|
||||
true,
|
||||
);
|
||||
expect(getFunctionDocumentationById(entry.id)).toEqual(entry);
|
||||
});
|
||||
});
|
||||
|
||||
it('covers every server runtime function id with documentation metadata', () => {
|
||||
SERVER_RUNTIME_FUNCTION_IDS.forEach((functionId) => {
|
||||
expect(getFunctionDocumentationById(functionId)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('builds flow helper options with the expected function ids', () => {
|
||||
const continueOption = buildContinueAdventureOption();
|
||||
const campTravelOption = buildCampTravelHomeOption('竹林古道');
|
||||
|
||||
expect(continueOption.functionId).toBe(CONTINUE_ADVENTURE_FUNCTION.id);
|
||||
expect(continueOption.priority).toBe(99);
|
||||
expect(campTravelOption.functionId).toBe('camp_travel_home_scene');
|
||||
expect(campTravelOption.actionText).toBe('前往 竹林古道');
|
||||
expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。');
|
||||
});
|
||||
|
||||
it('builds npc preview talk options from the current encounter', () => {
|
||||
const option = buildNpcPreviewTalkOption(createEncounter());
|
||||
|
||||
expect(option.functionId).toBe(NPC_PREVIEW_TALK_FUNCTION.id);
|
||||
expect(option.actionText).toBe('与 梁伯 交谈');
|
||||
expect(isNpcPreviewTalkOption(option)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds modal helper state for trade, gift and recruit flows', () => {
|
||||
const state = createModalState();
|
||||
const encounter = createEncounter();
|
||||
const tradeModal = buildNpcTradeModalState(
|
||||
state,
|
||||
encounter,
|
||||
'先看看货',
|
||||
[
|
||||
createInventoryItem('npc-herb', '止血草'),
|
||||
createInventoryItem('npc-ore', '陨铁碎片'),
|
||||
],
|
||||
);
|
||||
const giftModal = buildNpcGiftModalState(
|
||||
state,
|
||||
encounter,
|
||||
'送你一样东西',
|
||||
'player-charm',
|
||||
);
|
||||
const recruitModal = buildNpcRecruitModalState(
|
||||
state,
|
||||
encounter,
|
||||
'谈谈同行的事',
|
||||
);
|
||||
|
||||
expect(tradeModal.selectedNpcItemId).toBe('npc-herb');
|
||||
expect(tradeModal.selectedPlayerItemId).toBe('player-potion');
|
||||
expect(giftModal.selectedItemId).toBe('player-charm');
|
||||
expect(recruitModal.selectedReleaseNpcId).toBe('npc-ally-1');
|
||||
expect(shouldNpcRecruitOpenModal(2, 2)).toBe(true);
|
||||
expect(shouldNpcRecruitOpenModal(1, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the first tradable player item when zero-quantity items exist', () => {
|
||||
const encounter = createEncounter();
|
||||
const tradeModal = buildNpcTradeModalState(
|
||||
createModalState({
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', '空槽位', { quantity: 0 }),
|
||||
createInventoryItem('usable-item', '可售草药', { quantity: 2 }),
|
||||
],
|
||||
}),
|
||||
encounter,
|
||||
'交易',
|
||||
[createInventoryItem('npc-herb', '止血草')],
|
||||
);
|
||||
|
||||
expect(tradeModal.selectedPlayerItemId).toBe('usable-item');
|
||||
});
|
||||
});
|
||||
@@ -21,13 +21,18 @@ export function buildNpcTradeModalState(
|
||||
actionText: string,
|
||||
npcInventory: InventoryItem[],
|
||||
): TradeModalState {
|
||||
const selectedNpcItemId =
|
||||
npcInventory.find((item) => item.quantity > 0)?.id ?? null;
|
||||
const selectedPlayerItemId =
|
||||
state.playerInventory.find((item) => item.quantity > 0)?.id ?? null;
|
||||
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
introText: buildNpcTradeModalIntroText(encounter),
|
||||
mode: 'buy',
|
||||
selectedNpcItemId: npcInventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: state.playerInventory[0]?.id ?? null,
|
||||
selectedNpcItemId,
|
||||
selectedPlayerItemId,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
204
src/data/stateFunctions.test.ts
Normal file
204
src/data/stateFunctions.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildStateFunctionDefinitions,
|
||||
getExecutableFunctions,
|
||||
getFunctionById,
|
||||
resolveFunctionOption,
|
||||
sortStoryOptionsByPriority,
|
||||
} from './stateFunctions';
|
||||
import {
|
||||
getScenePresetsByWorld,
|
||||
getTravelScenePreset,
|
||||
getWorldCampScenePreset,
|
||||
} from './scenePresets';
|
||||
import type { FunctionAvailabilityContext } from './stateFunctions';
|
||||
import type { SceneHostileNpc, StoryOption } from '../types';
|
||||
import { AnimationState, WorldType } from '../types';
|
||||
|
||||
function createMonster(
|
||||
overrides: Partial<SceneHostileNpc> = {},
|
||||
): SceneHostileNpc {
|
||||
return {
|
||||
id: 'monster-wolf',
|
||||
name: '山狼',
|
||||
action: '朝你低吼逼近',
|
||||
description: '一只逼近的山狼。',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.4,
|
||||
speed: 1,
|
||||
hp: 18,
|
||||
maxHp: 18,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<FunctionAvailabilityContext> = {},
|
||||
): FunctionAvailabilityContext {
|
||||
const defaultScene = getScenePresetsByWorld(WorldType.WUXIA)[0];
|
||||
if (!defaultScene) {
|
||||
throw new Error('Expected wuxia scene presets to exist');
|
||||
}
|
||||
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
playerCharacter: null,
|
||||
inBattle: false,
|
||||
currentSceneId: defaultScene.id,
|
||||
currentSceneName: defaultScene.name,
|
||||
monsters: [],
|
||||
playerHp: 80,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 50,
|
||||
playerMaxMana: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('stateFunctions', () => {
|
||||
it('builds runtime state function definitions without inactive idle_follow_clue', () => {
|
||||
const definitions = buildStateFunctionDefinitions();
|
||||
|
||||
expect(getFunctionById('idle_follow_clue', definitions)).toBeNull();
|
||||
expect(getFunctionById('idle_explore_forward', definitions)?.text).toBe(
|
||||
'继续向前探索',
|
||||
);
|
||||
});
|
||||
|
||||
it('prioritizes battle_recover_breath in high-pressure combat contexts', () => {
|
||||
const executableFunctions = getExecutableFunctions(
|
||||
createContext({
|
||||
inBattle: true,
|
||||
monsters: [createMonster()],
|
||||
playerHp: 20,
|
||||
playerMana: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(executableFunctions[0]?.id).toBe('battle_recover_breath');
|
||||
});
|
||||
|
||||
it('hides idle_explore_forward in the world camp scene', () => {
|
||||
const campScene = getWorldCampScenePreset(WorldType.WUXIA);
|
||||
if (!campScene) {
|
||||
throw new Error('Expected wuxia camp scene to exist');
|
||||
}
|
||||
|
||||
const executableIds = getExecutableFunctions(
|
||||
createContext({
|
||||
currentSceneId: campScene.id,
|
||||
currentSceneName: campScene.name,
|
||||
}),
|
||||
).map((definition) => definition.id);
|
||||
|
||||
expect(executableIds).not.toContain('idle_explore_forward');
|
||||
});
|
||||
|
||||
it('forces suggested action text for travel options and keeps custom battle copy', () => {
|
||||
const idleContext = createContext();
|
||||
const travelScene = getTravelScenePreset(
|
||||
idleContext.worldType,
|
||||
idleContext.currentSceneId,
|
||||
);
|
||||
const travelOption = resolveFunctionOption(
|
||||
'idle_travel_next_scene',
|
||||
idleContext,
|
||||
'这段自定义文案会被覆盖',
|
||||
);
|
||||
const battleOption = resolveFunctionOption(
|
||||
'battle_all_in_crush',
|
||||
createContext({
|
||||
inBattle: true,
|
||||
monsters: [createMonster()],
|
||||
}),
|
||||
'顶上去狠狠干一轮',
|
||||
);
|
||||
|
||||
expect(travelOption).not.toBeNull();
|
||||
expect(travelOption?.actionText).toBe(
|
||||
travelScene ? `前往${travelScene.name}` : '前往其他场景',
|
||||
);
|
||||
expect(battleOption?.actionText).toBe('顶上去狠狠干一轮');
|
||||
});
|
||||
|
||||
it('sorts story options after preserving the first two model-locked entries', () => {
|
||||
const options: StoryOption[] = [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
functionId: 'battle_probe_pressure',
|
||||
actionText: '稳住节奏',
|
||||
text: '稳住节奏',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
functionId: 'npc_preview_talk',
|
||||
actionText: '和对方说话',
|
||||
text: '和对方说话',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
functionId: 'camp_travel_home_scene',
|
||||
actionText: '离开营地',
|
||||
text: '离开营地',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const sortedOptions = sortStoryOptionsByPriority(options);
|
||||
|
||||
expect(sortedOptions.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'battle_probe_pressure',
|
||||
'camp_travel_home_scene',
|
||||
'npc_preview_talk',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes battle_recover_breath when combat has no living monsters', () => {
|
||||
const executableIds = getExecutableFunctions(
|
||||
createContext({
|
||||
inBattle: true,
|
||||
monsters: [createMonster({ hp: 0 })],
|
||||
}),
|
||||
).map((definition) => definition.id);
|
||||
|
||||
expect(executableIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -390,7 +390,7 @@ function matchesCategory(
|
||||
return !context.inBattle;
|
||||
case 'recovery':
|
||||
return definition.state === 'battle'
|
||||
? context.inBattle
|
||||
? context.inBattle && hasAliveMonsters(context.monsters)
|
||||
: !context.inBattle;
|
||||
default:
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user