File size: 7,584 Bytes
b8a03fa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | import { checksum, deepClone, stableStringify } from './deterministic.js';
import type { EntityId, SaveEnvelope, UniverseState } from './types.js';
export class PersistenceSystem {
constructor(private state: UniverseState) {}
replaceState(state: UniverseState): void {
this.state = state;
}
createEnvelope(): SaveEnvelope {
this.validate(this.state);
const snapshot = deepClone(this.state);
return {
format: 'snapkitty-universe-save',
schemaVersion: 1,
createdAtIso: snapshot.worldTimeIso,
checksum: checksum(snapshot),
state: snapshot,
};
}
serialize(): string {
return stableStringify(this.createEnvelope());
}
deserialize(serialized: string | SaveEnvelope | UniverseState): UniverseState {
const parsed: unknown = typeof serialized === 'string' ? JSON.parse(serialized) : serialized;
if (this.isUniverseState(parsed)) {
const state = deepClone(parsed);
this.validate(state);
return state;
}
if (!this.isSaveEnvelope(parsed)) throw new Error('Unsupported or malformed SnapKitty save envelope.');
if (checksum(parsed.state) !== parsed.checksum) throw new Error('Save checksum mismatch; state was altered or corrupted.');
const state = deepClone(parsed.state);
this.validate(state);
return state;
}
validate(state: UniverseState): void {
if (state.schemaVersion !== 1) throw new Error(`Unsupported universe schema version '${String(state.schemaVersion)}'.`);
if (!Number.isInteger(state.tick) || state.tick < 0) throw new Error('Universe tick must be a non-negative integer.');
if (!Number.isFinite(state.elapsedMs) || state.elapsedMs < 0 || !Number.isFinite(state.accumulatorMs) || state.accumulatorMs < 0) throw new Error('Universe time state is invalid.');
if (Number.isNaN(Date.parse(state.worldTimeIso))) throw new Error('Universe worldTimeIso is invalid.');
if (state.rooms.length < 20) throw new Error('A playable station requires at least twenty rooms.');
if (state.npcs.length < 50) throw new Error('Persistent population requires at least fifty NPCs.');
if (state.agents.length < 6) throw new Error('At least six sovereign agents are required.');
if (state.missions.length < 10) throw new Error('At least ten state-driven missions are required.');
this.assertUnique(state.rooms, 'room');
this.assertUnique(state.doors, 'door');
this.assertUnique(state.airlocks, 'airlock');
this.assertUnique(state.ships, 'ship');
this.assertUnique(state.npcs, 'NPC');
this.assertUnique(state.agents, 'agent');
this.assertUnique(state.missions, 'mission');
this.assertUnique(state.factions, 'faction');
this.assertUnique(state.starSystems, 'star system');
this.assertUnique(state.jumpConnections, 'jump connection');
this.assertUnique(state.settlements, 'settlement');
this.assertUnique(state.lifeforms, 'lifeform');
this.assertFiniteNumbers(state);
const roomIds = new Set(state.rooms.map((room) => room.id));
const doorIds = new Set(state.doors.map((door) => door.id));
const factionIds = new Set(state.factions.map((faction) => faction.id));
const bodyIds = new Set(state.celestialBodies.map((body) => body.id));
const systemIds = new Set(state.starSystems.map((system) => system.id));
for (const door of state.doors) {
if (!roomIds.has(door.fromRoomId) || (door.toRoomId !== null && !roomIds.has(door.toRoomId))) throw new Error(`Door '${door.id}' references a missing room.`);
}
for (const airlock of state.airlocks) {
if (!roomIds.has(airlock.chamberRoomId) || !doorIds.has(airlock.innerDoorId) || !doorIds.has(airlock.outerDoorId)) throw new Error(`Airlock '${airlock.id}' has an invalid room or door reference.`);
const inner = state.doors.find((door) => door.id === airlock.innerDoorId);
const outer = state.doors.find((door) => door.id === airlock.outerDoorId);
if (inner?.position === 'open' && outer?.position === 'open') throw new Error(`Airlock '${airlock.id}' violates the door interlock.`);
if (airlock.pressureKpa < 0 || airlock.pressureKpa > 110) throw new Error(`Airlock '${airlock.id}' pressure is outside supported limits.`);
}
for (const npc of state.npcs) {
if (!roomIds.has(npc.homeLocationId) || !roomIds.has(npc.workLocationId) || !roomIds.has(npc.currentLocationId)) throw new Error(`NPC '${npc.id}' references a missing station location.`);
if (!factionIds.has(npc.factionId)) throw new Error(`NPC '${npc.id}' references a missing faction.`);
if (npc.schedule.length === 0 || npc.goals.length === 0) throw new Error(`NPC '${npc.id}' is missing schedule or goal state.`);
}
for (const agent of state.agents) {
if (agent.permissions.length === 0 || agent.authorityScope.length === 0 || !agent.deterministicFallback) throw new Error(`Agent '${agent.id}' lacks bounded authority or fallback behavior.`);
}
for (const system of state.starSystems) {
if (!bodyIds.has(system.starId) || system.bodyIds.some((id) => !bodyIds.has(id))) throw new Error(`Star system '${system.id}' references a missing celestial body.`);
}
for (const connection of state.jumpConnections) {
if (!systemIds.has(connection.fromSystemId) || !systemIds.has(connection.toSystemId)) throw new Error(`Jump connection '${connection.id}' references a missing star system.`);
}
for (const settlement of state.settlements) {
if (!systemIds.has(settlement.systemId) || !factionIds.has(settlement.factionId)) throw new Error(`Settlement '${settlement.id}' references a missing system or faction.`);
}
if (!roomIds.has(state.player.locationId) && !state.ships.some((ship) => ship.id === state.player.locationId) && !state.hullSections.some((hull) => hull.id === state.player.locationId) && !state.planetaryRegions.some((region) => region.id === state.player.locationId) && !state.settlements.some((settlement) => settlement.id === state.player.locationId)) {
throw new Error(`Player location '${state.player.locationId}' does not exist.`);
}
stableStringify(state);
}
private assertUnique(items: ReadonlyArray<{ id: EntityId }>, kind: string): void {
const ids = new Set<EntityId>();
for (const item of items) {
if (!item.id || ids.has(item.id)) throw new Error(`Duplicate or empty ${kind} id '${item.id}'.`);
ids.add(item.id);
}
}
private assertFiniteNumbers(value: unknown, path = 'state', seen = new WeakSet<object>()): void {
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new Error(`Non-finite number at ${path}.`);
return;
}
if (!value || typeof value !== 'object') return;
if (seen.has(value)) throw new Error(`Cyclic data at ${path}.`);
seen.add(value);
if (Array.isArray(value)) {
value.forEach((entry, index) => this.assertFiniteNumbers(entry, `${path}[${index}]`, seen));
} else {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) this.assertFiniteNumbers(entry, `${path}.${key}`, seen);
}
}
private isUniverseState(value: unknown): value is UniverseState {
return Boolean(value && typeof value === 'object' && (value as { schemaVersion?: unknown }).schemaVersion === 1 && 'player' in value && 'rooms' in value);
}
private isSaveEnvelope(value: unknown): value is SaveEnvelope {
return Boolean(value && typeof value === 'object' && (value as { format?: unknown }).format === 'snapkitty-universe-save' && 'checksum' in value && 'state' in value);
}
}
|