--- /dev/null
+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;