Integrate role asset studio into custom world agent flow
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user