snapkitty
hardware
erlang
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);
  }
}