@colyseus/schema 5.0.5 → 5.0.8

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.
Files changed (69) hide show
  1. package/build/Reflection.d.ts +53 -0
  2. package/build/Schema.d.ts +26 -1
  3. package/build/annotations.d.ts +8 -1
  4. package/build/codegen/cli.cjs +74 -64
  5. package/build/codegen/cli.cjs.map +1 -1
  6. package/build/codegen/types.d.ts +0 -1
  7. package/build/decoder/Decoder.d.ts +28 -0
  8. package/build/decoder/Resync.d.ts +61 -0
  9. package/build/encoder/ChangeTree.d.ts +15 -0
  10. package/build/encoder/Encoder.d.ts +13 -0
  11. package/build/encoder/Pool.d.ts +62 -0
  12. package/build/encoder/Root.d.ts +5 -4
  13. package/build/encoder/StateView.d.ts +12 -3
  14. package/build/index.cjs +1192 -284
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +4 -1
  17. package/build/index.js +1192 -284
  18. package/build/index.mjs +1189 -285
  19. package/build/index.mjs.map +1 -1
  20. package/build/input/InputDecoder.d.ts +9 -4
  21. package/build/input/InputEncoder.d.ts +44 -53
  22. package/build/input/index.cjs +102 -7321
  23. package/build/input/index.cjs.map +1 -1
  24. package/build/input/index.mjs +97 -7316
  25. package/build/input/index.mjs.map +1 -1
  26. package/build/types/HelperTypes.d.ts +25 -0
  27. package/build/types/builder.d.ts +57 -4
  28. package/build/types/custom/ArraySchema.d.ts +11 -1
  29. package/build/types/custom/CollectionSchema.d.ts +9 -1
  30. package/build/types/custom/MapSchema.d.ts +9 -1
  31. package/build/types/custom/SetSchema.d.ts +9 -1
  32. package/build/types/custom/StreamSchema.d.ts +2 -1
  33. package/build/types/quantize.d.ts +102 -0
  34. package/build/types/symbols.d.ts +15 -0
  35. package/package.json +8 -1
  36. package/src/Metadata.ts +34 -9
  37. package/src/Reflection.ts +49 -11
  38. package/src/Schema.ts +64 -2
  39. package/src/annotations.ts +133 -51
  40. package/src/bench_churn.ts +121 -0
  41. package/src/codegen/languages/haxe.ts +0 -16
  42. package/src/codegen/parser.ts +29 -11
  43. package/src/codegen/types.ts +45 -63
  44. package/src/decoder/DecodeOperation.ts +46 -4
  45. package/src/decoder/Decoder.ts +48 -0
  46. package/src/decoder/ReferenceTracker.ts +9 -5
  47. package/src/decoder/Resync.ts +170 -0
  48. package/src/encoder/ChangeTree.ts +59 -0
  49. package/src/encoder/Encoder.ts +53 -12
  50. package/src/encoder/Pool.ts +90 -0
  51. package/src/encoder/Root.ts +22 -32
  52. package/src/encoder/StateView.ts +101 -50
  53. package/src/encoder/changeTree/liveIteration.ts +4 -0
  54. package/src/encoder/changeTree/treeAttachment.ts +28 -24
  55. package/src/index.ts +12 -2
  56. package/src/input/InputDecoder.ts +11 -5
  57. package/src/input/InputEncoder.ts +85 -126
  58. package/src/types/HelperTypes.ts +30 -0
  59. package/src/types/TypeContext.ts +7 -0
  60. package/src/types/builder.ts +62 -5
  61. package/src/types/custom/ArraySchema.ts +70 -12
  62. package/src/types/custom/CollectionSchema.ts +36 -1
  63. package/src/types/custom/MapSchema.ts +50 -1
  64. package/src/types/custom/SetSchema.ts +36 -1
  65. package/src/types/custom/StreamSchema.ts +7 -0
  66. package/src/types/quantize.ts +173 -0
  67. package/src/types/symbols.ts +16 -0
  68. package/build/encoder/RefIdAllocator.d.ts +0 -35
  69. package/src/encoder/RefIdAllocator.ts +0 -68
