peer-kit / test / valley-blocks-host.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
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
// Valley-blocks live wiring — game2's wire shapes, the split-brain rules, and
// a bot-hosted race end-to-end over an in-memory hub (mocked timers + clock).
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { ValleyBlocksClient } from "../dist/games/valley-blocks.js";
import { mulberry32 } from "../dist/games/host/rng.js";

class MockPeer extends EventEmitter {
  constructor(hub, id) {
    super();
    this.hub = hub;
    this.id = id;
    this.sent = []; // [to|"*", frame]
    this.net = {
      roomPeers: () => new Set([...hub.peers.keys()].filter((p) => p !== id)),
      openPeers: () => [...hub.peers.keys()].filter((p) => p !== id),
      relayConnected: () => true,
      roomId: "testroom",
      wake: () => {},
    };
  }
  get myId() {
    return this.id;
  }
  send(to, frame) {
    this.sent.push([to, frame]);
    this.hub.peers.get(to)?.emit("message", this.id, structuredClone(frame));
  }
  broadcast(frame) {
    this.sent.push(["*", frame]);
    for (const [pid, p] of this.hub.peers) if (pid !== this.id) p.emit("message", this.id, structuredClone(frame));
  }
  identityOf() {
    return null;
  }
  close() {
    return Promise.resolve();
  }
}

class Hub {
  peers = new Map();
  join(id) {
    const p = new MockPeer(this, id);
    this.peers.set(id, p);
    return p;
  }
  openAll() {
    for (const [id, p] of this.peers) {
      for (const [oid] of this.peers) if (oid !== id) p.emit("peerOpen", oid);
    }
  }
}

const drain = () => new Promise((r) => setImmediate(r));
const base = { app: "valley-blocks", roomSecret: "s", keepAliveMs: 0 };

test("the ro frame speaks game2: 3-tuple ps, mode/target, playing flag; hi.name seats", async (t) => {
  t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
  const hub = new Hub();
  const hostPeer = hub.join("aaa-host");
  const host = new ValleyBlocksClient(hostPeer, {
    ...base,
    nick: "gazda",
    host: { mode: "race", target: 500, rng: mulberry32(7), match: { minPlayers: 2, expectedPlayers: 3, startTimeoutMs: 60_000 } },
  });
  hostPeer.emit("roster", []);
  assert.equal(host.hosting, true);
  const ro0 = hostPeer.sent.find(([, f]) => f.t === "ro")[1];
  assert.deepEqual(ro0.ps, [["aaa-host", "gazda", 0]], "wire roster is [id, name, wins]");
  assert.equal(ro0.mode, "race");
  assert.equal(ro0.target, 500);
  assert.equal(ro0.playing, 0);

  // a browser announces the game2 way — name, not nick
  hub.join("bbb-browser");
  hostPeer.emit("message", "bbb-browser", { t: "hi", name: "om", wins: 2 });
  const ro1 = hostPeer.sent.filter(([, f]) => f.t === "ro").at(-1)[1];
  assert.deepEqual(ro1.ps, [["aaa-host", "gazda", 0], ["bbb-browser", "om", 2]], "hi.name seated, wins carried");
});

test("split-brain: playing beats idle both ways, lowest id breaks the tie", async (t) => {
  t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
  const hub = new Hub();
  const hostPeer = hub.join("mmm-host");
  const host = new ValleyBlocksClient(hostPeer, { ...base, nick: "gazda", host: {} });
  hostPeer.emit("roster", []);
  assert.equal(host.hosting, true);

  // idle incumbent vs a HIGHER id mid-round claim → concede (live beats idle)
  hub.join("zzz-live");
  hostPeer.emit("message", "zzz-live", {
    t: "ro", host: "zzz-live", ps: [["zzz-live", "viu", 0]], mode: "race-survival", target: 5000, playing: 1,
  });
  assert.equal(host.hostId, "zzz-live", "an idle lobby concedes to a live round");
  assert.equal(host.hosting, false);

  // both idle → plain lowest id: reassert against higher, concede to lower
  const hub2 = new Hub();
  const p2 = hub2.join("mmm-host");
  const h2 = new ValleyBlocksClient(p2, { ...base, nick: "gazda", host: {} });
  p2.emit("roster", []);
  hub2.join("zzz-idle");
  const before = p2.sent.length;
  p2.emit("message", "zzz-idle", {
    t: "ro", host: "zzz-idle", ps: [["zzz-idle", "gol", 0]], mode: "race-survival", target: 5000, playing: 0,
  });
  assert.equal(h2.hostId, "mmm-host", "higher idle id never displaces");
  assert.ok(p2.sent.slice(before).some(([, f]) => f.t === "ro" && f.host === "mmm-host"), "reasserted");
  hub2.join("aaa-idle");
  p2.emit("message", "aaa-idle", {
    t: "ro", host: "aaa-idle", ps: [["aaa-idle", "mic", 0]], mode: "race-survival", target: 5000, playing: 0,
  });
  assert.equal(h2.hostId, "aaa-idle", "lower idle id takes the room");
});

