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