From 27f13e2f9a1889fa3cd03d7b94ab7c20d948547b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:48:19 +0300 Subject: [PATCH] fix(mancala.js): skip the opponent's store when sowing Mangala skips the opponent's store, but Board.move dropped a stone in every pit and store. Moves with enough stones to pass it gave the opponent a stone and ended one pit too early, so captures and extra turns were decided on the wrong pit. Board.getSowingPath now computes the pits a move sows into, and Board.move and core's getSowingPath (the web board's move preview) both use it. Game step indexes are now always within the board. --- packages/core/src/history/MoveHistory.ts | 11 +++--- .../core/tests/history/MoveHistory.test.ts | 4 +-- packages/mancala.js/src/core/Board.ts | 35 +++++++++++++------ packages/mancala.js/tests/Board.test.ts | 26 +++++++++++++- .../game_rules/GRDoubleStoneInPit.test.ts | 2 +- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/core/src/history/MoveHistory.ts b/packages/core/src/history/MoveHistory.ts index 97fd21e..fc1c55c 100644 --- a/packages/core/src/history/MoveHistory.ts +++ b/packages/core/src/history/MoveHistory.ts @@ -64,14 +64,11 @@ export function describeMove(moves: PlayedMove[], moveNumber: number): MoveDescr }; } -// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them: a single stone goes -// to the next pit; otherwise the first stone goes back into the played pit. Every pit and store is passed, the -// opponent's store too. The last index is where the last stone lands. Empty for an empty pit or a store. +// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them (see +// Board.getSowingPath): the opponent's store is skipped. The last index is where the last stone lands. Empty for an +// empty pit or a store. export function getSowingPath(stones: number[], pitIndex: number): number[] { - const stoneCount = stones[pitIndex]; - if (!stoneCount || referenceBoard.checkPitTypeIsBankByIndex(pitIndex)) return []; - const start = stoneCount === 1 ? pitIndex + 1 : pitIndex; - return Array.from({ length: stoneCount }, (_, step) => (start + step) % stones.length); + return referenceBoard.getSowingPath(pitIndex, stones[pitIndex] ?? 0); } // The game as it was after the given move, for showing or animating that position (e.g. with PitAnimator, diff --git a/packages/core/tests/history/MoveHistory.test.ts b/packages/core/tests/history/MoveHistory.test.ts index aa4bd2d..3865e79 100644 --- a/packages/core/tests/history/MoveHistory.test.ts +++ b/packages/core/tests/history/MoveHistory.test.ts @@ -145,12 +145,12 @@ describe("getSowingPath", () => { expect(getSowingPath(stones, 4)).toEqual([5]); }); - it("passes both stores and wraps around the board", () => { + it("passes the player's own store, skips the opponent's and wraps around the board", () => { const stones = getInitialBoard(); stones[12] = 4; expect(getSowingPath(stones, 12)).toEqual([12, 13, 0, 1]); stones[5] = 10; - expect(getSowingPath(stones, 5)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 13, 0]); + expect(getSowingPath(stones, 5)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 0, 1]); }); it("is empty for an empty pit or a store", () => { diff --git a/packages/mancala.js/src/core/Board.ts b/packages/mancala.js/src/core/Board.ts index 0e383c1..063186f 100644 --- a/packages/mancala.js/src/core/Board.ts +++ b/packages/mancala.js/src/core/Board.ts @@ -107,19 +107,34 @@ export class Board { `StoneNotFound => index : ${index} stoneCount = ${pit.stoneCount}` ); } - const stepCount = pit.stoneCount; - this.pits[index].stoneCount = 0; - const startIndex = stepCount === 1 ? index + 1 : index; - this.moveInternal(startIndex, stepCount); + const path = this.getSowingPath(index); + pit.stoneCount = 0; + this.fireOnGameMoveStart(path[0]); + path.forEach((pathIndex) => this.onGameMoveStep(pathIndex)); + this.fireOnGameMoveEnd(path[path.length - 1]); } - private moveInternal(startIndex: number, stepCount: number) { - this.fireOnGameMoveStart(startIndex); - for (let i = startIndex; i < startIndex + stepCount; i++) { - this.onGameMoveStep(i); + // Indexes the stones of the pit are dropped into, in order. A single stone goes to the next pit; otherwise the first + // stone goes back into the played pit. The player's own bank gets a stone, the opponent's bank is skipped. The last + // index is where the last stone lands. Empty for a bank or an empty pit. + public getSowingPath( + index: number, + stoneCount = this.pits[index].stoneCount + ): number[] { + this.checkIndeAndMaybeThrowError(index); + if (stoneCount <= 0 || this.checkPitTypeIsBankByIndex(index)) return []; + const opponentBankIndex = + this.getPitTypeByIndex(index) === 'player1Pit' + ? this.player2BankIndex() + : this.player1BankIndex(); + const path: number[] = []; + let current = + stoneCount === 1 ? this.getNextPitIndexCircularly(index) : index; + while (path.length < stoneCount) { + if (current !== opponentBankIndex) path.push(current); + current = this.getNextPitIndexCircularly(current); } - const stopIndex = startIndex + stepCount - 1; - this.fireOnGameMoveEnd(stopIndex); + return path; } public onGameMoveStep(index: number) { diff --git a/packages/mancala.js/tests/Board.test.ts b/packages/mancala.js/tests/Board.test.ts index 85ca668..5b76ccd 100644 --- a/packages/mancala.js/tests/Board.test.ts +++ b/packages/mancala.js/tests/Board.test.ts @@ -103,9 +103,33 @@ describe('Board Test', () => { expect(board.getStoneArray()).toStrictEqual(initialBoard); board.move(5); expect(board.getStoneArray()).toStrictEqual([ - 5, 5, 5, 5, 5, 2, 1, 5, 5, 5, 5, 5, 5, 1 + 5, 5, 5, 5, 5, 2, 2, 5, 5, 5, 5, 5, 5, 0 ]); }); + test('move skips the opponent bank for both players', () => { + const board = new Board(6, 4); + board.pits[12].stoneCount = 10; + board.move(12); + expect(board.getStoneArray()).toStrictEqual([ + 5, 5, 5, 5, 5, 5, 0, 5, 5, 4, 4, 4, 1, 1 + ]); + }); + test('test getSowingPath', () => { + const board = new Board(6, 4); + expect(board.getSowingPath(0)).toStrictEqual([0, 1, 2, 3]); + expect(board.getSowingPath(0, 1)).toStrictEqual([1]); + expect(board.getSowingPath(5, 2)).toStrictEqual([5, 6]); + expect(board.getSowingPath(5, 9)).toStrictEqual([ + 5, 6, 7, 8, 9, 10, 11, 12, 0 + ]); + expect(board.getSowingPath(12, 3)).toStrictEqual([12, 13, 0]); + expect(board.getSowingPath(11, 10)).toStrictEqual([ + 11, 12, 13, 0, 1, 2, 3, 4, 5, 7 + ]); + expect(board.getSowingPath(0, 0)).toStrictEqual([]); + expect(board.getSowingPath(6)).toStrictEqual([]); + expect(board.getSowingPath(13)).toStrictEqual([]); + }); test('test getOppositePitIndex', () => { const board = new Board(6, 4); diff --git a/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts b/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts index 0d07a12..1698a30 100644 --- a/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts +++ b/packages/mancala.js/tests/game_rules/GRDoubleStoneInPit.test.ts @@ -33,7 +33,7 @@ describe('GRDoubleStoneInPit Test', () => { expect(game.history[game.history.length - 1].gameSteps).toStrictEqual([ new GameStep(12, GAME_STEP_GAME_MOVE), new GameStep(13, GAME_STEP_GAME_MOVE), - new GameStep(14, GAME_STEP_GAME_MOVE), + new GameStep(0, GAME_STEP_GAME_MOVE), new GameStep(0, GAME_STEP_DOUBLE_STONE_IN_PIT, { pitIndex: 0, bankIndex: 13