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 | /**
* Provenance generator for test/vectors/tessera-client.json — the fixture that
* pins TesseraClient's host-claim comparator (peer-kit 2.0.2).
*
* The ground truth is the OTHER side of the wire: game3's ClojureScript client,
* which is the spec of record for tessera's protocol. Every row of the claim
* table is game3's own `sim/bystander-claim-wins` answer, and every frame the
* test replays — the "ro" claim included — is built by game3's own
* `game/frames.cljs` builders. So the fixture cannot encode a misreading of
* game3, only game3.
*
* The phase column is game3's own state machine, read off game/game.cljs:
* apply-start -> "countdown", apply-tick -> "playing" (from countdown),
* apply-over -> "roundover", apply-match -> "matchover", to-lobby -> "lobby",
* apply-snap -> "playing"; and `round-live?` is
* playing | countdown | roundover.
*
* Point GAME3_TESTLIB at a game3 checkout's compiled test entry
* (client/test-dist/testlib.js, written by `npm test` in that repo) and run:
*
* GAME3_TESTLIB=<path to game3 client/test-dist/testlib.js> \
* node test/vectors/generate-tessera.mjs
*
* Nothing is transcribed here: the constants below are the ones game3's
* config.cljs publishes on the wire in "st"/"sn", and they only have to be
* plausible for a frame — the comparator never reads them.
*/
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const testlib = process.env.GAME3_TESTLIB;
if (!testlib) {
console.error("set GAME3_TESTLIB to a game3 checkout's client/test-dist/testlib.js");
process.exit(1);
}
const lib = await import(testlib);
const OUT = dirname(fileURLToPath(import.meta.url));
const clone = (x) => JSON.parse(JSON.stringify(x));
/* ---------------------------------------------------------------- config ---- */
// game3's config.cljs, as it appears in the frames below.
const GW = 40;
const GH = 30;
const TICK_MS = 125;
const ROUND_TICKS = 360;
const IDS = [
"12D3KooWAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"12D3KooWBbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"12D3KooWCcccccccccccccccccccccccccccccccccccccccccccc",
];
const SEAT = 0; // the seat the bot under test sits in
/* ----------------------------------------- the drive frames, game3's own ---- */
// The frames that walk a client into each phase, built by game3's own frame
// builders — so the test reaches every row through the wire instead of poking
// `_phase`, and the field names are game3's rather than ours.
const starts = lib.spawnStarts(IDS.length);
const st = clone(lib.mkSt(1, TICK_MS, ROUND_TICKS, IDS.slice(), starts));
const tk = clone(lib.mkTk(1, [[SEAT, starts[SEAT][0], starts[SEAT][1]]], null, null));
const ov = clone(lib.mkOv(null, IDS.map(() => 0), IDS.map((id) => [id, 0])));
const ma = clone(lib.mkMa(IDS[1], IDS.map((id) => [id, 1])));
const lo = clone(lib.mkLo());
// a late join mid-round: game3's send-snap shape (ps = [x y color alive],
// runs = the RLE'd mosaic, bl = [[x y] ...])
const sn = clone(lib.mkSn(
1, TICK_MS, ROUND_TICKS, 12, IDS.slice(),
starts.map((s) => [s[0], s[1], 0, 1]),
lib.rleEncode(new Uint8Array(GW * GH)),
[[5, 7]],
));
const DRIVE = { st: [st], tk: [st, tk], ov: [ov], ma: [ma], lo: [lo], sn: [sn] };
/* ------------------------------------------------------------------- claims -- */
// The host-election truth table, answered by game3's OWN comparator
// (sim/bystander-claim-wins) — the rule a tessera browser applies to a foreign
// self-claim while it follows another host. `drive` names the DRIVE sequence
// that puts a TesseraClient into that phase; `ro` is the claim frame itself,
// built by game3's mk-ro (which is what puts `live: 1` on the wire).
const PHASES = [
{ phase: "idle", live: false, drive: null },
{ phase: "lobby", live: false, drive: "lo" },
{ phase: "countdown", live: true, drive: "st" },
{ phase: "playing", live: true, drive: "tk" },
{ phase: "roundover", live: true, drive: "ov" },
{ phase: "matchover", live: false, drive: "ma" },
{ phase: "playing (late join)", live: true, drive: "sn" },
];
const claims = [];
for (const p of PHASES) {
for (const theirLive of [true, false]) {
for (const [from, host] of [["aaa", "bbb"], ["bbb", "aaa"], ["mmm", "mmm"]]) {
claims.push({
phase: p.phase, drive: p.drive, incumbentLive: p.live, theirLive, from, host,
ro: clone(lib.mkRo(from, [], theirLive)),
out: Boolean(lib.bystanderClaimWins(theirLive, p.live, from, host)),
});
}
}
}
/* -------------------------------------------------------------------- write -- */
const data = {
_note:
"Ground truth imported from game3's compiled CLJS client: " +
"game/sim.cljs bystanderClaimWins + spawnStarts/rleEncode, and the " +
"game/frames.cljs builders mkRo/mkSt/mkTk/mkOv/mkMa/mkLo/mkSn. The phase " +
"column is game3 game/game.cljs's own state machine (apply-start/-tick/" +
"-over/-match/-snap, to-lobby) over its round-live?. See generate-tessera.mjs.",
GW, GH, tickMs: TICK_MS, roundTicks: ROUND_TICKS,
seat: SEAT,
order: IDS,
drive: DRIVE,
claims,
};
writeFileSync(join(OUT, "tessera-client.json"), `${JSON.stringify(data)}\n`);
console.log("wrote tessera-client.json");
console.log("claim rows:", claims.length,
"| live phases:", [...new Set(claims.filter((r) => r.incumbentLive).map((r) => r.phase))].join(","),
"| rows the plain lowest-id rule gets wrong:",
claims.filter((r) => r.out !== (r.from < r.host)).length);
|