From: Carl Worth Date: Fri, 6 Mar 2026 11:53:31 +0000 (-0800) Subject: Initial implementation of Letter Rip X-Git-Url: https://git.cworth.org/git?a=commitdiff_plain;h=323fb817feb1a91aaea8774e92d11ecb5c7b1812;p=lmno-server Initial implementation of Letter Rip This is the game we originally learned as "Speed Scrabble", (but similar to what others may have learned later as Bananagrams). This implementation was created with the assistance of claude opus 4.6. --- diff --git a/letterrip.js b/letterrip.js new file mode 100644 index 0000000..23e682d --- /dev/null +++ b/letterrip.js @@ -0,0 +1,261 @@ +const express = require("express"); +const Game = require("./game.js"); + +/* Tile distribution (98 tiles + 2 blanks = 100). + * Letter counts: A9 B2 C2 D4 E12 F2 G3 H2 I9 J1 K1 L4 M2 + * N6 O8 P2 Q1 R6 S4 T6 U4 V2 W2 X1 Y2 Z1 + */ +const TILE_DISTRIBUTION = + "AAAAAAAAA" + + "BB" + "CC" + "DDDD" + + "EEEEEEEEEEEE" + + "FF" + "GGG" + "HH" + + "IIIIIIIII" + + "J" + "K" + + "LLLL" + "MM" + + "NNNNNN" + + "OOOOOOOO" + + "PP" + "Q" + + "RRRRRR" + + "SSSS" + + "TTTTTT" + + "UUUU" + + "VV" + "WW" + + "X" + "YY" + "Z" + + "__"; + +const INITIAL_TILES = 7; + +class LetterRip extends Game { + constructor(id) { + super(id); + this.state = { + bag: LetterRip.make_bag(), + player_tiles: {}, + stuck: new Set(), + finished: false, + winner: null + }; + } + + static make_bag() { + const tiles = TILE_DISTRIBUTION.split(""); + /* Fisher-Yates shuffle */ + for (let i = tiles.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [tiles[i], tiles[j]] = [tiles[j], tiles[i]]; + } + return tiles; + } + + /* Deal count_per_player tiles to each active player. + * Returns true on success, false if not enough tiles. */ + deal(count_per_player) { + const sessions = Object.keys(this.state.player_tiles); + const needed = count_per_player * sessions.length; + + if (this.state.bag.length < needed) { + if (count_per_player > 1) { + return this.deal(count_per_player - 1); + } + return false; + } + + for (const session_id of sessions) { + const new_tiles = []; + for (let i = 0; i < count_per_player; i++) { + new_tiles.push(this.state.bag.pop()); + } + this.state.player_tiles[session_id].push(...new_tiles); + + /* Send new tiles only to this specific player. */ + const player = this.players_by_session[session_id]; + if (player) { + const data = JSON.stringify({ tiles: new_tiles }); + player.send(`event: new-tiles\ndata: ${data}\n\n`); + } + } + + this.state.stuck = new Set(); + this.broadcast_event_object("dealt", { + remaining: this.state.bag.length + }); + + return true; + } + + handle_join(request, response) { + const session_id = request.session.id; + const player = this.players_by_session[session_id]; + + if (!player) { + response.sendStatus(404); + return; + } + + /* Don't re-deal if player already has tiles. */ + if (this.state.player_tiles[session_id]) { + response.json({ tiles: this.state.player_tiles[session_id] }); + return; + } + + /* Deal initial tiles. */ + const tiles = []; + for (let i = 0; i < INITIAL_TILES && this.state.bag.length > 0; i++) { + tiles.push(this.state.bag.pop()); + } + this.state.player_tiles[session_id] = tiles; + + this.broadcast_event_object("player-joined-game", { + name: player.name + }); + + this.broadcast_event_object("dealt", { + remaining: this.state.bag.length + }); + + response.json({ tiles: tiles }); + } + + handle_stuck(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_tiles[session_id]) { + response.sendStatus(400); + return; + } + + /* Toggle stuck flag. */ + if (this.state.stuck.has(session_id)) { + this.state.stuck.delete(session_id); + } else { + this.state.stuck.add(session_id); + } + + /* Broadcast who's stuck (by name). */ + const stuck_names = []; + for (const sid of this.state.stuck) { + const p = this.players_by_session[sid]; + if (p) stuck_names.push(p.name); + } + this.broadcast_event_object("stuck", { stuck: stuck_names }); + + response.json({ stuck: this.state.stuck.has(session_id) }); + + /* If all active players with tiles are stuck, deal new tiles. */ + const active_sessions = Object.keys(this.state.player_tiles).filter( + sid => { + const p = this.players_by_session[sid]; + return p && p.active; + } + ); + + if (active_sessions.length > 0 && + active_sessions.every(sid => this.state.stuck.has(sid))) { + this.deal(2); + } + } + + handle_complete(request, response) { + const session_id = request.session.id; + const player = this.players_by_session[session_id]; + + if (!player) { + response.sendStatus(404); + return; + } + + const tiles = this.state.player_tiles[session_id]; + if (!tiles || tiles.length === 0) { + response.sendStatus(400); + return; + } + + if (this.state.finished) { + response.json({ finished: true, winner: this.state.winner }); + return; + } + + /* Try to deal 2 tiles to everyone. If we can't deal at all, + * this player wins. */ + const dealt = this.deal(2); + if (!dealt) { + this.state.finished = true; + this.state.winner = player.name; + this.broadcast_event_object("game-over", { + winner: player.name + }); + response.json({ finished: true, winner: player.name }); + return; + } + + response.json({ finished: false }); + } + + handle_events(request, response) { + super.handle_events(request, response); + + const session_id = request.session.id; + const tiles = this.state.player_tiles[session_id]; + + /* Send this player's current tiles so reconnecting players + * recover their state. */ + if (tiles) { + const data = JSON.stringify({ tiles: tiles }); + response.write(`event: tiles\ndata: ${data}\n\n`); + } + + /* Send current bag count. */ + response.write(`event: dealt\ndata: ${JSON.stringify({ + remaining: this.state.bag.length + })}\n\n`); + + /* Send current stuck state. */ + const stuck_names = []; + for (const sid of this.state.stuck) { + const p = this.players_by_session[sid]; + if (p) stuck_names.push(p.name); + } + response.write(`event: stuck\ndata: ${JSON.stringify({ + stuck: stuck_names + })}\n\n`); + + /* Send game-over if finished. */ + if (this.state.finished) { + response.write(`event: game-over\ndata: ${JSON.stringify({ + winner: this.state.winner + })}\n\n`); + } + } +} + +LetterRip.router = express.Router(); +const router = LetterRip.router; + +router.post('/join', (request, response) => { + request.game.handle_join(request, response); +}); + +router.post('/stuck', (request, response) => { + request.game.handle_stuck(request, response); +}); + +router.post('/complete', (request, response) => { + request.game.handle_complete(request, response); +}); + +LetterRip.meta = { + name: "Letter Rip", + identifier: "letterrip", + options: { + allow_guest: true + } +}; + +exports.Game = LetterRip; diff --git a/lmno.js b/lmno.js index a220ea2..5b0559b 100644 --- a/lmno.js +++ b/lmno.js @@ -116,7 +116,8 @@ const engines = { empires: require("./empires").Game, tictactoe: require("./tictactoe").Game, scribe: require("./scribe").Game, - empathy: require("./empathy").Game + empathy: require("./empathy").Game, + letterrip: require("./letterrip").Game }; class LMNO { diff --git a/templates/letterrip-game.html b/templates/letterrip-game.html new file mode 100644 index 0000000..acd1cb5 --- /dev/null +++ b/templates/letterrip-game.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block head %} + + + + + + +{% endblock %} + +{% block page %} +

Letter Rip

+ +
+ +{% endblock %}