game2 / client / test / referee.test.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
// Golden-vector tests for the party state machine — roster, host election,
// split-brain resolution, seating, refereeing and the outbound frame BYTES.
//
// Every scenario in the fixture is a list of ops recorded from the TypeScript
// run, so this test replays the exact same input sequence and compares the wire
// traffic, the emitted events and the full party state after every step. A
// mistake in this driver cannot produce a false pass: the states would diverge.
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import * as lib from "../test-dist/testlib.js";

const v = JSON.parse(readFileSync(new URL("./vectors/referee.json", import.meta.url), "utf8"));

// A controllable performance.now() — the party stamps view.lastHeard and gates
// its outbound cadence on it. Installed once, before any party exists.
let clock = 0;
globalThis.performance = globalThis.performance ?? {};
globalThis.performance.now = () => clock;

function harness(myId) {
  const wire = [];
  const events = [];
  let room = new Set();
  let open = [];
  let relay = true;
  let game = null;
  const timers = [];
  const realSetInterval = globalThis.setInterval;
  globalThis.setInterval = (fn) => { timers.push(fn); return timers.length; };
  clock = 0;
  const p = lib.makeParty();
  const players = () => [...lib.partyPlayers(p)].map((x) => [x.id, x.name, x.wins]);
  p.events = {
    onRoster: () => events.push(["onRoster", p.hostId, p.mode, p.target, players()]),
    onStart: (seed) => events.push(["onStart", seed, p.round, [...p.order], lib.partyMySeat(p)]),
    onSpectate: () => events.push(["onSpectate", p.round, [...p.order]]),
    onPeerUpdate: (peer) => events.push(["onPeerUpdate", peer.id, peer.seat, peer.over, peer.left, peer.state ? peer.state.score : null]),
    onRoundOver: (r) => events.push(["onRoundOver", r.winnerId, r.reason,
      [...r.standings].map((s) => [s.id, s.name, s.score, s.wins, s.out])]),
    onLobby: (voided) => events.push(["onLobby", voided]),
    onHostChange: (me) => events.push(["onHostChange", me]),
    onRoomFull: () => events.push(["onRoomFull"]),
    onHostMatchEnd: (scores) => events.push(["onHostMatchEnd", [...scores].map((s) => [...s])]),
  };
  lib.partyInit(p, {
    myId,
    roomPeers: () => room,
    relayConnected: () => relay,
    openPeers: () => open,
    send: (to, f) => wire.push({ dir: "send", to, json: JSON.stringify(f) }),
    broadcast: (f) => wire.push({ dir: "bcast", json: JSON.stringify(f) }),
  });
  globalThis.setInterval = realSetInterval;

  const run = (op) => {
    switch (op[0]) {
      case "room": room = new Set(op[1]); return null;
      case "open": open = op[1]; return null;
      case "relay": relay = op[1]; return null;
      case "now": clock = op[1]; return null;
      case "join": lib.partyJoin(p, op[1]); return null;
      case "leave": lib.partyLeave(p); return null;
      case "settings": lib.partySetSettings(p, op[1], op[2]); return null;
      case "startRound": return lib.partyStartRound(p, op[1]);
      case "endToLobby": lib.partyEndToLobby(p); return null;
      case "msg": lib.partyHandleMsg(p, op[1], op[2]); return null;
      case "peerOpen": lib.partyOnPeerOpen(p, op[1]); return null;
      case "peerGone": lib.partyOnPeerGone(p, op[1]); return null;
      case "peerState": lib.partyOnPeerState(p, op[1], op[2]); return null;
      case "roomRoster": lib.partyOnRoomRoster(p, op[1]); return null;
      case "relayError": lib.partyOnRelayError(p, op[1]); return null;
      case "tick": timers[0]?.(); return null;
      case "newGame": game = lib.makeGame({}, op[1]); return null;
      case "gameScore": game.score = op[1]; return null;
      case "gameOver": game.over = true; return null;
      case "sync": lib.partySyncFrom(p, game); return null;
      case "forceSync": lib.partyForceSync(p, game); return null;
      default: throw new Error("unknown op " + op[0]);
    }
  };

  const state = () => ({
    hostId: p.hostId ?? null,
    amHost: lib.partyAmHost(p),
    joined: p.joined,
    phase: p.phase,
    round: p.round,
    seed: p.seed,
    order: [...p.order],
    roundActive: p.roundActive,
    mode: p.mode,
    target: p.target,
    mySeat: lib.partyMySeat(p),
    spectating: lib.partySpectating(p),
    myWins: lib.partyMyWins(p),
    roster: players(),
    views: [...p.views.entries()].map(([id, x]) => [id, x.seat, x.over, x.left, x.state ? x.state.score : null]),
    lastResult: p.lastResult
      ? {
          winnerId: p.lastResult.winnerId,
          reason: p.lastResult.reason,
          standings: [...p.lastResult.standings].map((s) => ({
            id: s.id, name: s.name, score: s.score, wins: s.wins, out: s.out,
          })),
        }
      : null,
  });

  return { run, state, drain: () => ({ wire: wire.splice(0), events: events.splice(0) }) };
}

for (const sc of v.scenarios) {
  test(`referee ${sc.name}`, () => {
    const h = harness(sc.myId);
    for (const step of sc.steps) {
      const rets = step.ops.map((op) => h.run(op));
      const { wire, events } = h.drain();
      const where = `${sc.name} / ${step.label}`;
      assert.deepEqual(rets.map((r) => (r === undefined ? null : r)), step.rets, `${where}: op return values`);
      assert.deepEqual(wire, step.wire, `${where}: outbound frames`);
      assert.deepEqual(events, step.events, `${where}: emitted events`);
      assert.deepEqual(h.state(), step.state, `${where}: party state`);
    }
  });
}

test("the fixture really covers the referee surface", () => {
  const names = v.scenarios.map((s) => s.name);
  // all 16 incumbent branches and all 16 bystander branches of the `ro` case
  assert.equal(names.filter((n) => n.startsWith("incumbent-")).length, 16);
  assert.equal(names.filter((n) => n.startsWith("bystander-")).length, 16);
  // every verdict shape in every mode
  assert.equal(names.filter((n) => n.startsWith("evaluate-")).length, 15);
  for (const must of ["solo-boot", "host-roster", "roster-cap", "check-vanished",
                      "sync-throttle", "late-joiner", "clamping",
                      "election-empty-room", "election-unjoined-host-takeover",
                      "election-unjoined-host-never-midround",
                      "election-relay-down-judges-nobody"]) {
    assert.ok(names.includes(must), `missing scenario ${must}`);
  }
  // and at least one outbound frame was actually compared
  assert.ok(v.scenarios.some((s) => s.steps.some((st) => st.wire.length > 0)));
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/game2.git