348 lines
9.5 KiB
TypeScript
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);
|
|
}
|