432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
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;
|