From e947dbd20ab32367fc34a243a92b295748f822eb Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Sat, 7 Mar 2026 19:06:48 -0500 Subject: [PATCH] Add Anagrams game server engine New game with real-time word claiming from a shared letter pool. Features queued claiming, word stealing, dictionary validation with player voting for non-TWL words, scaled letter-request timer, and game-end steal mechanic. Co-Authored-By: Claude Opus 4.6 --- anagrams.js | 887 +++++++++++++++++++++++++++++++++++ lmno.js | 3 +- templates/anagrams-game.html | 17 + 3 files changed, 906 insertions(+), 1 deletion(-) create mode 100644 anagrams.js create mode 100644 templates/anagrams-game.html diff --git a/anagrams.js b/anagrams.js new file mode 100644 index 0000000..c081214 --- /dev/null +++ b/anagrams.js @@ -0,0 +1,887 @@ +const express = require("express"); +const Game = require("./game.js"); +const TWL_WORDS = new Set(require("./twl-words.js")); +const { TILE_DISTRIBUTION_NO_BLANKS, make_bag } = require("./tiles.js"); + +const CLAIM_TIMEOUT_MS = 60000; /* 60 seconds to form a word */ +const CLAIM_WARNING_MS = 15000; /* show timer for last 15 seconds */ +const VOTE_TIMEOUT_MS = 15000; /* 15 seconds to vote */ +const REVEAL_COUNTDOWN_MS = 3000; /* 3 seconds to reveal a tile */ +const MIN_WORD_LENGTH = 4; + +class Anagrams extends Game { + constructor(id) { + super(id); + this.state = { + bag: make_bag(TILE_DISTRIBUTION_NO_BLANKS), + /* Center letters: array of { id, letter }. + * id is a unique incrementing integer for tracking. */ + center: [], + next_letter_id: 0, + /* Per-player claimed words: { session_id: [ { id, word, letters } ] } + * letters is an array of { id, letter } for display. */ + player_words: {}, + /* Claim queue: array of session_ids. First entry is active claimer. */ + claim_queue: [], + /* Letters/words currently held by the active claimer. */ + claimed_letters: [], /* letter objects from center */ + claimed_words: [], /* { owner_session, word_id, word_obj } */ + /* Voting state. */ + vote_pending: null, + /* Letter request state. */ + letter_requests: new Set(), + /* Game state. */ + finished: false, + done_players: new Set() + }; + this._claim_timer = null; + this._claim_warning_timer = null; + this._vote_timer = null; + this._letter_request_timer = null; + this._reveal_timer = null; + } + + /***************************************************** + * Helpers * + *****************************************************/ + + active_claimer_session() { + return this.state.claim_queue.length > 0 + ? this.state.claim_queue[0] : null; + } + + active_claimer_player() { + const sid = this.active_claimer_session(); + return sid ? this.players_by_session[sid] : null; + } + + /* Count active players who have joined (have a words array). */ + joined_player_count() { + let count = 0; + for (const sid of Object.keys(this.state.player_words)) { + const p = this.players_by_session[sid]; + if (p && p.active) count++; + } + return count; + } + + /***************************************************** + * Letter management * + *****************************************************/ + + /* Deal a letter from bag to center with a countdown reveal. */ + deal_letter() { + if (this.state.bag.length === 0) return false; + + const letter = this.state.bag.pop(); + const id = this.state.next_letter_id++; + const tile = { id, letter }; + + this.state.center.push(tile); + this.state.letter_requests = new Set(); + + this.broadcast_event_object("letter-reveal", { + tile, + remaining: this.state.bag.length, + countdown_ms: REVEAL_COUNTDOWN_MS + }); + + /* Also broadcast updated bag count. */ + this.broadcast_event_object("bag-count", { + remaining: this.state.bag.length + }); + + return true; + } + + /* Handle a player requesting a new letter. */ + handle_request_letter(request, response) { + const session_id = request.session.id; + if (!this.state.player_words[session_id]) { + response.sendStatus(400); + return; + } + + if (this.state.bag.length === 0) { + response.json({ remaining: 0 }); + return; + } + + /* Don't allow requests during a reveal countdown. */ + if (this._reveal_timer) { + response.json({ queued: false }); + return; + } + + this.state.letter_requests.add(session_id); + + const joined = this.joined_player_count(); + const votes = this.state.letter_requests.size; + + this.broadcast_event_object("letter-request", { + votes, + needed: joined + }); + + /* Scale timer: all players voting = immediate. + * One player = full 10-second wait. + * Linear interpolation between. */ + if (this._letter_request_timer) { + clearTimeout(this._letter_request_timer); + this._letter_request_timer = null; + } + + if (votes >= joined) { + this.deal_letter(); + } else { + const ratio = votes / joined; + const delay_ms = Math.round(10000 * (1 - ratio)); + this._letter_request_timer = setTimeout(() => { + this._letter_request_timer = null; + this.deal_letter(); + }, delay_ms); + } + + response.json({ queued: true, remaining: this.state.bag.length }); + } + + /***************************************************** + * Claim mode * + *****************************************************/ + + handle_claim(request, response) { + const session_id = request.session.id; + if (!this.state.player_words[session_id]) { + response.sendStatus(400); + return; + } + + /* Don't allow claiming if game is finished. */ + if (this.state.finished) { + response.json({ queued: false }); + return; + } + + /* Don't allow if already in queue. */ + if (this.state.claim_queue.includes(session_id)) { + response.json({ queued: true, active: session_id === this.active_claimer_session() }); + return; + } + + this.state.claim_queue.push(session_id); + + /* If this is the only person in queue, activate them. */ + if (this.state.claim_queue.length === 1) { + this._activate_claimer(); + } else { + /* Let them know they're queued. */ + const player = this.players_by_session[session_id]; + if (player) { + player.send(`event: claim-queued\ndata: ${JSON.stringify({ + position: this.state.claim_queue.indexOf(session_id) + })}\n\n`); + } + } + + response.json({ queued: true, active: session_id === this.active_claimer_session() }); + } + + _activate_claimer() { + const session_id = this.active_claimer_session(); + if (!session_id) return; + + this.state.claimed_letters = []; + this.state.claimed_words = []; + + const player = this.players_by_session[session_id]; + this.broadcast_event_object("claim-start", { + player_name: player ? player.name : "Unknown", + timeout_ms: CLAIM_TIMEOUT_MS, + warning_ms: CLAIM_WARNING_MS + }); + + /* Start claim timeout. */ + this._claim_warning_timer = setTimeout(() => { + this.broadcast_event_object("claim-warning", { + remaining_ms: CLAIM_WARNING_MS + }); + }, CLAIM_TIMEOUT_MS - CLAIM_WARNING_MS); + + this._claim_timer = setTimeout(() => { + this._cancel_claim("timeout"); + }, CLAIM_TIMEOUT_MS); + } + + /* Take a letter from the center. */ + handle_take_letter(request, response) { + const session_id = request.session.id; + if (session_id !== this.active_claimer_session()) { + response.sendStatus(403); + return; + } + + const letter_id = request.body.letter_id; + const idx = this.state.center.findIndex(t => t.id === letter_id); + if (idx < 0) { + response.sendStatus(404); + return; + } + + const tile = this.state.center.splice(idx, 1)[0]; + this.state.claimed_letters.push(tile); + + const player = this.players_by_session[session_id]; + this.broadcast_event_object("letter-claimed", { + player_name: player ? player.name : "Unknown", + tile + }); + + response.json({ ok: true }); + } + + /* Return a letter to the center. */ + handle_return_letter(request, response) { + const session_id = request.session.id; + if (session_id !== this.active_claimer_session()) { + response.sendStatus(403); + return; + } + + const letter_id = request.body.letter_id; + const idx = this.state.claimed_letters.findIndex(t => t.id === letter_id); + if (idx < 0) { + response.sendStatus(404); + return; + } + + const tile = this.state.claimed_letters.splice(idx, 1)[0]; + this.state.center.push(tile); + + const player = this.players_by_session[session_id]; + this.broadcast_event_object("letter-returned", { + player_name: player ? player.name : "Unknown", + tile + }); + + response.json({ ok: true }); + } + + /* Steal a word from a player. */ + handle_steal_word(request, response) { + const session_id = request.session.id; + if (session_id !== this.active_claimer_session()) { + response.sendStatus(403); + return; + } + + const { owner_session, word_id } = request.body; + const owner_words = this.state.player_words[owner_session]; + if (!owner_words) { + response.sendStatus(404); + return; + } + + const word_idx = owner_words.findIndex(w => w.id === word_id); + if (word_idx < 0) { + response.sendStatus(404); + return; + } + + /* Check if already claimed this word. */ + if (this.state.claimed_words.find(cw => cw.word_id === word_id)) { + response.sendStatus(409); + return; + } + + const word_obj = owner_words[word_idx]; + + /* Remove from owner and add to claimed. */ + owner_words.splice(word_idx, 1); + this.state.claimed_words.push({ owner_session, word_id, word_obj }); + + const player = this.players_by_session[session_id]; + const owner_player = this.players_by_session[owner_session]; + this.broadcast_event_object("word-stolen", { + player_name: player ? player.name : "Unknown", + from_player: owner_player ? owner_player.name : "Unknown", + from_session: owner_session, + word: word_obj.word, + word_id + }); + + response.json({ ok: true }); + } + + /* Return a stolen word to its owner. */ + handle_return_word(request, response) { + const session_id = request.session.id; + if (session_id !== this.active_claimer_session()) { + response.sendStatus(403); + return; + } + + const word_id = request.body.word_id; + const idx = this.state.claimed_words.findIndex(cw => cw.word_id === word_id); + if (idx < 0) { + response.sendStatus(404); + return; + } + + const { owner_session, word_obj } = this.state.claimed_words.splice(idx, 1)[0]; + this.state.player_words[owner_session].push(word_obj); + + const player = this.players_by_session[session_id]; + const owner_player = this.players_by_session[owner_session]; + this.broadcast_event_object("word-returned", { + player_name: player ? player.name : "Unknown", + to_player: owner_player ? owner_player.name : "Unknown", + to_session: owner_session, + word: word_obj.word, + word_id + }); + + response.json({ ok: true }); + } + + /* Cancel claim and return all letters/words. */ + handle_cancel_claim(request, response) { + const session_id = request.session.id; + if (session_id !== this.active_claimer_session()) { + response.sendStatus(403); + return; + } + + this._cancel_claim("cancelled"); + response.json({ ok: true }); + } + + _cancel_claim(reason) { + /* Return all claimed letters to center. */ + for (const tile of this.state.claimed_letters) { + this.state.center.push(tile); + } + this.state.claimed_letters = []; + + /* Return all claimed words to their owners. */ + for (const { owner_session, word_obj } of this.state.claimed_words) { + this.state.player_words[owner_session].push(word_obj); + } + this.state.claimed_words = []; + + this._clear_claim_timers(); + + const session_id = this.state.claim_queue.shift(); + const player = this.players_by_session[session_id]; + this.broadcast_event_object("claim-end", { + player_name: player ? player.name : "Unknown", + reason + }); + + /* Activate next claimer if any. */ + if (this.state.claim_queue.length > 0) { + this._activate_claimer(); + } + } + + _clear_claim_timers() { + if (this._claim_timer) { + clearTimeout(this._claim_timer); + this._claim_timer = null; + } + if (this._claim_warning_timer) { + clearTimeout(this._claim_warning_timer); + this._claim_warning_timer = null; + } + } + + /***************************************************** + * Word submission and validation * + *****************************************************/ + + handle_submit(request, response) { + const session_id = request.session.id; + if (session_id !== this.active_claimer_session()) { + response.sendStatus(403); + return; + } + + const word = (request.body.word || "").toUpperCase(); + if (word.length < MIN_WORD_LENGTH) { + response.json({ ok: false, error: "Word must be at least 4 letters" }); + return; + } + + /* Verify the submitted word uses exactly the claimed letters. */ + const available = []; + for (const tile of this.state.claimed_letters) { + available.push(tile.letter); + } + for (const cw of this.state.claimed_words) { + for (const ch of cw.word_obj.word) { + available.push(ch); + } + } + + if (word.length !== available.length) { + response.json({ ok: false, error: "Word must use all claimed letters" }); + return; + } + + /* Check letter-by-letter match. */ + const avail_sorted = available.slice().sort().join(""); + const word_sorted = word.split("").sort().join(""); + if (avail_sorted !== word_sorted) { + response.json({ ok: false, error: "Word must use exactly the claimed letters" }); + return; + } + + /* Validate steal rules. */ + const steal_error = this._validate_steal(word); + if (steal_error) { + response.json({ ok: false, error: steal_error }); + return; + } + + /* Check 4-letter word limit. */ + if (word.length === 4) { + const existing_four = (this.state.player_words[session_id] || []) + .find(w => w.word.length === 4); + if (existing_four) { + response.json({ ok: false, error: "You already have a 4-letter word" }); + return; + } + } + + /* Check dictionary. */ + if (TWL_WORDS.has(word)) { + this._accept_word(session_id, word); + response.json({ ok: true, accepted: true }); + } else { + /* Start voting. */ + this._start_vote(session_id, word); + response.json({ ok: true, voting: true }); + } + } + + /* Validate that steal rules are followed. + * Returns an error string, or null if valid. */ + _validate_steal(word) { + const stolen = this.state.claimed_words; + const center_count = this.state.claimed_letters.length; + + if (stolen.length === 0) { + /* Pure center claim — no steal rules to check. */ + return null; + } + + /* Can't just rearrange a single word to the same length. */ + if (stolen.length === 1 && center_count === 0) { + return "Must add at least one letter when stealing"; + } + + /* For single-word steal: the original word must not appear as a + * contiguous prefix or suffix (but can appear in the middle). */ + if (stolen.length === 1) { + const original = stolen[0].word_obj.word; + if (word.startsWith(original) && !word.endsWith(original)) { + /* Only appended to end. */ + return "Cannot steal by only adding letters to the end"; + } + if (word.endsWith(original) && !word.startsWith(original)) { + /* Only prepended to front. */ + return "Cannot steal by only adding letters to the front"; + } + if (word.startsWith(original) && word.endsWith(original) + && word.length === original.length) { + /* Same word (shouldn't happen due to length check above). */ + return "Must add at least one letter when stealing"; + } + /* startsWith AND endsWith with added letters = both sides = OK. + * Neither starts nor ends = rearranged = OK. */ + } + + return null; + } + + _accept_word(session_id, word) { + const word_id = this.state.next_letter_id++; + + /* Collect the letter objects for display. */ + const letters = []; + for (const tile of this.state.claimed_letters) { + letters.push(tile); + } + for (const cw of this.state.claimed_words) { + for (const ch of cw.word_obj.word) { + letters.push({ id: this.state.next_letter_id++, letter: ch }); + } + } + + const word_obj = { id: word_id, word, letters }; + this.state.player_words[session_id].push(word_obj); + + /* Clear claimed state. */ + this.state.claimed_letters = []; + this.state.claimed_words = []; + this._clear_claim_timers(); + + const session_id_done = this.state.claim_queue.shift(); + const player = this.players_by_session[session_id_done]; + + const score = this._compute_score(session_id); + this.broadcast_event_object("word-accepted", { + player_name: player ? player.name : "Unknown", + session: session_id, + word, + word_id, + score + }); + + /* Broadcast full player words for state sync. */ + this._broadcast_player_state(); + + /* Activate next claimer if any. */ + if (this.state.claim_queue.length > 0) { + this._activate_claimer(); + } + } + + _compute_score(session_id) { + const words = this.state.player_words[session_id] || []; + let score = 0; + for (const w of words) { + score += w.word.length - 1; + } + return score; + } + + _compute_all_scores() { + const scores = {}; + for (const sid of Object.keys(this.state.player_words)) { + const player = this.players_by_session[sid]; + scores[sid] = { + name: player ? player.name : "Unknown", + score: this._compute_score(sid), + words: this.state.player_words[sid].map(w => w.word) + }; + } + return scores; + } + + /***************************************************** + * Voting * + *****************************************************/ + + _start_vote(session_id, word) { + this.state.vote_pending = { + session_id, + word, + votes: {}, /* session_id -> true/false */ + /* Submitter's vote is implicitly "accept". */ + }; + + const player = this.players_by_session[session_id]; + this.broadcast_event_object("vote-start", { + player_name: player ? player.name : "Unknown", + word, + timeout_ms: VOTE_TIMEOUT_MS + }); + + this._vote_timer = setTimeout(() => { + this._resolve_vote(); + }, VOTE_TIMEOUT_MS); + } + + handle_vote(request, response) { + const session_id = request.session.id; + const vote = this.state.vote_pending; + + if (!vote) { + response.sendStatus(400); + return; + } + + /* Submitter cannot vote (their vote is implicit). */ + if (session_id === vote.session_id) { + response.sendStatus(400); + return; + } + + vote.votes[session_id] = !!request.body.accept; + + this.broadcast_event_object("vote-update", { + votes_cast: Object.keys(vote.votes).length, + voters_total: this.joined_player_count() - 1 + }); + + /* If all other players have voted, resolve immediately. */ + if (Object.keys(vote.votes).length >= this.joined_player_count() - 1) { + this._resolve_vote(); + } + + response.json({ ok: true }); + } + + _resolve_vote() { + if (this._vote_timer) { + clearTimeout(this._vote_timer); + this._vote_timer = null; + } + + const vote = this.state.vote_pending; + if (!vote) return; + + /* Count votes. Non-voters are treated as abstentions. */ + let accept = 0, reject = 0; + for (const v of Object.values(vote.votes)) { + if (v) accept++; + else reject++; + } + + /* Majority rules, ties accept (submitter is the tiebreaker). */ + const accepted = accept >= reject; + + this.state.vote_pending = null; + + if (accepted) { + this._accept_word(vote.session_id, vote.word); + } else { + /* Word rejected — cancel the claim. */ + this._cancel_claim("word rejected"); + } + + this.broadcast_event_object("vote-result", { + word: vote.word, + accepted, + accept_count: accept, + reject_count: reject + }); + } + + /***************************************************** + * Join / Events / Game lifecycle * + *****************************************************/ + + handle_join(request, response) { + const session_id = request.session.id; + const player = this.players_by_session[session_id]; + + if (!player) { + response.sendStatus(404); + return; + } + + if (!this.state.player_words[session_id]) { + this.state.player_words[session_id] = []; + } + + response.json({ + center: this.state.center, + player_words: this._all_player_words(), + scores: this._compute_all_scores(), + remaining: this.state.bag.length + }); + } + + handle_done(request, response) { + const session_id = request.session.id; + if (!this.state.player_words[session_id]) { + response.sendStatus(400); + return; + } + + this.state.done_players.add(session_id); + + const joined = this.joined_player_count(); + const done = this.state.done_players.size; + + this.broadcast_event_object("done-update", { done, needed: joined }); + + if (done >= joined) { + this._finish_game(); + } + + response.json({ ok: true }); + } + + _finish_game() { + this.state.finished = true; + + /* Attempt a final "game steal" for fun. */ + const game_steal = this._find_game_steal(); + + this.broadcast_event_object("game-over", { + scores: this._compute_all_scores(), + game_steal + }); + } + + /* Find a possible steal the "game" could make using words from + * different players. Returns null or a description of the steal. */ + _find_game_steal() { + const all_words = []; + for (const sid of Object.keys(this.state.player_words)) { + for (const w of this.state.player_words[sid]) { + const player = this.players_by_session[sid]; + all_words.push({ + word: w.word, + owner: player ? player.name : "Unknown", + session: sid, + word_id: w.id + }); + } + } + + /* Try combining pairs of words to see if they form a valid word. */ + for (let i = 0; i < all_words.length; i++) { + for (let j = i + 1; j < all_words.length; j++) { + const combined = (all_words[i].word + all_words[j].word).split(""); + combined.sort(); + const sorted = combined.join(""); + + /* Check all TWL words of this length. */ + for (const candidate of TWL_WORDS) { + if (candidate.length !== combined.length) continue; + const candidate_sorted = candidate.split("").sort().join(""); + if (candidate_sorted === sorted) { + return { + words: [all_words[i], all_words[j]], + result: candidate + }; + } + } + } + } + + return null; + } + + _all_player_words() { + const result = {}; + for (const sid of Object.keys(this.state.player_words)) { + const player = this.players_by_session[sid]; + result[sid] = { + name: player ? player.name : "Unknown", + words: this.state.player_words[sid] + }; + } + return result; + } + + _broadcast_player_state() { + this.broadcast_event_object("player-words", this._all_player_words()); + this.broadcast_event_object("scores", this._compute_all_scores()); + } + + handle_events(request, response) { + super.handle_events(request, response); + + const session_id = request.session.id; + + /* Send center state. */ + response.write(`event: center\ndata: ${JSON.stringify( + this.state.center + )}\n\n`); + + /* Send all player words. */ + response.write(`event: player-words\ndata: ${JSON.stringify( + this._all_player_words() + )}\n\n`); + + /* Send scores. */ + response.write(`event: scores\ndata: ${JSON.stringify( + this._compute_all_scores() + )}\n\n`); + + /* Send bag count. */ + response.write(`event: bag-count\ndata: ${JSON.stringify({ + remaining: this.state.bag.length + })}\n\n`); + + /* Send active claim state if any. */ + if (this.state.claim_queue.length > 0) { + const claimer = this.active_claimer_player(); + response.write(`event: claim-start\ndata: ${JSON.stringify({ + player_name: claimer ? claimer.name : "Unknown", + timeout_ms: 0, + warning_ms: 0 + })}\n\n`); + } + + /* Send vote state if any. */ + if (this.state.vote_pending) { + const v = this.state.vote_pending; + const submitter = this.players_by_session[v.session_id]; + response.write(`event: vote-start\ndata: ${JSON.stringify({ + player_name: submitter ? submitter.name : "Unknown", + word: v.word, + timeout_ms: 0 + })}\n\n`); + } + + /* Send game-over if finished. */ + if (this.state.finished) { + response.write(`event: game-over\ndata: ${JSON.stringify({ + scores: this._compute_all_scores(), + game_steal: null + })}\n\n`); + } + } +} + +Anagrams.router = express.Router(); +const router = Anagrams.router; + +router.post('/join', (request, response) => { + request.game.handle_join(request, response); +}); + +router.post('/claim', (request, response) => { + request.game.handle_claim(request, response); +}); + +router.post('/take-letter', (request, response) => { + request.game.handle_take_letter(request, response); +}); + +router.post('/return-letter', (request, response) => { + request.game.handle_return_letter(request, response); +}); + +router.post('/steal-word', (request, response) => { + request.game.handle_steal_word(request, response); +}); + +router.post('/return-word', (request, response) => { + request.game.handle_return_word(request, response); +}); + +router.post('/submit', (request, response) => { + request.game.handle_submit(request, response); +}); + +router.post('/cancel-claim', (request, response) => { + request.game.handle_cancel_claim(request, response); +}); + +router.post('/request-letter', (request, response) => { + request.game.handle_request_letter(request, response); +}); + +router.post('/vote', (request, response) => { + request.game.handle_vote(request, response); +}); + +router.post('/done', (request, response) => { + request.game.handle_done(request, response); +}); + +Anagrams.meta = { + name: "Anagrams", + identifier: "anagrams", + options: { + allow_guest: true + } +}; + +exports.Game = Anagrams; diff --git a/lmno.js b/lmno.js index 5b0559b..deb8172 100644 --- a/lmno.js +++ b/lmno.js @@ -117,7 +117,8 @@ const engines = { tictactoe: require("./tictactoe").Game, scribe: require("./scribe").Game, empathy: require("./empathy").Game, - letterrip: require("./letterrip").Game + letterrip: require("./letterrip").Game, + anagrams: require("./anagrams").Game }; class LMNO { diff --git a/templates/anagrams-game.html b/templates/anagrams-game.html new file mode 100644 index 0000000..8e43747 --- /dev/null +++ b/templates/anagrams-game.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block head %} + + + + + + +{% endblock %} + +{% block page %} +

Anagrams

+ +
+ +{% endblock %} -- 2.45.2