@colyseus/schema 5.0.6 → 5.0.9

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 +16 -1
  4. package/build/codegen/cli.cjs +103 -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 +1211 -284
  15. package/build/index.cjs.map +1 -1
  16. package/build/index.d.ts +4 -1
  17. package/build/index.js +1211 -284
  18. package/build/index.mjs +1207 -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 +3 -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 +157 -51
  40. package/src/bench_churn.ts +121 -0
  41. package/src/codegen/languages/haxe.ts +0 -16
  42. package/src/codegen/parser.ts +69 -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 -1
  56. package/src/input/InputDecoder.ts +11 -5
  57. package/src/input/InputEncoder.ts +85 -126
  58. package/src/types/HelperTypes.ts +7 -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,
@@ -529,6 +575,30 @@ export function deprecated(throws: boolean = true): PropertyDecorator {
529
575
  }
530
576
  }
531
577
 
578
+ let defineTypesWarned = false;
579
+
580
+ /**
581
+ * Adds synchronizable fields to an existing `Schema` subclass — the pre-5.0
582
+ * helper for plain JavaScript users.
583
+ *
584
+ * @deprecated Use `schema()` with `t.*` field builders instead:
585
+ * https://docs.colyseus.io/state/schema
586
+ */
587
+ export function defineTypes(
588
+ target: typeof Schema,
589
+ fields: Definition,
590
+ options?: TypeOptions
591
+ ) {
592
+ if (!defineTypesWarned) {
593
+ defineTypesWarned = true;
594
+ console.warn("@colyseus/schema: defineTypes() is deprecated and will be removed in a future release. Use schema() with t.* field builders instead → https://docs.colyseus.io/state/schema");
595
+ }
596
+ for (let field in fields) {
597
+ type(fields[field], options)(target.prototype, field);
598
+ }
599
+ return target;
600
+ }
601
+
532
602
  // Helper type to extract InitProps from initialize method.
533
603
  // - Non-empty initialize params: use them directly.
534
604
  // - Zero-arg initialize: no args accepted (`never`) — user-supplied field
@@ -620,20 +690,21 @@ export interface SchemaWithExtendsConstructor<
620
690
  }
621
691
 
622
692
  /**
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.
693
+ * Build a per-construction factory for a builder type's auto-instantiated
694
+ * default (empty collection or zero-arg Schema ref), or `undefined` when the
695
+ * type has no auto-default. Returning a factory lets each construction `new` a
696
+ * fresh value directly instead of cloning a shared prototype instance.
626
697
  */
627
- function autoInstantiateDefault(rawType: any): any {
698
+ function makeAutoDefaultFactory(rawType: any): (() => any) | undefined {
628
699
  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(); }
700
+ if (rawType.array !== undefined) { return () => new ArraySchema(); }
701
+ if (rawType.map !== undefined) { return () => new MapSchema(); }
702
+ if (rawType.set !== undefined) { return () => new SetSchema(); }
703
+ if (rawType.collection !== undefined) { return () => new CollectionSchema(); }
704
+ if (rawType.stream !== undefined) { return () => new StreamSchema(); }
634
705
  } else if (typeof rawType === "function" && Schema.is(rawType)) {
635
706
  if (!rawType.prototype.initialize || rawType.prototype.initialize.length === 0) {
636
- return new rawType();
707
+ return () => new rawType();
637
708
  }
638
709
  }
639
710
  return undefined;
@@ -669,7 +740,37 @@ export function schema<
669
740
 
670
741
  const fields: any = {};
671
742
  const methods: any = {};
743
+ // Two buckets, both keyed by field name and applied at construction:
744
+ // - `defaultValues`: static values copied as-is (shared reference).
745
+ // - `defaultFactories`: invoked per construction for a fresh value —
746
+ // `.default(fn)`, clone-able defaults, and auto-instantiated collections/refs.
672
747
  const defaultValues: any = {};
748
+ const defaultFactories: { [field: string]: () => any } = {};
749
+
750
+ // Decide once (at definition time) how each `.default(v)` materializes per
751
+ // construction: a function is a factory; a clone-able value clones fresh;
752
+ // anything else is a shared static value.
753
+ const assignDefault = (field: string, value: any) => {
754
+ if (typeof value === "function") {
755
+ defaultFactories[field] = value;
756
+ } else if (value && typeof value.clone === "function") {
757
+ defaultFactories[field] = () => value.clone();
758
+ } else {
759
+ defaultValues[field] = value;
760
+ }
761
+ };
762
+
763
+ // Seed a field's construction default: explicit `.default(v)`, else the
764
+ // auto-instantiated empty collection / zero-arg ref (skipped for `.optional()`).
765
+ const seedDefault = (field: string, def: BuilderDefinition) => {
766
+ if (def.hasDefault) {
767
+ assignDefault(field, def.default);
768
+ } else if (!def.optional) {
769
+ const factory = makeAutoDefaultFactory(def.type);
770
+ if (factory) { defaultFactories[field] = factory; }
771
+ }
772
+ };
773
+
673
774
  const viewTagFields: { [field: string]: number } = {};
