Phase 1: reducer, SQLite, rooms & invite links
Turns the Phase 0 deployment shell into a playable async multiplayer game:
- shared/game.js + shared/board.js: pure reducer (reduce(state, action) ->
newState) over a small branching board subset mirroring the sketched
design (Career/Education fork, Relationship/Investment fork, High-Risk/Safe
fork, race to Finish). Dice randomness is generated server-side and shipped
inside the ROLL action payload, so the reducer itself stays fully pure and
is identically importable by both server and browser.
- server/db.js: SQLite (better-sqlite3) schema for games/players/tokens,
config and state stored as JSON. tokens covers both room invite links and
per-player reconnect secrets.
- server/rooms.js: in-memory room registry that is the only place the shared
reducer is invoked server-side — validates intents, applies actions,
persists, and broadcasts to every socket in the room.
- server/index.js: REST endpoints to create/join/inspect a game, and a
room-aware /ws that authenticates via a first {type:'AUTH'} message rather
than a URL query param (keeps session tokens out of access/proxy logs).
- public/client.js + public/index.html: NetworkTransport wrapping the
WebSocket, localStorage-backed session persistence so a reload resumes as
the same player, and a lobby/waiting-room/game-view UI.
- Dockerfile: adds python3/make/g++ so better-sqlite3's node-gyp fallback
builds on Alpine when a prebuilt binary isn't available for the exact
Node/musl combo.
Verified: shared/game.test.js (node --test) covers the full rules engine;
a scripted two-client run over real HTTP+WS confirms both clients converge
on identical state through create/join/start/play-to-finish; a server
restart mid-game preserves state and reconnect resumes the same player
without creating a duplicate.
This commit is contained in:
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* SQLite persistence. games/players hold identity + metadata; tokens covers
|
||||
* both unclaimed room invite links (kind='invite', player_id NULL) and
|
||||
* per-player reconnect secrets (kind='session'). config/state are stored as
|
||||
* JSON text — Phase 1 never needs to query into them.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
board_id TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby','active','finished')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id TEXT PRIMARY KEY,
|
||||
game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
seat INTEGER NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
last_seen_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_players_game_id ON players(game_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_players_game_seat ON players(game_id, seat);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
|
||||
player_id TEXT REFERENCES players(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('invite','session')),
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_game_id ON tokens(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_player_id ON tokens(player_id);
|
||||
`;
|
||||
|
||||
let db;
|
||||
|
||||
export function initDb(dbPath) {
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function deserializeGame(row) {
|
||||
if (!row) return null;
|
||||
return { ...row, config: JSON.parse(row.config), state: JSON.parse(row.state) };
|
||||
}
|
||||
|
||||
export function createGame({ id, code, boardId, config, state }) {
|
||||
db.prepare(
|
||||
`INSERT INTO games (id, code, board_id, config, state) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, code, boardId, JSON.stringify(config), JSON.stringify(state));
|
||||
return getGameById(id);
|
||||
}
|
||||
|
||||
export function getGameByCode(code) {
|
||||
return deserializeGame(db.prepare(`SELECT * FROM games WHERE code = ?`).get(code));
|
||||
}
|
||||
|
||||
export function getGameById(id) {
|
||||
return deserializeGame(db.prepare(`SELECT * FROM games WHERE id = ?`).get(id));
|
||||
}
|
||||
|
||||
export function updateGameState(id, state, status) {
|
||||
db.prepare(`UPDATE games SET state = ?, status = ?, updated_at = ? WHERE id = ?`).run(
|
||||
JSON.stringify(state),
|
||||
status,
|
||||
now(),
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlayer({ id, gameId, name, seat, color }) {
|
||||
db.prepare(
|
||||
`INSERT INTO players (id, game_id, name, seat, color) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, gameId, name, seat, color);
|
||||
}
|
||||
|
||||
export function getPlayersByGame(gameId) {
|
||||
return db.prepare(`SELECT * FROM players WHERE game_id = ? ORDER BY seat`).all(gameId);
|
||||
}
|
||||
|
||||
export function countPlayers(gameId) {
|
||||
return db.prepare(`SELECT COUNT(*) AS n FROM players WHERE game_id = ?`).get(gameId).n;
|
||||
}
|
||||
|
||||
export function touchPlayerLastSeen(playerId) {
|
||||
db.prepare(`UPDATE players SET last_seen_at = ? WHERE id = ?`).run(now(), playerId);
|
||||
}
|
||||
|
||||
export function createToken({ token, gameId, playerId = null, kind, expiresAt = null }) {
|
||||
db.prepare(
|
||||
`INSERT INTO tokens (token, game_id, player_id, kind, expires_at) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(token, gameId, playerId, kind, expiresAt);
|
||||
}
|
||||
|
||||
export function getToken(token) {
|
||||
return db.prepare(`SELECT * FROM tokens WHERE token = ?`).get(token) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user