Early version
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Life Journey — Phase 1 board.
|
||||
*
|
||||
* A small representative subset of the full hand-drawn board (assets/game_board.png):
|
||||
* a Career-vs-Education fork, a Relationship-vs-Investment fork, a High-Risk-vs-Safe
|
||||
* fork, and a Finish. Same shape as the full sketch — more spaces can be inserted into
|
||||
* any branch array later (repointing one `next`) without touching the reducer.
|
||||
*
|
||||
* Space shape: { id, type, label, next?, choices?, cash?, flavor? }
|
||||
* type: 'start' | 'event' | 'money' | 'choice' | 'finish'
|
||||
* next: id of the following space (absent on 'choice' and 'finish' spaces)
|
||||
* choices: [id, id] of the branches offered by a 'choice' space
|
||||
* cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces)
|
||||
*/
|
||||
|
||||
const spaceList = [
|
||||
{ id: 'start', type: 'start', label: 'Start', next: 'crossroads' },
|
||||
{ id: 'crossroads', type: 'choice', label: 'Crossroads', choices: ['career_1', 'edu_1'] },
|
||||
|
||||
{ id: 'career_1', type: 'event', label: 'High School Grad', next: 'career_2' },
|
||||
{ id: 'career_2', type: 'event', label: 'New Job', next: 'career_3' },
|
||||
{ id: 'career_3', type: 'money', label: 'Paycheck', cash: 300, next: 'career_4' },
|
||||
{ id: 'career_4', type: 'money', label: 'Workplace Drama', cash: -150, next: 'career_5' },
|
||||
{ id: 'career_5', type: 'money', label: 'Performance Review', cash: 250, next: 'join_1' },
|
||||
|
||||
{ id: 'edu_1', type: 'event', label: 'College Enrolled', next: 'edu_2' },
|
||||
{ id: 'edu_2', type: 'money', label: 'Study Abroad', cash: -100, next: 'edu_3' },
|
||||
{ id: 'edu_3', type: 'money', label: 'Student Loan', cash: -300, next: 'edu_4' },
|
||||
{ id: 'edu_4', type: 'money', label: 'Scholarship', cash: 400, next: 'edu_5' },
|
||||
{ id: 'edu_5', type: 'event', label: 'Graduation', next: 'join_1' },
|
||||
|
||||
{ id: 'join_1', type: 'event', label: 'Adulting Begins', next: 'life_crossroads' },
|
||||
{ id: 'life_crossroads', type: 'choice', label: 'Life Crossroads', choices: ['relationship_1', 'investment_1'] },
|
||||
|
||||
{ id: 'relationship_1', type: 'event', label: 'New City', next: 'relationship_2' },
|
||||
{ id: 'relationship_2', type: 'event', label: 'Dinner Party', next: 'relationship_3' },
|
||||
{ id: 'relationship_3', type: 'money', label: 'Wedding', cash: -200, next: 'join_2' },
|
||||
|
||||
{ id: 'investment_1', type: 'money', label: 'Side Investment', cash: -150, next: 'investment_2' },
|
||||
{ id: 'investment_2', type: 'money', label: 'Market Move', cash: 350, next: 'investment_3' },
|
||||
{ id: 'investment_3', type: 'money', label: '401K Contribution', cash: -100, flavor: 'Future savings', next: 'join_2' },
|
||||
|
||||
{ id: 'join_2', type: 'event', label: 'Settling Down', next: 'high_risk_choice' },
|
||||
{ id: 'high_risk_choice', type: 'choice', label: 'One Last Fork', choices: ['high_risk_1', 'safe_1'] },
|
||||
|
||||
{ id: 'high_risk_1', type: 'money', label: 'Startup Gamble', cash: 500, next: 'high_risk_2' },
|
||||
{ id: 'high_risk_2', type: 'money', label: 'Bankruptcy', cash: -400, flavor: 'Ouch.', next: 'retirement_party' },
|
||||
|
||||
{ id: 'safe_1', type: 'money', label: 'Steady Savings', cash: 100, next: 'safe_2' },
|
||||
{ id: 'safe_2', type: 'money', label: 'Modest Raise', cash: 150, next: 'retirement_party' },
|
||||
|
||||
{ id: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' },
|
||||
{ id: 'finish', type: 'finish', label: 'Finish' },
|
||||
];
|
||||
|
||||
export const board = {
|
||||
id: 'phase1-demo',
|
||||
startSpaceId: 'start',
|
||||
spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])),
|
||||
};
|
||||
|
||||
/**
|
||||
* Advance from `startId` by up to `steps` spaces, stopping immediately on arrival
|
||||
* at a 'choice' or 'finish' space even if pips remain (they're discarded).
|
||||
*/
|
||||
export function walkForward(startId, steps) {
|
||||
let current = startId;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const space = board.spaces[current];
|
||||
if (!space.next) break; // sitting on a choice/finish space already — nowhere to advance
|
||||
current = space.next;
|
||||
const landed = board.spaces[current];
|
||||
if (landed.type === 'choice' || landed.type === 'finish') break;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Life Journey — pure game reducer.
|
||||
*
|
||||
* reduce(state, action) -> newState is the ONLY way state changes, and it never
|
||||
* generates randomness itself: the die value is generated by the server and
|
||||
* travels inside the ROLL action payload, so server and client can both apply
|
||||
* the exact same action through this exact same function and land on identical
|
||||
* state. Illegal transitions throw rather than no-op — the server is expected
|
||||
* to gate intents with getLegalIntents()/isPlayersTurn() before ever
|
||||
* constructing an action, so a throw here means that gate was bypassed.
|
||||
*/
|
||||
|
||||
import { board, walkForward } from './board.js';
|
||||
|
||||
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
|
||||
const LOG_LIMIT = 50;
|
||||
|
||||
export function createInitialState(config = {}) {
|
||||
return {
|
||||
boardId: board.id,
|
||||
config: { ...DEFAULT_CONFIG, ...config },
|
||||
status: 'lobby',
|
||||
turnOrder: [],
|
||||
turnIndex: 0,
|
||||
currentTurn: null,
|
||||
winnerId: null,
|
||||
players: {},
|
||||
log: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function reduce(state, action) {
|
||||
switch (action.type) {
|
||||
case 'JOIN':
|
||||
return applyJoin(state, action);
|
||||
case 'START_GAME':
|
||||
return applyStart(state, action);
|
||||
case 'ROLL':
|
||||
return applyRoll(state, action);
|
||||
case 'CHOOSE':
|
||||
return applyChoose(state, action);
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${action.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** What intents (if any) `playerId` may legally send right now — the single
|
||||
* source of truth used by both server-side validation and client-side UI. */
|
||||
export function getLegalIntents(state, playerId) {
|
||||
const player = state.players[playerId];
|
||||
if (!player) return [];
|
||||
if (state.status === 'lobby') {
|
||||
const count = Object.keys(state.players).length;
|
||||
return count >= state.config.minPlayers ? ['REQUEST_START'] : [];
|
||||
}
|
||||
if (state.status !== 'active' || state.currentTurn !== playerId) return [];
|
||||
return player.pendingChoice ? ['REQUEST_CHOOSE'] : ['REQUEST_ROLL'];
|
||||
}
|
||||
|
||||
export function isPlayersTurn(state, playerId) {
|
||||
return state.status === 'active' && state.currentTurn === playerId;
|
||||
}
|
||||
|
||||
// --- internals -------------------------------------------------------------
|
||||
|
||||
function assertLobby(state) {
|
||||
if (state.status !== 'lobby') throw new Error('Game is not in lobby');
|
||||
}
|
||||
|
||||
function assertActive(state) {
|
||||
if (state.status !== 'active') throw new Error('Game is not active');
|
||||
}
|
||||
|
||||
function assertPlayersTurn(state, playerId) {
|
||||
if (state.currentTurn !== playerId) throw new Error('Not your turn');
|
||||
}
|
||||
|
||||
function applyJoin(state, { playerId, name, seat, color }) {
|
||||
assertLobby(state);
|
||||
if (state.players[playerId]) throw new Error('Player already joined');
|
||||
if (Object.keys(state.players).length >= state.config.maxPlayers) {
|
||||
throw new Error('Game is full');
|
||||
}
|
||||
if (Object.values(state.players).some((p) => p.seat === seat)) {
|
||||
throw new Error('Seat already taken');
|
||||
}
|
||||
const player = {
|
||||
id: playerId,
|
||||
name,
|
||||
seat,
|
||||
color,
|
||||
position: board.startSpaceId,
|
||||
cash: state.config.startingCash,
|
||||
pendingChoice: null,
|
||||
finished: false,
|
||||
};
|
||||
return { ...state, players: { ...state.players, [playerId]: player } };
|
||||
}
|
||||
|
||||
function applyStart(state) {
|
||||
assertLobby(state);
|
||||
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
|
||||
if (players.length < state.config.minPlayers) {
|
||||
throw new Error('Not enough players to start');
|
||||
}
|
||||
const turnOrder = players.map((p) => p.id);
|
||||
return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] };
|
||||
}
|
||||
|
||||
function applyRoll(state, { playerId, value }) {
|
||||
assertActive(state);
|
||||
assertPlayersTurn(state, playerId);
|
||||
const player = state.players[playerId];
|
||||
if (player.pendingChoice) throw new Error('Resolve pending choice before rolling');
|
||||
const landed = walkForward(player.position, value);
|
||||
return landOn(state, playerId, landed, { type: 'ROLL', value });
|
||||
}
|
||||
|
||||
function applyChoose(state, { playerId, spaceId }) {
|
||||
assertActive(state);
|
||||
assertPlayersTurn(state, playerId);
|
||||
const player = state.players[playerId];
|
||||
const pending = player.pendingChoice;
|
||||
if (!pending) throw new Error('No pending choice');
|
||||
if (!pending.options.includes(spaceId)) throw new Error('Illegal choice');
|
||||
return landOn(state, playerId, spaceId, { type: 'CHOOSE' });
|
||||
}
|
||||
|
||||
/** Shared landing logic for both a ROLL's terminal space and a CHOOSE's
|
||||
* resolved branch: apply the space's effect, then either leave the turn
|
||||
* open (choice pending), end the game (finish), or advance to the next player. */
|
||||
function landOn(state, playerId, spaceId, logMeta) {
|
||||
const space = board.spaces[spaceId];
|
||||
const cashDelta = space.cash ?? 0;
|
||||
const priorPlayer = state.players[playerId];
|
||||
const nextPlayer = {
|
||||
...priorPlayer,
|
||||
position: spaceId,
|
||||
cash: priorPlayer.cash + cashDelta,
|
||||
pendingChoice: space.type === 'choice' ? { atSpace: spaceId, options: space.choices } : null,
|
||||
finished: space.type === 'finish',
|
||||
};
|
||||
|
||||
let newState = { ...state, players: { ...state.players, [playerId]: nextPlayer } };
|
||||
newState.log = appendLog(state.log, {
|
||||
playerId,
|
||||
landedOn: spaceId,
|
||||
label: space.label,
|
||||
cashDelta,
|
||||
...logMeta,
|
||||
});
|
||||
|
||||
if (space.type === 'finish') {
|
||||
newState.status = 'finished';
|
||||
newState.winnerId = playerId;
|
||||
newState.currentTurn = null;
|
||||
return newState;
|
||||
}
|
||||
|
||||
if (nextPlayer.pendingChoice) return newState; // turn stays with this player
|
||||
|
||||
return advanceTurn(newState);
|
||||
}
|
||||
|
||||
function advanceTurn(state) {
|
||||
const turnIndex = (state.turnIndex + 1) % state.turnOrder.length;
|
||||
return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] };
|
||||
}
|
||||
|
||||
function appendLog(log, entry) {
|
||||
const next = [...log, entry];
|
||||
return next.length > LOG_LIMIT ? next.slice(next.length - LOG_LIMIT) : next;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
|
||||
|
||||
function join(state, playerId, name, seat) {
|
||||
return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' });
|
||||
}
|
||||
|
||||
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('full game: both choice points, race to finish', () => {
|
||||
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.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/);
|
||||
});
|
||||
Reference in New Issue
Block a user