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