game4 / client / test / sim.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
// Golden-vector tests for the host séance machine and the referee decision
// rules — fixtures extracted from src/game/game.ts while the TS was canon.
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 read = (n) => JSON.parse(readFileSync(new URL(`./vectors/${n}`, import.meta.url), "utf8"));
const sim = read("sim.json");
const referee = read("referee.json");
const scoring = read("scoring.json");
const plain = (x) => JSON.parse(JSON.stringify(x));

test("the constants the bell machine is built from must not drift", () => {
  assert.equal(sim.CANDLES, 3);
  assert.equal(sim.MAX_GUESTS, 16);
  assert.equal(sim.REVEAL_MS, 6500);
  assert.deepEqual(sim.rooms.map((r) => r.k), ["study", "parlour", "cellar"]);
  assert.equal(sim.sigils.length, 16);
});

test("walk window shrinks 25s -> 12s floor, exactly as TS", () => {
  for (const c of sim.walkMs) assert.equal(lib.walkMs(c.bell), c.ms, `bell ${c.bell}`);
});

test("the ghost's draw weights and the room it picks match TS", () => {
  for (const row of sim.haunts) {
    assert.deepEqual(plain(lib.hauntWeights([...row.occ])), row.w, `occ ${row.occ}`);
    for (const c of row.cases) {
      assert.equal(lib.pickHaunt([...row.occ], c.r), c.haunt, `occ ${row.occ} r=${c.r}`);
    }
  }
});

test("the medium's whisper names one of the two unhaunted rooms", () => {
  for (const c of sim.hintNots) {
    assert.equal(lib.hintNot(c.haunt, c.r), c.not, JSON.stringify(c));
    assert.notEqual(lib.hintNot(c.haunt, c.r), c.haunt, "never the haunted room");
  }
});

test("the medium is drawn from the alive seats (-1 at an empty table)", () => {
  for (const c of sim.mediums) {
    assert.equal(lib.mediumSeat([...c.alive], c.r), c.seat, JSON.stringify(c));
  }
  assert.equal(lib.mediumSeat([], 0.5), -1, "empty table");
});

// The full recorded transcript: for every bell the fixture replays the three
// draws, then the resolution the host computed from the recorded picks.
test("the recorded séance transcript replays bell for bell", () => {
  assert.ok(sim.bells.length >= 3, "transcript must cover several bells");
  const order = sim.seatOrder;
  for (const b of sim.bells) {
    const label = `bell ${b.bell}`;
    // 1. the three draws, in the pinned order
    assert.equal(b.draws.length, 3, `${label}: exactly three draws per bell`);
    const haunt = lib.pickHaunt([...b.before.lastOcc], b.draws[0]);
    assert.equal(haunt, b.haunt, `${label} haunt`);
    assert.equal(lib.hintNot(haunt, b.draws[1]), b.hintNot, `${label} hintNot`);
    const alive = plain(lib.aliveSeats([...b.before.candles]));
    assert.deepEqual(alive, b.aliveSeats, `${label} alive seats`);
    assert.equal(lib.mediumSeat([...alive], b.draws[2]), b.mediumSeat, `${label} medium`);

    // 2. resolution: silence means stay, the ghost snuffs, occupancy baits next
    const candles = [...b.before.candles];
    const rooms = lib.resolveRooms(
      [...order],
      candles,
      lib.picksMap(b.picks.map(([s, r]) => [s, r])),
      [...b.before.prevRooms],
    );
    assert.deepEqual(plain(rooms), b.rooms, `${label} rooms`);
    assert.deepEqual(plain(lib.occupancy(rooms)), b.occAfter, `${label} occupancy`);
    lib.snuff(candles, rooms, haunt);
    assert.deepEqual(candles, b.candlesAfter, `${label} candles after the sweep`);
    assert.deepEqual(
      plain(lib.nextPrevRooms(rooms, [...b.before.prevRooms])),
      b.prevRoomsAfter,
      `${label} prevRooms (a snuffed seat keeps its last room)`,
    );

    // 3. the end condition
    const aliveAfter = plain(lib.aliveSeats(candles)).length;
    assert.equal(aliveAfter, b.aliveAfter, `${label} alive count`);
    assert.equal(Boolean(lib.seanceOver(order.length, aliveAfter)), b.over, `${label} over?`);
  }
  const last = sim.bells[sim.bells.length - 1];
  assert.equal(last.over, true, "the transcript must reach an ending");
  const winner = lib.winnerOf([...sim.seatOrder], lib.aliveSeats([...last.candlesAfter]));
  assert.equal(winner, sim.over.w, "the enduring guest");
  assert.equal(last.bell, sim.over.bells, "bells survived");
});

