This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -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();
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '服务端故事',
}),
);
});
});

View File

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