1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284 | // OdeonClient's host-claim comparator, against dist/.
//
// odeon is the one game whose "ro" carries no live-phase flag. It ALWAYS
// carries `age` — the rounded seconds of the sending host's incumbency
// (game6 frames.cljs mk-ro) — and colliding claims are settled by SENIORITY:
// the older host wins outside a ±AGE-SLACK-S band, inside which lowest id
// decides (game6 sim.cljs incumbent-wins?, applied by ro-action's bystander
// branch). The plain lowest-id rule OdeonClient inherited from room.cljs
// therefore matched game6 only inside that band, and a bot and a browser in one
// odeon room could follow different hosts — the mixed bot/browser partition
// room.cljs's own header warns about, and in odeon an audible one: the bot
// broadcasts "nt"/"nf" against the wrong host's transport clock.
//
// The truth table below is game6's OWN answer, recorded in the fixture, and
// every row is reached through the wire: the incumbent's roster and the
// challenger's claim are both game6's mk-ro output, and the incumbent's age is
// aged forward by a REAL clock advance rather than by poking _hostAge.
//
// That last part is what makes this comparator different from its siblings':
// live-beats-idle is a snapshot, seniority MOVES. FakeClock is the whole point
// of this file, and the ±slack boundaries are pinned to the millisecond.
//
// Fixture provenance: test/vectors/generate-odeon.mjs (imports game6's
// compiled client as ground truth).
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { OdeonClient } from "../dist/games/odeon.js";
import { NeonGridClient } from "../dist/games/neon-grid.js";
import { TesseraClient } from "../dist/games/tessera.js";
import { LampionClient } from "../dist/games/lampion.js";
import { ValleyBlocksClient } from "../dist/games/valley-blocks.js";
import { GameRoom } from "../dist/games/room.js";
import { FakeClock } from "./helpers/mock-hub.mjs";
const V = JSON.parse(readFileSync(new URL("./vectors/odeon-client.json", import.meta.url), "utf8"));
class MockPeer extends EventEmitter {
constructor(id) {
super();
this.id = id;
this.sent = []; // [to, frame]
this.room = new Set(); // who roomPeers() reports
this.net = {
roomPeers: () => this.room,
openPeers: () => [...this.room],
relayConnected: () => true,
roomId: "testroom",
wake: () => {},
};
}
get myId() {
return this.id;
}
send(to, frame) {
this.sent.push([to, frame]);
}
broadcast(frame) {
this.sent.push(["*", frame]);
}
identityOf() {
return null;
}
close() {
return Promise.resolve();
}
}
const feed = (peer, from, frame) => peer.emit("message", from, structuredClone(frame));
/**
* A client seated at V.seat that has heard `row.host`'s roster — the frame that
* declares `row.hostAge` seconds of incumbency, built by game6's mk-ro. Returns
* [client, peer].
*/
const following = (row) => {
const peer = new MockPeer(V.order[V.seat]);
peer.room.add(row.host);
peer.room.add(row.from);
const client = new OdeonClient(peer, {
app: "odeon", roomSecret: "s", nick: "bot", keepAliveMs: 0,
});
feed(peer, row.host, row.hostRo);
assert.equal(client.hostId, row.host);
peer.sent.length = 0; // drop the "hi"/"pg" the roster provoked
return [client, peer];
};
/** The age the client itself would compute right now — game6's current-host-age. */
const ageNow = (client) =>
client.hostId === null ? 0 : client._hostAge + (performance.now() - client._hostAgeAt) / 1000;
/* ============================ the claim comparator ======================== */
test("odeon: claimBeats is seniority, ties to lowest id (game6's own table)", (t) => {
const clock = new FakeClock();
t.after(() => clock.uninstall());
assert.equal(V.claims.length, 70);
assert.equal(V.ageSlackS, 10);
for (const row of V.claims) {
const [client] = following(row);
clock.tick(row.advanceMs);
// the incumbency the client reached on its own, from the wire age plus the
// clock — this is the number game6's comparator was handed in the fixture
assert.equal(ageNow(client), row.myAge,
`age drift: tag=${row.tag} hostAge=${row.hostAge} advanceMs=${row.advanceMs}`);
assert.equal(
client.claimBeats(row.from, structuredClone(row.ro)), row.out,
`tag=${row.tag} myAge=${row.myAge} theirAge=${row.theirAge} diff=${row.diff} ` +
`from<host=${row.from < row.host}`,
);
}
});
test("odeon: the fixture's verdicts are game6's own bystander decision", () => {
// claimBeats is the boolean under ro-action's third branch: adopt when the
// challenger outranks the host I follow, ignore otherwise. If those two ever
// disagreed the fixture would be encoding our reading, not game6's.
for (const row of V.claims) {
assert.ok(row.action === "adopt" || row.action === "ignore", row.action);
assert.equal(row.out, row.action === "adopt");
assert.equal(row.out, !row.incumbentWins);
}
// and both id orders and both sides of the band are really in the table
const seen = new Set(V.claims.map((r) => `${r.from < r.host}|${Math.sign(r.diff)}`));
assert.deepEqual([...seen].sort(), ["false|-1", "false|0", "false|1", "true|-1", "true|0", "true|1"]);
});
/* ========================= the wall-clock dimension ====================== */
test("odeon: the comparator advances with wall time — one claim, flipped by the clock", (t) => {
const clock = new FakeClock();
t.after(() => clock.uninstall());
// The scenario the seniority rule exists for: a cold-booted peer whose solo
// backstop fired before discovery completed self-claims with age 0. Its
// verdict must change under a single client as the incumbent's age grows —
// no new frames, only time. Rows in ascending advanceMs, ticked cumulatively.
for (const idOrder of [true, false]) {
const rows = V.claims
.filter((r) => r.tag === "coldboot" && (r.from < r.host) === idOrder)
.sort((a, b) => a.advanceMs - b.advanceMs);
assert.equal(rows.length, 7);
const [client] = following(rows[0]);
let at = 0;
const got = [];
for (const row of rows) {
clock.tick(row.advanceMs - at);
at = row.advanceMs;
assert.equal(ageNow(client), row.myAge, `advanceMs=${row.advanceMs}`);
const out = client.claimBeats(row.from, structuredClone(row.ro));
assert.equal(out, row.out, `advanceMs=${row.advanceMs} idOrder=${idOrder}`);
got.push(out);
}
// the lower-id usurper is adopted while the incumbent is inside the slack
// band and refused once it is out of it: the 10000 -> 10001 ms step is the
// strictness of `>` in incumbent-wins?, to the millisecond
if (idOrder) {
assert.deepEqual(got, [true, true, true, true, false, false, false]);
assert.deepEqual(rows.map((r) => r.advanceMs), [0, 5000, 9000, 10000, 10001, 11000, 120000]);
} else {
// the higher-id usurper never wins: seniority and lowest id agree here
assert.deepEqual(got, [false, false, false, false, false, false, false]);
}
}
});
test("odeon: the incumbency is re-read from every roster, and voided when the host goes", (t) => {
const clock = new FakeClock();
t.after(() => clock.uninstall());
const row = V.claims.find((r) => r.tag === "slack" && r.hostAge === 45 && r.advanceMs === 0);
const [client, peer] = following(row);
assert.equal(ageNow(client), 45);
// a later roster from the same host replaces the base, it is not extrapolated
clock.tick(30_000);
assert.equal(ageNow(client), 75);
feed(peer, row.host, { t: "ro", host: row.host, ps: row.hostRo.ps, age: 200 });
assert.equal(ageNow(client), 200);
clock.tick(2_500);
assert.equal(ageNow(client), 202.5);
// a malformed age is game6's `(if (number? raw-age) raw-age 0)`, not a NaN
for (const bad of [undefined, null, "77", true, {}]) {
feed(peer, row.host, { t: "ro", host: row.host, ps: row.hostRo.ps, age: bad });
assert.equal(ageNow(client), 0, `age=${JSON.stringify(bad)}`);
}
// the host leaves: no host, no incumbency (game6 elects and notes 0)
feed(peer, row.host, { t: "ro", host: row.host, ps: row.hostRo.ps, age: 500 });
clock.tick(1_000);
assert.equal(ageNow(client), 501);
peer.room.delete(row.host);
peer.emit("peerGone", row.host);
assert.equal(client.hostId, null);
assert.equal(ageNow(client), 0);
assert.equal(client._hostAge, 0);
// ... and an unknown host is outranked by anything, at any age it claims
assert.equal(client.claimBeats(row.from, structuredClone(row.ro)), true);
assert.equal(client.claimBeats(row.from, { t: "ro", host: row.from, ps: [], age: 9999 }), true);
});
/* ===================== against the rule it replaced ====================== */
test("odeon: the base lowest-id rule is genuinely overridden", (t) => {
const clock = new FakeClock();
t.after(() => clock.uninstall());
// The rows room.cljs's inherited default gets WRONG — proof the override does
// work rather than restating the rule it replaced. Everything inside the
// near-tie band agrees with it BY CONSTRUCTION (incumbent-wins? falls through
// to `host < from` there, so claimBeats is exactly `from < host`), which is
// why only the out-of-band rows can diverge.
const differs = V.claims.filter((r) => r.out !== (r.from < r.host));
assert.equal(differs.length, 11);
assert.ok(differs.every((r) => Math.abs(r.diff) > V.ageSlackS),
"a diverging row inside the slack band would mean the tiebreak drifted");
const inBand = V.claims.filter((r) => Math.abs(r.diff) <= V.ageSlackS);
assert.equal(inBand.length, 48);
assert.equal(V.claims.length - inBand.length, 22);
assert.ok(inBand.every((r) => r.out === (r.from < r.host)));
// so the 11 divergences are 11 of the 22 out-of-band rows: exactly those
// where seniority points the other way from id order (the other 11 are rows
// where the two rules happen to agree)
assert.equal(differs.length * 2, V.claims.length - inBand.length);
for (const row of differs) {
const [client] = following(row);
clock.tick(row.advanceMs);
const out = client.claimBeats(row.from, structuredClone(row.ro));
assert.equal(out, row.out);
assert.notEqual(out, row.from < row.host);
}
// it is an own override, not the inherited default
assert.ok(Object.getOwnPropertyNames(OdeonClient.prototype).includes("claimBeats"));
const src = (C) => C.prototype.claimBeats.toString();
assert.notEqual(src(OdeonClient), src(GameRoom));
// and NOT any sibling's live-phase comparator: odeon reads `age`, has no
// phase set at all, and never looks at `live` or `playing`
for (const C of [NeonGridClient, TesseraClient, LampionClient, ValleyBlocksClient]) {
assert.notEqual(src(OdeonClient), src(C));
}
assert.match(src(OdeonClient), /hostId/);
assert.doesNotMatch(src(OdeonClient), /\.live/);
assert.doesNotMatch(src(OdeonClient), /\.playing/);
assert.doesNotMatch(src(OdeonClient), /countdown|roundover|matchover|lobby/);
});
test("odeon: room.cljs's incumbent-gone escape still adopts over a losing comparator", (t) => {
const clock = new FakeClock();
t.after(() => clock.uninstall());
// ro-action's `host-in-room?` term sits ABOVE the comparator, and room.cljs
// supplies it from the peer's roomPeers — so a challenger the comparator
// refuses is still adopted once the incumbent is gone from the room. game6
// answers both ways in the fixture; the bot must match both.
for (const e of V.escape) {
assert.equal(e.incumbentWins, true);
assert.equal(e.actionInRoom, "ignore");
assert.equal(e.actionGone, "adopt");
// incumbent present: a senior host is kept, the claim is refused outright
// (room.cljs swallows the frame, so no host change and no age note)
const [kept, keptPeer] = following({ ...e, hostAge: e.myAge, advanceMs: 0 });
assert.equal(kept.claimBeats(e.from, structuredClone(e.ro)), false);
feed(keptPeer, e.from, e.ro);
assert.equal(kept.hostId, e.host);
assert.equal(ageNow(kept), e.myAge);
// incumbent gone from roomPeers: the same claim is adopted, and the new
// host's own age replaces the one we were tracking
const [gone, gonePeer] = following({ ...e, hostAge: e.myAge, advanceMs: 0 });
gonePeer.room.delete(e.host);
feed(gonePeer, e.from, e.ro);
assert.equal(gone.hostId, e.from);
assert.equal(ageNow(gone), e.theirAge);
}
});
|