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' }); } // 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) { const spaceId = player.pendingChoice.options[0]; return reduce(state, { type: 'CHOOSE', playerId, spaceId, rolls: rollsFor(board.spaces[spaceId]) }); } const nextSpaceId = board.spaces[player.position].next; return reduce(state, { type: 'ROLL', playerId, rolls: rollsFor(board.spaces[nextSpaceId]) }); } test('lobby: join validation', () => { let state = createInitialState(); state = join(state, 'p1', 'Alice', 1); assert.throws(() => join(state, 'p1', 'Alice again', 2), /already joined/); assert.throws(() => join(state, 'p2', 'Bob', 1), /Seat already taken/); 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); state = join(state, 'p2', 'Bob', 2); state = reduce(state, { type: 'START_GAME' }); assert.equal(state.status, 'active'); assert.deepEqual(state.turnOrder, ['p1', 'p2']); assert.equal(state.currentTurn, 'p1'); assert.equal(isPlayersTurn(state, 'p1'), true); assert.equal(isPlayersTurn(state, 'p2'), false); assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']); assert.deepEqual(getLegalIntents(state, 'p2'), []); 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('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 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 > 1000) throw new Error('game did not finish in a reasonable number of turns'); const turnPlayerId = state.currentTurn; state = takeTurn(state, turnPlayerId); const after = state.players[turnPlayerId]; 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); assert.equal(state.currentTurn, null); assert.equal(state.players[state.winnerId].position, 'finish'); assert.deepEqual(getLegalIntents(state, 'p1'), []); // 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.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 === 'finish') { assert.equal(space.next, undefined); continue; } nonFinishCount++; assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`); } assert.equal(nonFinishCount, 211); });