Expand the board to the full sketch (~107 spaces, three forks)
shared/board.js now transcribes every distinct space label from the hand-drawn sketch (assets/game_board.png) rather than Phase 1's small subset. The sketch's arrows get genuinely ambiguous in a few places (it reads as a mockup, not an engineered spec) and reuses several space names across zones (Start A Business, Family Reunion, Market Crash, ...) — that repetition is kept as intentional flavor rather than deduplicated, and the ambiguous bits are resolved into a clean DAG with the same shape Phase 1 proved out: a fork, a chain() per branch, a convergence — repeated three times (Career/Education/Gap Year, then Relationship/Investment, then High Risk/Safe), into a long shared retirement tail. Branches are built with a small chain() helper that auto-wires each entry's `next` to the following one, since hand-wiring ~107 ids was too error-prone. The reducer, SQLite schema, and rooms/WS layer needed zero changes — the whole point of the pure-reducer/graph-data design from Phase 1. Two things did need generalizing: - public/boardRender.js's lane offset was hardcoded to a 2-way fork; the new "Which Path?" fork is 3-way (Career/Education/Gap Year), so the offset formula is now symmetric for any number of branches. - shared/game.test.js hardcoded Phase 1's specific space ids. Rewritten to be board-structure-agnostic: it always resolves the first offered choice and otherwise rolls the max die value, which reliably makes progress regardless of board shape (walkForward always stops early at the next choice/finish), plus a graph-well-formedness check. Verified: unit tests green; a full two-player game played headlessly end-to-end through all three forks to Finish in 38 turns with zero console/page errors; the rendered board visually confirmed at full scale (5-row snake layout, 3-way fork fans out correctly, all labels legible).
This commit is contained in:
+70
-68
@@ -1,11 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
|
||||
import { board } from './board.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. */
|
||||
function takeTurn(state, playerId) {
|
||||
const player = state.players[playerId];
|
||||
if (player.pendingChoice) {
|
||||
return reduce(state, { type: 'CHOOSE', playerId, spaceId: player.pendingChoice.options[0] });
|
||||
}
|
||||
return reduce(state, { type: 'ROLL', playerId, value: state.config.diceSides });
|
||||
}
|
||||
|
||||
test('lobby: join validation', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
@@ -14,7 +27,7 @@ test('lobby: join validation', () => {
|
||||
assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/);
|
||||
});
|
||||
|
||||
test('full game: both choice points, race to finish', () => {
|
||||
test('turn order and illegal actions', () => {
|
||||
let state = createInitialState();
|
||||
state = join(state, 'p1', 'Alice', 1);
|
||||
state = join(state, 'p2', 'Bob', 2);
|
||||
@@ -23,75 +36,64 @@ test('full game: both choice points, race to finish', () => {
|
||||
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.equal(isPlayersTurn(state, 'p2'), false);
|
||||
|
||||
// Not p2's turn yet.
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/);
|
||||
|
||||
// p1 rolls onto the first choice space; movement stops immediately even
|
||||
// though only 1 of the roll's pips was needed.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
|
||||
assert.equal(state.players.p1.position, 'crossroads');
|
||||
assert.deepEqual(state.players.p1.pendingChoice, { atSpace: 'crossroads', options: ['career_1', 'edu_1'] });
|
||||
assert.equal(state.currentTurn, 'p1', 'turn stays with the player until they choose');
|
||||
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_CHOOSE']);
|
||||
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 2 }), /Resolve pending choice/);
|
||||
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'finish' }), /Illegal choice/);
|
||||
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'career_1' });
|
||||
assert.equal(state.players.p1.position, 'career_1');
|
||||
assert.equal(state.players.p1.pendingChoice, null);
|
||||
assert.equal(state.currentTurn, 'p2', 'choosing resolves the turn');
|
||||
|
||||
// p2 takes the education branch.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 });
|
||||
assert.equal(state.players.p2.position, 'crossroads');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'edu_1' });
|
||||
assert.equal(state.players.p2.position, 'edu_1');
|
||||
assert.equal(state.currentTurn, 'p1');
|
||||
|
||||
// p1: career_1 -> life_crossroads is exactly 6 steps; only the landed
|
||||
// space's cash effect applies, not spaces merely passed through.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 6 });
|
||||
assert.equal(state.players.p1.position, 'life_crossroads');
|
||||
assert.equal(state.players.p1.cash, 0, 'passed-through Paycheck/Drama/Review do not apply');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'investment_1' });
|
||||
assert.equal(state.players.p1.position, 'investment_1');
|
||||
assert.equal(state.players.p1.cash, -150);
|
||||
assert.equal(state.currentTurn, 'p2');
|
||||
|
||||
// p2: edu_1 -> life_crossroads is also exactly 6 steps.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 6 });
|
||||
assert.equal(state.players.p2.position, 'life_crossroads');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'relationship_1' });
|
||||
assert.equal(state.players.p2.position, 'relationship_1');
|
||||
assert.equal(state.currentTurn, 'p1');
|
||||
|
||||
// p1: investment_1 -> high_risk_choice is exactly 4 steps.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 4 });
|
||||
assert.equal(state.players.p1.position, 'high_risk_choice');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'safe_1' });
|
||||
assert.equal(state.players.p1.cash, -50); // -150 + 100
|
||||
assert.equal(state.currentTurn, 'p2');
|
||||
|
||||
// p2: relationship_1 -> high_risk_choice is also exactly 4 steps.
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 4 });
|
||||
assert.equal(state.players.p2.position, 'high_risk_choice');
|
||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'high_risk_1' });
|
||||
assert.equal(state.players.p2.cash, 500);
|
||||
assert.equal(state.currentTurn, 'p1');
|
||||
|
||||
// p1: safe_1 -> finish is exactly 3 steps. First arrival ends the game.
|
||||
assert.equal(state.status, 'active');
|
||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
|
||||
assert.equal(state.players.p1.position, 'finish');
|
||||
assert.equal(state.status, 'finished');
|
||||
assert.equal(state.winnerId, 'p1');
|
||||
assert.equal(state.currentTurn, null);
|
||||
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
||||
|
||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 1 }), /not active/);
|
||||
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'anything' }), /No pending choice/);
|
||||
});
|
||||
|
||||
test('full game: every player reaches every fork, race to finish', () => {
|
||||
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();
|
||||
let guard = 0;
|
||||
while (state.status === 'active') {
|
||||
if (++guard > 500) 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');
|
||||
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.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');
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
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') {
|
||||
assert.equal(space.next, undefined);
|
||||
} else {
|
||||
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user