package/src/Schema.ts CHANGED
@@ -4,7 +4,7 @@ import { DEFAULT_VIEW_TAG, type DefinitionType } from "./annotations.js";
4
4
  import { AssignableProps, NonFunctionPropNames, ToJSON } from './types/HelperTypes.js';
5
5
 
6
6
  import { ChangeTree, installUntrackedChangeTree, IRef, Ref } from './encoder/ChangeTree.js';
7
- import { $changes, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $numFields, $refId, $track, $values } from './types/symbols.js';
7
+ import { $changes, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $numFields, $refId, $refTypeFieldIndexes, $reset, $track, $values } from './types/symbols.js';
8
8
  import { StateView } from './encoder/StateView.js';
9
9
 
10
10
  import { encodeSchemaOperation } from './encoder/EncodeOperation.js';
@@ -66,6 +66,67 @@ export class Schema<C = any> implements IRef {
66
66
  return inst;
67
67
  }
68
68
 
69
+ /**
70
+ * Reset a DETACHED instance to construction defaults so it can be returned
71
+ * to a {@link SchemaPool} and reused, avoiding the cost of `new`. Recurses
72
+ * into ref-type fields (child Schemas / collections).
73
+ *
74
+ * Preconditions (enforced):
75
+ * - The instance must be tracked (encoder-side), not a decoder mirror.
76
+ * - The instance must NOT be shared across multiple parents.
77
+ * - The instance must already be removed from its parent collection/field
78
+ * (so the encoder detached it: `root === undefined`).
79
+ *
80
+ * NOTE: primitive field values are NOT reset to class defaults — re-assign
81
+ * the fields you care about when you reuse the instance (standard
82
+ * object-pool discipline).
83
+ */
84
+ static reset(instance: Schema): void {
85
+ const changeTree: ChangeTree = (instance as any)?.[$changes];
86
+
87
+ // Only tracked (encoder-side) instances are poolable. Decoder-side
88
+ // instances carry an UntrackedChangeTree, which has no recycle().
89
+ if (changeTree === undefined || typeof (changeTree as any).recycle !== "function") {
90
+ throw new Error(`@colyseus/schema: Schema.reset() requires a tracked (encoder-side) instance.`);
91
+ }
92
+
93
+ // Instances reachable through more than one parent are unsafe to pool:
94
+ // another owner may still hold this instance.
95
+ if (changeTree.extraParents !== undefined) {
96
+ throw new Error(`@colyseus/schema: cannot reset a shared instance (${instance.constructor.name}) with multiple parents.`);
97
+ }
98
+
99
+ (instance as any)[$reset]();
100
+ }
101
+
102
+ /**
103
+ * Per-instance reset primitive (the recursive worker behind
104
+ * {@link Schema.reset}). Resets ref-type children first (depth-first),
105
+ * then recycles this instance's ChangeTree and drops its `$refId` so a
106
+ * re-add is assigned a fresh refId exactly like a freshly constructed
107
+ * instance. Dropping `$refId` is what makes instance reuse
108
+ * wire-format-identical to `new T()`.
109
+ */
110
+ [$reset](): void {
111
+ const metadata: Metadata = (this.constructor as typeof Schema)[Symbol.metadata];
112
+ const refIndexes = (metadata?.[$refTypeFieldIndexes] as number[]) ?? [];
113
+ const values = this[$values];
114
+
115
+ for (let i = 0; i < refIndexes.length; i++) {
116
+ const child = values[refIndexes[i]];
117
+ // ref fields hold a child Schema or collection (both implement
118
+ // [$reset]); skip undefined/null. Optional-chain is a cheap guard.
119
+ child?.[$reset]?.();
120
+ }
121
+
122
+ this[$changes].recycle();
123
+ // Clear the refId by ASSIGNMENT (not `delete`): `delete` would force the
124
+ // instance into V8 dictionary mode, making release() as expensive as the
125
+ // construction it saves. `=== undefined` in Root.add still assigns a fresh
126
+ // refId exactly like a freshly-constructed instance.
127
+ this[$refId] = undefined;
128
+ }
129
+
69
130
  /**
70
131
  * Check whether `type` describes a Schema *class* (a subclass
71
132
  * constructor carrying `Symbol.metadata`, as installed by `@type`).
@@ -132,7 +193,8 @@ export class Schema<C = any> implements IRef {
132
193
  return view.isChangeTreeVisible(ref[$changes]);
133
194
 
134
195
  } else {
135
- // view pass: custom tag
196
+ // view pass: custom tag (bitmask) — field's stored mask matches
197
+ // if it shares any bit with a tag this view was add()ed with.
136
198
  return view.hasTagOnTree(ref[$changes], tag);
137
199
  }
138
200
  }
@@ -13,7 +13,8 @@ import type { InferValueType, InferSchemaInstanceType, AssignableProps, BuilderI
13
13
  import { CollectionSchema } from "./types/custom/CollectionSchema.js";
14
14
  import { SetSchema } from "./types/custom/SetSchema.js";
15
15
  import { StreamSchema } from "./types/custom/StreamSchema.js";
16
- import { FieldBuilder, isBuilder } from "./types/builder.js";
16
+ import { FieldBuilder, isBuilder, type BuilderDefinition } from "./types/builder.js";
17
+ import { dequantize, isQuantizedType, makeQuantizedEncoder, quantize, type QuantizeDescriptor } from "./types/quantize.js";
17
18
 
18
19
  export type RawPrimitiveType = "string" |
19
20
  "number" |
@@ -51,8 +52,15 @@ export interface TypeOptions {
51
52
 
52
53
  export const DEFAULT_VIEW_TAG = -1;
53
54
 
54
- export function entity(constructor: any): any {
55
- TypeContext.register(constructor as typeof Schema);
55
+ /**
56
+ * Class decorator that registers a `@type`-style Schema class with the
57
+ * TypeContext (required for reflection / cross-language codegen).
58
+ *
59
+ * @entity
60
+ * class Player extends Schema { ... }
61
+ */
62
+ export function entity<T extends Function>(constructor: T): T {
63
+ TypeContext.register(constructor as unknown as typeof Schema);
56
64
  return constructor;
57
65
  }
