Skip to content

Commit f6699c3

Browse files
committed
StateView: fix more scenarios / add more tests.
1 parent 88e3389 commit f6699c3

File tree

7 files changed

+128
-109
lines changed

7 files changed

+128
-109
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@colyseus/schema",
3-
"version": "3.0.12",
3+
"version": "3.0.13",
44
"description": "Binary state serializer with delta encoding for games",
55
"bin": {
66
"schema-codegen": "./bin/schema-codegen",

src/encoder/ChangeTree.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ export class ChangeTree<T extends Ref=any> {
394394
} else {
395395
enqueueChangeTree(this.root, this, 'changes');
396396
}
397+
398+
return previousValue;
397399
}
398400

399401
endEncode() {

src/encoder/Encoder.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Schema } from "../Schema";
22
import { TypeContext } from "../types/TypeContext";
3-
import { $changes, $encoder, $filter } from "../types/symbols";
3+
import { $changes, $encoder, $filter, $getByIndex } from "../types/symbols";
44

55
import { encode } from "../encoding/encode";
66
import type { Iterator } from "../encoding/decode";
@@ -10,6 +10,7 @@ import { Root } from "./Root";
1010

1111
import type { StateView } from "./StateView";
1212
import type { Metadata } from "../Metadata";
13+
import type { ChangeTree } from "./ChangeTree";
1314

