import { createNpcBattleMonster } from '../../data/npcInteractions'; import { buildNpcBattleFormationFromEncounter, RESOLVED_ENTITY_X_METERS, } from '../../data/sceneEncounterPreviews'; import { getForwardScenePreset } from '../../data/scenePresets'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { getRpgRuntimeClientVersion, getRpgRuntimeSessionId, getRpgRuntimeStoryState, resolveRpgRuntimeStoryAction, resolveRpgRuntimeStoryMoment, type RuntimeStoryChoicePayload, type RuntimeStoryResponse, type RuntimeStorySnapshotRequest, } from '../../services/rpg-runtime/rpgRuntimeStoryClient'; import type { GameState, SceneHostileNpc, StoryMoment, StoryOption } from '../../types'; import { buildMapTravelResolution } from './storyGenerationState'; function isNpcBattleAlignmentDebugEnabled() { if (typeof window === 'undefined') { return false; } return ( window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' || window.location.search.includes('npcBattleAlignmentDebug=1') ); } function logNpcBattleAlignment(label: string, monsters: GameState['sceneHostileNpcs']) { if (!isNpcBattleAlignmentDebugEnabled()) { return; } console.info( `[npc-battle-alignment] ${label}`, monsters.map((monster) => ({ id: monster.id, encounterId: monster.encounter?.id ?? null, encounterName: monster.encounter?.npcName ?? null, xMeters: monster.xMeters, yOffset: monster.yOffset, facing: monster.facing, animation: monster.animation, })), ); } function cloneBattleFormation(monsters: GameState['sceneHostileNpcs']) { return monsters.map( (monster) => ({ ...monster, encounter: monster.encounter ? { ...monster.encounter, } : monster.encounter, }) satisfies SceneHostileNpc, ); } function alignBattleFormationToVisibleFormation(params: { visibleFormation: GameState['sceneHostileNpcs']; battleFormation: GameState['sceneHostileNpcs']; }) { const { visibleFormation, battleFormation } = params; if (visibleFormation.length === 0 || battleFormation.length === 0) { return battleFormation; } const visibleFormationByEncounterId = new Map( visibleFormation.map((monster) => [ monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id, monster, ]), ); return battleFormation.map((monster) => { const encounterKey = monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id; const visibleMonster = visibleFormationByEncounterId.get(encounterKey); if (!visibleMonster) { return monster; } return { ...monster, xMeters: visibleMonster.xMeters, yOffset: visibleMonster.yOffset, facing: visibleMonster.facing, encounter: monster.encounter ? { ...monster.encounter, xMeters: visibleMonster.encounter?.xMeters ?? visibleMonster.xMeters, } : monster.encounter, } satisfies SceneHostileNpc; }); } function getRuntimeResponseOptions(response: RuntimeStoryResponse) { return response.viewModel.availableOptions.length > 0 ? response.viewModel.availableOptions : response.presentation.options; } function buildRuntimeSnapshotRequest( gameState: GameState, currentStory: StoryMoment | null, ): RuntimeStorySnapshotRequest { return { gameState, bottomTab: 'adventure', currentStory, }; } 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; } function bridgeServerNpcBattleSnapshot(params: { previousState: GameState; hydratedSnapshot: HydratedSavedGameSnapshot; functionId: string; }) { const { previousState, hydratedSnapshot, functionId } = params; if (functionId !== 'npc_fight' && functionId !== 'npc_spar') { return hydratedSnapshot; } const snapshotState = hydratedSnapshot.gameState; const isNpcBattleActive = snapshotState.inBattle && Boolean(snapshotState.currentBattleNpcId) && Boolean(snapshotState.currentNpcBattleMode); const hasResolvedBattleMonster = snapshotState.sceneHostileNpcs.length > 0; const sourceEncounter = previousState.currentEncounter?.kind === 'npc' ? previousState.currentEncounter : null; // 中文注释:作品测试/幕预览里最容易出现的错位,是服务端已经把 // currentBattleNpcId / currentNpcBattleMode 切进战斗,但快照里没有把 // sceneHostileNpcs 一起带回。这样前端本地 battlePlan 会直接判定 // “场上没有敌人”,点击 battle_* 后立刻把整场战斗收掉。 // 这里统一在网关层补齐 NPC 战场快照,保证后续本地逐轮回合一定有敌方单位可结算。 if (!isNpcBattleActive || !sourceEncounter) { return hydratedSnapshot; } const fallbackNpcState = snapshotState.npcStates[ snapshotState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName ] ?? previousState.npcStates[ previousState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName ] ?? { affinity: sourceEncounter.initialAffinity ?? (sourceEncounter.hostile ? -10 : 0), helpUsed: false, chattedCount: 0, giftsGiven: 0, inventory: [], recruited: false, }; const battleMode = snapshotState.currentNpcBattleMode === 'spar' ? 'spar' : 'fight'; const fallbackFormationFromSceneAct = buildNpcBattleFormationFromEncounter({ state: previousState, encounter: { ...sourceEncounter, xMeters: sourceEncounter.xMeters ?? RESOLVED_ENTITY_X_METERS, }, mode: battleMode, }); const fallbackFormation = previousState.sceneHostileNpcs.length > 0 ? cloneBattleFormation(previousState.sceneHostileNpcs) : fallbackFormationFromSceneAct.length > 0 ? fallbackFormationFromSceneAct : [ createNpcBattleMonster( sourceEncounter, fallbackNpcState, battleMode, { worldType: snapshotState.worldType, customWorldProfile: snapshotState.customWorldProfile, }, ), ]; const resolvedBattleFormation = hasResolvedBattleMonster ? alignBattleFormationToVisibleFormation({ visibleFormation: previousState.sceneHostileNpcs, battleFormation: snapshotState.sceneHostileNpcs, }) : fallbackFormation; logNpcBattleAlignment('previous-visible-formation', previousState.sceneHostileNpcs); logNpcBattleAlignment('server-battle-formation', snapshotState.sceneHostileNpcs); logNpcBattleAlignment('resolved-battle-formation', resolvedBattleFormation); return { ...hydratedSnapshot, gameState: { ...snapshotState, // 中文注释:优先沿用进入战斗前已经可见的阵容与站位; // 若上一帧还没有 battle combatants,则从幕预览/当前遭遇恢复完整 NPC 编队, // 避免只补出一个前排角色,造成后排消失和敌方位置突变。 sceneHostileNpcs: resolvedBattleFormation, currentEncounter: null, npcInteractionActive: false, // 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter, // 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。 sparReturnEncounter: snapshotState.sparReturnEncounter ?? (previousState.currentEncounter?.kind === 'npc' ? previousState.currentEncounter : null), }, } satisfies HydratedSavedGameSnapshot; } /** * 前端访问服务端 runtime story 的统一网关。 * 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。 */ export async function loadServerRuntimeOptionCatalog(params: { gameState: GameState; currentStory: StoryMoment | null; }) { const response = await getRpgRuntimeStoryState({ sessionId: getRpgRuntimeSessionId(params.gameState), clientVersion: getRpgRuntimeClientVersion(params.gameState), snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory), }); const options = resolveRpgRuntimeStoryMoment({ response, hydratedSnapshot: response.snapshot, fallbackGameState: params.gameState, fallbackStoryText: response.presentation.storyText, }).options; return options.length > 0 ? options : null; } export async function resumeServerRuntimeStory( snapshot: HydratedSavedGameSnapshot, ) { const hydratedSnapshot = rehydrateSavedSnapshot(snapshot); const shouldRefreshFromServer = hydratedSnapshot.gameState.currentScene === 'Story' && Boolean(hydratedSnapshot.gameState.worldType) && Boolean(hydratedSnapshot.gameState.playerCharacter); if (!shouldRefreshFromServer) { return { hydratedSnapshot, nextStory: hydratedSnapshot.currentStory, }; } const response = await getRpgRuntimeStoryState({ sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState), }); const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot); const runtimeOptions = getRuntimeResponseOptions(response); const nextStory = response.presentation.storyText || runtimeOptions.length > 0 ? resolveRpgRuntimeStoryMoment({ response, hydratedSnapshot: resumedSnapshot, fallbackGameState: hydratedSnapshot.gameState, fallbackStoryText: response.presentation.storyText || resumedSnapshot.currentStory?.text || hydratedSnapshot.currentStory?.text || '', }) : resumedSnapshot.currentStory; return { hydratedSnapshot: resumedSnapshot, nextStory, }; } export async function resolveServerRuntimeChoice(params: { gameState: GameState; currentStory: StoryMoment | null; option: Pick & Partial>; payload?: RuntimeStoryChoicePayload; }) { const response = await resolveRpgRuntimeStoryAction({ sessionId: getRpgRuntimeSessionId(params.gameState), clientVersion: getRpgRuntimeClientVersion(params.gameState), option: params.option, targetId: params.option.interaction?.kind === 'npc' ? params.option.interaction.npcId : undefined, payload: params.payload, snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory), }); const hydratedSnapshot = bridgeServerSceneTravelSnapshot({ previousState: params.gameState, hydratedSnapshot: bridgeServerNpcBattleSnapshot({ previousState: params.gameState, hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot), functionId: params.option.functionId, }), functionId: params.option.functionId, }); return { response, hydratedSnapshot, nextStory: resolveRpgRuntimeStoryMoment({ response, hydratedSnapshot, fallbackGameState: params.gameState, fallbackStoryText: response.presentation.storyText || hydratedSnapshot.currentStory?.text || params.option.actionText, }), }; } export type LoadRpgRuntimeOptionCatalogParams = Parameters< typeof loadServerRuntimeOptionCatalog >[0]; export type ResolveRpgRuntimeChoiceParams = Parameters< typeof resolveServerRuntimeChoice >[0]; export const loadRpgRuntimeOptionCatalog = loadServerRuntimeOptionCatalog; export const resumeRpgRuntimeStory = resumeServerRuntimeStory; export const resolveRpgRuntimeChoice = resolveServerRuntimeChoice;