674
775
  const ownedFields: string[] = [];
675
776
  const unreliableFields: string[] = [];
@@ -698,18 +799,19 @@ export function schema<
698
799
  `A local-only field cannot be synchronized.`
699
800
  );
700
801
  }
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
- }
802
+ seedDefault(fieldName, def);
709
803
  continue;
710
804
  }
711
805
 
712
- fields[fieldName] = getNormalizedType(def.type);
806
+ const normalizedType = getNormalizedType(def.type);
807
+ // A synced ref must be encodable (a Schema, or Metadata.setFields()'d) — reject a bare class.
808
+ if (typeof normalizedType === "function" && !Schema.is(normalizedType)) {
809
+ throw new Error(
810
+ `schema(${name ? `'${name}'` : ""}): field '${fieldName}' is a synced ref to non-Schema ` +
811
+ `class '${normalizedType.name || "(anonymous)"}' — use .noSync(), or Metadata.setFields().`
812
+ );
813
+ }
814
+ fields[fieldName] = normalizedType;
713
815
 
714
816
  if (def.view !== undefined) { viewTagFields[fieldName] = def.view; }
715
817
  if (def.owned) { ownedFields.push(fieldName); }
@@ -721,23 +823,14 @@ export function schema<
721
823
  if (def.streamPriority !== undefined) { streamPriorityFields[fieldName] = def.streamPriority; }
722
824
  if (def.optional) { optionalFields.push(fieldName); }
723
825
 
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
- }
826
+ seedDefault(fieldName, def);
734
827
 
735
828
  } else if (typeof value === "function") {
736
829
  if (Schema.is(value)) {
737
830
  // Convenience: allow a bare Schema subclass (equivalent to `t.ref(Class)`).
738
831
  fields[fieldName] = getNormalizedType(value);
739
832
  if (!value.prototype.initialize || value.prototype.initialize.length === 0) {
740
- defaultValues[fieldName] = new value();
833
+ defaultFactories[fieldName] = () => new (value as any)();
741
834
  }
742
835
  } else {
743
836
  methods[fieldName] = value;
@@ -751,16 +844,20 @@ export function schema<
751
844
  }
752
845
  }
753
846
 
754
- const getDefaultValues = () => {
755
- const defaults: any = {};
847
+ // Write construction defaults onto `target` — either the instance directly
848
+ // (no-args fast path) or a throwaway object that gets merged with props.
849
+ const applyDefaults = (target: any) => {
756
850
  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
- }
851
+ target[fieldName] = defaultValues[fieldName];
852
+ }
853
+ for (const fieldName in defaultFactories) {
854
+ target[fieldName] = defaultFactories[fieldName]();
763
855
  }
856
+ };
857
+
858
+ const getDefaultValues = () => {
859
+ const defaults: any = {};
860
+ applyDefaults(defaults);
764
861
  return defaults;
765
862
  };
766
863
 
@@ -775,17 +872,26 @@ export function schema<
775
872
  return parentProps;
776
873
  };
777
874
 
875
+ const hasInitialize = typeof methods.initialize === "function";
876
+
778
877
  /** @codegen-ignore */
779
878
  const klass = Metadata.setFields<any>(class extends (inherits as any) {
780
879
  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
- }
880
+ const props = args[0];
881
+ if (props === undefined) {
882
+ // No-args: write defaults straight onto the instance skips the
883
+ // throwaway defaults object + Object.assign + assignProps walk.
884
+ super();
885
+ applyDefaults(this);
787
886
  } else {
788
- super(Object.assign({}, getDefaultValues(), args[0] || {}));
887
+ // With props: merge into the fresh defaults object in place (no
888
+ // extra `{}` target); the super chain runs assignProps once. An
889
+ // `initialize()` owns the schema fields, so only parent props flow up.
890
+ super(Object.assign(getDefaultValues(), hasInitialize ? getParentProps(props) : props));
891
+ }
892
+ // Only call initialize() on the exact target class, not parents.
893
+ if (hasInitialize && new.target === klass) {
894
+ methods.initialize.apply(this, args);
789
895
  }
790
896
  }
791
897
  }, 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
  */
@@ -8,6 +8,8 @@ let currentProperty: Property;
8
8
 
9
9
  let globalContext: Context;
10
10
 
