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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210 | // Live driver wiring — a bot-hosted séance end-to-end over an in-memory hub
// (no relay, mocked timers), plus the split-brain concede/reassert rules.
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { SeanceClient } from "../dist/games/seance.js";
import { walkMs } from "../dist/games/host/seance-sim.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;
}
/** Open streams both ways between every pair (fires peerOpen). */
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));
test("a designated bot hosts a full séance for a guest and both see the winner", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const hub = new Hub();
const hostPeer = hub.join("aaa-host");
let hauntOf = () => -1;
const host = new SeanceClient(hostPeer, {
app: "seance",
roomSecret: "s",
nick: "gazda",
keepAliveMs: 0,
actDelayMs: 0,
onTurn: () => hauntOf(), // the host walks into the ghost every bell
host: { rng: mulberry32(9), match: { minPlayers: 2, expectedPlayers: 2, maxMatches: 1 } },
});
hauntOf = () => host.host.sim.haunt;
// boots alone → claims
hostPeer.emit("roster", []);
assert.equal(host.hostId, "aaa-host");
assert.equal(host.hosting, true);
// a guest arrives and dodges every bell
const guestPeer = hub.join("bbb-guest");
const guest = new SeanceClient(guestPeer, {
app: "seance",
roomSecret: "s",
nick: "oaspe",
keepAliveMs: 0,
actDelayMs: 0,
onTurn: () => (host.host.sim.haunt + 1) % 3,
});
const ends = { host: null, guest: null };
host.on("matchEnd", (w) => (ends.host = w));
guest.on("matchEnd", (w) => (ends.guest = w));
hub.openAll(); // both announce; the host seats the guest; auto-start fires
await drain();
assert.equal(guest.hostId, "aaa-host", "guest adopted the bot host from its ro");
const st = hostPeer.sent.find(([, f]) => f.t === "st");
assert.ok(st, "séance auto-started at expectedPlayers");
assert.deepEqual(st[1].order, ["aaa-host", "bbb-guest"], "séance order is sorted");
// ride the timer chain: st→bell(2600), then [walk+500, reveal 6500] per bell
t.mock.timers.tick(2600);
for (let bell = 1; bell <= 3; bell++) {
await drain(); // both onTurn policies run and their picks land
t.mock.timers.tick(walkMs(bell) + 500); // resolve the bell
await drain();
if (bell < 3) t.mock.timers.tick(6500); // reveal lingers, next bell rings
}
t.mock.timers.tick(6500); // ov → end step scheduled by the last resolve
await drain();
assert.equal(ends.host, "bbb-guest", "host saw the guest win");
assert.equal(ends.guest, "bbb-guest", "guest saw itself win");
assert.equal(guest.wins, 1);
const ov = hostPeer.sent.find(([, f]) => f.t === "ov")[1];
assert.deepEqual(
ov.ps.map((e) => [e[0], e[3]]),
[["aaa-host", 0], ["bbb-guest", 1]],
"roster wins credited",
);
t.mock.timers.tick(6500); // back to lobby; maxMatches:1 stops a second séance
await drain();
assert.equal(hostPeer.sent.filter(([, f]) => f.t === "st").length, 1, "no second séance past maxMatches");
});
test("séance incumbent concedes to a lower id and reasserts against a higher one", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const hub = new Hub();
const hostPeer = hub.join("mmm-host");
const host = new SeanceClient(hostPeer, {
app: "seance",
roomSecret: "s",
nick: "gazda",
keepAliveMs: 0,
host: {},
});
hostPeer.emit("roster", []);
assert.equal(host.hosting, true);
// a higher id claims — the incumbent reasserts with its own roster
hub.join("zzz-late");
const before = hostPeer.sent.length;
hostPeer.emit("message", "zzz-late", { t: "ro", host: "zzz-late", ps: [["zzz-late", "tarziu", 0, 0]] });
assert.equal(host.hostId, "mmm-host", "higher id never displaces the incumbent");
assert.ok(
hostPeer.sent.slice(before).some(([, f]) => f.t === "ro" && f.host === "mmm-host"),
"incumbent reasserted",
);
// a lower id claims — séance rule is plain lowest id: concede
hub.join("aaa-early");
hostPeer.emit("message", "aaa-early", { t: "ro", host: "aaa-early", ps: [["aaa-early", "devreme", 0, 0]] });
assert.equal(host.hostId, "aaa-early", "incumbent conceded to the lower id");
assert.equal(host.hosting, false);
assert.equal(host.host.sim.running, false, "conceding voids the séance");
});
test("a bot-hosted neon-grid round runs to the draw and the next round auto-starts", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { NeonGridClient } = await import("../dist/games/neon-grid.js");
const hub = new Hub();
const hostPeer = hub.join("aaa-host");
const host = new NeonGridClient(hostPeer, {
app: "neon-grid",
roomSecret: "s",
nick: "gazda",
keepAliveMs: 0,
onTick: () => null, // ride straight
host: { match: { minPlayers: 2, expectedPlayers: 2 } },
});
hostPeer.emit("roster", []);
const guestPeer = hub.join("bbb-guest");
new NeonGridClient(guestPeer, {
app: "neon-grid",
roomSecret: "s",
nick: "oaspe",
keepAliveMs: 0,
onTick: () => null, // ride straight into the head-on
});
hub.openAll();
await drain();
const st = hostPeer.sent.find(([, f]) => f.t === "st");
assert.ok(st, "round auto-started");
assert.equal(st[1].tickMs, 80);
t.mock.timers.tick(3000); // countdown → first tick
// a late joiner announces mid-round: its roster carries live:1 + a snapshot
hub.join("ccc-late");
hostPeer.emit("message", "ccc-late", { t: "hi", nick: "tarziu", score: 0 });
const liveRo = hostPeer.sent.find(([, f]) => f.t === "ro" && f.live === 1);
assert.ok(liveRo, "mid-round roster broadcasts carry live:1");
assert.ok(
hostPeer.sent.some(([to, f]) => to === "ccc-late" && f.t === "sn"),
"late joiner got the snapshot",
);
for (let i = 0; i < 35; i++) t.mock.timers.tick(80);
await drain();
const ov = hostPeer.sent.find(([, f]) => f.t === "ov");
assert.ok(ov, "head-on draw ends the round at tick 36");
assert.equal(ov[1].w, null);
assert.equal(hostPeer.sent.filter(([, f]) => f.t === "tk").length, 36);
t.mock.timers.tick(3200); // interstitial → the driver executes step "round"
await drain();
const starts = hostPeer.sent.filter(([, f]) => f.t === "st");
assert.equal(starts.length, 2, "round 2 auto-started");
assert.equal(starts[1][1].tickMs, 76, "tick speed ramped");
});
|