58
66
 
@@ -336,8 +344,8 @@ export function type (
336
344
  Object.defineProperty(target, field, metadata[$descriptors][field]);
337
345
  }
338
346
 
339
- // Pre-compute encoder function for primitive types.
340
- if (typeof type === "string") {
347
+ // Pre-compute encoder function for primitive + quantized types.
348
+ if (typeof type === "string" || isQuantizedType(type)) {
341
349
  if (!metadata[$encoders]) {
342
350
  Object.defineProperty(metadata, $encoders, {
343
351
  value: [],
@@ -346,7 +354,9 @@ export function type (
346
354
  writable: true,
347
355
  });
348
356
  }
349
- metadata[$encoders][fieldIndex] = (encode as any)[type];
357
+ metadata[$encoders][fieldIndex] = (typeof type === "string")
358
+ ? (encode as any)[type]
359
+ : makeQuantizedEncoder((type as any).quantized);
350
360
  }
351
361
  }
352
362
  }
@@ -474,6 +484,38 @@ function makeCollectionSetter(
474
484
  };
475
485
  }
476
486
 
487
+ /**
488
+ * Setter for a `t.quantized()` field. SNAPS the assigned float to the wire-exact
489
+ * value (`dequant(quant(x))`) on write, so the stored value — and every read,
490
+ * including the reconciler's live step off the staged input — is identical to
491
+ * what the server decodes off the wire. This is the half that kills the
492
+ * predict-from-the-wrong-value footgun; the encoder re-quantizes the snapped
493
+ * value at send (a lossless round-trip). Change tracking keys on the SNAPPED
494
+ * value, so a sub-step jitter that quantizes to the same integer emits no delta.
495
+ */
496
+ function makeQuantizedSetter(fieldName: string, fieldIndex: number, desc: QuantizeDescriptor) {
497
+ return function (this: Schema, value: any) {
498
+ const values = this[$values];
499
+ const previousValue = values[fieldIndex];
500
+ if (value !== undefined && value !== null) {
501
+ if (typeof value !== "number") {
502
+ throw new EncodeSchemaError(
503
+ `a 'number' was expected, but '${JSON.stringify(value)}' was provided in ${this.constructor.name}#${fieldName}`
504
+ );
505
+ }
506
+ value = dequantize(desc, quantize(desc, value)); // snap to wire-exact
507
+ if (value === previousValue) return;
508
+ (this.constructor as typeof Schema)[$track](this[$changes], fieldIndex, OPERATION.ADD);
509
+ } else {
510
+ if (value === previousValue) return; // undefined === undefined
511
+ if (previousValue !== undefined && previousValue !== null) {
512
+ this[$changes].delete(fieldIndex);
513
+ }
514
+ }
515
+ values[fieldIndex] = value;
516
+ };
517
+ }
518
+
477
519
  export function getPropertyDescriptor(
478
520
  fieldName: string,
479
521
  fieldIndex: number,
@@ -485,10 +527,14 @@ export function getPropertyDescriptor(
485
527
  setter = makeCollectionSetter(fieldName, fieldIndex, type, complexTypeKlass);
486
528
  } else if (typeof type === "string") {
487
529
  setter = makePrimitiveSetter(fieldName, fieldIndex, type);
530
+ } else if (isQuantizedType(type)) {
531
+ setter = makeQuantizedSetter(fieldName, fieldIndex, type.quantized);
488
532
  } else {
489
533
  setter = makeSchemaRefSetter(fieldName, fieldIndex, type as typeof Schema);
490
534
  }
491
535
  return {
536
+ // Quantized stores the already-snapped float, so the getter is the plain
537
+ // $values read — the field yields dequant(q) with no per-read math.
492
538
  get: function (this: Schema) { return this[$values][fieldIndex]; },
493
539
  set: setter,
494
540
  enumerable: true,
@@ -620,20 +666,21 @@ export interface SchemaWithExtendsConstructor<
620
666
  }
621
667
 
622
668
  /**
623
- * Produce the auto-instantiated construction default for a builder type
624
- * (empty collection or zero-arg Schema ref), or `undefined` when the type
625
- * has no auto-default. Shared by synced and `.noSync()` field handling.
669
+ * Build a per-construction factory for a builder type's auto-instantiated
670
+ * default (empty collection or zero-arg Schema ref), or `undefined` when the
671
+ * type has no auto-default. Returning a factory lets each construction `new` a
672
+ * fresh value directly instead of cloning a shared prototype instance.
626
673
  */
627
- function autoInstantiateDefault(rawType: any): any {
674
+ function makeAutoDefaultFactory(rawType: any): (() => any) | undefined {
628
675
  if (rawType && typeof rawType === "object") {
629
- if (rawType.array !== undefined) { return new ArraySchema(); }
630
- if (rawType.map !== undefined) { return new MapSchema(); }
631
- if (rawType.set !== undefined) { return new SetSchema(); }
632
- if (rawType.collection !== undefined) { return new CollectionSchema(); }
633
- if (rawType.stream !== undefined) { return new StreamSchema(); }
676
+ if (rawType.array !== undefined) { return () => new ArraySchema(); }
677
+ if (rawType.map !== undefined) { return () => new MapSchema(); }
678
+ if (rawType.set !== undefined) { return () => new SetSchema(); }
679
+ if (rawType.collection !== undefined) { return () => new CollectionSchema(); }
680
+ if (rawType.stream !== undefined) { return () => new StreamSchema(); }
634
681
  } else if (typeof rawType === "function" && Schema.is(rawType)) {
635
682
  if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
636
- return new rawType();
683
+ return () => new rawType();
637
684
  }
638
685
  }
639
686
  return undefined;
@@ -669,7 +716,37 @@ export function schema<
669
716
 
670
717
  const fields: any = {};
671
718
  const methods: any = {};
719
+ // Two buckets, both keyed by field name and applied at construction:
720
+ // - `defaultValues`: static values copied as-is (shared reference).
721
+ // - `defaultFactories`: invoked per construction for a fresh value —
722
+ // `.default(fn)`, clone-able defaults, and auto-instantiated collections/refs.
672
723
  const defaultValues: any = {};
724
+ const defaultFactories: { [field: string]: () => any } = {};
725
+
726
+ // Decide once (at definition time) how each `.default(v)` materializes per
727
+ // construction: a function is a factory; a clone-able value clones fresh;
728
+ // anything else is a shared static value.
729
+ const assignDefault = (field: string, value: any) => {
730
+ if (typeof value === "function") {
731
+ defaultFactories[field] = value;
732
+ } else if (value && typeof value.clone === "function") {
733
+ defaultFactories[field] = () => value.clone();
734
+ } else {
735
+ defaultValues[field] = value;
736
+ }
737
+ };
738
+
739
+ // Seed a field's construction default: explicit `.default(v)`, else the
740
+ // auto-instantiated empty collection / zero-arg ref (skipped for `.optional()`).
741
+ const seedDefault = (field: string, def: BuilderDefinition) => {
742
+ if (def.hasDefault) {
743
+ assignDefault(field, def.default);
744
+ } else if (!def.optional) {
745
+ const factory = makeAutoDefaultFactory(def.type);
746
+ if (factory) { defaultFactories[field] = factory; }
747
+ }
748
+ };
749
+
673
750
  const viewTagFields: { [field: string]: number } = {};
674
751
  const ownedFields: string[] = [];
675
752
  const unreliableFields: string[] = [];
@@ -698,18 +775,19 @@ export function schema<
698
775
  `A local-only field cannot be synchronized.`
699
776
  );
700
777
  }
