Extend tests for the real board and add tileEffects unit tests
game.test.js: rewritten for the new {type, rolls} action shape and 5-stat
player model. The full-playthrough test now walks the real 212-space board
turn by turn (deterministic dummy rolls — always the low end of each die's
range) until finish, asserting every stat stays numeric and every one of
the 9 real tile types actually gets resolved along the way. board-shape
assertions updated to match (212 spaces, no `choices` populated yet).
tileEffects.test.js (new): unit tests per tile type in isolation, including
exact deterministic assertions against real placeholder-table content (roll
a known value, assert the exact banded effect) and a multi-table
roll_table_ref cross-check computed independently from the raw table data —
no seeded-rng machinery anywhere, same explicit-action-value pattern the
reducer tests already used. Also a sweep resolving every real tile across
the full range of its die to catch any type/die combination that throws.
package.json: test script now runs shared/*.test.js instead of naming one
file.
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.js",
|
||||
"test": "node --test shared/game.test.js"
|
||||
"test": "node --test shared/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.3.0",
|
||||
|
||||
+61
-31
@@ -2,21 +2,28 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
|
||||
import { board } from './board.js';
|
||||
import { rollsNeededFor } from './tileEffects.js';
|
||||
|
||||
function join(state, playerId, name, seat) {
|
||||
return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' });
|
||||
}
|
||||
|
||||
/** Drives `playerId` forward by always resolving any pending choice with its
|
||||
* first option and otherwise rolling the max die value — walkForward stops
|
||||
* early at the next choice/finish regardless of pips, so this reliably makes
|
||||
* progress without the test needing to know the board's shape or size. */
|
||||
// Deterministic dummy rolls — always the low end of each die's range, so
|
||||
// these tests exercise the reducer's mechanics without depending on real
|
||||
// (or synthesized-placeholder) table content. shared/tileEffects.test.js
|
||||
// covers the actual banding/effect math.
|
||||
function rollsFor(space) {
|
||||
return rollsNeededFor(space).map(() => 1);
|
||||
}
|
||||
|
||||
function takeTurn(state, playerId) {
|
||||
const player = state.players[playerId];
|
||||
if (player.pendingChoice) {
|
||||
return reduce(state, { type: 'CHOOSE', playerId, spaceId: player.pendingChoice.options[0] });
|
||||
const spaceId = player.pendingChoice.options[0];
|
||||
return reduce(state, { type: 'CHOOSE', playerId, spaceId, rolls: rollsFor(board.spaces[spaceId]) });
|
||||
}
|
||||
return reduce(state, { type: 'ROLL', playerId, value: state.config.diceSides });
|
||||
const nextSpaceId = board.spaces[player.position].next;
|
||||
return reduce(state, { type: 'ROLL', playerId, rolls: rollsFor(board.spaces[nextSpaceId]) });
|
||||
}
|
||||
|
||||
test('lobby: join validation', () => {
|
||||
@@ -27,6 +34,16 @@ test('lobby: join validation', () => {
|
||||
assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/);
|
||||
});
|
||||
|
||||
test('join gives every player the five starting stats', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
const p1 = state.players.p1;
|
||||
assert.deepEqual(
|
||||
{ cash: p1.cash, love: p1.love, education: p1.education, wealth: p1.wealth, age: p1.age },
|
||||
state.config.startingStats
|
||||
);
|
||||
});
|
||||
|
||||
test('turn order and illegal actions', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
@@ -41,59 +58,72 @@ test('turn order and illegal actions', () => {
|
||||
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']);
|
||||
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
||||
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/);
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', rolls: [] }), /Not your turn/);
|
||||
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'anything' }), /No pending choice/);
|
||||
});
|
||||
|
||||
test('full game: every player reaches every fork, race to finish', () => {
|
||||
test('a single roll advances exactly one tile and applies its effect', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
state = join(state, 'p2', 'Bob', 2);
|
||||
state = reduce(state, { type: 'START_GAME' });
|
||||
|
||||
const seenChoiceSpaces = new Set();
|
||||
const expectedNext = board.spaces[board.startSpaceId].next;
|
||||
state = takeTurn(state, 'p1');
|
||||
assert.equal(state.players.p1.position, expectedNext);
|
||||
assert.equal(state.currentTurn, 'p2', 'turn advances since no choice is pending yet');
|
||||
assert.equal(state.log.at(-1).landedOn, expectedNext);
|
||||
});
|
||||
|
||||
test('full board: walking every tile to the temporary finish never crashes and always terminates', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
state = join(state, 'p2', 'Bob', 2);
|
||||
state = reduce(state, { type: 'START_GAME' });
|
||||
|
||||
const seenTypes = new Set();
|
||||
let guard = 0;
|
||||
while (state.status === 'active') {
|
||||
if (++guard > 500) throw new Error('game did not finish in a reasonable number of turns');
|
||||
if (++guard > 1000) throw new Error('game did not finish in a reasonable number of turns');
|
||||
const turnPlayerId = state.currentTurn;
|
||||
const before = state.players[turnPlayerId];
|
||||
if (before.pendingChoice) seenChoiceSpaces.add(before.position);
|
||||
|
||||
state = takeTurn(state, turnPlayerId);
|
||||
|
||||
const after = state.players[turnPlayerId];
|
||||
assert.equal(typeof after.cash, 'number');
|
||||
seenTypes.add(board.spaces[after.position].type);
|
||||
for (const stat of ['cash', 'love', 'education', 'wealth', 'age']) {
|
||||
assert.equal(typeof after[stat], 'number', `${stat} stays numeric`);
|
||||
}
|
||||
assert.ok(board.spaces[after.position], 'player is always on a real space');
|
||||
}
|
||||
|
||||
assert.equal(state.status, 'finished');
|
||||
assert.ok(state.winnerId, 'a winner is recorded');
|
||||
assert.ok(state.winnerId);
|
||||
assert.equal(state.currentTurn, null);
|
||||
assert.equal(state.players[state.winnerId].position, 'finish');
|
||||
assert.deepEqual(getLegalIntents(state, 'p1'), []);
|
||||
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 1 }), /not active/);
|
||||
|
||||
// Always picking the first option at every fork should have visited all
|
||||
// three choice points at least once between two full playthroughs' worth
|
||||
// of turns (the loser doesn't necessarily finish, so this just checks the
|
||||
// race itself exercised real fork logic, not that both players finished).
|
||||
assert.ok(seenChoiceSpaces.size >= 1, 'at least one fork was actually resolved');
|
||||
// Every distinct tile type in the real board actually got resolved along the way.
|
||||
for (const type of [
|
||||
'payday', 'action_space', 'dice_space', 'roll_table_ref',
|
||||
'inline_table', 'cash_bonus', 'event', 'choice', 'stop',
|
||||
]) {
|
||||
assert.ok(seenTypes.has(type), `expected to land on a "${type}" tile during a full playthrough`);
|
||||
}
|
||||
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', rolls: [] }), /not active/);
|
||||
});
|
||||
|
||||
test('board graph is well-formed', () => {
|
||||
const ids = Object.keys(board.spaces);
|
||||
assert.ok(ids.length > 100, 'the full board should be a substantial expansion of the Phase 1 subset');
|
||||
|
||||
assert.equal(ids.length, 212, '211 real tiles + 1 synthetic finish');
|
||||
let nonFinishCount = 0;
|
||||
for (const id of ids) {
|
||||
const space = board.spaces[id];
|
||||
if (space.type === 'choice') {
|
||||
assert.ok(Array.isArray(space.choices) && space.choices.length >= 2, `${id} needs 2+ choices`);
|
||||
for (const target of space.choices) assert.ok(board.spaces[target], `${id} -> missing ${target}`);
|
||||
} else if (space.type === 'finish') {
|
||||
if (space.type === 'finish') {
|
||||
assert.equal(space.next, undefined);
|
||||
} else {
|
||||
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
|
||||
continue;
|
||||
}
|
||||
nonFinishCount++;
|
||||
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
|
||||
}
|
||||
assert.equal(nonFinishCount, 211);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveTileEffect, rollsNeededFor, DIE_SIZES } from './tileEffects.js';
|
||||
import { board } from './board.js';
|
||||
import rollTables from './rollTables.js';
|
||||
|
||||
const spaces = Object.values(board.spaces);
|
||||
const byType = (type) => spaces.filter((s) => s.type === type);
|
||||
|
||||
test('DIE_SIZES covers every die actually referenced in the real board', () => {
|
||||
const usedDice = new Set(spaces.map((s) => s.die).filter(Boolean));
|
||||
for (const die of usedDice) assert.ok(DIE_SIZES[die], `unknown die size: ${die}`);
|
||||
});
|
||||
|
||||
test('rollsNeededFor asks for exactly one roll per referenced table', () => {
|
||||
const multi = byType('roll_table_ref').find((s) => s.externalTables.length === 3);
|
||||
assert.ok(multi, 'expected a 3-table roll_table_ref tile in the real board');
|
||||
assert.equal(rollsNeededFor(multi).length, 3);
|
||||
|
||||
const single = byType('dice_space')[0];
|
||||
assert.equal(rollsNeededFor(single).length, 1);
|
||||
|
||||
const payday = byType('payday')[0];
|
||||
assert.deepEqual(rollsNeededFor(payday), []);
|
||||
});
|
||||
|
||||
test('dice_space resolves against its real referenced table with an exact banded effect', () => {
|
||||
const space = byType('dice_space')[0];
|
||||
const table = rollTables.tables.find((t) => t.source_doc_id === space.externalTables[0]);
|
||||
assert.ok(table, 'dice_space tile should reference a real table');
|
||||
|
||||
const [loStr] = table.entries[0].range.split('-');
|
||||
const roll = Number(loStr);
|
||||
const result = resolveTileEffect(space, [roll]);
|
||||
assert.deepEqual(result.statDelta, table.entries[0].effect);
|
||||
assert.equal(result.todo, false, 'a real referenced table is not an invented placeholder');
|
||||
});
|
||||
|
||||
test('multi-table roll_table_ref sums every referenced table\'s effect', () => {
|
||||
const multi = byType('roll_table_ref').find((s) => s.externalTables.length >= 2);
|
||||
const rolls = rollsNeededFor(multi).map(() => 1); // roll the minimum on every table
|
||||
|
||||
const expected = {};
|
||||
for (const tableId of multi.externalTables) {
|
||||
const table = rollTables.tables.find((t) => t.source_doc_id === tableId);
|
||||
for (const [stat, delta] of Object.entries(table.entries[0].effect)) {
|
||||
expected[stat] = (expected[stat] ?? 0) + delta;
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(resolveTileEffect(multi, rolls).statDelta, expected);
|
||||
});
|
||||
|
||||
test('stop applies the rolled D8 value directly to age, no table lookup', () => {
|
||||
const stop = byType('stop')[0];
|
||||
assert.equal(stop.die, 'D8');
|
||||
const result = resolveTileEffect(stop, [5]);
|
||||
assert.deepEqual(result.statDelta, { age: 5 });
|
||||
assert.equal(result.todo, false, 'age += roll is a specified mechanic, not invented content');
|
||||
});
|
||||
|
||||
test('choice tiles are a harmless no-op while no board-graph.json choices exist yet', () => {
|
||||
const choice = byType('choice')[0];
|
||||
assert.equal(choice.choices, undefined);
|
||||
const result = resolveTileEffect(choice, []);
|
||||
assert.deepEqual(result.statDelta, {});
|
||||
assert.equal(result.todo, true);
|
||||
});
|
||||
|
||||
test('inline_table tiles resolve against a synthesized placeholder, clearly marked todo', () => {
|
||||
const inline = byType('inline_table')[0];
|
||||
assert.deepEqual(rollsNeededFor(inline), [inline.die]);
|
||||
const max = DIE_SIZES[inline.die];
|
||||
const result = resolveTileEffect(inline, [max]); // top of the range
|
||||
assert.equal(result.todo, true);
|
||||
assert.equal(typeof result.statDelta.cash, 'number');
|
||||
});
|
||||
|
||||
test('payday and named cash_bonus tiles use fixed placeholder amounts, no roll needed', () => {
|
||||
const payday = byType('payday')[0];
|
||||
assert.deepEqual(rollsNeededFor(payday), []);
|
||||
const paydayResult = resolveTileEffect(payday, []);
|
||||
assert.ok(paydayResult.statDelta.cash > 0);
|
||||
assert.equal(paydayResult.todo, true);
|
||||
|
||||
const hundredK = byType('cash_bonus').find((s) => s.label === '100K');
|
||||
assert.deepEqual(resolveTileEffect(hundredK, []).statDelta, { cash: 100000 });
|
||||
|
||||
const tenK = byType('cash_bonus').find((s) => s.label === '10K');
|
||||
assert.deepEqual(resolveTileEffect(tenK, []).statDelta, { cash: 10000 });
|
||||
});
|
||||
|
||||
test('401k (a cash_bonus with a die but no table) falls into the synthesized placeholder path', () => {
|
||||
const four01k = byType('cash_bonus').find((s) => s.label === '401k');
|
||||
assert.ok(four01k, 'expected a 401k cash_bonus tile');
|
||||
assert.equal(four01k.die, 'D20');
|
||||
assert.deepEqual(rollsNeededFor(four01k), ['D20']);
|
||||
const result = resolveTileEffect(four01k, [20]);
|
||||
assert.equal(result.todo, true);
|
||||
});
|
||||
|
||||
test('every real tile resolves without throwing across the full range of its die', () => {
|
||||
for (const space of spaces) {
|
||||
const dice = rollsNeededFor(space);
|
||||
for (const roll of [1, ...dice.map((d) => DIE_SIZES[d])]) {
|
||||
const rolls = dice.map(() => roll);
|
||||
assert.doesNotThrow(() => resolveTileEffect(space, rolls), `${space.id} (${space.type}) threw on roll=${roll}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user