import { OPERATION } from "../../encoding/spec.js"; import { registerType } from "../registry.js"; import { $changes, $childType, $decoder, $deleteByIndex, $encoder, $filter, $getByIndex, $onEncodeEnd, $refId, $reset, $resyncPrune } from "../symbols.js"; import { Collection } from "../HelperTypes.js"; import { ChangeTree, installUntrackedChangeTree, type IRef } from "../../encoder/ChangeTree.js"; import { encodeIndexedEntry } from "../../encoder/EncodeOperation.js"; import { CollectionKind, decodeKeyValueOperation } from "../../decoder/DecodeOperation.js"; import { createStreamableState, streamDropView, streamRouteAdd, streamRouteRemove, type StreamableState, } from "../../encoder/streaming.js"; import type { StateView } from "../../encoder/StateView.js"; import type { Schema } from "../../Schema.js"; export class SetSchema implements Collection, IRef { [$changes]: ChangeTree; [$refId]?: number; protected [$childType]: string | typeof Schema; /** The user-visible data, keyed directly by the wire-protocol index. */ protected $items: Map = new Map(); /** Snapshots of values that were deleted this tick (for filter visibility). */ protected deletedItems: { [field: string]: V } = {}; /** Monotonic counter for assigning indexes to newly-added items. */ protected $refId: number = 0; /** * Streamable state — lazily allocated when the field is opted into * streaming via `t.set(X).stream()`. See MapSchema for the same * pattern / rationale. */ _stream?: StreamableState; /** Max ADD ops emitted per tick per view. Ignored outside streaming mode. */ get maxPerTick(): number { return this._stream?.maxPerTick ?? 32; } set maxPerTick(n: number) { (this._stream ??= createStreamableState()).maxPerTick = n; } /** Per-view priority callback — see StreamSchema / MapSchema. */ get priority(): ((view: any, element: V) => number) | undefined { return this._stream?.priority as ((view: any, element: V) => number) | undefined; } set priority(fn: ((view: any, element: V) => number) | undefined) { (this._stream ??= createStreamableState()).priority = fn; } static [$encoder] = encodeIndexedEntry; static [$decoder] = decodeKeyValueOperation; /** Integer tag read by `decodeKeyValueOperation` — see `CollectionKind`. */ static readonly COLLECTION_KIND = CollectionKind.Set; /** * Determine if a property must be filtered. * - If returns false, the property is NOT going to be encoded. * - If returns true, the property is going to be encoded. * * Encoding with "filters" happens in two steps: * - First, the encoder iterates over all "not owned" properties and encodes them. * - Then, the encoder iterates over all "owned" properties per instance and encodes them. */ static [$filter] (ref: SetSchema, index: number, view: StateView) { return ( !view || typeof (ref[$childType]) === "string" || view.isVisible((ref[$getByIndex](index) ?? ref.deletedItems[index])[$changes]) ); } static is(type: any) { return type['set'] !== undefined; } constructor (initialValues?: Array) { // $changes must be non-enumerable to avoid deepStrictEqual recursing // into ChangeTree's circular refs. Object.defineProperty(this, $changes, { value: new ChangeTree(this), enumerable: false, writable: true, }); this[$childType] = undefined as any; if (initialValues) { initialValues.forEach((v) => this.add(v)); } } /** * Decoder-side factory. Skips the tracking `ChangeTree` allocation; * `Object.create` also bypasses the class-field initializers, so we * replicate the minimum slot init here. Must stay in sync with the * class-field declarations above. */ static initializeForDecoder(): SetSchema { const self: any = Object.create(SetSchema.prototype); self.$items = new Map(); self.deletedItems = {}; self.$refId = 0; self[$childType] = undefined; installUntrackedChangeTree(self); return self; } add(value: V) { // immediatelly return false if value already added. if (this.has(value)) { return false; } // assign the next wire-protocol index const index = this.$refId++; const changeTree = this[$changes]; this.$items.set(index, value); // Streaming-mode ADD: route into per-view pending or broadcast // pending instead of the tree's recorder. See MapSchema.set for // the same branch / rationale. if (changeTree.isStreamCollection) { if (changeTree.root !== undefined) { streamRouteAdd(this, changeTree.root, index); } } else { changeTree.change(index, OPERATION.ADD); } // after the ADD — setParent queues the child's changes, which must follow it if (value[$changes] !== undefined) { value[$changes].setParent(this, changeTree.root, index); } return index; } entries () { return this.$items.entries(); } delete(item: V) { const entries = this.$items.entries(); let index: number; let entry: IteratorResult<[number, V]>; while (entry = entries.next()) { if (entry.done) { break; } if (item === entry.value[1]) { index = entry.value[0]; break; } } if (index === undefined) { return false; } const changeTree = this[$changes]; // Streaming-mode: route through stream's pending/sent bookkeeping // — silent drop if never sent to any view, force DELETE for views // that already received it. Mirror of MapSchema.delete's streaming // branch. if (changeTree.isStreamCollection) { const root = changeTree.root; const previousValue = this.$items.get(index); if (root !== undefined) { streamRouteRemove(this, root, (this as any)[$refId], index); } if ((previousValue as any)?.[$changes] !== undefined) { root?.remove((previousValue as any)[$changes]); } this.deletedItems[index] = previousValue as V; return this.$items.delete(index); } this.deletedItems[index] = changeTree.delete(index); return this.$items.delete(index); } clear() { const changeTree = this[$changes]; // discard previous operations. changeTree.discard(); // clear items this.$items.clear(); changeTree.operation(OPERATION.CLEAR); } /** * Pool reset: empty this set and recycle its ChangeTree WITHOUT recording * any wire op (the parent field's ADD/DELETE owns the wire). Recurses into * ref-type children. Called by Schema.reset when a pooled entity has a set * field. The instance must already be detached from the encoder. */ [$reset]() { const changeTree = this[$changes]; if (changeTree.isStreamCollection) { throw new Error(`@colyseus/schema: cannot reset a streamed SetSchema (pooling not supported).`); } this.$items.forEach((value: any) => value?.[$reset]?.()); this.$items.clear(); this.deletedItems = {}; this.$refId = 0; // reset the monotonic index counter (field, not the symbol) changeTree.recycle(); this[$refId] = undefined; // drop encoder ref identity by assign (not delete: avoids dict-mode deopt) } has (value: V): boolean { const values = this.$items.values(); let has = false; let entry: IteratorResult; while (entry = values.next()) { if (entry.done) { break; } if (value === entry.value) { has = true; break; } } return has; } forEach(callbackfn: (value: V, key: number, collection: SetSchema) => void) { this.$items.forEach((value, key, _) => callbackfn(value, key, this)); } values() { return this.$items.values(); } get size () { return this.$items.size; } // ────── Change tracking control (same API as Schema) ────── pauseTracking(): void { this[$changes].pause(); } resumeTracking(): void { this[$changes].resume(); } untracked(fn: () => T): T { return this[$changes].untracked(fn); } get isTrackingPaused(): boolean { return this[$changes].paused; } /** Iterator */ [Symbol.iterator](): IterableIterator { return this.$items.values(); } // ──────────────────────────────────────────────────────────────────── // Decoder-side index hooks. SetSchema's "key" IS the wire index, so // these are identity operations. Kept for protocol symmetry with // MapSchema (decoder calls them polymorphically). // ──────────────────────────────────────────────────────────────────── protected setIndex(_index: number, _key: number) { // no-op: indexes are identity } protected getIndex(index: number): number { return index; } [$getByIndex](index: number): any { return this.$items.get(index); } [$deleteByIndex](index: number): void { this.$items.delete(index); } [$resyncPrune]( visited: Set, prune: (value: V, identity: number | string) => void, keep: (value: V) => void, ): void { let toDelete: number[] | null = null; this.$items.forEach((value, index) => { if (visited.has(index)) { keep(value); return; } (toDelete ??= []).push(index); prune(value, index); }); if (toDelete !== null) { for (let i = 0; i < toDelete.length; i++) { this[$deleteByIndex](toDelete[i]); } } } protected [$onEncodeEnd]() { for (const key in this.deletedItems) { delete this.deletedItems[key]; } } // ─── Streamable interface (Encoder priority / broadcast pass) ────── _dropView(viewId: number): void { streamDropView(this, viewId); } _unregister(): void { // no-op — `Root.unregisterStream` handles the Set removal. } toArray() { return Array.from(this.$items.values()); } toJSON() { const values: V[] = []; this.forEach((value: any, key: number) => { values.push( (typeof (value['toJSON']) === "function") ? value['toJSON']() : value ); }); return values; } // // Decoding utilities // clone(isDecoding?: boolean): SetSchema { let cloned: SetSchema; if (isDecoding) { // client-side cloned = Object.assign(new SetSchema(), this); } else { // server-side cloned = new SetSchema(); this.forEach((value: any) => { if (value[$changes]) { cloned.add(value['clone']()); } else { cloned.add(value); } }) } return cloned; } } registerType("set", { constructor: SetSchema });