701
- if (def.hasDefault) {
702
- defaultValues[fieldName] = def.default;
703
- } else if (!def.optional) {
704
- const autoDefault = autoInstantiateDefault(def.type);
705
- if (autoDefault !== undefined) {
706
- defaultValues[fieldName] = autoDefault;
707
- }
708
- }
778
+ seedDefault(fieldName, def);
709
779
  continue;
710
780
  }
711
781
 
712
- fields[fieldName] = getNormalizedType(def.type);
782
+ const normalizedType = getNormalizedType(def.type);
783
+ // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
784
+ if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
785
+ throw new Error(
786
+ `schema(${name ? `'${name}'` : ""}): field '${fieldName}' is a synced ref to non-Schema ` +
787
+ `class '${normalizedType.name || "(anonymous)"}' — use .noSync(), or Metadata.setFields().`
788
+ );
789
+ }
790
+ fields[fieldName] = normalizedType;
713
791
 
714
792
  if (def.view !== undefined) { viewTagFields[fieldName] = def.view; }
715
793
  if (def.owned) { ownedFields.push(fieldName); }
@@ -721,23 +799,14 @@ export function schema<
721
799
  if (def.streamPriority !== undefined) { streamPriorityFields[fieldName] = def.streamPriority; }
722
800
  if (def.optional) { optionalFields.push(fieldName); }
