Files
Genarrative/src/persistence/runtimeSnapshot.ts
高物 8a7bd90458
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 11:30:19 +08:00

348 lines
9.5 KiB
TypeScript

import {
buildInitialNpcState,
createNpcBattleMonster,
} from '../data/npcInteractions';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import type {
Encounter,
GameState,
NpcPersistentState,
SceneHostileNpc,
StoryMoment,
} from '../types';
import { WorldType } from '../types';
import type { BottomTab } from '../types/navigation';
import type {
HydratableGameState,
HydratedGameState,
HydratedSnapshotState,
SnapshotState,
} from './runtimeSnapshotTypes';
function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
return bottomTab === 'character' || bottomTab === 'inventory'
? bottomTab
: 'adventure';
}
function normalizeEquipmentLoadout(
playerEquipment: HydratableGameState['playerEquipment'],
) {
if (!playerEquipment || typeof playerEquipment !== 'object') {
return null;
}
return {
weapon: playerEquipment.weapon ?? null,
armor: playerEquipment.armor ?? null,
relic: playerEquipment.relic ?? null,
} satisfies GameState['playerEquipment'];
}
function createEmptyEquipmentLoadout() {
return {
weapon: null,
armor: null,
relic: null,
} satisfies GameState['playerEquipment'];
}
function resolveHydrationWorldType(worldType: GameState['worldType']) {
const normalizedWorldType =
typeof worldType === 'string' ? worldType.toUpperCase() : worldType;
if (normalizedWorldType === WorldType.WUXIA) {
return WorldType.WUXIA;
}
if (normalizedWorldType === WorldType.XIANXIA) {
return WorldType.XIANXIA;
}
if (normalizedWorldType === WorldType.CUSTOM) {
return WorldType.CUSTOM;
}
return null;
}
function hasRenderableRuntimeNpcBattleFields(hostileNpc: SceneHostileNpc) {
const candidate = hostileNpc as Partial<SceneHostileNpc>;
return Boolean(
candidate.encounter &&
typeof candidate.animation === 'string' &&
typeof candidate.xMeters === 'number' &&
typeof candidate.yOffset === 'number' &&
typeof candidate.facing === 'string' &&
typeof candidate.attackRange === 'number' &&
typeof candidate.speed === 'number',
);
}
function normalizeRuntimeBattleEncounter(
encounter: GameState['currentEncounter'],
): Encounter | null {
if (!encounter || encounter.kind !== 'npc') {
return null;
}
const npcName =
typeof encounter.npcName === 'string' ? encounter.npcName.trim() : '';
if (!npcName) {
return null;
}
return {
...encounter,
kind: 'npc',
npcName,
npcDescription:
typeof encounter.npcDescription === 'string'
? encounter.npcDescription
: '',
npcAvatar:
typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '',
context: typeof encounter.context === 'string' ? encounter.context : '',
hostile: true,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward,
} satisfies Encounter;
}
function resolveRuntimeNpcBattleState(
gameState: Pick<
GameState,
| 'currentBattleNpcId'
| 'currentEncounter'
| 'customWorldProfile'
| 'npcStates'
| 'sceneHostileNpcs'
| 'worldType'
>,
) {
const encounter = normalizeRuntimeBattleEncounter(gameState.currentEncounter);
if (!encounter || gameState.sceneHostileNpcs.length === 0) {
return null;
}
const npcStateKey =
gameState.currentBattleNpcId ?? encounter.id ?? encounter.npcName;
const npcState =
gameState.npcStates[npcStateKey] ??
buildInitialNpcState(
encounter,
resolveHydrationWorldType(gameState.worldType),
gameState as GameState,
);
return {
encounter,
npcState,
};
}
function hydrateRuntimeNpcBattleMonster(params: {
hostileNpc: SceneHostileNpc;
encounter: Encounter;
npcState: NpcPersistentState;
gameState: Pick<GameState, 'customWorldProfile' | 'worldType'>;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) {
const template = createNpcBattleMonster(
params.encounter,
params.npcState,
params.battleMode,
{
worldType: resolveHydrationWorldType(params.gameState.worldType),
customWorldProfile: params.gameState.customWorldProfile,
},
);
const candidate = params.hostileNpc as Partial<SceneHostileNpc>;
const xMeters =
typeof candidate.xMeters === 'number'
? candidate.xMeters
: template.xMeters;
const yOffset =
typeof candidate.yOffset === 'number'
? candidate.yOffset
: template.yOffset;
return {
...template,
id:
typeof candidate.id === 'string' && candidate.id.trim()
? candidate.id
: template.id,
name:
typeof candidate.name === 'string' && candidate.name.trim()
? candidate.name
: template.name,
description:
typeof candidate.description === 'string'
? candidate.description
: template.description,
hp: typeof candidate.hp === 'number' ? candidate.hp : template.hp,
maxHp:
typeof candidate.maxHp === 'number' ? candidate.maxHp : template.maxHp,
animation:
typeof candidate.animation === 'string'
? candidate.animation
: template.animation,
xMeters,
yOffset,
facing:
candidate.facing === 'left' || candidate.facing === 'right'
? candidate.facing
: template.facing,
attackRange:
typeof candidate.attackRange === 'number'
? candidate.attackRange
: template.attackRange,
speed:
typeof candidate.speed === 'number' ? candidate.speed : template.speed,
levelProfile: candidate.levelProfile ?? template.levelProfile,
experienceReward:
typeof candidate.experienceReward === 'number'
? candidate.experienceReward
: template.experienceReward,
encounter: {
...template.encounter,
xMeters,
},
} satisfies SceneHostileNpc;
}
export function hydrateRuntimeNpcBattleGameState(
gameState: HydratedGameState,
): HydratedGameState {
const battleMode = gameState.currentNpcBattleMode;
if (
gameState.inBattle !== true ||
(battleMode !== 'fight' && battleMode !== 'spar') ||
gameState.currentEncounter?.kind !== 'npc' ||
gameState.sceneHostileNpcs.length === 0 ||
gameState.sceneHostileNpcs.every(hasRenderableRuntimeNpcBattleFields)
) {
return gameState;
}
const resolvedState = resolveRuntimeNpcBattleState(gameState);
if (!resolvedState) {
return gameState;
}
return {
...gameState,
sceneHostileNpcs: gameState.sceneHostileNpcs.map((hostileNpc) =>
hasRenderableRuntimeNpcBattleFields(hostileNpc)
? hostileNpc
: hydrateRuntimeNpcBattleMonster({
hostileNpc,
encounter: resolvedState.encounter,
npcState: resolvedState.npcState,
gameState,
battleMode,
}),
),
};
}
export function normalizeSavedStory(story: StoryMoment | null) {
if (!story) {
return null;
}
return {
...story,
streaming: false,
} satisfies StoryMoment;
}
export function normalizeSavedGameState(gameState: GameState) {
const hydratableState = gameState as HydratableGameState;
const resolvedEquipment = normalizeEquipmentLoadout(
hydratableState.playerEquipment,
);
const playerMaxHp = Math.max(1, hydratableState.playerMaxHp);
const playerMaxMana = Math.max(1, hydratableState.playerMaxMana);
return hydrateRuntimeNpcBattleGameState({
...hydratableState,
playerProgression: normalizePlayerProgressionState(
hydratableState.playerProgression ?? null,
),
playerMaxHp,
playerHp: Math.min(hydratableState.playerHp, playerMaxHp),
playerMaxMana,
playerMana: Math.min(hydratableState.playerMana, playerMaxMana),
playerEquipment: resolvedEquipment ?? createEmptyEquipmentLoadout(),
runtimeActionVersion:
typeof hydratableState.runtimeActionVersion === 'number'
? hydratableState.runtimeActionVersion
: 0,
runtimeSessionId:
typeof hydratableState.runtimeSessionId === 'string'
? hydratableState.runtimeSessionId
: null,
} satisfies HydratedGameState);
}
export function hydrateSnapshotState(snapshot: {
gameState: GameState;
currentStory: StoryMoment | null;
bottomTab: string;
}): HydratedSnapshotState {
return {
gameState: normalizeSavedGameState(snapshot.gameState),
currentStory: normalizeSavedStory(snapshot.currentStory),
bottomTab: normalizeBottomTab(snapshot.bottomTab),
};
}
export function isHydratedSnapshotState(
snapshot: SnapshotState,
): snapshot is HydratedSnapshotState {
const { gameState, currentStory, bottomTab } = snapshot;
return Boolean(
(bottomTab === 'adventure' ||
bottomTab === 'character' ||
bottomTab === 'inventory') &&
(!currentStory || currentStory.streaming !== true) &&
typeof gameState.runtimeActionVersion === 'number' &&
(gameState.runtimeSessionId === null ||
typeof gameState.runtimeSessionId === 'string') &&
(!gameState.playerCharacter ||
Boolean(
gameState.playerEquipment &&
typeof gameState.playerEquipment === 'object',
)),
);
}
export function rehydrateSavedSnapshot<T extends HydratedSnapshotState>(
snapshot: T,
): T {
const hydratedGameState = hydrateRuntimeNpcBattleGameState(
snapshot.gameState,
);
if (hydratedGameState === snapshot.gameState) {
return snapshot;
}
return {
...snapshot,
gameState: hydratedGameState,
};
}
export function resolveHydratedSnapshotState(snapshot: SnapshotState) {
return isHydratedSnapshotState(snapshot)
? rehydrateSavedSnapshot(snapshot)
: hydrateSnapshotState(snapshot);
}