Files
Genarrative/server-node/src/modules/runtime/runtimeSnapshotHydration.ts
2026-04-10 15:37:02 +08:00

644 lines
19 KiB
TypeScript

import { jsonClone } from '../../http.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
import { normalizeQuestEntries } from '../quest/questProgressionService.js';
import {
createEmptyEquipmentLoadout,
getEquipmentBonuses,
getEquipmentSlotLabel,
} from './runtimeEquipmentModule.js';
import { normalizeNpcPersistentState } from './runtimeNpcStatePrimitives.js';
type JsonRecord = Record<string, unknown>;
type SnapshotShape = {
savedAt: string;
bottomTab: unknown;
gameState: unknown;
currentStory: unknown;
};
const STORY_ENGINE_MIGRATION_VERSION = 'story-engine-v5';
const STORY_ENGINE_REQUIRED_TRANSFORMS = [
'ensure_story_engine_memory',
'ensure_campaign_state',
'ensure_player_style_profile',
];
const UNIVERSAL_MAX_MANA = 999;
const EQUIPMENT_SLOTS = ['weapon', 'armor', 'relic'] as const;
type RuntimeEquipmentSlotId = (typeof EQUIPMENT_SLOTS)[number];
type LegacyCharacterEquipmentItem = {
slot: string;
item: string;
rarity?: string;
};
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readBoolean(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function readArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function clampNonNegativeInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.floor(value));
}
function normalizeBottomTab(value: unknown) {
return value === 'character' || value === 'inventory'
? value
: 'adventure';
}
function buildSaveMigrationManifest() {
return {
version: 'story-engine-v5',
requiredTransforms: [
'ensure_story_engine_memory',
'ensure_campaign_state',
'ensure_player_style_profile',
],
backwardCompatible: true,
};
}
function createEmptyStoryEngineMemoryState() {
return {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
campaignState: null,
actState: null,
consequenceLedger: [],
companionResolutions: [],
endingState: null,
authorialConstraintPack: null,
branchBudgetStatus: null,
narrativeQaReport: null,
narrativeCodex: [],
playerStyleProfile: null,
simulationRunResults: [],
releaseGateReport: null,
saveMigrationManifest: {
version: STORY_ENGINE_MIGRATION_VERSION,
requiredTransforms: STORY_ENGINE_REQUIRED_TRANSFORMS,
backwardCompatible: true,
},
};
}
function normalizeRuntimeStats(
stats: unknown,
options: {
isActiveRun?: boolean;
now?: number;
} = {},
) {
const now = options.now ?? Date.now();
const rawStats = isRecord(stats) ? stats : {};
return {
playTimeMs:
typeof rawStats.playTimeMs === 'number' &&
Number.isFinite(rawStats.playTimeMs)
? Math.max(0, rawStats.playTimeMs)
: 0,
lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null,
hostileNpcsDefeated: clampNonNegativeInteger(
rawStats.hostileNpcsDefeated,
),
questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted),
itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed),
scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled),
};
}
function normalizeCharacterChats(value: unknown) {
return Object.fromEntries(
Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => {
const rawRecord = isRecord(record) ? record : {};
return [
characterId,
{
history: readArray(rawRecord.history)
.filter(
(turn) =>
isRecord(turn) &&
typeof turn.text === 'string' &&
(turn.speaker === 'player' || turn.speaker === 'character'),
)
.map((turn) => ({
speaker: turn.speaker,
text: turn.text,
})),
summary: readString(rawRecord.summary),
updatedAt: readString(rawRecord.updatedAt) || null,
},
];
}),
);
}
function normalizeCompanionState(value: unknown) {
if (!isRecord(value)) {
return null;
}
const npcId = readString(value.npcId);
if (!npcId) {
return null;
}
return {
...jsonClone(value),
npcId,
characterId: readString(value.characterId),
joinedAtAffinity: Math.round(readNumber(value.joinedAtAffinity, 0)),
};
}
function dedupeCompanions(value: unknown) {
const seenNpcIds = new Set<string>();
return readArray(value)
.map((entry) => normalizeCompanionState(entry))
.filter((entry): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
if (!entry || seenNpcIds.has(entry.npcId)) {
return false;
}
seenNpcIds.add(entry.npcId);
return true;
});
}
function normalizeRoster(
roster: ReturnType<typeof dedupeCompanions>,
companions: ReturnType<typeof dedupeCompanions>,
) {
const activeNpcIds = new Set(companions.map((companion) => companion.npcId));
return roster.filter((companion) => !activeNpcIds.has(companion.npcId));
}
function normalizeNpcStates(value: unknown) {
return Object.fromEntries(
Object.entries(isRecord(value) ? value : {}).map(([npcId, npcState]) => {
const rawState = isRecord(npcState) ? npcState : {};
return [
npcId,
normalizeNpcPersistentState({
...jsonClone(rawState),
affinity: Math.round(readNumber(rawState.affinity, 0)),
chattedCount: Math.max(
0,
Math.round(readNumber(rawState.chattedCount, 0)),
),
helpUsed: readBoolean(rawState.helpUsed),
giftsGiven: Math.max(
0,
Math.round(readNumber(rawState.giftsGiven, 0)),
),
inventory: jsonClone(readArray(rawState.inventory)),
recruited: readBoolean(rawState.recruited),
}),
];
}),
);
}
function resolveInitialPlayerCurrency(gameState: JsonRecord) {
const customWorldProfile = isRecord(gameState.customWorldProfile)
? gameState.customWorldProfile
: null;
const customWorldInitialCurrency = readNumber(
(customWorldProfile?.ownedSettingLayers as JsonRecord | undefined)
?.ruleProfile &&
isRecord(
(customWorldProfile.ownedSettingLayers as JsonRecord).ruleProfile,
) &&
isRecord(
(
(customWorldProfile.ownedSettingLayers as JsonRecord)
.ruleProfile as JsonRecord
).economyProfile,
)
? (
(
(
customWorldProfile.ownedSettingLayers as JsonRecord
).ruleProfile as JsonRecord
).economyProfile as JsonRecord
).initialCurrency
: undefined,
Number.NaN,
);
if (Number.isFinite(customWorldInitialCurrency)) {
return Math.max(0, Math.round(customWorldInitialCurrency));
}
return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160;
}
function normalizeEquipmentLoadout(value: unknown) {
if (!isRecord(value)) {
return null;
}
return {
weapon: isRecord(value.weapon) ? jsonClone(value.weapon) : null,
armor: isRecord(value.armor) ? jsonClone(value.armor) : null,
relic: isRecord(value.relic) ? jsonClone(value.relic) : null,
};
}
function normalizePresetRarity(rarityText: string | undefined) {
if (!rarityText) return 'common' as const;
if (/|legendary/iu.test(rarityText)) return 'legendary' as const;
if (/|epic/iu.test(rarityText)) return 'epic' as const;
if (/|rare/iu.test(rarityText)) return 'rare' as const;
if (/|uncommon/iu.test(rarityText)) return 'uncommon' as const;
return 'common' as const;
}
function inferEquipmentSlot(value: string) {
if (/||||||||/u.test(value)) {
return 'weapon' as const;
}
if (/|||||/u.test(value)) {
return 'armor' as const;
}
if (/|||||||||/u.test(value)) {
return 'relic' as const;
}
return null;
}
function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) {
const tags = new Set<string>([slot]);
if (/|||||/u.test(name)) tags.add('mana');
if (/|||/u.test(name)) tags.add('armor');
if (/||||/u.test(name)) tags.add('weapon');
if (/|||||/u.test(name)) tags.add('relic');
if (/||/u.test(name)) tags.add('healing');
return [...tags];
}
function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] {
const equipmentById: Record<string, LegacyCharacterEquipmentItem[]> = {
'sword-princess': [
{ slot: '武器', item: '王庭剑', rarity: '稀有' },
{ slot: '护甲', item: '王庭轻甲', rarity: '稀有' },
{ slot: '饰品', item: '皇室徽章', rarity: '史诗' },
],
'archer-hero': [
{ slot: '武器', item: '流风弓', rarity: '稀有' },
{ slot: '护甲', item: '风行者皮甲', rarity: '稀有' },
{ slot: '饰品', item: '鹰眼石', rarity: '史诗' },
],
'girl-hero': [
{ slot: '武器', item: '双影刃', rarity: '稀有' },
{ slot: '护甲', item: '疾影轻甲', rarity: '稀有' },
{ slot: '饰品', item: '敏捷徽章', rarity: '史诗' },
],
'punch-hero': [
{ slot: '武器', item: '破军拳套', rarity: '稀有' },
{ slot: '护甲', item: '刚岩护甲', rarity: '稀有' },
{ slot: '饰品', item: '力量护符', rarity: '史诗' },
],
'fighter-4': [
{ slot: '武器', item: '玄甲战刃', rarity: '稀有' },
{ slot: '护甲', item: '玄铁甲', rarity: '稀有' },
{ slot: '饰品', item: '守护徽章', rarity: '史诗' },
],
};
const characterId = readString(character.id);
if (equipmentById[characterId]) {
return equipmentById[characterId];
}
const characterName = readString(character.name, '旅人');
return EQUIPMENT_SLOTS.map((slot) => ({
slot: getEquipmentSlotLabel(slot),
item: {
weapon: `${characterName}的主手器`,
armor: `${characterName}的护身装`,
relic: `${characterName}的随身符`,
}[slot],
rarity: '普通',
}));
}
function buildLegacyStarterEquipmentLoadout(character: JsonRecord) {
const characterId = readString(character.id, 'unknown');
const loadout = createEmptyEquipmentLoadout();
const starterEquipment = getLegacyCharacterEquipment(character);
starterEquipment.forEach((equipmentItem, index) => {
const slot =
inferEquipmentSlot(`${equipmentItem.slot} ${equipmentItem.item}`) ??
EQUIPMENT_SLOTS[index] ??
null;
if (!slot || loadout[slot]) {
return;
}
loadout[slot] = {
id: `starter:${characterId}:${slot}`,
category: getEquipmentSlotLabel(slot),
name: equipmentItem.item,
quantity: 1,
rarity: normalizePresetRarity(equipmentItem.rarity),
tags: inferEquipmentTags(slot, equipmentItem.item),
equipmentSlotId: slot,
};
});
return loadout;
}
function hasEquippedItems(
equipment: ReturnType<typeof normalizeEquipmentLoadout>,
) {
return Boolean(equipment?.weapon || equipment?.armor || equipment?.relic);
}
function readCharacterAttributes(character: JsonRecord) {
return isRecord(character.attributes) ? character.attributes : {};
}
function getLegacyCharacterBaseMaxHp(character: JsonRecord) {
const attributes = readCharacterAttributes(character);
return Math.max(
120,
90 +
readNumber(attributes.strength, 0) * 10 +
readNumber(attributes.spirit, 0) * 4,
);
}
function buildCharacterResourceProfile(character: JsonRecord) {
const resourceProfile = isRecord(character.resourceProfile)
? character.resourceProfile
: null;
if (
resourceProfile &&
Number.isFinite(resourceProfile.maxHp) &&
Number.isFinite(resourceProfile.maxMana)
) {
return {
maxHp: Math.max(1, Math.round(readNumber(resourceProfile.maxHp, 1))),
maxMana: Math.max(
1,
Math.round(readNumber(resourceProfile.maxMana, UNIVERSAL_MAX_MANA)),
),
};
}
const source = [
readString(character.title),
readString(character.description),
readString(character.personality),
...readArray(character.combatTags).filter(
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
),
].join(' ');
const skills = readArray(character.skills);
const baseHp = /|||||/u.test(source)
? 210
: /|||/u.test(source)
? 168
: /||||/u.test(source)
? 176
: 188;
return {
maxHp: Math.max(
getLegacyCharacterBaseMaxHp(character),
baseHp + Math.min(18, skills.length * 4),
),
maxMana: UNIVERSAL_MAX_MANA,
};
}
function normalizeSavedStory(currentStory: unknown) {
if (!isRecord(currentStory)) {
return null;
}
return {
...jsonClone(currentStory),
streaming: false,
};
}
function normalizeGameState(gameState: unknown) {
const rawState = isRecord(gameState) ? jsonClone(gameState) : {};
const { playerEquipment: _rawPlayerEquipment, ...rawStateWithoutEquipment } =
rawState;
const playerCharacter = isRecord(rawState.playerCharacter)
? rawState.playerCharacter
: null;
const companions = dedupeCompanions(rawState.companions);
const roster = normalizeRoster(dedupeCompanions(rawState.roster), companions);
const storyEngineMemory = {
...createEmptyStoryEngineMemoryState(),
...(isRecord(rawState.storyEngineMemory)
? jsonClone(rawState.storyEngineMemory)
: {}),
saveMigrationManifest: buildSaveMigrationManifest(),
};
const savedPlayerMaxHp = Math.max(
1,
Math.round(readNumber(rawState.playerMaxHp, 1)),
);
const savedPlayerMaxMana = Math.max(
1,
Math.round(readNumber(rawState.playerMaxMana, 1)),
);
const resolvedEquipment =
normalizeEquipmentLoadout(rawState.playerEquipment) ??
(playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null);
const baseResourceProfile = playerCharacter
? buildCharacterResourceProfile(playerCharacter)
: null;
const basePlayerMaxHp = baseResourceProfile
? hasEquippedItems(resolvedEquipment)
? baseResourceProfile.maxHp
: Math.max(baseResourceProfile.maxHp, savedPlayerMaxHp)
: savedPlayerMaxHp;
const basePlayerMaxMana = baseResourceProfile
? hasEquippedItems(resolvedEquipment)
? baseResourceProfile.maxMana
: Math.max(baseResourceProfile.maxMana, savedPlayerMaxMana)
: savedPlayerMaxMana;
const normalizedCommonState = {
...rawStateWithoutEquipment,
customWorldProfile:
isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null
? rawState.customWorldProfile ?? null
: null,
runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, {
isActiveRun: Boolean(
rawState.playerCharacter && rawState.currentScene === 'Story',
),
}),
storyEngineMemory,
chapterState:
rawState.chapterState ??
(isRecord(storyEngineMemory.currentChapter)
? storyEngineMemory.currentChapter
: null),
campaignState:
rawState.campaignState ??
(isRecord(storyEngineMemory.campaignState)
? storyEngineMemory.campaignState
: storyEngineMemory.campaignState ?? null),
activeScenarioPackId:
readString(rawState.activeScenarioPackId) ||
readString(
(rawState.customWorldProfile as JsonRecord | null)?.scenarioPackId,
) ||
null,
activeCampaignPackId:
readString(rawState.activeCampaignPackId) ||
readString(
(rawState.customWorldProfile as JsonRecord | null)?.campaignPackId,
) ||
null,
npcInteractionActive: readBoolean(rawState.npcInteractionActive),
playerCurrency:
typeof rawState.playerCurrency === 'number' &&
Number.isFinite(rawState.playerCurrency)
? Math.round(rawState.playerCurrency)
: resolveInitialPlayerCurrency(rawState),
quests: normalizeQuestEntries(
jsonClone(readArray(rawState.quests)) as Parameters<
typeof normalizeQuestEntries
>[0],
),
roster,
companions,
npcStates: normalizeNpcStates(rawState.npcStates),
characterChats: normalizeCharacterChats(rawState.characterChats),
activeBuildBuffs: jsonClone(readArray(rawState.activeBuildBuffs)),
runtimeSessionId: readString(rawState.runtimeSessionId) || null,
runtimeActionVersion:
typeof rawState.runtimeActionVersion === 'number' &&
Number.isFinite(rawState.runtimeActionVersion)
? Math.round(rawState.runtimeActionVersion)
: 0,
};
if (!playerCharacter) {
return {
...normalizedCommonState,
playerEquipment: createEmptyEquipmentLoadout(),
playerMaxHp: savedPlayerMaxHp,
playerHp: Math.max(
0,
Math.min(
savedPlayerMaxHp,
Math.round(readNumber(rawState.playerHp, savedPlayerMaxHp)),
),
),
playerMaxMana: savedPlayerMaxMana,
playerMana: Math.max(
0,
Math.min(
savedPlayerMaxMana,
Math.round(readNumber(rawState.playerMana, savedPlayerMaxMana)),
),
),
};
}
const stateWithResourceCaps = {
...normalizedCommonState,
playerCharacter,
playerMaxHp: basePlayerMaxHp,
playerHp: Math.max(
0,
Math.round(readNumber(rawState.playerHp, basePlayerMaxHp)),
),
playerMaxMana: basePlayerMaxMana,
playerMana: Math.max(
0,
Math.round(readNumber(rawState.playerMana, basePlayerMaxMana)),
),
};
if (!resolvedEquipment) {
return stateWithResourceCaps;
}
const equipmentBonuses = getEquipmentBonuses(resolvedEquipment);
const nextPlayerMaxHp = basePlayerMaxHp + equipmentBonuses.maxHpBonus;
const nextPlayerMaxMana = basePlayerMaxMana + equipmentBonuses.maxManaBonus;
return {
...stateWithResourceCaps,
playerEquipment: resolvedEquipment,
playerMaxHp: nextPlayerMaxHp,
playerHp: Math.min(nextPlayerMaxHp, stateWithResourceCaps.playerHp),
playerMaxMana: nextPlayerMaxMana,
playerMana: Math.min(nextPlayerMaxMana, stateWithResourceCaps.playerMana),
};
}
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(snapshot: T) {
return {
...snapshot,
bottomTab: normalizeBottomTab(snapshot.bottomTab),
gameState: normalizeGameState(snapshot.gameState),
currentStory: normalizeSavedStory(snapshot.currentStory),
};
}
export function hydrateSavedSnapshot(
snapshot: SavedSnapshot | null,
): SavedSnapshot | null {
if (!snapshot) {
return null;
}
return normalizeSavedSnapshotPayload(snapshot);
}