723
801
 
724
- if (def.hasDefault) {
725
- defaultValues[fieldName] = def.default;
726
- } else if (!def.optional) {
727
- // Auto-instantiate collection/Schema defaults when none is provided.
728
- // `.optional()` opts out — field starts as undefined.
729
- const autoDefault = autoInstantiateDefault(def.type);
730
- if (autoDefault !== undefined) {
731
- defaultValues[fieldName] = autoDefault;
732
- }
733
- }
802
+ seedDefault(fieldName, def);
734
803
 
735
804
  } else if (typeof value === "function") {
736
805
  if (Schema.is(value)) {
737
806
  // Convenience: allow a bare Schema subclass (equivalent to `t.ref(Class)`).
738
807
  fields[fieldName] = getNormalizedType(value);
739
808
  if (!value.prototype.initialize || value.prototype.initialize.length === 0) {
740
- defaultValues[fieldName] = new value();
809
+ defaultFactories[fieldName] = () => new (value as any)();
741
810
  }
742
811
  } else {
743
812
  methods[fieldName] = value;
@@ -751,16 +820,20 @@ export function schema<
751
820
  }
752
821
  }
753
822
 
754
- const getDefaultValues = () => {
755
- const defaults: any = {};
823
+ // Write construction defaults onto `target` — either the instance directly
824
+ // (no-args fast path) or a throwaway object that gets merged with props.
825
+ const applyDefaults = (target: any) => {
756
826
  for (const fieldName in defaultValues) {
757
- const defaultValue = defaultValues[fieldName];
758
- if (defaultValue && typeof defaultValue.clone === "function") {
759
- defaults[fieldName] = defaultValue.clone();
760
- } else {
761
- defaults[fieldName] = defaultValue;
762
- }
827
+ target[fieldName] = defaultValues[fieldName];
763
828
  }
829
+ for (const fieldName in defaultFactories) {
830
+ target[fieldName] = defaultFactories[fieldName]();
831
+ }
832
+ };
833
+
834
+ const getDefaultValues = () => {
835
+ const defaults: any = {};
836
+ applyDefaults(defaults);
764
837
  return defaults;
765
838
  };
766
839
 
@@ -775,17 +848,26 @@ export function schema<
775
848
  return parentProps;
776
849
  };
