@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.
- package/build/Reflection.d.ts +53 -0
- package/build/Schema.d.ts +26 -1
- package/build/annotations.d.ts +16 -1
- package/build/codegen/cli.cjs +103 -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 +1211 -284
- package/build/index.cjs.map +1 -1
- package/build/index.d.ts +4 -1
- package/build/index.js +1211 -284
- package/build/index.mjs +1207 -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 +3 -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 +157 -51
- package/src/bench_churn.ts +121 -0
- package/src/codegen/languages/haxe.ts +0 -16
- package/src/codegen/parser.ts +69 -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 -1
- package/src/input/InputDecoder.ts +11 -5
- package/src/input/InputEncoder.ts +85 -126
- package/src/types/HelperTypes.ts +7 -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,
|
|
@@ -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
|
-
*
|
|
624
|
-
* (empty collection or zero-arg Schema ref), or `undefined` when the
|
|
625
|
-
* has no auto-default.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/src/codegen/parser.ts
CHANGED
|
@@ -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.
|
|
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
|
|
301
|
-
|
|
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
|
-
|
|
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;
|