Files
coorl-lost-cities/web/src/game/engine.test.ts
T
2026-07-14 19:49:03 +09:00

64 lines
2.5 KiB
TypeScript

import { describe, expect, test } from "vitest";
import { boardScore, currentHandSorted, encodeAction, legalActionMask, resetFromOrder, step } from "./engine";
import { DISCARD, DRAW_DECK, N_CARDS } from "./types";
import { cardColor } from "./cards";
import { observation } from "./observation";
import fixture from "./parity-fixture.json";
import type { GameState } from "./types";
const orderedDeck = Array.from({ length: N_CARDS }, (_, index) => index);
describe("JAX-compatible game engine", () => {
test("deals eight cards to each player in explicit deck order", () => {
const state = resetFromOrder(orderedDeck);
expect(currentHandSorted(state, 0)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
expect(currentHandSorted(state, 1)).toEqual([8, 9, 10, 11, 12, 13, 14, 15]);
});
test("cannot discard and redraw the same card", () => {
const state = resetFromOrder(orderedDeck);
const color = cardColor(currentHandSorted(state)[0]);
const mask = legalActionMask(state);
expect(mask[encodeAction(0, DISCARD, color + 1)]).toBe(false);
expect(mask[encodeAction(0, DISCARD, DRAW_DECK)]).toBe(true);
});
test("deck-only games terminate after exactly 44 plies", () => {
let state = resetFromOrder(orderedDeck);
let plies = 0;
while (!state.done) {
const action = legalActionMask(state).findIndex((legal, action) => legal && action % 6 === 0);
state = step(state, action);
plies += 1;
}
expect(plies).toBe(44);
expect(state.drawPtr).toBe(N_CARDS);
});
test("observation has the trained model shape", () => {
expect(observation(resetFromOrder(orderedDeck), 0)).toHaveLength(454);
});
test("illegal actions are no-ops", () => {
const state = resetFromOrder(orderedDeck);
expect(step(state, -1)).toBe(state);
expect(boardScore(state)).toEqual([0, 0]);
});
test("matches deterministic JAX masks, observations, and transitions", () => {
for (let index = 0; index < fixture.rows.length; index += 1) {
const row = fixture.rows[index];
const state = row.state as GameState;
expect(legalActionMask(state)).toEqual(row.legalMask);
const p0 = observation(state, 0);
const p1 = observation(state, 1);
row.observationP0.forEach((value, offset) => expect(p0[offset]).toBeCloseTo(value, 6));
row.observationP1.forEach((value, offset) => expect(p1[offset]).toBeCloseTo(value, 6));
if (index + 1 < fixture.rows.length) {
expect(step(state, row.action)).toEqual(fixture.rows[index + 1].state);
}
}
});
});