777
850
 
851
+ const hasInitialize = typeof methods.initialize === "function";
852
+
778
853
  /** @codegen-ignore */
779
854
  const klass = Metadata.setFields<any>(class extends (inherits as any) {
780
855
  constructor(...args: any[]) {
781
- if (methods.initialize && typeof methods.initialize === "function") {
782
- super(Object.assign({}, getDefaultValues(), getParentProps(args[0] || {})));
783
- // Only call initialize() on the exact target class, not parents.
784
- if (new.target === klass) {
785
- methods.initialize.apply(this, args);
786
- }
856
+ const props = args[0];
857
+ if (props === undefined) {
858
+ // No-args: write defaults straight onto the instance skips the
859
+ // throwaway defaults object + Object.assign + assignProps walk.
860
+ super();
861
+ applyDefaults(this);
787
862
  } else {
788
- super(Object.assign({}, getDefaultValues(), args[0] || {}));
863
+ // With props: merge into the fresh defaults object in place (no
864
+ // extra `{}` target); the super chain runs assignProps once. An
865
+ // `initialize()` owns the schema fields, so only parent props flow up.
866
+ super(Object.assign(getDefaultValues(), hasInitialize ? getParentProps(props) : props));
867
+ }
868
+ // Only call initialize() on the exact target class, not parents.
869
+ if (hasInitialize && new.target === klass) {
870
+ methods.initialize.apply(this, args);
789
871
  }
790
872
  }
791
873
  }, fields) as unknown as SchemaWithExtendsConstructor<T, ExtractInitProps<T>, P>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Benchmark: entity churn (spawn → despawn → respawn) with and without the
3
+ * native SchemaPool. Existing benches (bench_encode/bench_bloat) are
4
+ * monotonic-growth and never return instances to a pool, so they can't show
5
+ * pooling ROI — this one models the ECS/projectile/bot workload the pool
6
+ * targets.
7
+ *
8
+ * Usage: npx tsx --tsconfig tsconfig.test.json src/bench_churn.ts
9
+ */
10
+ import v8 from "node:v8";
11
+ import vm from "node:vm";
12
+ import { Schema, type, MapSchema, Encoder, createPool, type SchemaPool } from "./index";
13
+
14
+ // gc() handle (works under the tsx CLI, where `node --expose-gc` doesn't reach
15
+ // the forked child).
16
+ v8.setFlagsFromString("--expose-gc");
17
+ const gc = (globalThis.gc as undefined | (() => void)) ?? (vm.runInNewContext("gc") as () => void);
18
+
19
+ class Vector extends Schema {
20
+ @type("number") x: number = 0;
21
+ @type("number") y: number = 0;
22
+ @type("number") z: number = 0;
23
+ }
24
+ // A nested entity: Entity + 2 child Vectors = 3 Schema instances per spawn,
25
+ // matching the miniplex profiling scenario.
26
+ class Entity extends Schema {
27
+ @type("string") name: string = "";
28
+ @type(Vector) position = new Vector();
29
+ @type(Vector) velocity = new Vector();
30
+ }
31
+ class State extends Schema {
32
+ @type({ map: Entity }) entities = new MapSchema<Entity>();
33
+ }
34
+
35
+ Encoder.BUFFER_SIZE = 4096 * 4096;
36
+
37
+ const N = 2_000; // entities alive per tick
38
+ const TICKS = 200; // churn cycles
39
+ const WARMUP = 10;
40
+ const INSTANCES_PER_ENTITY = 3; // Entity + position + velocity
41
+ const spawns = N * TICKS;
42
+
43
+ const heap = () => {
44
+ gc?.();
45
+ gc?.();
46
+ return process.memoryUsage().heapUsed;
47
+ };
48
+
49
+ function runChurn(usePool: boolean) {
50
+ const state = new State();
51
+ const encoder = new Encoder(state);
52
+ encoder.encode();
53
+ encoder.discardChanges();
54
+
55
+ const pool: SchemaPool<Entity> | undefined = usePool ? createPool(Entity, { preallocate: N }) : undefined;
56
+ const live: Entity[] = new Array(N);
57
+
58
+ const spawnTick = (timeConstruct: boolean): number => {
59
+ // CONSTRUCTION phase (the differentiator): new vs acquire
60
+ const c0 = performance.now();
61
+ for (let i = 0; i < N; i++) {
62
+ live[i] = pool ? pool.acquire() : new Entity();
63
+ }
64
+ const c1 = performance.now();
65
+
66
+ // init + attach + encode
67
+ for (let i = 0; i < N; i++) {
68
+ const e = live[i];
69
+ e.name = "e" + i;
70
+ e.position.x = i; e.position.y = i; e.position.z = i;
71
+ e.velocity.x = 1;
72
+ state.entities.set(String(i), e);
73
+ }
74
+ encoder.encode();
75
+ encoder.discardChanges();
76
+
77
+ // despawn + release
78
+ for (let i = 0; i < N; i++) state.entities.delete(String(i));
79
+ encoder.encode();
80
+ encoder.discardChanges();
81
+ if (pool) for (let i = 0; i < N; i++) pool.release(live[i]);
82
+
83
+ return timeConstruct ? c1 - c0 : 0;
84
+ };
85
+
86
+ for (let t = 0; t < WARMUP; t++) spawnTick(false);
87
+
88
+ const before = heap();
89
+ let constructMs = 0;
90
+ const t0 = performance.now();
91
+ for (let t = 0; t < TICKS; t++) constructMs += spawnTick(true);
92
+ const t1 = performance.now();
93
+ const after = heap();
94
+
95
+ return {
96
+ cycleMs: t1 - t0,
97
+ constructMs,
98
+ constructNsPerEntity: (constructMs * 1e6) / spawns,
99
+ heapDelta: after - before,
100
+ poolSize: pool?.size ?? 0
101
+ };
102
+ }
103
+
104
+ console.log(`\nEntity churn benchmark — ${N} entities × ${TICKS} ticks = ${spawns.toLocaleString("en-US")} spawns`);
105
+ console.log(`(each entity = ${INSTANCES_PER_ENTITY} Schema instances; gc=${gc ? "on" : "off"})\n`);
106
+
107
+ const noPool = runChurn(false);
108
+ const pooled = runChurn(true);
109
+
110
+ const row = (label: string, r: ReturnType<typeof runChurn>) => {
111
+ console.log(`${label.padEnd(14)} construct: ${r.constructMs.toFixed(1).padStart(8)} ms ` +
112
+ `${r.constructNsPerEntity.toFixed(0).padStart(6)} ns/entity ` +
113
+ `full cycle: ${r.cycleMs.toFixed(1).padStart(8)} ms ` +
114
+ `heapΔ: ${(r.heapDelta / 1024 / 1024).toFixed(1).padStart(7)} MB`);
115
+ };
116
+
117
+ row("new Entity()", noPool);
118
+ row("pool.acquire()", pooled);
119
+ console.log(`\nConstruction speedup: ${(noPool.constructNsPerEntity / pooled.constructNsPerEntity).toFixed(1)}x faster` +
120
+ ` (full-cycle: ${(noPool.cycleMs / pooled.cycleMs).toFixed(2)}x — diluted by identical encode/decode cost)`);
121
+ console.log(`Pool retained ${pooled.poolSize} instances after the run.\n`);
@@ -67,22 +67,6 @@ ${classBodies.join("\n\n")}
67
67
  return { name: fileName, content };
68
68
  }