test("a bot-hosted race runs to the target and both seats see the verdict", async (t) => {
  t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
  const hub = new Hub();
  const fast = { inputMs: 0, thinkMs: () => 0 }; // test pacing — instant brain
  const hostPeer = hub.join("aaa-host");
  const host = new ValleyBlocksClient(hostPeer, {
    ...base,
    nick: "gazda",
    pace: fast,
    host: { mode: "race", target: 500, rng: mulberry32(9), match: { minPlayers: 2, expectedPlayers: 2, maxMatches: 1 } },
  });
  hostPeer.emit("roster", []);
  const guestPeer = hub.join("bbb-guest");
  const guest = new ValleyBlocksClient(guestPeer, { ...base, nick: "oaspe", pace: fast });
  const ends = { host: null, guest: null };
  host.on("matchEnd", (w) => (ends.host = w));
  guest.on("matchEnd", (w) => (ends.guest = w));

  hub.openAll();
  await drain();
  assert.equal(guest.hostId, "aaa-host", "guest adopted the bot host");
  const st = hostPeer.sent.find(([, f]) => f.t === "st");
  assert.ok(st, "round auto-started at expectedPlayers");
  assert.equal(st[1].mode, "race");
  assert.equal(st[1].target, 500);
  assert.deepEqual(st[1].order, ["aaa-host", "bbb-guest"]);

  let ov = null;
  for (let i = 0; i < 4000 && !ov; i++) {
    t.mock.timers.tick(50);
    ov = hostPeer.sent.find(([, f]) => f.t === "ov")?.[1];
  }
  await drain();
  assert.ok(ov, "the race reached a verdict");
  assert.equal(ov.reason, "target");
  assert.ok(["aaa-host", "bbb-guest"].includes(ov.w));
  assert.equal(ends.host, ov.w, "host saw the winner");
  assert.equal(ends.guest, ov.w, "guest saw the winner");
  const winner = ov.w === "aaa-host" ? host : guest;
  assert.equal(winner.roundWins, 1);
  const winRow = ov.standings.find(([id]) => id === ov.w);
  assert.ok(winRow[2] >= 500, "the winner's standing crossed the target");
  assert.ok(ov.ps.some(([id, , wins]) => id === ov.w && wins === 1), "roster win credited");

  t.mock.timers.tick(6000); // roundover interstitial → lobby
  await drain();
  assert.ok(hostPeer.sent.some(([, f]) => f.t === "lo"), "host returned everyone to the lobby");
  assert.equal(hostPeer.sent.filter(([, f]) => f.t === "st").length, 1, "maxMatches:1 stops a second round");
});

test("forfeit keeps the graceful-loser shape: dead board, score 0", async (t) => {
  t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
  const hub = new Hub();
  const peer = hub.join("ccc-loser");
  const bot = new ValleyBlocksClient(peer, { ...base, nick: "pierzator", forfeit: true });
  peer.emit("message", "aaa-host", { t: "ro", host: "aaa-host", ps: [["aaa-host", "om", 0], ["ccc-loser", "pierzator", 0]], mode: "race", target: 5000, playing: 0 });
  peer.emit("message", "aaa-host", { t: "st", round: 1, seed: 42, order: ["aaa-host", "ccc-loser"], mode: "race", target: 5000 });
  t.mock.timers.tick(200);
  const bs = peer.sent.find(([, f]) => f.t === "bs");
  assert.ok(bs, "forfeit still announces a board");
  assert.equal(bs[1].seat, 1, "with its true seat");
  assert.equal(bs[1].s.over, true, "a topped-out board");
  assert.equal(bs[1].s.score, 0);
  await bot.close();
});

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