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

@@ -155,6 +155,106 @@ describe('createStoryChoiceActions', () => {
resolveServerRuntimeChoiceMock.mockReset();
});
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
const state = {
...createBaseState(),
inBattle: false,
sceneHostileNpcs: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
};
const deferredOptions = [
{
functionId: 'idle_explore_forward',
actionText: '继续向前探索',
text: '继续向前探索',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
},
] satisfies StoryOption[];
const continueOption: StoryOption = {
functionId: 'story_continue_adventure',
actionText: '查看后续',
text: '查看后续',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
};
const currentStory: StoryMoment = {
text: '对话已经完成',
options: [continueOption],
deferredOptions,
};
const setCurrentStory = vi.fn();
const generateStoryForState = vi.fn();
const handleNpcInteraction = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory,
isLoading: false,
setGameState: vi.fn(),
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn(
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(
(option: StoryOption) =>
option.functionId === 'story_continue_adventure',
),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(continueOption);
expect(setCurrentStory).toHaveBeenCalledWith({
...currentStory,
options: deferredOptions,
deferredOptions: undefined,
});
expect(generateStoryForState).not.toHaveBeenCalled();
expect(handleNpcInteraction).not.toHaveBeenCalled();
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
});
it('routes task5 story choices through the server runtime action endpoint', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');

View File

@@ -33,12 +33,13 @@ vi.mock('../../services/runtimeStoryService', async () => {
};
});
import type { GameState, StoryMoment, StoryOption } from '../../types';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../../types';
import { WorldType } from '../../types';
import {
loadServerRuntimeOptionCatalog,
resumeServerRuntimeStory,
resolveServerRuntimeChoice,
resumeServerRuntimeStory,
} from './runtimeStoryCoordinator';
function createStory(text: string): StoryMoment {
@@ -55,6 +56,97 @@ function createGameState(): GameState {
} as GameState;
}
function createRuntimeNpcBattleSnapshot(
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
) {
return {
version: 8,
savedAt: '2026-04-14T00:00:00.000Z',
bottomTab: 'adventure' as const,
currentStory: createStory('战斗中的服务端故事'),
gameState: {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: {
id: 'hero',
},
runtimeActionVersion: 8,
runtimeSessionId: 'runtime-main',
currentScene: 'Story',
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: {
kind: 'npc',
id: 'npc-bandit',
npcName: '断桥匪首',
npcDescription: '拦路的刀客',
context: '断桥口',
hostile: true,
},
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [
{
id: 'npc-bandit',
name: '断桥匪首',
hp: 21,
maxHp: 32,
description: '拦路的刀客',
},
],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 42,
playerMaxHp: 50,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-bandit': {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-bandit',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as unknown as GameState,
} as HydratedSavedGameSnapshot;
}
describe('runtimeStoryCoordinator', () => {
beforeEach(() => {
putSaveSnapshotMock.mockReset();
@@ -363,4 +455,181 @@ describe('runtimeStoryCoordinator', () => {
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
});
it('rehydrates npc_fight server snapshots before returning runtime choices', async () => {
const gameState = createGameState();
const currentStory = createStory('当前故事');
const option = {
functionId: 'npc_fight',
actionText: '直接开战',
text: '直接开战',
interaction: {
kind: 'npc',
npcId: 'npc-bandit',
action: 'fight',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
const rawBattleSnapshot = createRuntimeNpcBattleSnapshot();
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-bandit',
kind: 'npc',
npcName: '断桥匪首',
hostile: true,
affinity: -12,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_probe_pressure',
actionText: '稳步试探',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '断桥匪首已经摆开架势。',
options: [],
},
patches: [],
snapshot: rawBattleSnapshot,
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect.objectContaining({
id: 'npc-bandit',
hp: 21,
maxHp: 32,
encounter: expect.objectContaining({
kind: 'npc',
id: 'npc-bandit',
npcName: '断桥匪首',
}),
}),
);
expect(result.nextStory.options[0]).toEqual(
expect.objectContaining({
functionId: 'battle_probe_pressure',
}),
);
});
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
runtimeActionVersion: 7,
});
const rawServerBattleSnapshot = createRuntimeNpcBattleSnapshot({
runtimeActionVersion: 8,
playerHp: 39,
sceneHostileNpcs: [
{
id: 'npc-bandit',
name: '断桥匪首',
hp: 14,
maxHp: 32,
description: '拦路的刀客',
},
] as unknown as GameState['sceneHostileNpcs'],
});
getRuntimeStoryStateMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 39,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-bandit',
kind: 'npc',
npcName: '断桥匪首',
hostile: true,
affinity: -12,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_guard_break',
actionText: '破架重击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '断桥匪首还在步步逼近。',
options: [],
},
patches: [],
snapshot: rawServerBattleSnapshot,
});
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect.objectContaining({
id: 'npc-bandit',
hp: 14,
maxHp: 32,
encounter: expect.objectContaining({
kind: 'npc',
id: 'npc-bandit',
}),
}),
);
expect(result.nextStory).not.toBeNull();
expect(result.nextStory?.options[0]).toEqual(
expect.objectContaining({
functionId: 'battle_guard_break',
}),
);
});
});

View File

@@ -1,3 +1,4 @@
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
buildStoryMomentFromRuntimeOptions,
@@ -49,7 +50,7 @@ export async function loadServerRuntimeOptionCatalog(params: {
export async function resumeServerRuntimeStory(
snapshot: HydratedSavedGameSnapshot,
) {
const hydratedSnapshot = snapshot;
const hydratedSnapshot = rehydrateSavedSnapshot(snapshot);
const shouldRefreshFromServer =
hydratedSnapshot.gameState.currentScene === 'Story' &&
Boolean(hydratedSnapshot.gameState.worldType) &&
@@ -65,7 +66,7 @@ export async function resumeServerRuntimeStory(
const response = await getRuntimeStoryState(
getRuntimeSessionId(hydratedSnapshot.gameState),
);
const resumedSnapshot = response.snapshot;
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const runtimeOptions = getRuntimeResponseOptions(response);
const nextStory =
response.presentation.storyText || runtimeOptions.length > 0
@@ -105,7 +106,7 @@ export async function resolveServerRuntimeChoice(params: {
: undefined,
payload: params.payload,
});
const hydratedSnapshot = response.snapshot;
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
return {
response,

View File

@@ -218,6 +218,26 @@ describe('storyGenerationState', () => {
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(),
@@ -306,4 +326,3 @@ describe('storyGenerationState', () => {
expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1);
});
});