69
69
 
70
- function getInheritanceTree(klass: Class, allClasses: Class[], includeSelf: boolean = true) {
71
- let currentClass = klass;
72
- let inheritanceTree: Class[] = [];
73
-
74
- if (includeSelf) {
75
- inheritanceTree.push(currentClass);
76
- }
77
-
78
- while (currentClass.extends !== "Schema") {
79
- currentClass = allClasses.find(klass => klass.name == currentClass.extends);
80
- inheritanceTree.push(currentClass);
81
- }
82
-
83
- return inheritanceTree;
84
- }
85
-
86
70
  /**
87
71
  * Generate just the class body (without package/imports) for bundling
88
72
  */
@@ -278,7 +278,7 @@ function inspectNode(node: ts.Node, context: Context, decoratorName: string) {
278
278
  if (!callee) break;
279
279
 
280
280
  const isSchemaCall = callee === "schema" || callee === "schema.schema";
281
- const isExtendCall = callee.indexOf(".extend") !== -1 && !callee.endsWith(".extends");
281
+ const isExtendCall = callee.endsWith(".extend");
282
282
  if (!isSchemaCall && !isExtendCall) break;
283
283
 
284
284
  // Signature: (fields, name?)
@@ -297,25 +297,43 @@ function inspectNode(node: ts.Node, context: Context, decoratorName: string) {
297
297
  }
298
298
  }
