@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.
- package/build/Reflection.d.ts +53 -0
- package/build/Schema.d.ts +26 -1
- package/build/annotations.d.ts +8 -1
- package/build/codegen/cli.cjs +74 -64
- package/build/codegen/cli.cjs.map +1 -1
- package/build/codegen/types.d.ts +0 -1
- package/build/decoder/Decoder.d.ts +28 -0
- package/build/decoder/Resync.d.ts +61 -0
- package/build/encoder/ChangeTree.d.ts +15 -0
- package/build/encoder/Encoder.d.ts +13 -0
- package/build/encoder/Pool.d.ts +62 -0
- package/build/encoder/Root.d.ts +5 -4
- package/build/encoder/StateView.d.ts +12 -3
- package/build/index.cjs +1192 -284
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +4 -1
- package/build/index.js +1192 -284
- package/build/index.mjs +1189 -285
- package/build/index.mjs.map +1 -1
- package/build/input/InputDecoder.d.ts +9 -4
- package/build/input/InputEncoder.d.ts +44 -53
- package/build/input/index.cjs +102 -7321
- package/build/input/index.cjs.map +1 -1
- package/build/input/index.mjs +97 -7316
- package/build/input/index.mjs.map +1 -1
- package/build/types/HelperTypes.d.ts +25 -0
- package/build/types/builder.d.ts +57 -4
- package/build/types/custom/ArraySchema.d.ts +11 -1
- package/build/types/custom/CollectionSchema.d.ts +9 -1
- package/build/types/custom/MapSchema.d.ts +9 -1
- package/build/types/custom/SetSchema.d.ts +9 -1
- package/build/types/custom/StreamSchema.d.ts +2 -1
- package/build/types/quantize.d.ts +102 -0
- package/build/types/symbols.d.ts +15 -0
- package/package.json +8 -1
- package/src/Metadata.ts +34 -9
- package/src/Reflection.ts +49 -11
- package/src/Schema.ts +64 -2
- package/src/annotations.ts +133 -51
- package/src/bench_churn.ts +121 -0
- package/src/codegen/languages/haxe.ts +0 -16
- package/src/codegen/parser.ts +29 -11
- package/src/codegen/types.ts +45 -63
- package/src/decoder/DecodeOperation.ts +46 -4
- package/src/decoder/Decoder.ts +48 -0
- package/src/decoder/ReferenceTracker.ts +9 -5
- package/src/decoder/Resync.ts +170 -0
- package/src/encoder/ChangeTree.ts +59 -0
- package/src/encoder/Encoder.ts +53 -12
- package/src/encoder/Pool.ts +90 -0
- package/src/encoder/Root.ts +22 -32
- package/src/encoder/StateView.ts +101 -50
- package/src/encoder/changeTree/liveIteration.ts +4 -0
- package/src/encoder/changeTree/treeAttachment.ts +28 -24
- package/src/index.ts +12 -2
- package/src/input/InputDecoder.ts +11 -5
- package/src/input/InputEncoder.ts +85 -126
- package/src/types/HelperTypes.ts +30 -0
- package/src/types/TypeContext.ts +7 -0
- package/src/types/builder.ts +62 -5
- package/src/types/custom/ArraySchema.ts +70 -12
- package/src/types/custom/CollectionSchema.ts +36 -1
- package/src/types/custom/MapSchema.ts +50 -1
- package/src/types/custom/SetSchema.ts +36 -1
- package/src/types/custom/StreamSchema.ts +7 -0
- package/src/types/quantize.ts +173 -0
- package/src/types/symbols.ts +16 -0
- package/build/encoder/RefIdAllocator.d.ts +0 -35
- 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
|
}
|
package/src/annotations.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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] = (
|
|
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
|
-
*
|
|
624
|
-
* (empty collection or zero-arg Schema ref), or `undefined` when the
|
|
625
|
-
* has no auto-default.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/src/codegen/parser.ts
CHANGED
|
@@ -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.
|
|
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
|
|
301
|
-
|
|
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
|
-
|
|
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;
|