4d78937dca
shared/board.js: rebuilt from shared/tileInventory.js instead of the
sketch-inspired placeholder — one chain() per segment (same helper as
before), TEMP_SEGMENT_ORDER concatenating all 12 segments in the design
doc's own order since board-graph.json (the real segment connections/STOP
forks) doesn't exist yet — loudly commented as a placeholder to replace, not
a guess at real topology. A synthetic `finish` space is appended since no
tile in the source data has that type (win condition is itself an open
question). Movement is now one tile per turn (board.spaces[pos].next) —
no tile in the data implies a movement die, every `die` belongs to that
tile's own effect resolution, so walkForward() is gone.
shared/tileEffects.js (new): pure resolveTileEffect(space, rolls) covering
all 9 real tile types (payday, action_space, dice_space, roll_table_ref,
inline_table, cash_bonus, event, choice, stop) plus rollsNeededFor(space) so
the server knows what to pre-roll before constructing an action. Real
roll_table_ref/action_space/dice_space tiles resolve against the actual 48
placeholder tables; the two real content gaps (inline_table's 85 tiles,
payday/cash_bonus amounts) resolve against a synthesized generic banded
table per die size, every result clearly flagged `todo: true` so it's
visible in the game log, not just in docs.
shared/game.js: player state is now flat {cash, love, education, wealth,
age} fields (mirrors a roll-table effect object's shape directly). Landing
logic calls resolveTileEffect() instead of applying a single cash delta, and
only pauses for a decision when a space's `choices` array is actually
populated (true of nothing yet) — keeps the existing pending-choice
machinery intact for once board-graph.json exists, instead of needing
choice/stop tiles to block on options nobody has defined.
server/rooms.js: REQUEST_ROLL now looks up the player's next tile,
determines what it needs via rollsNeededFor(), and pre-rolls each die via
crypto.randomInt before constructing the ROLL action — same
randomness-happens-once-at-the-server-boundary pattern as before, just
covering N table rolls instead of one movement die. server/index.js:
default config now carries startingStats instead of startingCash, and
maxPlayers is 8.
98 lines
3.6 KiB
JavaScript
98 lines
3.6 KiB
JavaScript
/**
|
|
* In-memory room registry, SQLite as the durable backing store. Every
|
|
* authoritative state change flows through applyAndBroadcast(), which is the
|
|
* only place the shared reducer is invoked server-side.
|
|
*/
|
|
|
|
import crypto from 'node:crypto';
|
|
import { reduce, getLegalIntents } from '../shared/game.js';
|
|
import { board } from '../shared/board.js';
|
|
import { rollsNeededFor, DIE_SIZES } from '../shared/tileEffects.js';
|
|
import * as db from './db.js';
|
|
|
|
export class RoomError extends Error {}
|
|
|
|
const rooms = new Map(); // gameId -> { gameId, state, sockets: Set<WebSocket> }
|
|
|
|
export function loadOrCreateRoom(gameId) {
|
|
const existing = rooms.get(gameId);
|
|
if (existing) return existing;
|
|
const row = db.getGameById(gameId);
|
|
if (!row) throw new RoomError(`Game not found: ${gameId}`);
|
|
const room = { gameId, state: row.state, sockets: new Set() };
|
|
rooms.set(gameId, room);
|
|
return room;
|
|
}
|
|
|
|
export function attachSocket(room, ws, playerId) {
|
|
ws.playerId = playerId;
|
|
room.sockets.add(ws);
|
|
send(ws, { type: 'state', gameId: room.gameId, state: room.state, lastAction: null });
|
|
ws.on('close', () => room.sockets.delete(ws));
|
|
}
|
|
|
|
/** Apply an already-constructed action through the shared reducer, persist,
|
|
* and broadcast the result to every socket in the room. Throws if the
|
|
* reducer rejects the action. */
|
|
export function applyAndBroadcast(room, action) {
|
|
const newState = reduce(room.state, action);
|
|
room.state = newState;
|
|
db.updateGameState(room.gameId, newState, newState.status);
|
|
broadcast(room, { type: 'state', gameId: room.gameId, state: newState, lastAction: action });
|
|
return newState;
|
|
}
|
|
|
|
/** Validate + resolve a client intent into a real action, then apply it.
|
|
* Sends an {type:'error'} back to the originating socket on rejection. */
|
|
export function handleIntent(room, ws, playerId, intent) {
|
|
try {
|
|
const action = buildAction(room.state, playerId, intent);
|
|
applyAndBroadcast(room, action);
|
|
} catch (err) {
|
|
send(ws, { type: 'error', message: err.message });
|
|
}
|
|
}
|
|
|
|
function buildAction(state, playerId, intent) {
|
|
const legal = getLegalIntents(state, playerId);
|
|
if (!legal.includes(intent.type)) {
|
|
throw new Error(`Not allowed: ${intent.type}`);
|
|
}
|
|
switch (intent.type) {
|
|
case 'REQUEST_START':
|
|
return { type: 'START_GAME' };
|
|
case 'REQUEST_ROLL': {
|
|
const player = state.players[playerId];
|
|
const nextSpaceId = board.spaces[player.position].next;
|
|
if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board');
|
|
return { type: 'ROLL', playerId, rolls: rollForSpace(board.spaces[nextSpaceId]) };
|
|
}
|
|
case 'REQUEST_CHOOSE': {
|
|
if (typeof intent.spaceId !== 'string') throw new Error('spaceId is required');
|
|
return { type: 'CHOOSE', playerId, spaceId: intent.spaceId, rolls: rollForSpace(board.spaces[intent.spaceId]) };
|
|
}
|
|
default:
|
|
throw new Error(`Unknown intent: ${intent.type}`);
|
|
}
|
|
}
|
|
|
|
/** Pre-roll whatever dice `space` needs to resolve its own tile effect — the
|
|
* only place actual randomness happens. rollsNeededFor() never touches
|
|
* randomness itself, it just says which die sizes are needed and in what
|
|
* order; reduce() applies the results deterministically. */
|
|
function rollForSpace(space) {
|
|
return rollsNeededFor(space).map((die) => {
|
|
const max = DIE_SIZES[die];
|
|
if (!max) throw new Error(`Unknown die size: ${die}`);
|
|
return 1 + crypto.randomInt(max);
|
|
});
|
|
}
|
|
|
|
function broadcast(room, payload) {
|
|
for (const ws of room.sockets) send(ws, payload);
|
|
}
|
|
|
|
function send(ws, payload) {
|
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(payload));
|
|
}
|