// Gym wrappers — determinism and a sanity check that skill shows in outcomes.
import test from "node:test";
import assert from "node:assert/strict";
import { SeanceGym } from "../dist/gym/seance.js";
import { NeonGridGym } from "../dist/gym/neon-grid.js";
import { mulberry32 } from "../dist/games/host/rng.js";
test("séance gym: fixed seed + fixed policy is byte-identical", () => {
const run = () => {
const gym = new SeanceGym({ players: 3, seed: 11 });
const log = [];
for (let ep = 0; ep < 3; ep++) {
let views = gym.reset();
log.push(views);
for (;;) {
// deterministic policy: medium takes the safe room, others rotate
const actions = views.map((v) => (v.isMedium ? v.hintNot : (v.bell + v.seat) % 3));
const r = gym.step(actions);
log.push(r);
if (r.done) break;
views = r.views;
}
}
return JSON.stringify(log);
};
assert.equal(run(), run());
});
test("séance gym: views wear the SeanceView shape and hide the hint from guests", () => {
const gym = new SeanceGym({ players: 3, seed: 5 });
const views = gym.reset();
for (const v of views) {
assert.deepEqual(Object.keys(v).sort(), ["bell", "candles", "hintNot", "isMedium", "rooms", "seat"]);
assert.equal(v.bell, 1);
if (!v.isMedium) assert.equal(v.hintNot, -1, "only the medium hears the spirit");
}
assert.equal(views.filter((v) => v.isMedium).length, 1);
});
test("neon-grid gym: wall-dodging beats wall-riding", () => {
const rng = mulberry32(3);
const blocked = (v, x, y) => x < 0 || y < 0 || x >= v.gw || y >= v.gh || v.grid[y * v.gw + x] !== 0;
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
const dodge = (v) => {
if (v.tickN === 0) return 1; // leave the shared column before the head-on
const [x, y] = v.pos;
if (!blocked(v, x + DX[v.dir], y + DY[v.dir])) return null;
const opts = [(v.dir + 1) % 4, (v.dir + 3) % 4].filter((d) => !blocked(v, x + DX[d], y + DY[d]));
return opts.length ? opts[Math.floor(rng() * opts.length)] : null;
};
const gym = new NeonGridGym({ players: 2 });
let dodgerWins = 0;
for (let ep = 0; ep < 20; ep++) {
let views = gym.reset();
for (;;) {
const r = gym.step([dodge(views[0]), null]); // seat 1 never turns
if (r.done) {
if (r.winner === 0) dodgerWins++;
break;
}
views = r.views;
}
}
assert.ok(dodgerWins >= 18, `dodger should dominate a straight-liner (won ${dodgerWins}/20)`);
});
test("neon-grid gym: rewards shape as documented", () => {
const gym = new NeonGridGym({ players: 2 });
gym.reset();
const first = gym.step([null, null]);
assert.deepEqual(first.rewards, [0.05, 0.05], "both alive, both paid the living wage");
// ride to the head-on draw
let r = first;
while (!r.done) r = gym.step([null, null]);
assert.deepEqual(r.rewards, [-1, -1], "a draw pays both riders the death penalty");
assert.equal(r.winner, null);
});