]> git.cworth.org Git - lmno-server/commitdiff
Add an implementation of Lines of Action master
authorCarl Worth <cworth@cworth.org>
Thu, 12 Mar 2026 04:55:05 +0000 (21:55 -0700)
committerCarl Worth <cworth@cworth.org>
Thu, 12 Mar 2026 05:02:20 +0000 (22:02 -0700)
This comes from a Claude-assisted port of the Lines of Action
implementation I had made originally in C which can be found here:

https://git.cworth.org/git?p=loa

lmno.js
loa-board.js [new file with mode: 0644]
loa.js [new file with mode: 0644]
templates/loa-game.html [new file with mode: 0644]

diff --git a/lmno.js b/lmno.js
index 0f89f0fc7a79a2be25fd9ba6b5c31ab0388b8f31..22e1c6f238a2910e40f2be0ec4e5f4398d0e196a 100644 (file)
--- a/lmno.js
+++ b/lmno.js
@@ -132,7 +132,8 @@ const engines = {
   scribe: require("./scribe").Game,
   empathy: require("./empathy").Game,
   letterrip: require("./letterrip").Game,
-  anagrams: require("./anagrams").Game
+  anagrams: require("./anagrams").Game,
+  loa: require("./loa").Game
 };
 
 class LMNO {
diff --git a/loa-board.js b/loa-board.js
new file mode 100644 (file)
index 0000000..dd135cb
--- /dev/null
@@ -0,0 +1,244 @@
+/* Lines of Action - shared game logic (server and client)
+ *
+ * In the browser this file is loaded via <script> so all symbols
+ * become globals.  In Node the conditional module.exports at the
+ * bottom makes them available to require().
+ */
+
+const BOARD_SIZE = 8;
+const DIAG_ARRAY_SIZE = 2 * BOARD_SIZE - 1;
+
+const CELL_EMPTY = 2;
+const CELL_BLACK = 0;
+const CELL_WHITE = 1;
+
+const PLAYER_BLACK = 0;
+const PLAYER_WHITE = 1;
+
+class LoaBoard {
+    constructor() {
+        this.cells = [];
+        this.numPieces = [0, 0];
+        this.rowPieces = new Array(BOARD_SIZE).fill(0);
+        this.colPieces = new Array(BOARD_SIZE).fill(0);
+        this.diagGravePieces = new Array(DIAG_ARRAY_SIZE).fill(0);
+        this.diagAcutePieces = new Array(DIAG_ARRAY_SIZE).fill(0);
+        this.player = PLAYER_BLACK;
+        this.init();
+    }
+
+    init() {
+        for (let x = 0; x < BOARD_SIZE; x++) {
+            this.cells[x] = [];
+            for (let y = 0; y < BOARD_SIZE; y++) {
+                this.cells[x][y] = CELL_EMPTY;
+            }
+        }
+        this.numPieces[0] = 0;
+        this.numPieces[1] = 0;
+        this.rowPieces.fill(0);
+        this.colPieces.fill(0);
+        this.diagGravePieces.fill(0);
+        this.diagAcutePieces.fill(0);
+
+        for (let i = 1; i < BOARD_SIZE - 1; i++) {
+            this._addPiece(i, 0, CELL_BLACK);
+            this._addPiece(i, BOARD_SIZE - 1, CELL_BLACK);
+            this._addPiece(0, i, CELL_WHITE);
+            this._addPiece(BOARD_SIZE - 1, i, CELL_WHITE);
+        }
+
+        this.player = PLAYER_BLACK;
+    }
+
+    _graveIndex(x, y) { return x - y + BOARD_SIZE - 1; }
+    _acuteIndex(x, y) { return x + y; }
+
+    _addPiece(x, y, cell) {
+        this.colPieces[x]++;
+        this.rowPieces[y]++;
+        this.diagGravePieces[this._graveIndex(x, y)]++;
+        this.diagAcutePieces[this._acuteIndex(x, y)]++;
+        this.numPieces[cell]++;
+        this.cells[x][y] = cell;
+    }
+
+    _removePiece(x, y) {
+        const cell = this.cells[x][y];
+        if (cell === CELL_EMPTY) return CELL_EMPTY;
+        this.colPieces[x]--;
+        this.rowPieces[y]--;
+        this.diagGravePieces[this._graveIndex(x, y)]--;
+        this.diagAcutePieces[this._acuteIndex(x, y)]--;
+        this.numPieces[cell]--;
+        this.cells[x][y] = CELL_EMPTY;
+        return cell;
+    }
+
+    _groupSize(x, y) {
+        const cell = this.cells[x][y];
+        if (cell === CELL_EMPTY) return 0;
+        const visited = new Set();
+        return this._groupSizeRecursive(x, y, cell, visited);
+    }
+
+    _groupSizeRecursive(x, y, cell, visited) {
+        if (x < 0 || y < 0 || x >= BOARD_SIZE || y >= BOARD_SIZE) return 0;
+        const key = x * BOARD_SIZE + y;
+        if (visited.has(key)) return 0;
+        visited.add(key);
+        if (this.cells[x][y] !== cell) return 0;
+        let count = 1;
+        for (let dx = -1; dx <= 1; dx++) {
+            for (let dy = -1; dy <= 1; dy++) {
+                if (dx === 0 && dy === 0) continue;
+                count += this._groupSizeRecursive(x + dx, y + dy, cell, visited);
+            }
+        }
+        return count;
+    }
+
+    isWon(x, y) {
+        const cell = this.cells[x][y];
+        if (cell === CELL_EMPTY) return false;
+        return this._groupSize(x, y) === this.numPieces[cell];
+    }
+
+    /* Check for a winner after a move to (x2, y2).
+     *
+     * In LOA a capture can connect the opponent's remaining pieces,
+     * so we must check both players.  If both are connected (rare),
+     * the player who just moved wins.
+     *
+     * Returns the winning player constant (PLAYER_BLACK / PLAYER_WHITE)
+     * or null if no winner yet.
+     */
+    checkWinner(x2, y2) {
+        /* The mover's piece is at (x2, y2).  board.player has already
+         * been toggled, so the mover is the *other* player. */
+        const mover = this.player === PLAYER_BLACK ? PLAYER_WHITE : PLAYER_BLACK;
+        const opponent = this.player;
+
+        /* Did the mover win? */
+        if (this.isWon(x2, y2))
+            return mover;
+
+        /* Did the capture connect all of the opponent's pieces? */
+        if (this.numPieces[opponent] > 0) {
+            for (let x = 0; x < BOARD_SIZE; x++) {
+                for (let y = 0; y < BOARD_SIZE; y++) {
+                    if (this.cells[x][y] === opponent) {
+                        if (this.isWon(x, y))
+                            return opponent;
+                        /* Only need to check one opponent piece --
+                         * if it's not in a group equal to numPieces
+                         * then no opponent piece is. */
+                        return null;
+                    }
+                }
+            }
+        }
+
+        return null;
+    }
+
+    moveLegal(x1, y1, x2, y2) {
+        if (x1 < 0 || y1 < 0 || x1 >= BOARD_SIZE || y1 >= BOARD_SIZE ||
+            x2 < 0 || y2 < 0 || x2 >= BOARD_SIZE || y2 >= BOARD_SIZE) {
+            return { legal: false, error: "Invalid coordinates (not on board)" };
+        }
+        if (this.cells[x1][y1] === CELL_EMPTY) {
+            return { legal: false, error: "There is no piece there to move" };
+        }
+        if (this.cells[x1][y1] !== this.player) {
+            return { legal: false, error: "You cannot move your opponent's piece" };
+        }
+        if (this.cells[x2][y2] === this.cells[x1][y1]) {
+            return { legal: false, error: "You cannot capture your own piece" };
+        }
+
+        const dx = x2 - x1;
+        const dy = y2 - y1;
+
+        if (dx === 0) {
+            if (Math.abs(dy) !== this.colPieces[x1]) {
+                return { legal: false, error: "The move distance does not match the number of pieces in that column" };
+            }
+        } else if (dy === 0) {
+            if (Math.abs(dx) !== this.rowPieces[y1]) {
+                return { legal: false, error: "The move distance does not match the number of pieces in that row" };
+            }
+        } else {
+            if (Math.abs(dx) !== Math.abs(dy)) {
+                return { legal: false, error: "That move is not in a row, column, or diagonal" };
+            }
+            if ((dx > 0) === (dy > 0)) {
+                if (Math.abs(dx) !== this.diagGravePieces[this._graveIndex(x1, y1)]) {
+                    return { legal: false, error: "The move distance does not match the number of pieces in that diagonal" };
+                }
+            } else {
+                if (Math.abs(dx) !== this.diagAcutePieces[this._acuteIndex(x1, y1)]) {
+                    return { legal: false, error: "The move distance does not match the number of pieces in that diagonal" };
+                }
+            }
+        }
+
+        const stepX = dx ? (dx < 0 ? -1 : 1) : 0;
+        const stepY = dy ? (dy < 0 ? -1 : 1) : 0;
+        let x = x1 + stepX, y = y1 + stepY;
+        while (x !== x2 || y !== y2) {
+            if (this.cells[x][y] !== CELL_EMPTY &&
+                this.cells[x][y] !== this.cells[x1][y1]) {
+                return { legal: false, error: "You cannot jump an opponent's piece" };
+            }
+            x += stepX;
+            y += stepY;
+        }
+
+        return { legal: true };
+    }
+
+    move(x1, y1, x2, y2) {
+        const result = this.moveLegal(x1, y1, x2, y2);
+        if (!result.legal) return result;
+
+        const captured = this.cells[x2][y2] !== CELL_EMPTY;
+        const cell = this._removePiece(x1, y1);
+        this._removePiece(x2, y2);
+        this._addPiece(x2, y2, cell);
+        this.player = this.player === PLAYER_BLACK ? PLAYER_WHITE : PLAYER_BLACK;
+
+        return { legal: true, captured };
+    }
+
+    toJSON() {
+        return {
+            cells: this.cells.map(col => [...col]),
+            numPieces: [...this.numPieces],
+            player: this.player
+        };
+    }
+
+    static fromJSON(data) {
+        const board = new LoaBoard();
+        /* Clear the default init state */
+        for (let x = 0; x < BOARD_SIZE; x++)
+            for (let y = 0; y < BOARD_SIZE; y++)
+                board._removePiece(x, y);
+        /* Rebuild from serialized cells */
+        for (let x = 0; x < BOARD_SIZE; x++)
+            for (let y = 0; y < BOARD_SIZE; y++)
+                if (data.cells[x][y] !== CELL_EMPTY)
+                    board._addPiece(x, y, data.cells[x][y]);
+        board.player = data.player;
+        return board;
+    }
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+    module.exports = {
+        LoaBoard, BOARD_SIZE, DIAG_ARRAY_SIZE,
+        CELL_EMPTY, CELL_BLACK, CELL_WHITE,
+        PLAYER_BLACK, PLAYER_WHITE
+    };
+}
diff --git a/loa.js b/loa.js
new file mode 100644 (file)
index 0000000..48c57fd
--- /dev/null
+++ b/loa.js
@@ -0,0 +1,74 @@
+const express = require("express");
+const Game = require("./game.js");
+const { LoaBoard, PLAYER_BLACK, PLAYER_WHITE } = require("./loa-board.js");
+
+class Loa extends Game {
+    constructor(id) {
+        super(id);
+        this.teams = [
+            { id: 0, name: "Black" },
+            { id: 1, name: "White" }
+        ];
+        this.board = new LoaBoard();
+        this.state = {
+            moves: [],
+            board: this.board.toJSON(),
+            team_to_play: this.teams[0],
+            winner: null
+        };
+    }
+
+    add_move(player, move) {
+        const result = super.add_move(player, move);
+        if (!result.legal)
+            return result;
+
+        const [x1, y1, x2, y2] = move;
+        const move_result = this.board.move(x1, y1, x2, y2);
+        if (!move_result.legal)
+            return { legal: false, message: move_result.error };
+
+        this.state.moves.push(move);
+        this.state.board = this.board.toJSON();
+
+        /* Check for a winner. */
+        const winner = this.board.checkWinner(x2, y2);
+        if (winner !== null) {
+            const winning_team = winner === PLAYER_BLACK ? this.teams[0] : this.teams[1];
+            this.state.winner = winning_team.name;
+            this.game_over = true;
+        }
+
+        /* Toggle turn. */
+        this.state.team_to_play =
+            this.state.team_to_play === this.teams[0]
+                ? this.teams[1]
+                : this.teams[0];
+
+        return { legal: true };
+    }
+
+    restore(data) {
+        super.restore(data);
+        this.board = LoaBoard.fromJSON(this.state.board);
+
+        /* Restore team_to_play as an actual team reference. */
+        if (this.state.team_to_play) {
+            const team = this.teams.find(t => t.name === this.state.team_to_play.name);
+            if (team)
+                this.state.team_to_play = team;
+        }
+    }
+}
+
+Loa.router = express.Router();
+
+Loa.meta = {
+    name: "Lines of Action",
+    identifier: "loa",
+    options: {
+        allow_guest: true
+    }
+};
+
+exports.Game = Loa;
diff --git a/templates/loa-game.html b/templates/loa-game.html
new file mode 100644 (file)
index 0000000..9943959
--- /dev/null
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+
+{% block head %}
+<link rel="stylesheet" href="/loa/loa.css" type="text/css" />
+
+<script src="/loa/loa-board.js"></script>
+<script src="/loa/loa-view.js"></script>
+<script type="module" src="/loa/loa-client.js"></script>
+{% endblock %}
+
+{% block page %}
+<h1><a href="/loa">Lines of Action</a></h1>
+
+<div id="game-info"></div>
+<div id="loa"></div>
+{% endblock %}