299
299
 
300
- if (!className && callExpression.parent.kind === ts.SyntaxKind.VariableDeclaration) {
301
- className = (callExpression.parent as ts.VariableDeclaration).name?.getText();
300
+ if (!className) {
301
+ // No explicit name arg — infer it from the variable the
302
+ // result is assigned to (`const Foo = schema({...})`).
303
+ let p: ts.Node = callExpression.parent;
304
+ while (p !== undefined && (
305
+ p.kind === ts.SyntaxKind.PropertyAccessExpression ||
306
+ p.kind === ts.SyntaxKind.CallExpression
307
+ )) {
308
+ p = p.parent;
309
+ }
310
+ if (p?.kind === ts.SyntaxKind.VariableDeclaration) {
311
+ className = (p as ts.VariableDeclaration).name?.getText();
312
+ }
302
313
  }
303
314
 
304
315
  if (!className) break;
305
316
 
317
+ // Resolve the base class BEFORE registering a structure. A
318
+ // chained `schema({...}).extend({...})` has a call expression
319
+ // (not an identifier) as its `.extend` base, which can't be
320
+ // statically named — bail here rather than leave a half-formed,
321
+ // nameless Class in the context (which corrupts inheritance walks).
322
+ let extendsClass = "Schema";
323
+ if (isExtendCall) {
324
+ extendsClass = (node as any).expression?.expression?.escapedText;
325
+ if (!extendsClass) {
326
+ console.warn(`schema-codegen: cannot resolve the base class of a chained .extend() for '${className}' — fields from that .extend({...}) are omitted.`);
327
+ break;
328
+ }
329
+ }
330
+
306
331
  if (currentStructure?.name !== className) {
307
332
  currentStructure = new Class();
308
333
  context.addStructure(currentStructure);
309
334
  }
310
335
 
311
- if (isExtendCall) {
312
- const extendsClass = (node as any).expression?.expression?.escapedText;
313
- if (!extendsClass) break;
314
- (currentStructure as Class).extends = extendsClass;
315
- } else {
316
- (currentStructure as Class).extends = "Schema";
317
- }
318
-
336
+ (currentStructure as Class).extends = extendsClass;
319
337
  currentStructure.name = className;
320
338
 
321
339
  const types = fieldsArg as any;