test("silence means stay: an untapped seat resolves to its previous room", () => {
  const order = ["a", "b", "c"];
  const rooms = lib.resolveRooms(order, [3, 3, 3], lib.picksMap([[0, 2]]), [0, 1, 2]);
  assert.deepEqual(plain(rooms), [2, 1, 2], "only seat 0 tapped");
  // a snuffed seat is -1 and keeps its previous room for the next bell
  const dead = lib.resolveRooms(order, [3, 0, 3], lib.picksMap([]), [0, 1, 2]);
  assert.deepEqual(plain(dead), [0, -1, 2]);
  assert.deepEqual(plain(lib.nextPrevRooms(dead, [0, 1, 2])), [0, 1, 2]);
  assert.deepEqual(plain(lib.occupancy(dead)), [1, 0, 1], "a gone seat occupies nothing");
});

test("the sweep only snuffs lit seats standing in the haunted room", () => {
  const candles = [3, 1, 0, 2];
  lib.snuff(candles, [1, 1, 1, 0], 1);
  assert.deepEqual(candles, [2, 0, 0, 2]);
});

test("the end condition: one candle at a shared table, zero at a solo one", () => {
  for (const c of scoring.endings) {
    const alive = plain(lib.aliveSeats([...c.candles]));
    assert.deepEqual(alive, c.aliveSeats, JSON.stringify(c.candles));
    assert.equal(Boolean(lib.seanceOver(c.order.length, alive.length)), c.over, JSON.stringify(c));
    assert.equal(lib.winnerOf([...c.order], alive), c.winner, `winner of ${JSON.stringify(c)}`);
  }
});

test("free sigil picks the first unused familiar", () => {
  for (const c of scoring.freeSigils) {
    assert.equal(lib.freeSigilOf([...c.used]), c.out, JSON.stringify(c));
  }
});

test("bell numerals and candle glyphs render as TS did", () => {
  for (const c of scoring.romans) assert.equal(lib.roman(c.n), c.s, `roman ${c.n}`);
  for (const c of scoring.candleStrs) assert.equal(lib.candleStr(c.n), c.s, `candles ${c.n}`);
});

test("the séance seat order is the sorted set of still-connected guests", () => {
  assert.deepEqual(plain(lib.seanceOrder("b", ["c", "a", "b"], ["a"])), ["a", "b"]);
  assert.deepEqual(plain(lib.seanceOrder("b", ["b"], [])), ["b"], "solo table");
  assert.deepEqual(plain(lib.seanceOrder("b", ["c", "a", "b"], ["a", "c"])), ["a", "b", "c"]);
});

// ---- the referee -----------------------------------------------------------
// The séance protocol has no "ro".live flag: both rules are plain lowest id.
// peer-kit's SeanceHost concedeRule is the same comparison.

test("referee: incumbent concedes only to a lower id", () => {
  for (const c of referee.incumbent) {
    assert.equal(Boolean(lib.claimDecision(c.myId, c.from)), c.iStayHost, JSON.stringify(c.name));
  }
});

test("referee: bystander adopts a lower-id claim, or any claim once its host is gone", () => {
  for (const c of referee.bystander) {
    assert.equal(
      Boolean(lib.bystanderClaimWins(c.from, c.hostIdBefore, c.incumbentPresent)),
      c.challengerWins,
      c.name,
    );
  }
});

test("referee: hosts and bystanders never disagree about the winner", () => {
  // whatever the incumbent decides, a bystander must reach the same host
  for (const from of ["a", "b", "c"]) {
    for (const host of ["a", "b", "c"]) {
      if (from === host) continue;
      const incumbentConcedes = !lib.claimDecision(host, from);
      const bystanderAdopts = Boolean(lib.bystanderClaimWins(from, host, true));
      assert.equal(bystanderAdopts, incumbentConcedes, `${from} vs ${host}`);
    }
  }
});

test("referee: host-loss election takes the lowest surviving id", () => {
  for (const c of referee.election) {
    assert.equal(lib.electHost(c.myId, [...c.open], c.gone), c.next, JSON.stringify(c));
  }
});

test("referee: the hostless-room election takes the lowest id in the room", () => {
  for (const c of referee.lowest) {
    assert.equal(lib.lowestOf(c.myId, [...c.open]), c.lowest, JSON.stringify(c));
  }
});

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