game4 / client / test / snapshot.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
// The late-joiner snapshot: the "sn" frame the host sends must be byte-exact
// (ten keys, past js-obj's eight-pair limit) for the recorded host states.
// Fixtures from src/game/game.ts's sendSnap 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 v = JSON.parse(readFileSync(new URL("./vectors/snapshot.json", import.meta.url), "utf8"));

// sendSnap's own arithmetic, re-expressed against the recorded host state: the
// walk flag and remainMs come from inWalk + the clock, and haunt is withheld
// while the walk is still open (revealing it mid-walk would leak the answer).
const snapOf = (h) =>
  lib.mkSn(
    h.seance,
    h.bell,
    h.inWalk ? 1 : 0,
    h.inWalk ? Math.max(0, h.walkEndAt - h.now) : 0,
    h.mediumSeat,
    h.order,
    h.candles,
    h.prevRooms,
    h.inWalk ? -1 : h.haunt,
  );

for (const c of v.cases) {
  test(`snapshot ${c.name} serializes byte-identically to TS`, () => {
    assert.equal(JSON.stringify(snapOf(c.host)), JSON.stringify(c.frame));
    assert.deepEqual(Object.keys(snapOf(c.host)), Object.keys(c.frame), "key order");
  });
}

test("a mid-walk snapshot withholds the haunted room and carries the remaining time", () => {
  const walk = v.cases.find((c) => c.name === "mid-walk");
  assert.ok(walk, "fixture present");
  assert.equal(walk.frame.walk, 1);
  assert.equal(walk.frame.haunt, -1, "the answer must not ride in the snapshot");
  assert.ok(walk.frame.remainMs > 0 && walk.frame.remainMs < 25000);
});

test("an in-reveal snapshot carries the haunted room and no clock", () => {
  const rev = v.cases.find((c) => c.name === "in-reveal");
  assert.ok(rev, "fixture present");
  assert.equal(rev.frame.walk, 0);
  assert.equal(rev.frame.remainMs, 0);
  assert.ok(rev.frame.haunt >= 0);
});

test("the snapshot's rooms are prevRooms, so a snuffed seat never reads -1 as a room", () => {
  for (const c of v.cases) {
    assert.deepEqual(c.frame.rooms, c.host.prevRooms);
    for (const r of c.frame.rooms) assert.ok(r >= 0 && r <= 2, `room index ${r}`);
  }
});

// The client side of the same fixtures: applySnap's resulting G, and the full
// phase-frame trace, are recorded so the CLJS apply path can be compared to the
// TS one by eye during review and by the browser smoke.
test("the recorded client apply trace covers every host frame type", () => {
  const labels = v.trace.map((s) => s.label);
  for (const want of [
    "after-st", "after-be-i-am-medium", "after-hm", "after-pc", "after-my-pick",
    "after-rv-i-was-hit", "after-be-not-medium", "after-rv-safe", "after-ov-i-won",
    "after-lo", "after-st-spectating", "after-rv-in-lobby-ignored",
  ]) {
    assert.ok(labels.includes(want), `missing trace step ${want}`);
  }
});

test("the apply trace pins the phase machine's transitions", () => {
  const at = (l) => v.trace.find((s) => s.label === l);
  assert.equal(at("after-st").phase, "begin");
  assert.equal(at("after-st").spectator, false);
  assert.deepEqual(at("after-st").candles, [3, 3, 3]);
  assert.deepEqual(at("after-st").rooms, [0, 1, 2], "seat%3 is where you start");
  assert.equal(at("after-be-i-am-medium").phase, "walk");
  assert.equal(at("after-be-i-am-medium").haunt, -1);
  assert.equal(at("after-hm").mediumNot, 2);
  assert.equal(at("after-pc").locked, 2);
  assert.equal(at("after-my-pick").myPick, 0);
  assert.equal(at("after-rv-i-was-hit").phase, "reveal");
  assert.equal(at("after-rv-i-was-hit").haunt, 0);
  // a new bell for someone else's medium clears my whisper
  assert.equal(at("after-be-not-medium").mediumNot, -1);
  assert.equal(at("after-ov-i-won").phase, "seanceover");
  assert.equal(at("after-lo").phase, "lobby");
  assert.deepEqual(at("after-lo").order, []);
  assert.equal(at("after-st-spectating").spectator, true, "absent from the order = spectator");
  // applyReveal's phase guard: a stray rv in the lobby changes nothing
  assert.equal(at("after-rv-in-lobby-ignored").phase, "lobby");
  assert.equal(at("after-rv-in-lobby-ignored").haunt, -1);
});

test("applySnap always seats the late joiner as a spectator", () => {
  for (const a of v.applied) {
    assert.equal(a.G.spectator, true, a.name);
    assert.equal(a.G.myPick, -1, a.name);
    assert.equal(a.G.mediumNot, -1, `${a.name}: the whisper is never in a snapshot`);
  }
  const walk = v.applied.find((a) => a.name === "mid-walk");
  assert.equal(walk.G.phase, "walk");
  assert.equal(walk.G.durMs, walk.G.durMs, "the remaining window becomes the whole ring");
  const rev = v.applied.find((a) => a.name === "in-reveal");
  assert.equal(rev.G.phase, "reveal");
});

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