@vnejs/plugins.views.screens.checkers 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -0
- package/dist/modules/checkers.d.ts +9 -0
- package/dist/modules/checkers.js +37 -0
- package/dist/modules/controller.accept.d.ts +30 -0
- package/dist/modules/controller.accept.js +165 -0
- package/dist/modules/controller.d.ts +13 -0
- package/dist/modules/controller.grid.d.ts +20 -0
- package/dist/modules/controller.grid.js +29 -0
- package/dist/modules/controller.js +34 -0
- package/dist/modules/view.d.ts +10 -0
- package/dist/modules/view.js +9 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.js +1 -0
- package/dist/utils/ai.d.ts +4 -0
- package/dist/utils/ai.js +27 -0
- package/dist/utils/board.d.ts +32 -0
- package/dist/utils/board.js +199 -0
- package/dist/utils/checkers.d.ts +42 -0
- package/dist/utils/checkers.js +40 -0
- package/dist/view/index.d.ts +3 -0
- package/dist/view/index.js +196 -0
- package/package.json +59 -0
- package/src/index.ts +18 -0
- package/src/modules/checkers.ts +52 -0
- package/src/modules/controller.accept.ts +202 -0
- package/src/modules/controller.grid.ts +43 -0
- package/src/modules/controller.ts +50 -0
- package/src/modules/view.ts +17 -0
- package/src/tests/ai.test.ts +61 -0
- package/src/tests/board.test.ts +43 -0
- package/src/tests/checkers.test.ts +58 -0
- package/src/tests/setup.ts +34 -0
- package/src/types.ts +23 -0
- package/src/utils/ai.ts +31 -0
- package/src/utils/board.ts +252 -0
- package/src/utils/checkers.ts +96 -0
- package/src/view/index.tsx +309 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ModuleController } from "@vnejs/module.components";
|
|
2
|
+
|
|
3
|
+
import type { CheckersPluginConstants, CheckersPluginEvents, CheckersPluginParams, CheckersPluginSettings } from "../types.js";
|
|
4
|
+
import { createInitialCheckersState, type CheckersPluginState } from "../utils/checkers.js";
|
|
5
|
+
|
|
6
|
+
export class CheckersController extends ModuleController<
|
|
7
|
+
CheckersPluginEvents,
|
|
8
|
+
CheckersPluginConstants,
|
|
9
|
+
CheckersPluginSettings,
|
|
10
|
+
CheckersPluginParams,
|
|
11
|
+
CheckersPluginState
|
|
12
|
+
> {
|
|
13
|
+
name = "checkers.controller";
|
|
14
|
+
|
|
15
|
+
EVENT_UPDATE_VIEW = this.EVENTS.CHECKERS.UPDATE;
|
|
16
|
+
|
|
17
|
+
CONTROLS = {};
|
|
18
|
+
CONTROLS_INDEX = this.PARAMS.CHECKERS.ZINDEX;
|
|
19
|
+
|
|
20
|
+
subscribe = () => {
|
|
21
|
+
this.on(this.EVENTS.CHECKERS.SHOW, this.onShow);
|
|
22
|
+
this.on(this.EVENTS.CHECKERS.HIDE, this.onHide);
|
|
23
|
+
this.on(this.EVENTS.CHECKERS.STATE_UPDATE, this.updateState);
|
|
24
|
+
this.on(this.EVENTS.CHECKERS.UPDATE_FAST, this.updateViewFast);
|
|
25
|
+
this.on(this.EVENTS.CHECKERS.FINISH, this.onFinish);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
beforeShow = async () => {
|
|
29
|
+
this.shared.checkersAiBusy = false;
|
|
30
|
+
const initial = createInitialCheckersState();
|
|
31
|
+
const ai = {
|
|
32
|
+
white: this.shared.checkersAi?.white ?? this.CONST.CHECKERS.AI_TYPES.PLAYER,
|
|
33
|
+
black: this.shared.checkersAi?.black ?? this.CONST.CHECKERS.AI_TYPES.PLAYER,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
await this.emit(this.EVENTS.CHECKERS.STATE_UPDATE, { ...initial, ai } satisfies Partial<CheckersPluginState>);
|
|
37
|
+
await this.emit(this.EVENTS.CHECKERS.GRID_UPDATE_ITEMS);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
afterHide = () => {
|
|
41
|
+
this.shared.checkersAiBusy = false;
|
|
42
|
+
this.setDefaultState();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
onFinish = async () => {
|
|
46
|
+
this.shared.checkersAiBusy = false;
|
|
47
|
+
await this.emit(this.EVENTS.CHECKERS.HIDE);
|
|
48
|
+
this.emitNext();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ModuleView } from "@vnejs/module.components";
|
|
2
|
+
|
|
3
|
+
import { render } from "../view/index.js";
|
|
4
|
+
import type { CheckersPluginConstants, CheckersPluginEvents, CheckersPluginParams, CheckersPluginSettings } from "../types.js";
|
|
5
|
+
import type { CheckersPluginState } from "../utils/checkers.js";
|
|
6
|
+
|
|
7
|
+
export class CheckersView extends ModuleView<CheckersPluginEvents, CheckersPluginConstants, CheckersPluginSettings, CheckersPluginParams, CheckersPluginState> {
|
|
8
|
+
name = "checkers.view";
|
|
9
|
+
|
|
10
|
+
EVENT_UPDATE_VIEW = this.EVENTS.CHECKERS.UPDATE;
|
|
11
|
+
|
|
12
|
+
LOC_LABEL = null;
|
|
13
|
+
|
|
14
|
+
TRANSITION = this.PARAMS.CHECKERS.TRANSITION;
|
|
15
|
+
|
|
16
|
+
renderFunc = render;
|
|
17
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { BOARD_SIZE, type CheckersBoard, type CheckersPiece } from "../utils/board.js";
|
|
4
|
+
import { isHangingMove, pickMove } from "../utils/ai.js";
|
|
5
|
+
|
|
6
|
+
const emptyBoard = (): CheckersBoard => Array.from({ length: BOARD_SIZE }, () => Array.from({ length: BOARD_SIZE }, () => null));
|
|
7
|
+
|
|
8
|
+
const piece = (id: string, color: CheckersPiece["color"]): CheckersPiece => ({ id, color, king: false });
|
|
9
|
+
|
|
10
|
+
/** White at (5,2): (4,1) hangs to black at (3,0); (4,3) is safe. */
|
|
11
|
+
const hangingScenarioBoard = (): CheckersBoard => {
|
|
12
|
+
const board = emptyBoard();
|
|
13
|
+
board[5]![2] = piece("w1", "white");
|
|
14
|
+
board[3]![0] = piece("b1", "black");
|
|
15
|
+
return board;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("checkers ai", () => {
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("isHangingMove is true when opponent gets a capture", () => {
|
|
24
|
+
const board = hangingScenarioBoard();
|
|
25
|
+
const hanging = { from: { row: 5, column: 2 }, to: { row: 4, column: 1 }, captures: [] };
|
|
26
|
+
const safe = { from: { row: 5, column: 2 }, to: { row: 4, column: 3 }, captures: [] };
|
|
27
|
+
|
|
28
|
+
expect(isHangingMove(board, hanging, "white")).toBe(true);
|
|
29
|
+
expect(isHangingMove(board, safe, "white")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("pickMove sacrifice prefers hanging moves", () => {
|
|
33
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
34
|
+
const board = hangingScenarioBoard();
|
|
35
|
+
const move = pickMove(board, "white", "sacrifice");
|
|
36
|
+
|
|
37
|
+
expect(move).toEqual({ from: { row: 5, column: 2 }, to: { row: 4, column: 1 }, captures: [] });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("pickMove strong prefers safe moves", () => {
|
|
41
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
42
|
+
const board = hangingScenarioBoard();
|
|
43
|
+
const move = pickMove(board, "white", "strong");
|
|
44
|
+
|
|
45
|
+
expect(move).toEqual({ from: { row: 5, column: 2 }, to: { row: 4, column: 3 }, captures: [] });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("pickMove random returns a legal move", () => {
|
|
49
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
50
|
+
const board = hangingScenarioBoard();
|
|
51
|
+
const move = pickMove(board, "white", "random");
|
|
52
|
+
|
|
53
|
+
expect(move).not.toBeNull();
|
|
54
|
+
expect(move?.from).toEqual({ row: 5, column: 2 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("pickMove player returns null", () => {
|
|
58
|
+
const board = hangingScenarioBoard();
|
|
59
|
+
expect(pickMove(board, "white", "player")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { applyMove, createInitialBoard, getAllMoves, getWinner, countPieces } from "../utils/board.js";
|
|
4
|
+
|
|
5
|
+
describe("checkers board", () => {
|
|
6
|
+
it("creates initial setup with 12 pieces each", () => {
|
|
7
|
+
const board = createInitialBoard();
|
|
8
|
+
|
|
9
|
+
expect(countPieces(board, "white")).toBe(12);
|
|
10
|
+
expect(countPieces(board, "black")).toBe(12);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("white has simple opening moves", () => {
|
|
14
|
+
const board = createInitialBoard();
|
|
15
|
+
const moves = getAllMoves(board, "white");
|
|
16
|
+
|
|
17
|
+
expect(moves.length).toBeGreaterThan(0);
|
|
18
|
+
expect(moves.every((move) => move.captures.length === 0)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("detects win when side has no pieces", () => {
|
|
22
|
+
const board = createInitialBoard();
|
|
23
|
+
for (let row = 0; row < 8; row++) {
|
|
24
|
+
for (let column = 0; column < 8; column++) {
|
|
25
|
+
if (board[row]?.[column]?.color === "black") board[row]![column] = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
expect(getWinner(board, "black")).toBe("white");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("applies a simple move", () => {
|
|
33
|
+
const board = createInitialBoard();
|
|
34
|
+
const moves = getAllMoves(board, "white");
|
|
35
|
+
const move = moves[0]!;
|
|
36
|
+
const pieceId = board[move.from.row]![move.from.column]!.id;
|
|
37
|
+
const next = applyMove(board, move);
|
|
38
|
+
|
|
39
|
+
expect(next[move.from.row]![move.from.column]).toBeNull();
|
|
40
|
+
expect(next[move.to.row]![move.to.column]?.color).toBe("white");
|
|
41
|
+
expect(next[move.to.row]![move.to.column]?.id).toBe(pieceId);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { spyEvent } from "@vnejs/test-utils";
|
|
4
|
+
|
|
5
|
+
import { Checkers } from "../modules/checkers.js";
|
|
6
|
+
import { createCheckersTestModule, registerCheckersPluginVne, SUBSCRIBE_EVENTS } from "./setup.js";
|
|
7
|
+
|
|
8
|
+
describe("Checkers", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
registerCheckersPluginVne();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("line exec show emits SHOW", async () => {
|
|
14
|
+
const { module, observer } = createCheckersTestModule(Checkers, { subscribe: false });
|
|
15
|
+
const show = spyEvent(observer, SUBSCRIBE_EVENTS.SHOW);
|
|
16
|
+
|
|
17
|
+
await (module as Checkers).init();
|
|
18
|
+
await (module as Checkers).onLineExec({ line: "show" });
|
|
19
|
+
|
|
20
|
+
expect(show).toHaveBeenCalledOnce();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("line exec ignores unknown action", async () => {
|
|
24
|
+
const { module, observer } = createCheckersTestModule(Checkers, { subscribe: false });
|
|
25
|
+
const show = spyEvent(observer, SUBSCRIBE_EVENTS.SHOW);
|
|
26
|
+
|
|
27
|
+
await (module as Checkers).init();
|
|
28
|
+
await (module as Checkers).onLineExec({ line: "open" });
|
|
29
|
+
|
|
30
|
+
expect(show).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("line exec ai sets shared.checkersAi and emits next", async () => {
|
|
34
|
+
const { module } = createCheckersTestModule(Checkers, { subscribe: false });
|
|
35
|
+
const checkers = module as Checkers;
|
|
36
|
+
const emitNext = vi.spyOn(checkers, "emitNext");
|
|
37
|
+
|
|
38
|
+
await checkers.postinject();
|
|
39
|
+
await checkers.init();
|
|
40
|
+
await checkers.onLineExec({ line: "ai black strong" });
|
|
41
|
+
|
|
42
|
+
expect(checkers.shared.checkersAi).toEqual({ white: "player", black: "strong" });
|
|
43
|
+
expect(emitNext).toHaveBeenCalledOnce();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("line exec ai with invalid args still emits next", async () => {
|
|
47
|
+
const { module } = createCheckersTestModule(Checkers, { subscribe: false });
|
|
48
|
+
const checkers = module as Checkers;
|
|
49
|
+
const emitNext = vi.spyOn(checkers, "emitNext");
|
|
50
|
+
|
|
51
|
+
await checkers.postinject();
|
|
52
|
+
await checkers.init();
|
|
53
|
+
await checkers.onLineExec({ line: "ai red random" });
|
|
54
|
+
|
|
55
|
+
expect(checkers.shared.checkersAi).toEqual({ white: "player", black: "player" });
|
|
56
|
+
expect(emitNext).toHaveBeenCalledOnce();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { CONSTANTS as CONTROLS_CONST, PLUGIN_NAME as CONTROLS, SUBSCRIBE_EVENTS as CONTROLS_EVENTS } from "@vnejs/contracts.controls";
|
|
2
|
+
import { CONSTANTS, PARAMS, PLUGIN_NAME, SUBSCRIBE_EVENTS } from "@vnejs/contracts.views.screens.checkers";
|
|
3
|
+
import { createTestModule as baseCreateTestModule, registerCoreVne, registerVnePlugin, stubSilentEvent } from "@vnejs/test-utils";
|
|
4
|
+
|
|
5
|
+
export const registerCheckersPluginVne = () => {
|
|
6
|
+
registerCoreVne();
|
|
7
|
+
registerVnePlugin(CONTROLS, { constants: CONTROLS_CONST, events: CONTROLS_EVENTS });
|
|
8
|
+
registerVnePlugin(PLUGIN_NAME, { constants: CONSTANTS, events: SUBSCRIBE_EVENTS, params: PARAMS });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const createCheckersTestModule = <T extends Parameters<typeof baseCreateTestModule>[0]>(
|
|
12
|
+
ModuleClass: T,
|
|
13
|
+
options: Parameters<typeof baseCreateTestModule>[1] = {},
|
|
14
|
+
) => {
|
|
15
|
+
const result = baseCreateTestModule(ModuleClass, {
|
|
16
|
+
state: {
|
|
17
|
+
checkers: { isShow: false },
|
|
18
|
+
...(options.state ?? {}),
|
|
19
|
+
},
|
|
20
|
+
...options,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
stubSilentEvent(result.observer, SUBSCRIBE_EVENTS.SHOW);
|
|
24
|
+
stubSilentEvent(result.observer, SUBSCRIBE_EVENTS.HIDE);
|
|
25
|
+
stubSilentEvent(result.observer, SUBSCRIBE_EVENTS.UPDATE);
|
|
26
|
+
stubSilentEvent(result.observer, SUBSCRIBE_EVENTS.UPDATE_FAST);
|
|
27
|
+
stubSilentEvent(result.observer, SUBSCRIBE_EVENTS.STATE_UPDATE);
|
|
28
|
+
stubSilentEvent(result.observer, SUBSCRIBE_EVENTS.GRID_UPDATE_ITEMS);
|
|
29
|
+
stubSilentEvent(result.observer, SUBSCRIBE_EVENTS.FINISH);
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export { SUBSCRIBE_EVENTS, PARAMS, PLUGIN_NAME, CONTROLS_EVENTS };
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ModuleComponentsConstants, ModuleComponentsEvents, ModuleComponentsParams, ModuleComponentsSettings } from "@vnejs/module.components";
|
|
2
|
+
import type { Constants as ControlsConstants, PluginName as ControlsPluginName } from "@vnejs/contracts.controls";
|
|
3
|
+
import type {
|
|
4
|
+
Constants as ScenarioConstants,
|
|
5
|
+
PluginName as ScenarioPluginName,
|
|
6
|
+
SubscribeEvents as ScenarioSubscribeEvents,
|
|
7
|
+
} from "@vnejs/contracts.core.scenario";
|
|
8
|
+
import type { PluginName as LogsPluginName, SubscribeEvents as LogsSubscribeEvents } from "@vnejs/contracts.core.logs";
|
|
9
|
+
import type { Constants, Params, PluginName, SubscribeEvents } from "@vnejs/contracts.views.screens.checkers";
|
|
10
|
+
|
|
11
|
+
export type CheckersPluginEvents = ModuleComponentsEvents &
|
|
12
|
+
Record<PluginName, SubscribeEvents> &
|
|
13
|
+
Record<ScenarioPluginName, ScenarioSubscribeEvents> &
|
|
14
|
+
Record<LogsPluginName, LogsSubscribeEvents>;
|
|
15
|
+
|
|
16
|
+
export type CheckersPluginConstants = ModuleComponentsConstants &
|
|
17
|
+
Record<PluginName, Constants> &
|
|
18
|
+
Record<ControlsPluginName, ControlsConstants> &
|
|
19
|
+
Record<ScenarioPluginName, ScenarioConstants>;
|
|
20
|
+
|
|
21
|
+
export type CheckersPluginSettings = ModuleComponentsSettings;
|
|
22
|
+
|
|
23
|
+
export type CheckersPluginParams = ModuleComponentsParams & Record<PluginName, Params>;
|
package/src/utils/ai.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { applyMove, getAllMoves, oppositeColor, type CheckersBoard, type CheckersColor, type CheckersMove } from "./board.js";
|
|
2
|
+
|
|
3
|
+
export type CheckersAiType = "player" | "sacrifice" | "random" | "strong";
|
|
4
|
+
|
|
5
|
+
export const isHangingMove = (board: CheckersBoard, move: CheckersMove, color: CheckersColor) => {
|
|
6
|
+
const nextBoard = applyMove(board, move);
|
|
7
|
+
const opponentMoves = getAllMoves(nextBoard, oppositeColor(color));
|
|
8
|
+
return opponentMoves.some((candidate) => candidate.captures.length > 0);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const pickRandom = <T>(items: T[]): T | null => {
|
|
12
|
+
if (items.length === 0) return null;
|
|
13
|
+
return items[Math.floor(Math.random() * items.length)] ?? null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const pickMove = (board: CheckersBoard, color: CheckersColor, type: CheckersAiType): CheckersMove | null => {
|
|
17
|
+
if (type === "player") return null;
|
|
18
|
+
|
|
19
|
+
const moves = getAllMoves(board, color);
|
|
20
|
+
if (moves.length === 0) return null;
|
|
21
|
+
|
|
22
|
+
if (type === "random") return pickRandom(moves);
|
|
23
|
+
|
|
24
|
+
const hanging = moves.filter((move) => isHangingMove(board, move, color));
|
|
25
|
+
const safe = moves.filter((move) => !isHangingMove(board, move, color));
|
|
26
|
+
|
|
27
|
+
if (type === "sacrifice") return pickRandom(hanging.length > 0 ? hanging : moves);
|
|
28
|
+
if (type === "strong") return pickRandom(safe.length > 0 ? safe : hanging.length > 0 ? hanging : moves);
|
|
29
|
+
|
|
30
|
+
return pickRandom(moves);
|
|
31
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
export type CheckersColor = "white" | "black";
|
|
2
|
+
|
|
3
|
+
export type CheckersPiece = {
|
|
4
|
+
id: string;
|
|
5
|
+
color: CheckersColor;
|
|
6
|
+
king: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type CheckersPos = {
|
|
10
|
+
row: number;
|
|
11
|
+
column: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type CheckersMove = {
|
|
15
|
+
from: CheckersPos;
|
|
16
|
+
to: CheckersPos;
|
|
17
|
+
captures: CheckersPos[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CheckersBoard = (CheckersPiece | null)[][];
|
|
21
|
+
|
|
22
|
+
export const BOARD_SIZE = 8;
|
|
23
|
+
|
|
24
|
+
export const oppositeColor = (color: CheckersColor): CheckersColor => (color === "white" ? "black" : "white");
|
|
25
|
+
|
|
26
|
+
export const isDarkSquare = (row: number, column: number) => (row + column) % 2 === 1;
|
|
27
|
+
|
|
28
|
+
export const inBounds = (row: number, column: number) => row >= 0 && row < BOARD_SIZE && column >= 0 && column < BOARD_SIZE;
|
|
29
|
+
|
|
30
|
+
export const cloneBoard = (board: CheckersBoard): CheckersBoard => board.map((row) => row.map((cell) => (cell ? { ...cell } : null)));
|
|
31
|
+
|
|
32
|
+
export const createInitialBoard = (): CheckersBoard => {
|
|
33
|
+
const board: CheckersBoard = Array.from({ length: BOARD_SIZE }, () => Array.from({ length: BOARD_SIZE }, () => null));
|
|
34
|
+
let nextId = 0;
|
|
35
|
+
|
|
36
|
+
for (let row = 0; row < BOARD_SIZE; row++) {
|
|
37
|
+
for (let column = 0; column < BOARD_SIZE; column++) {
|
|
38
|
+
if (!isDarkSquare(row, column)) continue;
|
|
39
|
+
if (row <= 2) board[row]![column] = { id: `piece-${nextId++}`, color: "black", king: false };
|
|
40
|
+
if (row >= 5) board[row]![column] = { id: `piece-${nextId++}`, color: "white", king: false };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return board;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DIAGONALS = [
|
|
48
|
+
{ dr: -1, dc: -1 },
|
|
49
|
+
{ dr: -1, dc: 1 },
|
|
50
|
+
{ dr: 1, dc: -1 },
|
|
51
|
+
{ dr: 1, dc: 1 },
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
const forwardRowDelta = (color: CheckersColor) => (color === "white" ? -1 : 1);
|
|
55
|
+
|
|
56
|
+
const posKey = (pos: CheckersPos) => `${pos.row}:${pos.column}`;
|
|
57
|
+
|
|
58
|
+
export const samePos = (a: CheckersPos, b: CheckersPos) => a.row === b.row && a.column === b.column;
|
|
59
|
+
|
|
60
|
+
const getPiece = (board: CheckersBoard, pos: CheckersPos) => board[pos.row]?.[pos.column] ?? null;
|
|
61
|
+
|
|
62
|
+
const setPiece = (board: CheckersBoard, pos: CheckersPos, piece: CheckersPiece | null) => {
|
|
63
|
+
board[pos.row]![pos.column] = piece;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const promoteIfNeeded = (piece: CheckersPiece, row: number): CheckersPiece => {
|
|
67
|
+
if (piece.king) return piece;
|
|
68
|
+
if (piece.color === "white" && row === 0) return { ...piece, king: true };
|
|
69
|
+
if (piece.color === "black" && row === BOARD_SIZE - 1) return { ...piece, king: true };
|
|
70
|
+
return piece;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const collectSimpleMoves = (board: CheckersBoard, from: CheckersPos, piece: CheckersPiece): CheckersMove[] => {
|
|
74
|
+
const moves: CheckersMove[] = [];
|
|
75
|
+
const deltas = piece.king ? DIAGONALS : DIAGONALS.filter(({ dr }) => dr === forwardRowDelta(piece.color));
|
|
76
|
+
|
|
77
|
+
for (const { dr, dc } of deltas) {
|
|
78
|
+
if (piece.king) {
|
|
79
|
+
let row = from.row + dr;
|
|
80
|
+
let column = from.column + dc;
|
|
81
|
+
while (inBounds(row, column) && !getPiece(board, { row, column })) {
|
|
82
|
+
moves.push({ from, to: { row, column }, captures: [] });
|
|
83
|
+
row += dr;
|
|
84
|
+
column += dc;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const row = from.row + dr;
|
|
90
|
+
const column = from.column + dc;
|
|
91
|
+
if (!inBounds(row, column) || getPiece(board, { row, column })) continue;
|
|
92
|
+
moves.push({ from, to: { row, column }, captures: [] });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return moves;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const collectImmediateCaptures = (board: CheckersBoard, from: CheckersPos, piece: CheckersPiece, capturedKeys: Set<string>): CheckersMove[] => {
|
|
99
|
+
const moves: CheckersMove[] = [];
|
|
100
|
+
|
|
101
|
+
for (const { dr, dc } of DIAGONALS) {
|
|
102
|
+
if (piece.king) {
|
|
103
|
+
let row = from.row + dr;
|
|
104
|
+
let column = from.column + dc;
|
|
105
|
+
let enemy: CheckersPos | null = null;
|
|
106
|
+
|
|
107
|
+
while (inBounds(row, column)) {
|
|
108
|
+
const occupant = getPiece(board, { row, column });
|
|
109
|
+
|
|
110
|
+
if (!occupant) {
|
|
111
|
+
if (enemy) {
|
|
112
|
+
moves.push({ from, to: { row, column }, captures: [enemy] });
|
|
113
|
+
}
|
|
114
|
+
row += dr;
|
|
115
|
+
column += dc;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (enemy) break;
|
|
120
|
+
if (occupant.color === piece.color || capturedKeys.has(posKey({ row, column }))) break;
|
|
121
|
+
|
|
122
|
+
enemy = { row, column };
|
|
123
|
+
row += dr;
|
|
124
|
+
column += dc;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const mid: CheckersPos = { row: from.row + dr, column: from.column + dc };
|
|
130
|
+
const to: CheckersPos = { row: from.row + 2 * dr, column: from.column + 2 * dc };
|
|
131
|
+
if (!inBounds(to.row, to.column) || getPiece(board, to)) continue;
|
|
132
|
+
|
|
133
|
+
const victim = getPiece(board, mid);
|
|
134
|
+
if (!victim || victim.color === piece.color || capturedKeys.has(posKey(mid))) continue;
|
|
135
|
+
|
|
136
|
+
moves.push({ from, to, captures: [mid] });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return moves;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const expandCaptureChains = (
|
|
143
|
+
board: CheckersBoard,
|
|
144
|
+
from: CheckersPos,
|
|
145
|
+
piece: CheckersPiece,
|
|
146
|
+
origin: CheckersPos,
|
|
147
|
+
capturedSoFar: CheckersPos[],
|
|
148
|
+
capturedKeys: Set<string>,
|
|
149
|
+
): CheckersMove[] => {
|
|
150
|
+
const immediate = collectImmediateCaptures(board, from, piece, capturedKeys);
|
|
151
|
+
if (immediate.length === 0) {
|
|
152
|
+
return capturedSoFar.length > 0 ? [{ from: origin, to: from, captures: capturedSoFar }] : [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const results: CheckersMove[] = [];
|
|
156
|
+
|
|
157
|
+
for (const step of immediate) {
|
|
158
|
+
const nextBoard = cloneBoard(board);
|
|
159
|
+
setPiece(nextBoard, from, null);
|
|
160
|
+
for (const capture of step.captures) setPiece(nextBoard, capture, null);
|
|
161
|
+
const nextPiece = promoteIfNeeded(piece, step.to.row);
|
|
162
|
+
setPiece(nextBoard, step.to, nextPiece);
|
|
163
|
+
|
|
164
|
+
const nextCaptures = [...capturedSoFar, ...step.captures];
|
|
165
|
+
const nextKeys = new Set(capturedKeys);
|
|
166
|
+
for (const capture of step.captures) nextKeys.add(posKey(capture));
|
|
167
|
+
|
|
168
|
+
results.push(...expandCaptureChains(nextBoard, step.to, nextPiece, origin, nextCaptures, nextKeys));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return results;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const getMovesFrom = (board: CheckersBoard, from: CheckersPos): CheckersMove[] => {
|
|
175
|
+
const piece = getPiece(board, from);
|
|
176
|
+
if (!piece) return [];
|
|
177
|
+
|
|
178
|
+
const captures = expandCaptureChains(board, from, piece, from, [], new Set());
|
|
179
|
+
if (captures.length > 0) return captures;
|
|
180
|
+
return collectSimpleMoves(board, from, piece);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const getAllMoves = (board: CheckersBoard, color: CheckersColor, onlyFrom?: CheckersPos): CheckersMove[] => {
|
|
184
|
+
const moves: CheckersMove[] = [];
|
|
185
|
+
|
|
186
|
+
for (let row = 0; row < BOARD_SIZE; row++) {
|
|
187
|
+
for (let column = 0; column < BOARD_SIZE; column++) {
|
|
188
|
+
if (onlyFrom && !samePos(onlyFrom, { row, column })) continue;
|
|
189
|
+
const piece = board[row]?.[column];
|
|
190
|
+
if (!piece || piece.color !== color) continue;
|
|
191
|
+
moves.push(...getMovesFrom(board, { row, column }));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const hasCapture = moves.some((move) => move.captures.length > 0);
|
|
196
|
+
return hasCapture ? moves.filter((move) => move.captures.length > 0) : moves;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const applyMove = (board: CheckersBoard, move: CheckersMove): CheckersBoard => {
|
|
200
|
+
const next = cloneBoard(board);
|
|
201
|
+
const piece = getPiece(next, move.from);
|
|
202
|
+
if (!piece) return next;
|
|
203
|
+
|
|
204
|
+
setPiece(next, move.from, null);
|
|
205
|
+
for (const capture of move.captures) setPiece(next, capture, null);
|
|
206
|
+
setPiece(next, move.to, promoteIfNeeded(piece, move.to.row));
|
|
207
|
+
return next;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Apply one hop of a multi-capture chain (first capture only). */
|
|
211
|
+
export const applyCaptureStep = (board: CheckersBoard, from: CheckersPos, to: CheckersPos, capture: CheckersPos): CheckersBoard => {
|
|
212
|
+
const next = cloneBoard(board);
|
|
213
|
+
const piece = getPiece(next, from);
|
|
214
|
+
if (!piece) return next;
|
|
215
|
+
|
|
216
|
+
setPiece(next, from, null);
|
|
217
|
+
setPiece(next, capture, null);
|
|
218
|
+
setPiece(next, to, promoteIfNeeded(piece, to.row));
|
|
219
|
+
return next;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const listBoardPieces = (board: CheckersBoard) => {
|
|
223
|
+
const pieces: Array<CheckersPiece & CheckersPos> = [];
|
|
224
|
+
|
|
225
|
+
for (let row = 0; row < BOARD_SIZE; row++) {
|
|
226
|
+
for (let column = 0; column < BOARD_SIZE; column++) {
|
|
227
|
+
const piece = board[row]?.[column];
|
|
228
|
+
if (!piece) continue;
|
|
229
|
+
pieces.push({ ...piece, row, column });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return pieces;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const countPieces = (board: CheckersBoard, color: CheckersColor) => {
|
|
237
|
+
let count = 0;
|
|
238
|
+
for (const row of board) {
|
|
239
|
+
for (const cell of row) {
|
|
240
|
+
if (cell?.color === color) count++;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return count;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export const getWinner = (board: CheckersBoard, colorToMove: CheckersColor): CheckersColor | null => {
|
|
247
|
+
if (countPieces(board, colorToMove) === 0) return oppositeColor(colorToMove);
|
|
248
|
+
if (getAllMoves(board, colorToMove).length === 0) return oppositeColor(colorToMove);
|
|
249
|
+
return null;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const findMove = (moves: CheckersMove[], from: CheckersPos, to: CheckersPos) => moves.find((move) => samePos(move.from, from) && samePos(move.to, to));
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { CheckersBoard, CheckersColor, CheckersMove, CheckersPiece, CheckersPos } from "./board.js";
|
|
2
|
+
import { BOARD_SIZE, createInitialBoard, getAllMoves, isDarkSquare } from "./board.js";
|
|
3
|
+
|
|
4
|
+
export type CheckersGridItem = {
|
|
5
|
+
row: number;
|
|
6
|
+
column: number;
|
|
7
|
+
isDark: boolean;
|
|
8
|
+
piece: CheckersPiece | null;
|
|
9
|
+
isSelected?: boolean;
|
|
10
|
+
isLegalTarget?: boolean;
|
|
11
|
+
isFocus?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type CheckersPluginState = {
|
|
15
|
+
isShow: boolean;
|
|
16
|
+
isForce: boolean;
|
|
17
|
+
board?: CheckersBoard;
|
|
18
|
+
turn?: CheckersColor;
|
|
19
|
+
selected?: CheckersPos | null;
|
|
20
|
+
legalMoves?: CheckersMove[];
|
|
21
|
+
winner?: CheckersColor | null;
|
|
22
|
+
statusText?: string;
|
|
23
|
+
gridRow?: number | null;
|
|
24
|
+
gridColumn?: number | null;
|
|
25
|
+
gridItems?: CheckersGridItem[];
|
|
26
|
+
gridIndex?: number;
|
|
27
|
+
gridsCount?: number;
|
|
28
|
+
ai?: {
|
|
29
|
+
white: string;
|
|
30
|
+
black: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type CheckersCellClickPayload = {
|
|
35
|
+
row?: number;
|
|
36
|
+
column?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const statusForTurn = (turn: CheckersColor, winner: CheckersColor | null = null) => {
|
|
40
|
+
if (winner) return winner === "white" ? "White wins" : "Black wins";
|
|
41
|
+
return turn === "white" ? "White to move" : "Black to move";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const buildGridItems = ({
|
|
45
|
+
board,
|
|
46
|
+
selected = null,
|
|
47
|
+
legalMoves = [],
|
|
48
|
+
gridRow = null,
|
|
49
|
+
gridColumn = null,
|
|
50
|
+
}: {
|
|
51
|
+
board: CheckersBoard;
|
|
52
|
+
selected?: CheckersPos | null;
|
|
53
|
+
legalMoves?: CheckersMove[];
|
|
54
|
+
gridRow?: number | null;
|
|
55
|
+
gridColumn?: number | null;
|
|
56
|
+
}): CheckersGridItem[] => {
|
|
57
|
+
const legalKeys = new Set(legalMoves.map((move) => `${move.to.row}:${move.to.column}`));
|
|
58
|
+
const items: CheckersGridItem[] = [];
|
|
59
|
+
|
|
60
|
+
for (let row = 0; row < BOARD_SIZE; row++) {
|
|
61
|
+
for (let column = 0; column < BOARD_SIZE; column++) {
|
|
62
|
+
items.push({
|
|
63
|
+
row,
|
|
64
|
+
column,
|
|
65
|
+
isDark: isDarkSquare(row, column),
|
|
66
|
+
piece: board[row]?.[column] ?? null,
|
|
67
|
+
isSelected: selected?.row === row && selected?.column === column,
|
|
68
|
+
isLegalTarget: legalKeys.has(`${row}:${column}`),
|
|
69
|
+
isFocus: gridRow === row && gridColumn === column,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return items;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const createInitialCheckersState = (): Pick<
|
|
78
|
+
CheckersPluginState,
|
|
79
|
+
"board" | "turn" | "selected" | "legalMoves" | "winner" | "statusText" | "gridItems" | "gridIndex" | "gridsCount"
|
|
80
|
+
> => {
|
|
81
|
+
const board = createInitialBoard();
|
|
82
|
+
const turn: CheckersColor = "white";
|
|
83
|
+
const legalMoves = getAllMoves(board, turn);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
board,
|
|
87
|
+
turn,
|
|
88
|
+
selected: null,
|
|
89
|
+
legalMoves: [],
|
|
90
|
+
winner: null,
|
|
91
|
+
statusText: statusForTurn(turn),
|
|
92
|
+
gridItems: buildGridItems({ board, legalMoves: [] }),
|
|
93
|
+
gridIndex: 0,
|
|
94
|
+
gridsCount: 1,
|
|
95
|
+
};
|
|
96
|
+
};
|