@its-not-rocket-science/ananke 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,379 @@
1
+ /**
2
+ * CE-9 — World-State Diffing + Incremental Snapshots
3
+ *
4
+ * Reduces long-run storage from O(ticks × fullState) to O(initialState + Σ deltas).
5
+ *
6
+ * ## Diff model
7
+ * `WorldStateDiff` captures:
8
+ * - World-level scalar changes (`tick`, `seed`, optional subsystem fields).
9
+ * - Entity-level changes at **top-level-field granularity**: each field that
10
+ * differs from the previous snapshot is stored in full; unchanged fields are
11
+ * omitted. This avoids deep-diffing complex nested types.
12
+ * - Newly added entities (stored in full).
13
+ * - Removed entity ids.
14
+ *
15
+ * ## Binary wire format (`packDiff` / `unpackDiff`)
16
+ * A compact, dependency-free binary encoding using a simple tag-value scheme.
17
+ * Zero external dependencies — implemented entirely with `DataView` / `Uint8Array`.
18
+ *
19
+ * Layout:
20
+ * [4 bytes magic "ANKD"] [1 byte version=1] [tag-value payload...]
21
+ *
22
+ * Tag bytes:
23
+ * 0x01 null | 0x02 true | 0x03 false
24
+ * 0x04 uint8 (1 byte) | 0x05 int32 LE (4 bytes) | 0x06 float64 LE (8 bytes)
25
+ * 0x07 string (uint16 LE length + UTF-8 bytes)
26
+ * 0x08 array (uint32 LE count + items)
27
+ * 0x09 object (uint32 LE count + key-value pairs, keys as 0x07 strings)
28
+ * 0x0A undefined/absent (skipped in round-trip)
29
+ */
30
+ // ── Diff ──────────────────────────────────────────────────────────────────────
31
+ /**
32
+ * Compute the diff between two `WorldState` snapshots.
33
+ *
34
+ * The diff is guaranteed to be **idempotent**: `applyDiff(prev, diffWorldState(prev, next))`
35
+ * produces a state that is JSON-round-trip-equal to `next`.
36
+ *
37
+ * Complexity: O(E × F) where E = entity count and F = top-level field count per entity.
38
+ *
39
+ * @param prev Base snapshot.
40
+ * @param next New snapshot (must have the same `seed`; `tick` may differ).
41
+ * @returns `WorldStateDiff` — empty diff if states are identical.
42
+ */
43
+ export function diffWorldState(prev, next) {
44
+ // ── World-level scalar/subsystem changes ──────────────────────────────────
45
+ const worldChanges = {};
46
+ const worldKeys = [
47
+ "tick", "seed",
48
+ "activeFieldEffects",
49
+ "__sensoryEnv",
50
+ "__factionRegistry",
51
+ "__partyRegistry",
52
+ "__relationshipGraph",
53
+ "__nutritionAccum",
54
+ ];
55
+ for (const key of worldKeys) {
56
+ if (key === "entities")
57
+ continue;
58
+ const pv = prev[key];
59
+ const nv = next[key];
60
+ if (!jsonEqual(pv, nv)) {
61
+ worldChanges[key] = nv;
62
+ }
63
+ }
64
+ // ── Entity diff ───────────────────────────────────────────────────────────
65
+ const prevMap = new Map(prev.entities.map(e => [e.id, e]));
66
+ const nextMap = new Map(next.entities.map(e => [e.id, e]));
67
+ const added = [];
68
+ const removed = [];
69
+ const modified = [];
70
+ // Removed or modified
71
+ for (const [id, pe] of prevMap) {
72
+ const ne = nextMap.get(id);
73
+ if (!ne) {
74
+ removed.push(id);
75
+ }
76
+ else {
77
+ const changes = entityChanges(pe, ne);
78
+ if (Object.keys(changes).length > 0) {
79
+ modified.push({ id, changes });
80
+ }
81
+ }
82
+ }
83
+ // Added
84
+ for (const [id, ne] of nextMap) {
85
+ if (!prevMap.has(id)) {
86
+ added.push(ne);
87
+ }
88
+ }
89
+ return { tick: next.tick, worldChanges, added, removed, modified };
90
+ }
91
+ /**
92
+ * Apply a `WorldStateDiff` to a base `WorldState`, producing the `next` state.
93
+ *
94
+ * **Does not mutate `base`** — returns a new `WorldState` object.
95
+ * The returned state may share sub-object references with `base` for unchanged
96
+ * entities (copy-on-write semantics).
97
+ *
98
+ * @param base The `prev` snapshot that was passed to `diffWorldState`.
99
+ * @param diff The diff produced by `diffWorldState`.
100
+ * @returns Reconstructed `next` state.
101
+ */
102
+ export function applyDiff(base, diff) {
103
+ // Reconstruct world-level fields
104
+ const next = {
105
+ ...base,
106
+ ...diff.worldChanges,
107
+ tick: diff.tick,
108
+ };
109
+ // Remove entities
110
+ const removedSet = new Set(diff.removed);
111
+ let entities = base.entities.filter(e => !removedSet.has(e.id));
112
+ // Modify entities (patch changed fields)
113
+ entities = entities.map(e => {
114
+ const patch = diff.modified.find(p => p.id === e.id);
115
+ if (!patch)
116
+ return e;
117
+ return { ...e, ...patch.changes };
118
+ });
119
+ // Add new entities
120
+ entities = [...entities, ...diff.added];
121
+ // Restore canonical sort order (ascending id)
122
+ entities.sort((a, b) => a.id - b.id);
123
+ return { ...next, entities };
124
+ }
125
+ // ── isEmpty / stats ───────────────────────────────────────────────────────────
126
+ /**
127
+ * Returns `true` when the diff contains no changes — states were identical.
128
+ */
129
+ export function isDiffEmpty(diff) {
130
+ return (Object.keys(diff.worldChanges).length === 0 &&
131
+ diff.added.length === 0 &&
132
+ diff.removed.length === 0 &&
133
+ diff.modified.length === 0);
134
+ }
135
+ export function diffStats(diff) {
136
+ return {
137
+ worldChangedFields: Object.keys(diff.worldChanges).length,
138
+ addedEntities: diff.added.length,
139
+ removedEntities: diff.removed.length,
140
+ modifiedEntities: diff.modified.length,
141
+ totalEntityChanges: diff.modified.reduce((s, p) => s + Object.keys(p.changes).length, 0),
142
+ };
143
+ }
144
+ // ── Binary pack / unpack ──────────────────────────────────────────────────────
145
+ const MAGIC = 0x414E4B44; // "ANKD"
146
+ const VERSION = 1;
147
+ const TAG = {
148
+ NULL: 0x01,
149
+ TRUE: 0x02,
150
+ FALSE: 0x03,
151
+ UINT8: 0x04,
152
+ INT32: 0x05,
153
+ FLOAT64: 0x06,
154
+ STRING: 0x07,
155
+ ARRAY: 0x08,
156
+ OBJECT: 0x09,
157
+ };
158
+ /**
159
+ * Encode a `WorldStateDiff` as a compact binary `Uint8Array`.
160
+ *
161
+ * The binary format is self-describing (no schema required for decoding).
162
+ * `unpackDiff(packDiff(diff))` is guaranteed to produce a diff that when
163
+ * applied gives the same result as the original.
164
+ *
165
+ * @param diff Diff to encode.
166
+ * @returns Binary representation.
167
+ */
168
+ export function packDiff(diff) {
169
+ const buf = new Writer();
170
+ buf.writeUint32(MAGIC);
171
+ buf.writeUint8(VERSION);
172
+ buf.writeValue(diff);
173
+ return buf.toUint8Array();
174
+ }
175
+ /**
176
+ * Decode a `WorldStateDiff` previously encoded by `packDiff`.
177
+ *
178
+ * @param bytes Binary data produced by `packDiff`.
179
+ * @returns Decoded `WorldStateDiff`.
180
+ * @throws If the magic bytes or version do not match.
181
+ */
182
+ export function unpackDiff(bytes) {
183
+ const r = new Reader(bytes);
184
+ const magic = r.readUint32();
185
+ if (magic !== MAGIC)
186
+ throw new Error(`snapshot: invalid magic 0x${magic.toString(16)}`);
187
+ const version = r.readUint8();
188
+ if (version !== VERSION)
189
+ throw new Error(`snapshot: unsupported version ${version}`);
190
+ return r.readValue();
191
+ }
192
+ // ── Internal: JSON equality ───────────────────────────────────────────────────
193
+ function jsonEqual(a, b) {
194
+ if (a === b)
195
+ return true;
196
+ if (a === null || b === null)
197
+ return false;
198
+ if (typeof a !== "object" || typeof b !== "object")
199
+ return false;
200
+ return JSON.stringify(a) === JSON.stringify(b);
201
+ }
202
+ /** Compute changed top-level fields between two entity versions. */
203
+ function entityChanges(prev, next) {
204
+ const changes = {};
205
+ const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]);
206
+ for (const key of allKeys) {
207
+ if (!jsonEqual(prev[key], next[key])) {
208
+ changes[key] = next[key];
209
+ }
210
+ }
211
+ return changes;
212
+ }
213
+ class Writer {
214
+ chunks = [];
215
+ size = 0;
216
+ writeUint8(v) {
217
+ const b = new Uint8Array(1);
218
+ b[0] = v & 0xFF;
219
+ this.push(b);
220
+ }
221
+ writeUint16(v) {
222
+ const b = new Uint8Array(2);
223
+ new DataView(b.buffer).setUint16(0, v, true);
224
+ this.push(b);
225
+ }
226
+ writeUint32(v) {
227
+ const b = new Uint8Array(4);
228
+ new DataView(b.buffer).setUint32(0, v >>> 0, true);
229
+ this.push(b);
230
+ }
231
+ writeInt32(v) {
232
+ const b = new Uint8Array(4);
233
+ new DataView(b.buffer).setInt32(0, v, true);
234
+ this.push(b);
235
+ }
236
+ writeFloat64(v) {
237
+ const b = new Uint8Array(8);
238
+ new DataView(b.buffer).setFloat64(0, v, true);
239
+ this.push(b);
240
+ }
241
+ writeString(s) {
242
+ const enc = new TextEncoder().encode(s);
243
+ this.writeUint8(TAG.STRING);
244
+ this.writeUint16(enc.length);
245
+ this.push(enc);
246
+ }
247
+ writeValue(v) {
248
+ if (v === null || v === undefined) {
249
+ this.writeUint8(TAG.NULL);
250
+ }
251
+ else if (typeof v === "boolean") {
252
+ this.writeUint8(v ? TAG.TRUE : TAG.FALSE);
253
+ }
254
+ else if (typeof v === "number") {
255
+ if (Number.isInteger(v) && v >= 0 && v <= 255) {
256
+ this.writeUint8(TAG.UINT8);
257
+ this.writeUint8(v);
258
+ }
259
+ else if (Number.isInteger(v) && v >= -2147483648 && v <= 2147483647) {
260
+ this.writeUint8(TAG.INT32);
261
+ this.writeInt32(v);
262
+ }
263
+ else {
264
+ this.writeUint8(TAG.FLOAT64);
265
+ this.writeFloat64(v);
266
+ }
267
+ }
268
+ else if (typeof v === "string") {
269
+ this.writeString(v);
270
+ }
271
+ else if (Array.isArray(v)) {
272
+ this.writeUint8(TAG.ARRAY);
273
+ this.writeUint32(v.length);
274
+ for (const item of v)
275
+ this.writeValue(item);
276
+ }
277
+ else {
278
+ const entries = Object.entries(v).filter(([, val]) => val !== undefined);
279
+ this.writeUint8(TAG.OBJECT);
280
+ this.writeUint32(entries.length);
281
+ for (const [key, val] of entries) {
282
+ this.writeString(key);
283
+ this.writeValue(val);
284
+ }
285
+ }
286
+ }
287
+ push(b) {
288
+ this.chunks.push(b);
289
+ this.size += b.length;
290
+ }
291
+ toUint8Array() {
292
+ const out = new Uint8Array(this.size);
293
+ let off = 0;
294
+ for (const c of this.chunks) {
295
+ out.set(c, off);
296
+ off += c.length;
297
+ }
298
+ return out;
299
+ }
300
+ }
301
+ // ── Binary Reader ─────────────────────────────────────────────────────────────
302
+ class Reader {
303
+ view;
304
+ pos = 0;
305
+ constructor(bytes) {
306
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
307
+ }
308
+ readUint8() {
309
+ return this.view.getUint8(this.pos++);
310
+ }
311
+ readUint16() {
312
+ const v = this.view.getUint16(this.pos, true);
313
+ this.pos += 2;
314
+ return v;
315
+ }
316
+ readUint32() {
317
+ const v = this.view.getUint32(this.pos, true);
318
+ this.pos += 4;
319
+ return v;
320
+ }
321
+ readInt32() {
322
+ const v = this.view.getInt32(this.pos, true);
323
+ this.pos += 4;
324
+ return v;
325
+ }
326
+ readFloat64() {
327
+ const v = this.view.getFloat64(this.pos, true);
328
+ this.pos += 8;
329
+ return v;
330
+ }
331
+ readString() {
332
+ const len = this.readUint16();
333
+ const bytes = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, len);
334
+ this.pos += len;
335
+ return new TextDecoder().decode(bytes);
336
+ }
337
+ readValue() {
338
+ const tag = this.readUint8();
339
+ switch (tag) {
340
+ case TAG.NULL: return null;
341
+ case TAG.TRUE: return true;
342
+ case TAG.FALSE: return false;
343
+ case TAG.UINT8: return this.readUint8();
344
+ case TAG.INT32: return this.readInt32();
345
+ case TAG.FLOAT64: return this.readFloat64();
346
+ case TAG.STRING: {
347
+ const len = this.readUint16();
348
+ const bytes = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, len);
349
+ this.pos += len;
350
+ return new TextDecoder().decode(bytes);
351
+ }
352
+ case TAG.ARRAY: {
353
+ const count = this.readUint32();
354
+ const arr = [];
355
+ for (let i = 0; i < count; i++)
356
+ arr.push(this.readValue());
357
+ return arr;
358
+ }
359
+ case TAG.OBJECT: {
360
+ const count = this.readUint32();
361
+ const obj = {};
362
+ for (let i = 0; i < count; i++) {
363
+ // key is always a STRING tag
364
+ const keyTag = this.readUint8();
365
+ if (keyTag !== TAG.STRING)
366
+ throw new Error(`snapshot: expected string key tag, got 0x${keyTag.toString(16)}`);
367
+ const keyLen = this.readUint16();
368
+ const keyBytes = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, keyLen);
369
+ this.pos += keyLen;
370
+ const key = new TextDecoder().decode(keyBytes);
371
+ obj[key] = this.readValue();
372
+ }
373
+ return obj;
374
+ }
375
+ default:
376
+ throw new Error(`snapshot: unknown tag 0x${tag.toString(16)}`);
377
+ }
378
+ }
379
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",