1415
export class Encoder<T extends Schema = any> {
1516
static BUFFER_SIZE = (typeof(Buffer) !== "undefined") && Buffer.poolSize || 8 * 1024; // 8KB
@@ -204,7 +205,7 @@ export class Encoder<T extends Schema = any> {
204205

205206
// encode visibility changes (add/remove for this view)
206207
for (const [refId, changes] of view.changes) {
207-
const changeTree = this.root.changeTrees[refId];
208+
const changeTree: ChangeTree = this.root.changeTrees[refId];
208209

209210
if (changeTree === undefined) {
210211
// detached instance, remove from view and skip.
@@ -229,12 +230,14 @@ export class Encoder<T extends Schema = any> {
229230
encode.number(bytes, changeTree.refId, it);
230231

231232
for (let i = 0, numChanges = keys.length; i < numChanges; i++) {
232-
const key = keys[i];
233-
const operation = changes[key];
233+
const index = Number(keys[i]);
234+
// workaround when using view.add() on item that has been deleted from state (see test "adding to view item that has been removed from state")
235+
const value = changeTree.ref[$getByIndex](index);
236+
const operation = (value !== undefined && changes[index]) || OPERATION.DELETE;
234237

235238
// isEncodeAll = false
236239
// hasView = true
237-
encoder(this, bytes, changeTree, Number(key), operation, it, false, true, metadata);
240+
encoder(this, bytes, changeTree, index, operation, it, false, true, metadata);
238241
}
239242
}
240243

src/encoder/StateView.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,22 +123,18 @@ export class StateView {
123123
const changeTree = childChangeTree.parent[$changes];
124124
const parentIndex = childChangeTree.parentIndex;
125125

126-
// view must have all "changeTree" parent tree
127-
this.items.add(changeTree);
128-
129-
// add parent's parent
130-
const parentChangeTree: ChangeTree = changeTree.parent?.[$changes];
131-
if (parentChangeTree && (parentChangeTree.filteredChanges !== undefined)) {
132-
this.addParentOf(changeTree, tag);
133-
}
126+
if (!this.items.has(changeTree)) {
127+
// view must have all "changeTree" parent tree
128+
this.items.add(changeTree);
129+
130+
// add parent's parent
131+
const parentChangeTree: ChangeTree = changeTree.parent?.[$changes];
132+
if (parentChangeTree && (parentChangeTree.filteredChanges !== undefined)) {
133+
this.addParentOf(changeTree, tag);
134+
}
134135

135-
if (
136136
// parent is already available, no need to add it!
137-
!this.invisible.has(changeTree) &&
138-
// item is being replaced, no need to add parent
139-
changeTree.indexedOperations[parentIndex] !== OPERATION.DELETE_AND_ADD
140-
) {
141-
return;
137+
if (!this.invisible.has(changeTree)) { return; }
142138
}
143139

144140
// add parent's tag properties
@@ -229,4 +225,13 @@ export class StateView {
229225

230226
return this;
231227
}
228+
229+
has(obj: Ref) {
230+
return this.items.has(obj[$changes]);
231+
}
232+
233+
hasTag(ob: Ref, tag: number = DEFAULT_VIEW_TAG) {
234+
const tags = this.tags?.get(ob[$changes]);
235+
return tags?.has(tag) ?? false;
236+
}
232237
}

src/types/custom/MapSchema.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class MapSchema<V=any, K extends string = string> implements Map<K, V>, C
1414

1515
protected $items: Map<K, V> = new Map<K, V>();
1616
protected $indexes: Map<number, K> = new Map<number, K>();
17+
protected deletedItems: { [field: string]: V } = {};
1718

1819
protected [$changes]: ChangeTree;
1920

@@ -31,9 +32,9 @@ export class MapSchema<V=any, K extends string = string> implements Map<K, V>, C
3132
*/
3233
static [$filter] (ref: MapSchema, index: number, view: StateView) {
3334
return (
34-
!view ||
35+
!view ||
3536
typeof (ref[$childType]) === "string" ||
36-
view.items.has(ref[$getByIndex](index)[$changes])
37+
view.items.has((ref[$getByIndex](index) ?? ref.deletedItems[index])[$changes])
3738
);
3839
}
3940

@@ -142,7 +143,7 @@ export class MapSchema<V=any, K extends string = string> implements Map<K, V>, C
142143
delete(key: K) {
143144
const index = this[$changes].indexes[key];
144145

145-
this[$changes].delete(index);
146+
this.deletedItems[index] = this[$changes].delete(index);;
146147

147148
return this.$items.delete(key);
148149
}
@@ -206,19 +207,7 @@ export class MapSchema<V=any, K extends string = string> implements Map<K, V>, C
206207
}
207208

208209
protected [$onEncodeEnd]() {
209-
const changeTree = this[$changes];
210-
const keys = Object.keys(changeTree.indexedOperations);
211-
212-
for (let i = 0, len = keys.length; i < len; i++) {
213-
const key = keys[i];
214-
const fieldIndex = Number(key);
215-
const operation = changeTree.indexedOperations[key];
216-
217-
if (operation === OPERATION.DELETE) {
218-
const index = this[$getByIndex](fieldIndex) as string;
219-
delete changeTree.indexes[index];
220-
}
221-
}
210+
this.deletedItems = {};
222211
}
223212

224213
toJSON() {

test/Schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,13 @@ export function encodeMultiple<T extends Schema>(encoder: Encoder<T>, state: T,
120120
const it = { offset: 0 };
121121

122122
// perform shared encode
123+
// console.log("\n\n\n\n>> SHARED ENCODE!");
123124
encoder.encode(it);
124125

125126
const sharedOffset = it.offset;
126127
const encodedViews = clients.map((client, i) => {
127128
// encode each view
129+
// console.log(">> ENCODE VIEW!", i + 1);
128130
const encoded = encoder.encodeView(client.view, sharedOffset, it);
129131
client.state.decode(encoded);
130132
return encoded;

test/StateView.test.ts

Lines changed: 91 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,16 @@ describe("StateView", () => {
280280
client1.view.add(state.item, Tag.TWO);
281281
const encodedTag2 = encodeMultiple(encoder, state, [client1])[0];
282282

283+
// TODO: can we reduce the amount of bytes here?
284+
// assert.strictEqual(4, Array.from(encodedTag1).length, "should encode only the new field");
285+
283286
// compare encode1 with encode2
284-
assert.strictEqual(4, Array.from(encodedTag1).length, "should encode only the new field");
287+
assert.strictEqual(8, Array.from(encodedTag1).length, "should encode only the new field");
285288
assert.strictEqual(Array.from(encodedTag1).length, Array.from(encodedTag2).length, "encode size should be the same");
286289
assert.strictEqual(Array.from(encodedTag1)[0], Array.from(encodedTag2)[0]);
287290
assert.strictEqual(Array.from(encodedTag1)[1], Array.from(encodedTag2)[1]);
288-
assert.strictEqual(Array.from(encodedTag1)[2] + 1, Array.from(encodedTag2)[2]); // field index (+1 so 1 -> 2)
289-
assert.strictEqual(Array.from(encodedTag1)[3] + 10, Array.from(encodedTag2)[3]); // value (+ 10 so 20 -> 30)
291+
assert.strictEqual(Array.from(encodedTag1)[2], Array.from(encodedTag2)[2]);
292+
assert.strictEqual(Array.from(encodedTag1)[3], Array.from(encodedTag2)[3]);
290293

291294
assert.strictEqual(10, client1.state.item.amount);
292295
assert.strictEqual(20, client1.state.item.fov1);
@@ -432,7 +435,6 @@ describe("StateView", () => {
432435

433436
assert.strictEqual(client2.state.prop1, state.prop1);
434437
assert.strictEqual(client2.state.items, undefined);
435-
436438
assertEncodeAllMultiple(encoder, state, [client1])
437439
});
438440

@@ -479,7 +481,17 @@ describe("StateView", () => {
479481
assert.strictEqual(client2.state.items.size, 2);
480482
assert.strictEqual(client2.state.items.get("4").amount, state.items.get("4").amount);
481483
assert.strictEqual(client2.state.items.get("5").amount, state.items.get("5").amount);
482-
//
484+
485+
assertEncodeAllMultiple(encoder, state, [client1, client2])
486+
487+
// removing items...
488+
state.items.delete("2"); // none of the clients have this item
489+
state.items.delete("4"); // shared item between client1 and client2
490+
state.items.delete("6"); // none of the clients have this item
491+
encodeMultiple(encoder, state, [client1, client2]);
492+
493+
assert.strictEqual(undefined, client1.state.items.get("4"));
494+
assert.strictEqual(undefined, client2.state.items.get("4"));
483495

484496
assertEncodeAllMultiple(encoder, state, [client1, client2])
485497
});
@@ -614,6 +626,39 @@ describe("StateView", () => {
614626

615627
assert.strictEqual(undefined, client2.state.entities);
616628
});
629+
630+
it("adding to view item that has been removed from state", () => {
631+
class Item extends Schema {
632+
@type("number") amount: number;
633+
}
634+
635+
class State extends Schema {
636+
@view() @type({ map: Item }) items = new MapSchema<Item>();
637+
}
638+
639+
const state = new State();
640+
const encoder = getEncoder(state);
641+
642+
const client1 = createClientWithView(state);
643+
encodeMultiple(encoder, state, [client1]);
644+
645+
for (let i = 0; i < 5; i++) {
646+
state.items.set(i.toString(), new Item().assign({ amount: i }));
647+
}
648+
649+
client1.view.add(state.items.get("3"));
650+
encodeMultiple(encoder, state, [client1]);
651+
652+
assert.strictEqual(client1.state.items.size, 1);
653+
assert.strictEqual(client1.state.items.get("3").amount, state.items.get("3").amount);
654+
655+
client1.view.add(state.items.get("3"));
656+
state.items.delete("3");
657+
658+
encodeMultiple(encoder, state, [client1]);
659+
assert.strictEqual(client1.state.items.size, 0);
660+
661+
});
617662
});
618663

619664
describe("ArraySchema", () => {
@@ -832,6 +877,47 @@ describe("StateView", () => {
832877
assertEncodeAllMultiple(encoder, state, [client1])
833878
});
834879

880+
it("removing and item should remove from their views", () => {
881+
class Item extends Schema {
882+
@type("number") amount: number;
883+
}
884+
885+
class State extends Schema {
886+
@view() @type([Item]) items = new ArraySchema<Item>();
887+
}
888+
889+
const state = new State();
890+
for (let i = 0; i < 5; i++) {
891+
state.items.push(new Item().assign({ amount: i }));
892+
}
893+
894+
const encoder = getEncoder(state);
895+
896+
const client1 = createClientWithView(state);
897+
const client2 = createClientWithView(state);
898+
899+
client1.view.add(state.items.at(2));
900+
client1.view.add(state.items.at(3));
901+
902+
client2.view.add(state.items.at(3));
903+
client2.view.add(state.items.at(4));
904+
905+
encodeMultiple(encoder, state, [client1, client2]);
906+
907+
assert.deepStrictEqual(client1.state.items.map(i => i.amount), [2, 3]);
908+
assert.deepStrictEqual(client2.state.items.map(i => i.amount), [3, 4]);
909+
910+
state.items.splice(3, 1)
911+
assert.deepStrictEqual(state.items.map(i => i.amount), [0, 1, 2, 4]);
912+
913+
encodeMultiple(encoder, state, [client1, client2]);
914+
915+
assert.deepStrictEqual(client1.state.items.map(i => i.amount), [2]);
916+
assert.deepStrictEqual(client2.state.items.map(i => i.amount), [4]);
917+
918+
assertEncodeAllMultiple(encoder, state, [client1, client2])
919+
});
920+
835921
it("visibility change should trigger onAdd/onRemove on arrays", () => {
836922
class Item extends Schema {
837923
@type("number") amount: number;
@@ -925,74 +1011,6 @@ describe("StateView", () => {
9251011
encodeAllMultiple(encoder, state, [client1]));
9261012
});
9271013

928-
xit("nested with MapSchema", () => {
929-
// TODO: couldn't reproduce original issue reported by @Indalamar
930-
class Component extends Schema {
931-
@type('string') name: string;
932-
}
933-
class Spell extends Schema {
934-
@type('number') id: number;
935-
@type('string') name: string;
936-
@type(['number']) relation = new ArraySchema<number>();
937-
constructor() {
938-
super();
939-
this.relation.push(1, 2, 3);
940-
}
941-
}
942-
class SpellBook extends Component {
943-
@type({ map: Spell }) spells: Map<string, Spell>;
944-
constructor() {
945-
super();
946-
this.name = "SpellBook";
947-
this.spells = new MapSchema<Spell>();
948-
this.spells.set("one", new Spell().assign({ id: 1, name: "Fireball", }));
949-
this.spells.set("two", new Spell().assign({ id: 2, name: "Iceball", }));
950-
this.spells.set("three", new Spell().assign({ id: 3, name: "Thunderball", }));
951-
}
952-
}
953-
class Entity extends Schema {
954-
@type("string") id: string = nanoid(9);
955-
@type([Component]) components: Component[];
956-
constructor() {
957-
super();
958-
this.components = new ArraySchema<Component>()
959-
this.components.push(new SpellBook());
960-
}
961-
}
962-
class State extends Schema {
963-
@view() @type({ map: Entity }) entities = new MapSchema<Entity>();
964-
}
965-
966-
const state = new State();
967-
const encoder = getEncoder(state);
968-
969-
const client1 = createClientWithView(state);
970-
const client2 = createClientWithView(state);
971-
972-
encodeMultiple(encoder, state, [client1, client2]);
973-
974-
const entity1 = new Entity();
975-
entity1.components.push(new Component().assign({ name: "health" }));
976-
state.entities.set("one", entity1);
977-
978-
client1.view.add(entity1);
979-
encodeMultiple(encoder, state, [client1, client2]);
980-
981-
const entity2 = new Entity();
982-
entity2.components.push(new Component().assign({ name: "health" }));
983-
state.entities.set("one", entity2);
984-
985-
client2.view.add(entity2);
986-
client2.view.add(entity1);
987-
988-
encodeMultiple(encoder, state, [client1, client2]);
989-
990-
console.log(client1.state.toJSON());
991-
console.log(client2.state.toJSON());
992-
993-
assertEncodeAllMultiple(encoder, state, [client1, client2])
994-
})
995-
9961014
it("setting a non-view field to undefined should not interfere on encoding", () => {
9971015
class Song extends Schema {
9981016
@type("string") url: string;

0 commit comments

Comments
 (0)