diff --git a/package.json b/package.json index 184f604..7664a8b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/game.test.js b/shared/game.test.js index 9ccf2b6..aff79e8 100644 --- a/shared/game.test.js +++ b/shared/game.test.js @@ -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); }); diff --git a/shared/tileEffects.test.js b/shared/tileEffects.test.js new file mode 100644 index 0000000..4c1e625 --- /dev/null +++ b/shared/tileEffects.test.js @@ -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}`); + } + } +});