11
+ let defineTypesWarned = false;
12
+
11
13
  const BUILDER_COLLECTION_KINDS = new Set(["array", "map", "set", "collection"]);
12
14
 
13
15
  /**
@@ -254,6 +256,44 @@ function inspectNode(node: ts.Node, context: Context, decoratorName: string) {
254
256
  defineProperty(property, prop.initializer);
255
257
  }
256
258
 
259
+ } else if (
260
+ node.getText() === "defineTypes" &&
261
+ (
262
+ node.parent.kind === ts.SyntaxKind.CallExpression ||
263
+ node.parent.kind === ts.SyntaxKind.PropertyAccessExpression
264
+ )
265
+ ) {
266
+ /**
267
+ * JavaScript source file (`.js`)
268
+ * Using `defineTypes()` (deprecated)
269
+ */
270
+ const callExpression = (node.parent.kind === ts.SyntaxKind.PropertyAccessExpression)
271
+ ? node.parent.parent as ts.CallExpression
272
+ : node.parent as ts.CallExpression;
273
+
274
+ if (callExpression.kind !== ts.SyntaxKind.CallExpression) {
275
+ break;
276
+ }
277
+
278
+ if (!defineTypesWarned) {
279
+ defineTypesWarned = true;
280
+ console.warn("schema-codegen: defineTypes() is deprecated and will be removed in a future release. Use schema() with t.* field builders instead → https://docs.colyseus.io/state/schema");
281
+ }
282
+
283
+ const className = callExpression.arguments[0].getText()
284
+ currentStructure.name = className;
285
+
286
+ const types = callExpression.arguments[1] as any;
287
+ for (let i = 0; i < types.properties.length; i++) {
288
+ const prop = types.properties[i];
289
+
290
+ const property = currentProperty || new Property();
291
+ property.name = prop.name.escapedText;
292
+ currentStructure.addProperty(property);
293
+
294
+ defineProperty(property, prop.initializer);
295
+ }
296
+
257
297
  }
258
298
 
259
299
  if (node.parent.kind === ts.SyntaxKind.ClassDeclaration) {
@@ -278,7 +318,7 @@ function inspectNode(node: ts.Node, context: Context, decoratorName: string) {
278
318
  if (!callee) break;
279
319
 
280
320
  const isSchemaCall = callee === "schema" || callee === "schema.schema";
281
- const isExtendCall = callee.indexOf(".extend") !== -1 && !callee.endsWith(".extends");
321
+ const isExtendCall = callee.endsWith(".extend");
282
322
  if (!isSchemaCall && !isExtendCall) break;
283
323
 
284
324
  // Signature: (fields, name?)
@@ -297,25 +337,43 @@ function inspectNode(node: ts.Node, context: Context, decoratorName: string) {
297
337
  }
298
338
  }
299
339
 
300
- if (!className && callExpression.parent.kind === ts.SyntaxKind.VariableDeclaration) {
301
- className = (callExpression.parent as ts.VariableDeclaration).name?.getText();
340
+ if (!className) {
341
+ // No explicit name arg — infer it from the variable the
342
+ // result is assigned to (`const Foo = schema({...})`).
343
+ let p: ts.Node = callExpression.parent;
344
+ while (p !== undefined && (
345
+ p.kind === ts.SyntaxKind.PropertyAccessExpression ||
346
+ p.kind === ts.SyntaxKind.CallExpression
347
+ )) {
348
+ p = p.parent;
349
+ }
350
+ if (p?.kind === ts.SyntaxKind.VariableDeclaration) {
351
+ className = (p as ts.VariableDeclaration).name?.getText();
352
+ }
302
353
  }
303
354
 
304
355
  if (!className) break;
305
356
 
357
+ // Resolve the base class BEFORE registering a structure. A
358
+ // chained `schema({...}).extend({...})` has a call expression
359
+ // (not an identifier) as its `.extend` base, which can't be
360
+ // statically named — bail here rather than leave a half-formed,
361
+ // nameless Class in the context (which corrupts inheritance walks).
362
+ let extendsClass = "Schema";
363
+ if (isExtendCall) {
364
+ extendsClass = (node as any).expression?.expression?.escapedText;
365
+ if (!extendsClass) {
366
+ console.warn(`schema-codegen: cannot resolve the base class of a chained .extend() for '${className}' — fields from that .extend({...}) are omitted.`);
367
+ break;
368
+ }
369
+ }
370
+
306
371
  if (currentStructure?.name !== className) {
307
372
  currentStructure = new Class();
308
373
  context.addStructure(currentStructure);
309
374
  }
310
375
 
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
-
376
+ (currentStructure as Class).extends = extendsClass;
319
377
  currentStructure.name = className;
320
378
 
321
379
  const types = fieldsArg as any;