Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View 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');
});
});

View File

@@ -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,
};
}

View 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([]);
});
});

View File

@@ -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;