Files
Genarrative/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts
高物 a9febe7678
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-28 10:57:40 +08:00

432 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<StoryOption, 'functionId' | 'actionText'> &
Partial<Pick<StoryOption, 'interaction'>>;
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;