@flighthq/snapshot 0.1.0

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,14 @@
1
+ import type { Snapshot } from '@flighthq/types';
2
+ /** Capture `source` into an immutable snapshot: a deep clone of the plain state, deep-frozen so
3
+ * nothing can mutate it afterward.
4
+ *
5
+ * The clone means the returned snapshot does not alias `source` — mutating `source` later never
6
+ * affects the snapshot, and vice versa. The freeze makes it a fixed point-in-time value safe to
7
+ * store in an undo stack, send over the wire, or interpolate toward.
8
+ *
9
+ * `source` must be plain, structured-cloneable data (numbers, strings, booleans, arrays, nested
10
+ * objects). Passing a class instance, function, or other non-cloneable value is programmer error and
11
+ * throws via `structuredClone`.
12
+ */
13
+ export declare function captureSnapshot<T>(source: Readonly<T>): Snapshot<T>;
14
+ //# sourceMappingURL=captureSnapshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"captureSnapshot.d.ts","sourceRoot":"","sources":["../src/captureSnapshot.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEhD;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAInE"}
@@ -0,0 +1,34 @@
1
+ /** Capture `source` into an immutable snapshot: a deep clone of the plain state, deep-frozen so
2
+ * nothing can mutate it afterward.
3
+ *
4
+ * The clone means the returned snapshot does not alias `source` — mutating `source` later never
5
+ * affects the snapshot, and vice versa. The freeze makes it a fixed point-in-time value safe to
6
+ * store in an undo stack, send over the wire, or interpolate toward.
7
+ *
8
+ * `source` must be plain, structured-cloneable data (numbers, strings, booleans, arrays, nested
9
+ * objects). Passing a class instance, function, or other non-cloneable value is programmer error and
10
+ * throws via `structuredClone`.
11
+ */
12
+ export function captureSnapshot(source) {
13
+ const clone = structuredClone(source);
14
+ freezeSnapshotDeep(clone);
15
+ return clone;
16
+ }
17
+ // Recursively `Object.freeze`s every object and array reachable from `value`, so the whole tree is
18
+ // immutable — not just the top level. Primitives and `null` are already immutable and skipped.
19
+ function freezeSnapshotDeep(value) {
20
+ if (value === null || typeof value !== 'object') {
21
+ return;
22
+ }
23
+ Object.freeze(value);
24
+ if (Array.isArray(value)) {
25
+ for (let index = 0; index < value.length; index += 1) {
26
+ freezeSnapshotDeep(value[index]);
27
+ }
28
+ return;
29
+ }
30
+ for (const key of Object.keys(value)) {
31
+ freezeSnapshotDeep(value[key]);
32
+ }
33
+ }
34
+ //# sourceMappingURL=captureSnapshot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"captureSnapshot.js","sourceRoot":"","sources":["../src/captureSnapshot.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAI,MAAmB;IACpD,MAAM,KAAK,GAAG,eAAe,CAAC,MAAM,CAAM,CAAC;IAC3C,kBAAkB,CAAC,KAAK,CAAC,CAAC;IAC1B,OAAO,KAAoB,CAAC;AAC9B,CAAC;AAED,mGAAmG;AACnG,+FAA+F;AAC/F,SAAS,kBAAkB,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAChD,OAAO;IACT,CAAC;IACD,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACrB,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YACrD,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;QACnC,CAAC;QACD,OAAO;IACT,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,KAAgC,CAAC,EAAE,CAAC;QAChE,kBAAkB,CAAE,KAAiC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC9D,CAAC;AACH,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { Snapshot } from '@flighthq/types';
2
+ /** Deep structural equality between two snapshots: `true` when they have the same shape and every
3
+ * leaf value is strictly equal.
4
+ *
5
+ * Objects must have the same own keys; arrays must have the same length with element-wise equal
6
+ * entries. Leaves are compared with `===`, so `NaN` never equals itself and `-0`/`+0` are equal.
7
+ * Used to detect whether state actually changed before pushing an undo entry or sending a frame.
8
+ */
9
+ export declare function equalsSnapshot<T>(a: Snapshot<T>, b: Snapshot<T>): boolean;
10
+ //# sourceMappingURL=equalsSnapshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"equalsSnapshot.d.ts","sourceRoot":"","sources":["../src/equalsSnapshot.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEhD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,OAAO,CAEzE"}
@@ -0,0 +1,51 @@
1
+ /** Deep structural equality between two snapshots: `true` when they have the same shape and every
2
+ * leaf value is strictly equal.
3
+ *
4
+ * Objects must have the same own keys; arrays must have the same length with element-wise equal
5
+ * entries. Leaves are compared with `===`, so `NaN` never equals itself and `-0`/`+0` are equal.
6
+ * Used to detect whether state actually changed before pushing an undo entry or sending a frame.
7
+ */
8
+ export function equalsSnapshot(a, b) {
9
+ return snapshotValuesEqual(a, b);
10
+ }
11
+ function snapshotValuesEqual(a, b) {
12
+ if (a === b) {
13
+ return true;
14
+ }
15
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
16
+ return false;
17
+ }
18
+ const aIsArray = Array.isArray(a);
19
+ if (aIsArray !== Array.isArray(b)) {
20
+ return false;
21
+ }
22
+ if (aIsArray) {
23
+ const aArray = a;
24
+ const bArray = b;
25
+ if (aArray.length !== bArray.length) {
26
+ return false;
27
+ }
28
+ for (let index = 0; index < aArray.length; index += 1) {
29
+ if (!snapshotValuesEqual(aArray[index], bArray[index])) {
30
+ return false;
31
+ }
32
+ }
33
+ return true;
34
+ }
35
+ const aObject = a;
36
+ const bObject = b;
37
+ const aKeys = Object.keys(aObject);
38
+ if (aKeys.length !== Object.keys(bObject).length) {
39
+ return false;
40
+ }
41
+ for (const key of aKeys) {
42
+ if (!Object.prototype.hasOwnProperty.call(bObject, key)) {
43
+ return false;
44
+ }
45
+ if (!snapshotValuesEqual(aObject[key], bObject[key])) {
46
+ return false;
47
+ }
48
+ }
49
+ return true;
50
+ }
51
+ //# sourceMappingURL=equalsSnapshot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"equalsSnapshot.js","sourceRoot":"","sources":["../src/equalsSnapshot.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAI,CAAc,EAAE,CAAc;IAC9D,OAAO,mBAAmB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AACnC,CAAC;AAED,SAAS,mBAAmB,CAAC,CAAU,EAAE,CAAU;IACjD,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACZ,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,OAAO,CAAC,KAAK,QAAQ,EAAE,CAAC;QAC/E,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,QAAQ,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAClC,IAAI,QAAQ,KAAK,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;QAClC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,CAAc,CAAC;QAC9B,MAAM,MAAM,GAAG,CAAc,CAAC;QAC9B,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,CAAC;YACpC,OAAO,KAAK,CAAC;QACf,CAAC;QACD,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YACtD,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;gBACvD,OAAO,KAAK,CAAC;YACf,CAAC;QACH,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,OAAO,GAAG,CAA4B,CAAC;IAC7C,MAAM,OAAO,GAAG,CAA4B,CAAC;IAC7C,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,KAAK,CAAC,MAAM,KAAK,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC;QACjD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,KAAK,EAAE,CAAC;QACxB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC,EAAE,CAAC;YACxD,OAAO,KAAK,CAAC;QACf,CAAC;QACD,IAAI,CAAC,mBAAmB,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YACrD,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -0,0 +1,5 @@
1
+ export * from './captureSnapshot';
2
+ export * from './equalsSnapshot';
3
+ export * from './interpolateSnapshots';
4
+ export * from './restoreSnapshot';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './captureSnapshot';
2
+ export * from './equalsSnapshot';
3
+ export * from './interpolateSnapshots';
4
+ export * from './restoreSnapshot';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC"}
@@ -0,0 +1,19 @@
1
+ import type { Snapshot, SnapshotSchema } from '@flighthq/types';
2
+ /** Interpolate between two snapshots into a live mutable `out`, the "tween over instances" for smooth
3
+ * netcode/replay rendering between two fixed frames.
4
+ *
5
+ * `t` is clamped to `[0, 1]`. For each numeric leaf present as a number in both `a` and `b`, `out`
6
+ * receives `lerp(a, b, t)`. Every non-numeric leaf — strings, booleans, `null`/`undefined`, or a
7
+ * slot that is a number in only one snapshot — snaps to the destination value from `b` (cloned so
8
+ * `out` stays mutable and unaliased). Nested objects and arrays are walked recursively; arrays are
9
+ * resized to `b`'s length and interpolated positionally.
10
+ *
11
+ * With a `schema` (dot-separated paths from the root, array elements by index), only numeric leaves
12
+ * whose path is listed interpolate; every other numeric leaf snaps to `b` — how a numeric id or
13
+ * discrete count is kept from blending. Without a schema, all numeric leaves interpolate.
14
+ *
15
+ * `out` may be a separate live object; it is read from `a`/`b` and written field by field, so it can
16
+ * safely be the caller's render-state object.
17
+ */
18
+ export declare function interpolateSnapshots<T>(a: Snapshot<T>, b: Snapshot<T>, t: number, out: T, schema?: Readonly<SnapshotSchema>): void;
19
+ //# sourceMappingURL=interpolateSnapshots.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interpolateSnapshots.d.ts","sourceRoot":"","sources":["../src/interpolateSnapshots.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEhE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EACd,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EACd,CAAC,EAAE,MAAM,EACT,GAAG,EAAE,CAAC,EACN,MAAM,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,GAChC,IAAI,CAYN"}
@@ -0,0 +1,79 @@
1
+ import { clamp, lerp } from '@flighthq/math';
2
+ /** Interpolate between two snapshots into a live mutable `out`, the "tween over instances" for smooth
3
+ * netcode/replay rendering between two fixed frames.
4
+ *
5
+ * `t` is clamped to `[0, 1]`. For each numeric leaf present as a number in both `a` and `b`, `out`
6
+ * receives `lerp(a, b, t)`. Every non-numeric leaf — strings, booleans, `null`/`undefined`, or a
7
+ * slot that is a number in only one snapshot — snaps to the destination value from `b` (cloned so
8
+ * `out` stays mutable and unaliased). Nested objects and arrays are walked recursively; arrays are
9
+ * resized to `b`'s length and interpolated positionally.
10
+ *
11
+ * With a `schema` (dot-separated paths from the root, array elements by index), only numeric leaves
12
+ * whose path is listed interpolate; every other numeric leaf snaps to `b` — how a numeric id or
13
+ * discrete count is kept from blending. Without a schema, all numeric leaves interpolate.
14
+ *
15
+ * `out` may be a separate live object; it is read from `a`/`b` and written field by field, so it can
16
+ * safely be the caller's render-state object.
17
+ */
18
+ export function interpolateSnapshots(a, b, t, out, schema) {
19
+ if (a === null ||
20
+ typeof a !== 'object' ||
21
+ b === null ||
22
+ typeof b !== 'object' ||
23
+ out === null ||
24
+ typeof out !== 'object') {
25
+ return;
26
+ }
27
+ interpolateSnapshotsInto(out, a, b, clamp(t, 0, 1), schema, '');
28
+ }
29
+ // Walks `b` (the destination shape) key by key, writing each interpolated or snapped field into
30
+ // `out`, reading the matching value from `a`. `prefix` is the dotted path to the current container.
31
+ function interpolateSnapshotsInto(out, a, b, t, schema, prefix) {
32
+ const outRecord = out;
33
+ const aRecord = a;
34
+ const bRecord = b;
35
+ if (Array.isArray(b)) {
36
+ out.length = b.length;
37
+ }
38
+ for (const key of Object.keys(bRecord)) {
39
+ const path = prefix === '' ? key : `${prefix}.${key}`;
40
+ const aValue = aRecord[key];
41
+ const bValue = bRecord[key];
42
+ if (typeof aValue === 'number' && typeof bValue === 'number') {
43
+ outRecord[key] = isSnapshotPathInterpolated(schema, path) ? lerp(aValue, bValue, t) : bValue;
44
+ continue;
45
+ }
46
+ if (aValue !== null &&
47
+ typeof aValue === 'object' &&
48
+ bValue !== null &&
49
+ typeof bValue === 'object' &&
50
+ Array.isArray(aValue) === Array.isArray(bValue)) {
51
+ const container = ensureSnapshotContainer(outRecord[key], Array.isArray(bValue));
52
+ outRecord[key] = container;
53
+ interpolateSnapshotsInto(container, aValue, bValue, t, schema, path);
54
+ continue;
55
+ }
56
+ outRecord[key] = cloneSnapshotValue(bValue);
57
+ }
58
+ }
59
+ // A numeric leaf interpolates when there is no schema, or when its path is listed in the schema.
60
+ function isSnapshotPathInterpolated(schema, path) {
61
+ return schema === undefined || schema.includes(path);
62
+ }
63
+ // Reuses `existing` when it is already a mutable container of the needed kind, else allocates a fresh
64
+ // empty one, so the walk can recurse into `out` even when `out` did not mirror the snapshot's shape.
65
+ function ensureSnapshotContainer(existing, isArray) {
66
+ if (existing !== null && typeof existing === 'object' && Array.isArray(existing) === isArray) {
67
+ return existing;
68
+ }
69
+ return isArray ? [] : {};
70
+ }
71
+ // Snaps a non-interpolated value into `out`: primitives pass through; an object/array is deep-cloned
72
+ // so `out` holds a fresh mutable copy rather than aliasing the frozen snapshot.
73
+ function cloneSnapshotValue(value) {
74
+ if (value === null || typeof value !== 'object') {
75
+ return value;
76
+ }
77
+ return structuredClone(value);
78
+ }
79
+ //# sourceMappingURL=interpolateSnapshots.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interpolateSnapshots.js","sourceRoot":"","sources":["../src/interpolateSnapshots.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import type { Snapshot } from '@flighthq/types';
2
+ /** Restore `snapshot` back into the live mutable `target`, deep-assigning every field in place.
3
+ *
4
+ * `target` keeps its own object identity — this mutates the caller's live state rather than
5
+ * replacing it. Where `target` already holds a compatible nested object or array, that container is
6
+ * reused and mutated in place (preserving its identity too); otherwise a fresh mutable clone of the
7
+ * snapshot's subtree is assigned, so the restored state never aliases the frozen snapshot and stays
8
+ * freely mutable.
9
+ *
10
+ * Array handling: a target array is resized to the snapshot array's length — extra tail elements are
11
+ * dropped, missing elements are added — then each element is restored positionally.
12
+ *
13
+ * A top-level primitive snapshot has nothing to assign into a live reference and is a no-op.
14
+ */
15
+ export declare function restoreSnapshot<T>(snapshot: Snapshot<T>, target: T): void;
16
+ //# sourceMappingURL=restoreSnapshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"restoreSnapshot.d.ts","sourceRoot":"","sources":["../src/restoreSnapshot.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,GAAG,IAAI,CAKzE"}
@@ -0,0 +1,51 @@
1
+ /** Restore `snapshot` back into the live mutable `target`, deep-assigning every field in place.
2
+ *
3
+ * `target` keeps its own object identity — this mutates the caller's live state rather than
4
+ * replacing it. Where `target` already holds a compatible nested object or array, that container is
5
+ * reused and mutated in place (preserving its identity too); otherwise a fresh mutable clone of the
6
+ * snapshot's subtree is assigned, so the restored state never aliases the frozen snapshot and stays
7
+ * freely mutable.
8
+ *
9
+ * Array handling: a target array is resized to the snapshot array's length — extra tail elements are
10
+ * dropped, missing elements are added — then each element is restored positionally.
11
+ *
12
+ * A top-level primitive snapshot has nothing to assign into a live reference and is a no-op.
13
+ */
14
+ export function restoreSnapshot(snapshot, target) {
15
+ if (snapshot === null || typeof snapshot !== 'object' || target === null || typeof target !== 'object') {
16
+ return;
17
+ }
18
+ restoreSnapshotInto(target, snapshot);
19
+ }
20
+ // Deep-assigns every own key of `source` into `target`, recursing through compatible nested
21
+ // containers and resizing arrays to match. Both are known to be objects/arrays here.
22
+ function restoreSnapshotInto(target, source) {
23
+ if (Array.isArray(source)) {
24
+ const targetArray = target;
25
+ const sourceArray = source;
26
+ targetArray.length = sourceArray.length;
27
+ for (let index = 0; index < sourceArray.length; index += 1) {
28
+ targetArray[index] = restoreSnapshotValue(targetArray[index], sourceArray[index]);
29
+ }
30
+ return;
31
+ }
32
+ const targetObject = target;
33
+ const sourceObject = source;
34
+ for (const key of Object.keys(sourceObject)) {
35
+ targetObject[key] = restoreSnapshotValue(targetObject[key], sourceObject[key]);
36
+ }
37
+ }
38
+ // Resolves the value to store for one field: primitives pass through; an object/array reuses a
39
+ // compatible mutable container in `targetValue` (mutated in place) or is cloned fresh and mutable.
40
+ function restoreSnapshotValue(targetValue, sourceValue) {
41
+ if (sourceValue === null || typeof sourceValue !== 'object') {
42
+ return sourceValue;
43
+ }
44
+ const sourceIsArray = Array.isArray(sourceValue);
45
+ if (targetValue !== null && typeof targetValue === 'object' && Array.isArray(targetValue) === sourceIsArray) {
46
+ restoreSnapshotInto(targetValue, sourceValue);
47
+ return targetValue;
48
+ }
49
+ return structuredClone(sourceValue);
50
+ }
51
+ //# sourceMappingURL=restoreSnapshot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"restoreSnapshot.js","sourceRoot":"","sources":["../src/restoreSnapshot.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,eAAe,CAAI,QAAqB,EAAE,MAAS;IACjE,IAAI,QAAQ,KAAK,IAAI,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QACvG,OAAO;IACT,CAAC;IACD,mBAAmB,CAAC,MAAgB,EAAE,QAAkB,CAAC,CAAC;AAC5D,CAAC;AAED,4FAA4F;AAC5F,qFAAqF;AACrF,SAAS,mBAAmB,CAAC,MAAc,EAAE,MAAc;IACzD,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAC1B,MAAM,WAAW,GAAG,MAAmB,CAAC;QACxC,MAAM,WAAW,GAAG,MAAmB,CAAC;QACxC,WAAW,CAAC,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC;QACxC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,WAAW,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YAC3D,WAAW,CAAC,KAAK,CAAC,GAAG,oBAAoB,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC;QACpF,CAAC;QACD,OAAO;IACT,CAAC;IACD,MAAM,YAAY,GAAG,MAAiC,CAAC;IACvD,MAAM,YAAY,GAAG,MAAiC,CAAC;IACvD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,EAAE,CAAC;QAC5C,YAAY,CAAC,GAAG,CAAC,GAAG,oBAAoB,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC;IACjF,CAAC;AACH,CAAC;AAED,+FAA+F;AAC/F,mGAAmG;AACnG,SAAS,oBAAoB,CAAC,WAAoB,EAAE,WAAoB;IACtE,IAAI,WAAW,KAAK,IAAI,IAAI,OAAO,WAAW,KAAK,QAAQ,EAAE,CAAC;QAC5D,OAAO,WAAW,CAAC;IACrB,CAAC;IACD,MAAM,aAAa,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACjD,IAAI,WAAW,KAAK,IAAI,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,aAAa,EAAE,CAAC;QAC5G,mBAAmB,CAAC,WAAqB,EAAE,WAAqB,CAAC,CAAC;QAClE,OAAO,WAAW,CAAC;IACrB,CAAC;IACD,OAAO,eAAe,CAAC,WAAW,CAAC,CAAC;AACtC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@flighthq/snapshot",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/math": "0.1.0",
31
+ "@flighthq/types": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.3.0"
35
+ },
36
+ "description": "Immutable recoverable-state captures over plain data — captureSnapshot deep-clones and deep-freezes a state object, restoreSnapshot deep-assigns a snapshot back into live mutable state, interpolateSnapshots schema-aware lerps numeric leaves between two snapshots, and snapshotsEqual deep-compares; the primitive under save/undo/netcode/replay, deps types + math only",
37
+ "sideEffects": false
38
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { captureSnapshot } from './captureSnapshot';
4
+
5
+ describe('captureSnapshot', () => {
6
+ it('returns a deep-equal copy of the source', () => {
7
+ const source = { x: 1, nested: { y: 2 }, items: [3, 4] };
8
+ const snapshot = captureSnapshot(source);
9
+ expect(snapshot).toEqual(source);
10
+ });
11
+
12
+ it('deep-freezes every nested object and array', () => {
13
+ const snapshot = captureSnapshot({ x: 1, nested: { y: 2, points: [{ z: 3 }] } });
14
+ expect(Object.isFrozen(snapshot)).toBe(true);
15
+ expect(Object.isFrozen(snapshot.nested)).toBe(true);
16
+ expect(Object.isFrozen(snapshot.nested.points)).toBe(true);
17
+ expect(Object.isFrozen(snapshot.nested.points[0])).toBe(true);
18
+ });
19
+
20
+ it('is unaffected by mutating the source after capture', () => {
21
+ const source = { x: 1, nested: { y: 2 }, items: [3, 4] };
22
+ const snapshot = captureSnapshot(source);
23
+ source.x = 99;
24
+ source.nested.y = 99;
25
+ source.items.push(5);
26
+ expect(snapshot.x).toBe(1);
27
+ expect(snapshot.nested.y).toBe(2);
28
+ expect(snapshot.items).toEqual([3, 4]);
29
+ });
30
+
31
+ it('copies nested arrays rather than sharing the reference', () => {
32
+ const source = { items: [1, 2, 3] };
33
+ const snapshot = captureSnapshot(source);
34
+ expect(snapshot.items).not.toBe(source.items);
35
+ expect(snapshot.items).toEqual([1, 2, 3]);
36
+ });
37
+
38
+ it('captures an empty object', () => {
39
+ const snapshot = captureSnapshot({});
40
+ expect(snapshot).toEqual({});
41
+ expect(Object.isFrozen(snapshot)).toBe(true);
42
+ });
43
+
44
+ it('captures null and undefined leaf fields', () => {
45
+ const snapshot = captureSnapshot({ a: null, b: undefined, c: 1 });
46
+ expect(snapshot.a).toBeNull();
47
+ expect(snapshot.b).toBeUndefined();
48
+ expect(snapshot.c).toBe(1);
49
+ });
50
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { captureSnapshot } from './captureSnapshot';
4
+ import { equalsSnapshot } from './equalsSnapshot';
5
+
6
+ describe('equalsSnapshot', () => {
7
+ it('is true for deep-equal snapshots', () => {
8
+ const a = captureSnapshot({ x: 1, nested: { y: 2 }, items: [3, 4] });
9
+ const b = captureSnapshot({ x: 1, nested: { y: 2 }, items: [3, 4] });
10
+ expect(equalsSnapshot(a, b)).toBe(true);
11
+ });
12
+
13
+ it('is false for a differing leaf value', () => {
14
+ const a = captureSnapshot({ x: 1, nested: { y: 2 } });
15
+ const b = captureSnapshot({ x: 1, nested: { y: 3 } });
16
+ expect(equalsSnapshot(a, b)).toBe(false);
17
+ });
18
+
19
+ it('is false for a differing shape', () => {
20
+ const a = captureSnapshot({ x: 1 } as Record<string, number>);
21
+ const b = captureSnapshot({ x: 1, y: 2 } as Record<string, number>);
22
+ expect(equalsSnapshot(a, b)).toBe(false);
23
+ });
24
+
25
+ it('is false for arrays of differing length', () => {
26
+ const a = captureSnapshot({ items: [1, 2] });
27
+ const b = captureSnapshot({ items: [1, 2, 3] });
28
+ expect(equalsSnapshot(a, b)).toBe(false);
29
+ });
30
+
31
+ it('is true for two empty objects', () => {
32
+ expect(equalsSnapshot(captureSnapshot({}), captureSnapshot({}))).toBe(true);
33
+ });
34
+
35
+ it('distinguishes null from a value at the same key', () => {
36
+ const a = captureSnapshot({ tag: null as number | null });
37
+ const b = captureSnapshot({ tag: 0 as number | null });
38
+ expect(equalsSnapshot(a, b)).toBe(false);
39
+ });
40
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { captureSnapshot } from './captureSnapshot';
4
+ import { interpolateSnapshots } from './interpolateSnapshots';
5
+
6
+ describe('interpolateSnapshots', () => {
7
+ it('lerps numerics and snaps the string at t=0.5', () => {
8
+ const a = captureSnapshot({ x: 0, y: 10, name: 'a' });
9
+ const b = captureSnapshot({ x: 10, y: 0, name: 'b' });
10
+ const out = { x: 0, y: 0, name: '' };
11
+ interpolateSnapshots(a, b, 0.5, out);
12
+ expect(out).toEqual({ x: 5, y: 5, name: 'b' });
13
+ });
14
+
15
+ it('returns a numerics at t=0 and b numerics at t=1', () => {
16
+ const a = captureSnapshot({ x: 0, y: 10 });
17
+ const b = captureSnapshot({ x: 10, y: 0 });
18
+ const atStart = { x: -1, y: -1 };
19
+ const atEnd = { x: -1, y: -1 };
20
+ interpolateSnapshots(a, b, 0, atStart);
21
+ interpolateSnapshots(a, b, 1, atEnd);
22
+ expect(atStart).toEqual({ x: 0, y: 10 });
23
+ expect(atEnd).toEqual({ x: 10, y: 0 });
24
+ });
25
+
26
+ it('interpolates only schema-listed paths and snaps the rest to b', () => {
27
+ const a = captureSnapshot({ x: 0, y: 10 });
28
+ const b = captureSnapshot({ x: 10, y: 0 });
29
+ const out = { x: 0, y: 0 };
30
+ interpolateSnapshots(a, b, 0.5, out, ['x']);
31
+ expect(out).toEqual({ x: 5, y: 0 });
32
+ });
33
+
34
+ it('lerps nested numeric fields', () => {
35
+ const a = captureSnapshot({ pos: { x: 0, y: 0 } });
36
+ const b = captureSnapshot({ pos: { x: 100, y: 40 } });
37
+ const out = { pos: { x: 0, y: 0 } };
38
+ interpolateSnapshots(a, b, 0.25, out);
39
+ expect(out.pos).toEqual({ x: 25, y: 10 });
40
+ });
41
+
42
+ it('clamps t below 0 and above 1', () => {
43
+ const a = captureSnapshot({ x: 0 });
44
+ const b = captureSnapshot({ x: 10 });
45
+ const under = { x: -1 };
46
+ const over = { x: -1 };
47
+ interpolateSnapshots(a, b, -5, under);
48
+ interpolateSnapshots(a, b, 5, over);
49
+ expect(under.x).toBe(0);
50
+ expect(over.x).toBe(10);
51
+ });
52
+
53
+ it('lerps arrays of numbers element-wise', () => {
54
+ const a = captureSnapshot({ points: [0, 100] });
55
+ const b = captureSnapshot({ points: [10, 0] });
56
+ const out = { points: [0, 0] };
57
+ interpolateSnapshots(a, b, 0.5, out);
58
+ expect(out.points).toEqual([5, 50]);
59
+ });
60
+
61
+ it('snaps a null field to b', () => {
62
+ const a = captureSnapshot({ value: 1, tag: null as number | null });
63
+ const b = captureSnapshot({ value: 3, tag: null as number | null });
64
+ const out = { value: 0, tag: 7 as number | null };
65
+ interpolateSnapshots(a, b, 0.5, out);
66
+ expect(out.value).toBe(2);
67
+ expect(out.tag).toBeNull();
68
+ });
69
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { captureSnapshot } from './captureSnapshot';
4
+ import { restoreSnapshot } from './restoreSnapshot';
5
+
6
+ describe('restoreSnapshot', () => {
7
+ it('writes a snapshot back into a live target', () => {
8
+ const snapshot = captureSnapshot({ x: 1, y: 2 });
9
+ const target = { x: 0, y: 0 };
10
+ restoreSnapshot(snapshot, target);
11
+ expect(target).toEqual({ x: 1, y: 2 });
12
+ });
13
+
14
+ it('restores nested objects and arrays', () => {
15
+ const snapshot = captureSnapshot({ nested: { a: 1 }, items: [1, 2, 3] });
16
+ const target = { nested: { a: 0 }, items: [0] };
17
+ restoreSnapshot(snapshot, target);
18
+ expect(target.nested).toEqual({ a: 1 });
19
+ expect(target.items).toEqual([1, 2, 3]);
20
+ });
21
+
22
+ it('keeps the target object identity, mutating in place', () => {
23
+ const snapshot = captureSnapshot({ x: 1 });
24
+ const target = { x: 0 };
25
+ const before = target;
26
+ restoreSnapshot(snapshot, target);
27
+ expect(target).toBe(before);
28
+ });
29
+
30
+ it('reuses a compatible nested container rather than replacing it', () => {
31
+ const snapshot = captureSnapshot({ nested: { a: 1 } });
32
+ const target = { nested: { a: 0 } };
33
+ const nestedBefore = target.nested;
34
+ restoreSnapshot(snapshot, target);
35
+ expect(target.nested).toBe(nestedBefore);
36
+ expect(target.nested.a).toBe(1);
37
+ });
38
+
39
+ it('leaves the restored target mutable and unaliased from the frozen snapshot', () => {
40
+ const snapshot = captureSnapshot({ nested: { a: 1 } });
41
+ const target = { nested: { a: 0 } } as { nested: { a: number } };
42
+ restoreSnapshot(snapshot, target);
43
+ target.nested.a = 42;
44
+ expect(target.nested.a).toBe(42);
45
+ expect(snapshot.nested.a).toBe(1);
46
+ });
47
+
48
+ it('resizes the target array to the snapshot array length', () => {
49
+ const snapshot = captureSnapshot({ items: [1, 2] });
50
+ const target = { items: [9, 9, 9, 9] };
51
+ restoreSnapshot(snapshot, target);
52
+ expect(target.items).toEqual([1, 2]);
53
+ });
54
+ });