1
This commit is contained in:
@@ -738,4 +738,204 @@ describe('createStoryChoiceActions', () => {
|
||||
expect.objectContaining({ hostileNpcsDefeated: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps battle attack and skill choices on the local combat path even if runtime server supports them', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf-1',
|
||||
name: '山狼',
|
||||
action: '低伏逼近',
|
||||
description: '一头山狼',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const option: StoryOption = {
|
||||
...createBattleOption('battle_use_skill'),
|
||||
runtimePayload: {
|
||||
skillId: 'skill-basic',
|
||||
},
|
||||
};
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const buildResolvedChoiceState = vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence: state,
|
||||
}));
|
||||
const playResolvedChoice = vi.fn().mockResolvedValue(state);
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: true,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => [option]),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
||||
getResolvedSceneHostileNpcs: vi.fn(
|
||||
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
|
||||
state,
|
||||
option,
|
||||
state.playerCharacter!,
|
||||
);
|
||||
expect(playResolvedChoice).toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenCalled();
|
||||
expect(setCurrentStory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps stale battle panel choices on the local combat path when combat presentation is still visible', async () => {
|
||||
const battleOption = createBattleOption('battle_attack_basic');
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf-1',
|
||||
name: '山狼',
|
||||
action: '低伏逼近',
|
||||
description: '一头山狼',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '山狼还在你面前压低身位,战斗并未真正结束。',
|
||||
options: [battleOption],
|
||||
};
|
||||
const buildResolvedChoiceState = vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence: {
|
||||
...state,
|
||||
inBattle: true,
|
||||
},
|
||||
}));
|
||||
const playResolvedChoice = vi.fn().mockResolvedValue({
|
||||
...state,
|
||||
inBattle: true,
|
||||
});
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: true,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => [battleOption]),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
||||
getResolvedSceneHostileNpcs: vi.fn(
|
||||
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(battleOption);
|
||||
|
||||
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
|
||||
state,
|
||||
battleOption,
|
||||
state.playerCharacter!,
|
||||
);
|
||||
expect(playResolvedChoice).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,39 @@ type IncrementRuntimeStats = (
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function isImmediateCombatChoice(option: StoryOption) {
|
||||
return (
|
||||
option.functionId.startsWith('battle_') ||
|
||||
option.functionId === 'inventory_use'
|
||||
);
|
||||
}
|
||||
|
||||
function shouldResolveCombatChoiceLocally(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
option: StoryOption,
|
||||
) {
|
||||
if (!isImmediateCombatChoice(option)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gameState.inBattle) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasBattleMarkers =
|
||||
Boolean(gameState.currentBattleNpcId || gameState.currentNpcBattleMode) ||
|
||||
gameState.sceneHostileNpcs.some((hostileNpc) => hostileNpc.hp > 0);
|
||||
const storyStillShowsBattleChoices = Boolean(
|
||||
currentStory?.options.some(isImmediateCombatChoice),
|
||||
);
|
||||
|
||||
// 中文注释:真实运行态里可能短暂出现“可见层仍在战斗,但逻辑态 inBattle
|
||||
// 已经被提前切回 false”的窗口。如果这时玩家点击了还在面板上的 battle_* /
|
||||
// inventory_use 选项,必须继续走本地逐帧战斗链,不能误分流到服务端直结算。
|
||||
return hasBattleMarkers || storyStillShowsBattleChoices;
|
||||
}
|
||||
|
||||
export function createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
@@ -219,7 +252,10 @@ export function createStoryChoiceActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
||||
if (
|
||||
isRpgRuntimeServerFunctionId(option.functionId) &&
|
||||
!shouldResolveCombatChoiceLocally(gameState, currentStory, option)
|
||||
) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
|
||||
@@ -1284,7 +1284,7 @@ describe('npcEncounterActions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets player exit hostile chat and offers fight or escape instead of continuing adventure', async () => {
|
||||
it('lets player exit hostile chat and offers fight plus scene escape routes instead of continuing adventure', async () => {
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 0,
|
||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||
@@ -1320,19 +1320,43 @@ describe('npcEncounterActions', () => {
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState).toBeUndefined();
|
||||
expect(lastStory.options).toEqual([
|
||||
expect(lastStory.options[0]).toEqual(expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '战斗',
|
||||
interaction: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'fight',
|
||||
}),
|
||||
}));
|
||||
expect(lastStory.options.slice(1)).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_fight',
|
||||
actionText: '战斗',
|
||||
interaction: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'fight',
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往东侧旧街还亮着灯。',
|
||||
runtimePayload: expect.objectContaining({
|
||||
targetSceneId: 'scene-east',
|
||||
escapeTargetSceneId: 'scene-east',
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
actionText: '逃往南侧河滩雾气更重。',
|
||||
runtimePayload: expect.objectContaining({
|
||||
targetSceneId: 'scene-south',
|
||||
escapeTargetSceneId: 'scene-south',
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃回当前场景起点',
|
||||
runtimePayload: expect.objectContaining({
|
||||
targetSceneId: 'scene-bridge',
|
||||
escapeTargetSceneId: 'scene-bridge',
|
||||
escapeReturnToSceneStart: true,
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.options).not.toEqual(
|
||||
@@ -1417,7 +1441,15 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
actionText: '逃往东侧旧街还亮着灯。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往南侧河滩雾气更重。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃回当前场景起点',
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.deferredOptions).toBeUndefined();
|
||||
@@ -1469,6 +1501,15 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往东侧旧街还亮着灯。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往南侧河滩雾气更重。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃回当前场景起点',
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.deferredOptions).toBeUndefined();
|
||||
@@ -1523,6 +1564,15 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往东侧旧街还亮着灯。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃往南侧河滩雾气更重。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃回当前场景起点',
|
||||
}),
|
||||
]);
|
||||
expect(lastStory.deferredRuntimeState).toBeUndefined();
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import { getForwardScenePreset } from '../../data/scenePresets';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
@@ -29,6 +31,95 @@ function buildRuntimeSnapshotRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServerTravelTargetSceneId(params: {
|
||||
previousState: GameState;
|
||||
snapshotState: GameState;
|
||||
}) {
|
||||
const { previousState, snapshotState } = params;
|
||||
const snapshotSceneId = snapshotState.currentScenePreset?.id ?? null;
|
||||
if (
|
||||
snapshotSceneId &&
|
||||
snapshotSceneId !== previousState.currentScenePreset?.id
|
||||
) {
|
||||
return snapshotSceneId;
|
||||
}
|
||||
|
||||
if (!previousState.worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
getForwardScenePreset(
|
||||
previousState.worldType,
|
||||
previousState.currentScenePreset?.id,
|
||||
)?.id ??
|
||||
previousState.currentScenePreset?.forwardSceneId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeServerSceneTravelSnapshot(params: {
|
||||
previousState: GameState;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
functionId: string;
|
||||
}) {
|
||||
const { previousState, hydratedSnapshot, functionId } = params;
|
||||
if (functionId !== 'idle_travel_next_scene' || !previousState.worldType) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const targetSceneId = resolveServerTravelTargetSceneId({
|
||||
previousState,
|
||||
snapshotState: hydratedSnapshot.gameState,
|
||||
});
|
||||
if (!targetSceneId) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const travelResolution = buildMapTravelResolution(previousState, targetSceneId);
|
||||
if (!travelResolution) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
return {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...hydratedSnapshot.gameState,
|
||||
// 中文注释:服务端 compat 当前只保证“本轮旅行动作已经结算完成”,
|
||||
// 前端这里复用既有地图旅行真相,补齐下一幕场景 preset、遭遇预览和任务推进结果。
|
||||
currentScenePreset: travelResolution.nextState.currentScenePreset,
|
||||
currentEncounter: travelResolution.nextState.currentEncounter,
|
||||
npcInteractionActive: travelResolution.nextState.npcInteractionActive,
|
||||
sceneHostileNpcs: travelResolution.nextState.sceneHostileNpcs,
|
||||
playerX: travelResolution.nextState.playerX,
|
||||
playerFacing: travelResolution.nextState.playerFacing,
|
||||
animationState: travelResolution.nextState.animationState,
|
||||
playerActionMode: travelResolution.nextState.playerActionMode,
|
||||
activeCombatEffects: travelResolution.nextState.activeCombatEffects,
|
||||
scrollWorld: travelResolution.nextState.scrollWorld,
|
||||
inBattle: travelResolution.nextState.inBattle,
|
||||
lastObserveSignsSceneId: travelResolution.nextState.lastObserveSignsSceneId,
|
||||
lastObserveSignsReport: travelResolution.nextState.lastObserveSignsReport,
|
||||
currentBattleNpcId: travelResolution.nextState.currentBattleNpcId,
|
||||
currentNpcBattleMode: travelResolution.nextState.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: travelResolution.nextState.currentNpcBattleOutcome,
|
||||
sparReturnEncounter: travelResolution.nextState.sparReturnEncounter,
|
||||
sparPlayerHpBefore: travelResolution.nextState.sparPlayerHpBefore,
|
||||
sparPlayerMaxHpBefore: travelResolution.nextState.sparPlayerMaxHpBefore,
|
||||
sparStoryHistoryBefore: travelResolution.nextState.sparStoryHistoryBefore,
|
||||
runtimeStats: {
|
||||
...hydratedSnapshot.gameState.runtimeStats,
|
||||
scenesTraveled:
|
||||
travelResolution.nextState.runtimeStats.scenesTraveled,
|
||||
},
|
||||
quests:
|
||||
hydratedSnapshot.gameState.quests.length > 0
|
||||
? hydratedSnapshot.gameState.quests
|
||||
: travelResolution.nextState.quests,
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
@@ -111,7 +202,11 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
payload: params.payload,
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
functionId: params.option.functionId,
|
||||
});
|
||||
|
||||
return {
|
||||
response,
|
||||
|
||||
@@ -56,6 +56,106 @@ function createGameState(): GameState {
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createTravelGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 7,
|
||||
worldType: WorldType.WUXIA,
|
||||
currentScene: 'Story',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '站在桥口的人。',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
personality: '谨慎',
|
||||
attributes: {
|
||||
strength: 8,
|
||||
agility: 8,
|
||||
intelligence: 6,
|
||||
spirit: 6,
|
||||
},
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as GameState['playerCharacter'],
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'encounter-current',
|
||||
npcName: '桥头行商',
|
||||
npcDescription: '正准备收摊离开的行商',
|
||||
context: '桥口',
|
||||
hostile: false,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
worldType: WorldType.WUXIA,
|
||||
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-rain-street',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿石板路继续前行',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 38,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 12,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
activeBuildBuffs: [],
|
||||
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,
|
||||
customWorldProfile: null,
|
||||
} as unknown as GameState;
|
||||
}
|
||||
|
||||
function createRuntimeNpcBattleSnapshot(
|
||||
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
|
||||
) {
|
||||
@@ -553,6 +653,97 @@ describe('runtimeStoryCoordinator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
|
||||
const gameState = createTravelGameState();
|
||||
const currentStory = createStory('桥口这一段已经收束。');
|
||||
const option = {
|
||||
functionId: 'idle_travel_next_scene',
|
||||
actionText: '前往相邻场景',
|
||||
text: '前往相邻场景',
|
||||
visuals: {
|
||||
playerAnimation: 'run',
|
||||
playerMoveMeters: 1,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
const serverSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure' as const,
|
||||
currentStory: createStory('你顺着石板路继续前行。'),
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 8,
|
||||
currentScenePreset: {
|
||||
...gameState.currentScenePreset!,
|
||||
id: 'wuxia-rain-street',
|
||||
name: '夜雨长街',
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 38,
|
||||
maxHp: 40,
|
||||
mana: 12,
|
||||
maxMana: 16,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'idle_observe_signs',
|
||||
actionText: '观察周围迹象',
|
||||
scope: 'story',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '前往相邻场景',
|
||||
resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: serverSnapshot,
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(result.hydratedSnapshot.gameState.currentScenePreset?.id).toBe(
|
||||
'wuxia-rain-street',
|
||||
);
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.runtimeStats.scenesTraveled,
|
||||
).toBe(1);
|
||||
expect(
|
||||
Boolean(
|
||||
result.hydratedSnapshot.gameState.currentEncounter ||
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.length > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
|
||||
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
|
||||
runtimeActionVersion: 7,
|
||||
|
||||
@@ -451,4 +451,101 @@ describe('storyChoiceRuntime', () => {
|
||||
);
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
|
||||
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
|
||||
const gameState = createState({
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: ['wuxia-rain-street'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-rain-street',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿石板路继续前行',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-bridge',
|
||||
npcName: '桥头行商',
|
||||
npcDescription: '正准备收摊离开的行商',
|
||||
npcAvatar: '桥',
|
||||
context: '桥口',
|
||||
hostile: false,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 3,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-rain-street',
|
||||
name: '夜雨长街',
|
||||
description: '雨丝压低灯火,街面反着潮光。',
|
||||
imageSrc: '/scene-b.png',
|
||||
connectedSceneIds: ['wuxia-bamboo-road'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-bamboo-road',
|
||||
relativePosition: 'back',
|
||||
summary: '可以沿原路退回竹林古道',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-ferry-bridge',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
runtimeStats: {
|
||||
...gameState.runtimeStats,
|
||||
scenesTraveled: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
nextStory: createStory('服务端故事'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: createStory('当前故事'),
|
||||
option: createOption('idle_travel_next_scene'),
|
||||
character: createCharacter(),
|
||||
setBattleReward: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: 'wuxia-rain-street',
|
||||
}),
|
||||
runtimeStats: expect.objectContaining({
|
||||
scenesTraveled: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '服务端故事',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1103,7 +1103,11 @@ export function createStoryNpcEncounterActions({
|
||||
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
|
||||
};
|
||||
|
||||
const buildHostileNpcEscapeOption = (character: Character): StoryOption => {
|
||||
const buildHostileNpcEscapeOption = (
|
||||
character: Character,
|
||||
actionText = '逃跑',
|
||||
runtimePayload?: StoryOption['runtimePayload'],
|
||||
): StoryOption => {
|
||||
const functionContext = gameState.worldType
|
||||
? {
|
||||
worldType: gameState.worldType,
|
||||
@@ -1125,16 +1129,20 @@ export function createStoryNpcEncounterActions({
|
||||
if (resolvedOption) {
|
||||
return {
|
||||
...resolvedOption,
|
||||
actionText: '逃跑',
|
||||
text: '逃跑',
|
||||
actionText,
|
||||
text: actionText,
|
||||
detailText: '',
|
||||
runtimePayload: {
|
||||
...(resolvedOption.runtimePayload ?? {}),
|
||||
...(runtimePayload ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: '逃跑',
|
||||
text: '逃跑',
|
||||
actionText,
|
||||
text: actionText,
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
@@ -1144,9 +1152,61 @@ export function createStoryNpcEncounterActions({
|
||||
scrollWorld: true,
|
||||
monsterChanges: [],
|
||||
},
|
||||
runtimePayload,
|
||||
};
|
||||
};
|
||||
|
||||
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
|
||||
const currentScene = gameState.currentScenePreset;
|
||||
const worldType = gameState.worldType;
|
||||
const options: StoryOption[] = [];
|
||||
const seenSceneIds = new Set<string>();
|
||||
|
||||
if (worldType && currentScene) {
|
||||
for (const connection of currentScene.connections ?? []) {
|
||||
if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenSceneIds.add(connection.sceneId);
|
||||
const targetScene = getScenePresetById(worldType, connection.sceneId);
|
||||
const targetSceneName =
|
||||
targetScene?.name ??
|
||||
connection.summary?.trim() ??
|
||||
connection.sceneId;
|
||||
|
||||
options.push(
|
||||
buildHostileNpcEscapeOption(
|
||||
character,
|
||||
`逃往${targetSceneName}`,
|
||||
{
|
||||
targetSceneId: connection.sceneId,
|
||||
escapeTargetSceneId: connection.sceneId,
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
options.push(
|
||||
buildHostileNpcEscapeOption(
|
||||
character,
|
||||
'逃回当前场景起点',
|
||||
{
|
||||
targetSceneId: currentScene.id,
|
||||
escapeTargetSceneId: currentScene.id,
|
||||
escapeReturnToSceneStart: true,
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options.length > 0
|
||||
? options
|
||||
: [buildHostileNpcEscapeOption(character)];
|
||||
};
|
||||
|
||||
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
|
||||
functionId: NPC_FIGHT_FUNCTION.id,
|
||||
actionText: '与他对战',
|
||||
@@ -1177,8 +1237,8 @@ export function createStoryNpcEncounterActions({
|
||||
return {
|
||||
text: declarationText,
|
||||
options: [
|
||||
buildHostileNpcEscapeOption(character),
|
||||
buildHostileNpcFightOption(encounter),
|
||||
...buildHostileNpcEscapeOptions(character),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
@@ -1220,7 +1280,7 @@ export function createStoryNpcEncounterActions({
|
||||
actionText: '战斗',
|
||||
text: '战斗',
|
||||
},
|
||||
buildHostileNpcEscapeOption(character),
|
||||
...buildHostileNpcEscapeOptions(character),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user