peer-kit / test / vectors / generate-odeon.mjs
  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
/**
 * Provenance generator for test/vectors/odeon-client.json — the fixture that
 * pins OdeonClient's host-claim comparator (peer-kit 2.0.3).
 *
 * The ground truth is the OTHER side of the wire: game6's ClojureScript client,
 * which is the spec of record for odeon's protocol. Every row of the claim table
 * carries game6's OWN answers — `sim/incumbent-wins?` and, above it,
 * `sim/ro-action`'s bystander verdict — and every frame the test replays (the
 * incumbent's roster and the challenger's claim alike) is built by game6's own
 * `game/frames.cljs` `mk-ro`. So the fixture cannot encode a misreading of
 * game6, only game6.
 *
 * odeon is the one game with NO live-phase flag. Its "ro" ALWAYS carries `age`
 * — the rounded seconds of the sending host's incumbency (mk-ro's third
 * argument) — and colliding claims are settled by SENIORITY: the older host
 * wins outside a ±AGE-SLACK-S band, inside which lowest id decides. The
 * incumbent's side of that comparison is game/game.cljs's `current-host-age`,
 * which ages the last observed claim forward by wall time, so this comparator
 * MOVES: the `advanceMs` column below is a real clock advance between the
 * incumbent's roster and the challenger's claim, and the fixture's `myAge` is
 * the number that produces — never a hand-written constant.
 *
 * Point GAME6_TESTLIB at a game6 checkout's compiled test entry
 * (client/test-dist/testlib.js, written by `npm test` in that repo) and run:
 *
 *   GAME6_TESTLIB=<path to game6 client/test-dist/testlib.js> \
 *     node test/vectors/generate-odeon.mjs
 *
 * Nothing is transcribed here: AGE-SLACK-S itself is read off the testlib, and
 * the roster rows 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.GAME6_TESTLIB;
if (!testlib) {
  console.error("set GAME6_TESTLIB to a game6 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 ---- */

// game6's own slack band, read off the testlib rather than retyped.
const SLACK = lib.ageSlackS;
if (typeof SLACK !== "number") throw new Error("testlib did not export ageSlackS");

// Three real-shaped peer ids, so the lowest-id tiebreak is a comparison over
// the strings odeon actually sees. MID is the bot under test; LOW/HIGH are the
// incumbent and the challenger, swapped to get both id orders.
const LOW = "12D3KooWAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const MID = "12D3KooWMmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm";
const HIGH = "12D3KooWZzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
const IDS = [MID, LOW, HIGH];
const SEAT = 0; // the seat the bot under test sits in

// odeon roster rows are [id, nick, colorIdx, instrument] (frames.cljs mk-ro).
const ps = (host) => [
  [host, "gazda", 0, 3],
  [MID, "bot", 1, 3],
];

/* ------------------------------------------------------------------ claims -- */

// The host-election truth table. Each row is one bystander situation:
//
//   1. the incumbent `host` broadcasts a roster claiming `hostAge` seconds of
//      incumbency  (frame `hostRo`, from game6's mk-ro)
//   2. `advanceMs` of wall time pass
//   3. the challenger `from` self-claims with `theirAge`  (frame `ro`)
//
// `myAge` is what game6's current-host-age yields at step 3 — hostAge plus the
// elapsed wall time — and is the number handed to game6's comparator here, so
// the peer-kit client must arrive at the identical float from its own clock.
const claims = [];
const row = (hostAge, advanceMs, diff, from, host, tag) => {
  const myAge = hostAge + advanceMs / 1000;
  const theirAge = myAge + diff;
  if (theirAge < 0) throw new Error(`negative wire age: ${theirAge}`);
  const incumbentWins = Boolean(lib.incumbentWins(myAge, theirAge, host, from));
  claims.push({
    tag, hostAge, advanceMs, myAge, theirAge, diff, from, host,
    hostRo: clone(lib.mkRo(host, ps(host), hostAge)),
    ro: clone(lib.mkRo(from, ps(from), theirAge)),
    incumbentWins,
    // what a game6 bystander DOES with it — the branch room.cljs's claimBeats
    // sits under (am-host? false, host-in-room? true so the escape stays shut)
    action: lib.roAction(from, from, MID, host, false, true, myAge, theirAge),
    out: !incumbentWins,
  });
};

// (a) the slack sweep: both id orders, two incumbency baselines, and a clock
// advance in half the rows, at every boundary the `>` comparisons can turn on.
// diff = theirAge - myAge, so a negative diff is the SENIOR incumbent.
for (const hostAge of [12, 45]) {
  for (const advanceMs of [0, 7000]) {
    for (const diff of [-(SLACK + 1), -SLACK, -(SLACK - 1), 0, SLACK - 1, SLACK, SLACK + 1]) {
      for (const [from, host] of [[LOW, HIGH], [HIGH, LOW]]) row(hostAge, advanceMs, diff, from, host, "slack");
    }
  }
}

// (b) the scenario the rule exists for: a cold-booted peer whose solo backstop
// fired before discovery finished claims with age 0 against a host whose
// incumbency is only the wall time since we heard its roster. The 10000/10001
// pair pins that `>` is strict — 10.0 s of seniority is still a near-tie.
for (const advanceMs of [0, 5000, 9000, 10000, 10001, 11000, 120000]) {
  for (const [from, host] of [[LOW, HIGH], [HIGH, LOW]]) row(0, advanceMs, -advanceMs / 1000, from, host, "coldboot");
}

/* ------------------------------------------------------------------ escape -- */

// room.cljs supplies ro-action's `host-in-room?` term ahead of claimBeats (it
// asks the peer's roomPeers), so these rows only document that game6 adopts
// regardless of seniority once the incumbent is gone — the bot must too.
const escape = [];
for (const [from, host] of [[LOW, HIGH], [HIGH, LOW]]) {
  const myAge = 600;
  escape.push({
    from, host, myAge, theirAge: 0,
    hostRo: clone(lib.mkRo(host, ps(host), myAge)),
    ro: clone(lib.mkRo(from, ps(from), 0)),
    incumbentWins: Boolean(lib.incumbentWins(myAge, 0, host, from)),
    actionInRoom: lib.roAction(from, from, MID, host, false, true, myAge, 0),
    actionGone: lib.roAction(from, from, MID, host, false, false, myAge, 0),
  });
}

/* -------------------------------------------------------------------- write -- */

const data = {
  _note:
    "Ground truth imported from game6's compiled CLJS client: game/sim.cljs " +
    "incumbentWins + roAction + AGE-SLACK-S, and the game/frames.cljs builder " +
    "mkRo (which is what puts `age` on the wire). The incumbent's side of the " +
    "comparison is game/game.cljs's current-host-age, so `myAge` = hostAge + " +
    "advanceMs/1000 and the test must reach it by really advancing a clock. " +
    "See generate-odeon.mjs.",
  ageSlackS: SLACK,
  seat: SEAT,
  order: IDS,
  claims,
  escape,
};

writeFileSync(join(OUT, "odeon-client.json"), `${JSON.stringify(data)}\n`);
console.log("wrote odeon-client.json");
console.log("claim rows:", claims.length,
  "| slack:", claims.filter((r) => r.tag === "slack").length,
  "| coldboot:", claims.filter((r) => r.tag === "coldboot").length,
  "| adopt:", claims.filter((r) => r.action === "adopt").length,
  "| ignore:", claims.filter((r) => r.action === "ignore").length,
  "| rows the plain lowest-id rule gets wrong:",
  claims.filter((r) => r.out !== (r.from < r.host)).length);

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/peer-kit.git