commit 9c50c92f2591687eb043472005057461d4619685 Author: bozhoujiangan Date: Sun Sep 13 23:42:25 2026 +0800 初始化五子棋 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cc4766 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 操作系统生成文件 +Thumbs.db +Desktop.ini +.DS_Store + +# 编辑器 / IDE 配置 +.vscode/ +.idea/ +*.swp + +# 本地测试过程产物(GUI 测试截图) +gui-test-screenshots/ diff --git a/.zcode/plans/plan-sess_15c8eda4-d9a9-4506-b8b6-2a3dfdf613a9.md b/.zcode/plans/plan-sess_15c8eda4-d9a9-4506-b8b6-2a3dfdf613a9.md new file mode 100644 index 0000000..0f67d5c --- /dev/null +++ b/.zcode/plans/plan-sess_15c8eda4-d9a9-4506-b8b6-2a3dfdf613a9.md @@ -0,0 +1,64 @@ +# 五子棋网页版 · 最小可玩版本计划 + +## 目标 +15×15 棋盘网页五子棋:**双人对战 + 打分法人机对战**,双击 index.html 即可游玩,零依赖零构建。架构上为后续增强(Minimax、悔棋、难度分级)预留接口。 + +## 文件结构 +``` +D:\五子棋\ +├── index.html # 页面结构,普通 + + + + + + diff --git a/js/ai.js b/js/ai.js new file mode 100644 index 0000000..cfb9daf --- /dev/null +++ b/js/ai.js @@ -0,0 +1,123 @@ +/* + * 打分法 AI(贪心策略) + * 对每个候选空位计算「己方落此处的进攻分 + 0.9 × 对方落此处的防守分」, + * 取总分最高的一点。分值表的相对大小自然形成优先级: + * 己方成五 > 挡对方成五 > 己方活四 > 挡对方活四 > … + * 浏览器中挂到 window.GomokuAI,Node 中通过 module.exports 导出供测试使用。 + */ +(function (root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } else { + root.GomokuAI = api; + } +})(typeof window !== 'undefined' ? window : globalThis, function () { + + const SIZE = 15; + const EMPTY = 0; + const BLACK = 1; + const WHITE = 2; + + // 横、竖、两条斜线 + const DIRECTIONS = [[1, 0], [0, 1], [1, 1], [1, -1]]; + + const DEFENSE_WEIGHT = 0.9; // 防守分权重:略低于进攻,能赢时优先赢 + const CENTER = { x: 7, y: 7 }; + + // 棋形分值表:按优先级从高到低排列,每个方向命中一个即止 + // 模式串中 '1' = 己方,'0' = 空位,'2' = 对方棋子或边界 + const PATTERN_TABLE = [ + [['11111'], 1000000], // 连五 + [['011110'], 100000], // 活四 + [['011112', '211110', '10111', '11011', '11101'], 10000], // 冲四(含跳四) + [['011100', '001110', '010110', '011010'], 8000], // 活三(含跳活三) + [['11100', '00111', '11010', '01011', '10110', '01101', + '10011', '11001', '10101'], 500], // 眠三 + [['001100', '01010'], 400], // 活二 + [['000112', '211000', '001012', '210100', '010012', '210010', + '10001'], 50], // 眠二 + ]; + + // 以 (x, y) 为中心、沿 (dx, dy) 方向取 9 格窗口,假设己方落在中心后转成模式串 + function buildWindow(board, x, y, dx, dy, color) { + let s = ''; + for (let i = -4; i <= 4; i++) { + if (i === 0) { s += '1'; continue; } + const nx = x + dx * i; + const ny = y + dy * i; + if (nx < 0 || nx >= SIZE || ny < 0 || ny >= SIZE) { s += '2'; continue; } + const c = board[ny][nx]; + s += c === EMPTY ? '0' : c === color ? '1' : '2'; + } + return s; + } + + // 评估 color 方落在 (x, y) 的价值:4 个方向的棋形分之和 + function scorePoint(board, x, y, color) { + let total = 0; + for (const [dx, dy] of DIRECTIONS) { + const win = buildWindow(board, x, y, dx, dy, color); + for (const [patterns, score] of PATTERN_TABLE) { + if (patterns.some(p => win.includes(p))) { + total += score; + break; + } + } + } + return total; + } + + function hasNeighbor(board, x, y, range) { + for (let dy = -range; dy <= range; dy++) { + for (let dx = -range; dx <= range; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && nx < SIZE && ny >= 0 && ny < SIZE && board[ny][nx] !== EMPTY) return true; + } + } + return false; + } + + // 候选点:已有棋子周围 2 格内的空位;空盘直接落天元 + function findCandidates(board) { + const result = []; + let hasStone = false; + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + if (board[y][x] !== EMPTY) { hasStone = true; break; } + } + if (hasStone) break; + } + if (!hasStone) return [CENTER]; + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + if (board[y][x] === EMPTY && hasNeighbor(board, x, y, 2)) { + result.push({ x, y }); + } + } + } + return result; + } + + function nextMove(game, color) { + const me = color || game.currentPlayer; + const opp = me === BLACK ? WHITE : BLACK; + let best = []; + let bestScore = -Infinity; + for (const spot of findCandidates(game.board)) { + const score = scorePoint(game.board, spot.x, spot.y, me) + + DEFENSE_WEIGHT * scorePoint(game.board, spot.x, spot.y, opp); + if (score > bestScore) { + bestScore = score; + best = [spot]; + } else if (score === bestScore) { + best.push(spot); + } + } + return best[Math.floor(Math.random() * best.length)]; + } + + return { nextMove, scorePoint, findCandidates }; +}); diff --git a/js/engine.js b/js/engine.js new file mode 100644 index 0000000..ee3c07a --- /dev/null +++ b/js/engine.js @@ -0,0 +1,95 @@ +/* + * 五子棋规则引擎(纯逻辑,无 DOM) + * 浏览器中挂到 window.GomokuEngine,Node 中通过 module.exports 导出供测试使用。 + * 规则:无禁手自由规则,任意方向连成五子及以上即胜。 + */ +(function (root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } else { + root.GomokuEngine = api; + } +})(typeof window !== 'undefined' ? window : globalThis, function () { + + const SIZE = 15; + const EMPTY = 0; + const BLACK = 1; + const WHITE = 2; + + // 横、竖、两条斜线 + const DIRECTIONS = [[1, 0], [0, 1], [1, 1], [1, -1]]; + + function inBounds(x, y) { + return x >= 0 && x < SIZE && y >= 0 && y < SIZE; + } + + function createGame() { + return { + size: SIZE, + board: Array.from({ length: SIZE }, () => new Array(SIZE).fill(EMPTY)), + currentPlayer: BLACK, // 黑先 + winner: 0, // 1 黑胜,2 白胜,0 未分 + draw: false, + gameOver: false, + winningLine: null, // 获胜连线的坐标数组 [{x, y}, ...] + moveHistory: [], // {x, y, player},为悔棋功能预留 + }; + } + + // 从 (x, y) 出发沿 4 个方向数连子,返回获胜连线(≥5 个)或 null + function checkWinAt(game, x, y) { + const player = game.board[y][x]; + if (player === EMPTY) return null; + for (const [dx, dy] of DIRECTIONS) { + const line = [{ x, y }]; + for (let nx = x + dx, ny = y + dy; inBounds(nx, ny) && game.board[ny][nx] === player; nx += dx, ny += dy) { + line.push({ x: nx, y: ny }); + } + for (let nx = x - dx, ny = y - dy; inBounds(nx, ny) && game.board[ny][nx] === player; nx -= dx, ny -= dy) { + line.unshift({ x: nx, y: ny }); + } + if (line.length >= 5) return line; + } + return null; + } + + function isBoardFull(game) { + return game.board.every(row => row.every(c => c !== EMPTY)); + } + + function place(game, x, y) { + if (game.gameOver) return { ok: false, reason: '游戏已结束' }; + if (!inBounds(x, y)) return { ok: false, reason: '超出棋盘范围' }; + if (game.board[y][x] !== EMPTY) return { ok: false, reason: '该位置已有棋子' }; + + game.board[y][x] = game.currentPlayer; + game.moveHistory.push({ x, y, player: game.currentPlayer }); + + const line = checkWinAt(game, x, y); + if (line) { + game.winner = game.currentPlayer; + game.winningLine = line; + game.gameOver = true; + return { ok: true, win: true }; + } + if (isBoardFull(game)) { + game.draw = true; + game.gameOver = true; + return { ok: true, draw: true }; + } + game.currentPlayer = game.currentPlayer === BLACK ? WHITE : BLACK; + return { ok: true }; + } + + return { + SIZE, + EMPTY, + BLACK, + WHITE, + createGame, + place, + checkWinAt, + inBounds, + }; +}); diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..d6bdc2b --- /dev/null +++ b/js/main.js @@ -0,0 +1,11 @@ +/* 入口:组装渲染器与交互控制 */ +(function () { + const canvas = document.getElementById('board'); + const renderer = GomokuRenderer.createRenderer(canvas); + const controller = GomokuUI.createController(canvas, renderer); + + window.addEventListener('resize', () => { + renderer.resize(); + controller.refresh(); + }); +})(); diff --git a/js/renderer.js b/js/renderer.js new file mode 100644 index 0000000..c03220d --- /dev/null +++ b/js/renderer.js @@ -0,0 +1,143 @@ +/* + * Canvas 渲染:木色棋盘、立体棋子、悬停预览、最后一手标记、获胜连线。 + * 不持有游戏状态,每次 render(game, hover) 根据传入状态整体重绘。 + */ +(function () { + + const SIZE = 15; + const STAR_POINTS = [[3, 3], [11, 3], [3, 11], [11, 11], [7, 7]]; + + function createRenderer(canvas) { + const ctx = canvas.getContext('2d'); + let cssSize = 600; + let margin = 0; + let cell = 0; + + function resize() { + const rect = canvas.getBoundingClientRect(); + cssSize = Math.max(200, Math.floor(rect.width)); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(cssSize * dpr); + canvas.height = Math.round(cssSize * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + margin = cssSize / (SIZE + 1); + cell = (cssSize - margin * 2) / (SIZE - 1); + } + + function toPixel(x, y) { + return { px: margin + x * cell, py: margin + y * cell }; + } + + function drawBoard() { + const bg = ctx.createLinearGradient(0, 0, cssSize, cssSize); + bg.addColorStop(0, '#e3b473'); + bg.addColorStop(1, '#d19a52'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, cssSize, cssSize); + + ctx.strokeStyle = '#7a5a2e'; + ctx.lineWidth = 1; + for (let i = 0; i < SIZE; i++) { + const p = margin + i * cell; + ctx.beginPath(); + ctx.moveTo(margin, p); + ctx.lineTo(cssSize - margin, p); + ctx.moveTo(p, margin); + ctx.lineTo(p, cssSize - margin); + ctx.stroke(); + } + // 外框加粗一圈 + ctx.lineWidth = 2; + ctx.strokeRect(margin, margin, cell * (SIZE - 1), cell * (SIZE - 1)); + + ctx.fillStyle = '#5a3f1c'; + for (const [sx, sy] of STAR_POINTS) { + const { px, py } = toPixel(sx, sy); + ctx.beginPath(); + ctx.arc(px, py, Math.max(2.5, cell * 0.09), 0, Math.PI * 2); + ctx.fill(); + } + } + + function drawStone(x, y, color, alpha) { + const { px, py } = toPixel(x, y); + const r = cell * 0.42; + ctx.save(); + ctx.globalAlpha = alpha; + if (alpha === 1) { + ctx.shadowColor = 'rgba(0, 0, 0, 0.35)'; + ctx.shadowBlur = r * 0.35; + ctx.shadowOffsetY = r * 0.12; + } + const g = ctx.createRadialGradient(px - r * 0.35, py - r * 0.35, r * 0.1, px, py, r); + if (color === 1) { + g.addColorStop(0, '#6a6a6a'); + g.addColorStop(1, '#0d0d0d'); + } else { + g.addColorStop(0, '#ffffff'); + g.addColorStop(1, '#c9c4ba'); + } + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(px, py, r, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + function render(game, hover) { + drawBoard(); + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + if (game.board[y][x] !== 0) drawStone(x, y, game.board[y][x], 1); + } + } + + // 最后一手红点标记(有获胜连线时不画,避免遮挡) + const last = game.moveHistory[game.moveHistory.length - 1]; + if (last && !game.winningLine) { + const { px, py } = toPixel(last.x, last.y); + ctx.fillStyle = '#e6493a'; + ctx.beginPath(); + ctx.arc(px, py, Math.max(2.5, cell * 0.1), 0, Math.PI * 2); + ctx.fill(); + } + + // 获胜连线 + if (game.winningLine) { + const a = toPixel(game.winningLine[0].x, game.winningLine[0].y); + const b = toPixel( + game.winningLine[game.winningLine.length - 1].x, + game.winningLine[game.winningLine.length - 1].y + ); + ctx.save(); + ctx.strokeStyle = 'rgba(230, 60, 45, 0.9)'; + ctx.lineWidth = cell * 0.14; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(a.px, a.py); + ctx.lineTo(b.px, b.py); + ctx.stroke(); + ctx.restore(); + } + + // 悬停预览:半透明显示当前行棋方棋子 + if (hover && !game.gameOver && game.board[hover.y][hover.x] === 0) { + drawStone(hover.x, hover.y, game.currentPlayer, 0.4); + } + } + + // 把鼠标事件换算成最近的交叉点,超出棋盘返回 null + function cellFromEvent(evt) { + const rect = canvas.getBoundingClientRect(); + const x = Math.round((evt.clientX - rect.left - margin) / cell); + const y = Math.round((evt.clientY - rect.top - margin) / cell); + if (x < 0 || x >= SIZE || y < 0 || y >= SIZE) return null; + return { x, y }; + } + + resize(); + return { render, resize, cellFromEvent }; + } + + window.GomokuRenderer = { createRenderer }; +})(); diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..66a431a --- /dev/null +++ b/js/ui.js @@ -0,0 +1,109 @@ +/* + * 交互控制:点击落子、模式/执子切换、AI 应手、状态栏文案。 + */ +(function () { + + function createController(canvas, renderer) { + const E = window.GomokuEngine; + const AI = window.GomokuAI; + + const statusEl = document.getElementById('status'); + const modeSel = document.getElementById('mode'); + const sideSel = document.getElementById('side'); + const sideRow = document.getElementById('side-row'); + const newBtn = document.getElementById('new-game'); + + let game = E.createGame(); + let mode = modeSel.value; // 'ai' | 'pvp' + let humanColor = Number(sideSel.value); + let aiThinking = false; + + function isHumanTurn() { + return !game.gameOver && (mode === 'pvp' || game.currentPlayer === humanColor); + } + + function updateStatus() { + if (game.gameOver) { + if (game.draw) { + statusEl.textContent = '平局,棋盘已下满'; + } else if (mode === 'ai') { + statusEl.textContent = game.winner === humanColor ? '你赢了!🎉' : 'AI 获胜,再接再厉'; + } else { + statusEl.textContent = game.winner === E.BLACK ? '黑方获胜!' : '白方获胜!'; + } + return; + } + if (aiThinking) { + statusEl.textContent = 'AI 思考中…'; + return; + } + if (mode === 'ai') { + statusEl.textContent = game.currentPlayer === humanColor + ? `轮到你落子(${humanColor === E.BLACK ? '黑子' : '白子'})` + : '轮到 AI 落子'; + } else { + statusEl.textContent = game.currentPlayer === E.BLACK ? '轮到黑方落子' : '轮到白方落子'; + } + } + + function refresh(hover) { + renderer.render(game, hover || null); + updateStatus(); + } + + // 轮到 AI 时延迟 300ms 落子:观感自然,也给界面留出渲染时间 + function maybeAIMove() { + if (mode !== 'ai' || game.gameOver || game.currentPlayer === humanColor) return; + aiThinking = true; + updateStatus(); + setTimeout(() => { + const mv = AI.nextMove(game, game.currentPlayer); + E.place(game, mv.x, mv.y); + aiThinking = false; + refresh(); + }, 300); + } + + canvas.addEventListener('click', (e) => { + if (!isHumanTurn() || aiThinking) return; + const pos = renderer.cellFromEvent(e); + if (!pos) return; + const r = E.place(game, pos.x, pos.y); + if (!r.ok) return; + refresh(); + maybeAIMove(); + }); + + canvas.addEventListener('mousemove', (e) => { + if (!isHumanTurn() || aiThinking) { + refresh(); + return; + } + refresh(renderer.cellFromEvent(e)); + }); + canvas.addEventListener('mouseleave', () => refresh()); + + function newGame() { + game = E.createGame(); + aiThinking = false; + refresh(); + maybeAIMove(); // 人执白时让 AI(黑)先落子 + } + + modeSel.addEventListener('change', () => { + mode = modeSel.value; + sideRow.style.display = mode === 'ai' ? '' : 'none'; + newGame(); + }); + sideSel.addEventListener('change', () => { + humanColor = Number(sideSel.value); + newGame(); + }); + newBtn.addEventListener('click', newGame); + + newGame(); + return { refresh }; + } + + window.GomokuUI = { createController }; +})(); diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..2baa33f --- /dev/null +++ b/server.mjs @@ -0,0 +1,31 @@ +/* + * 本地静态文件服务器(可选工具):node server.mjs [端口] + * 双击 index.html 即可游玩,无需本服务器;仅当浏览器安全策略限制 file:// 页面时使用。 + */ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, join, resolve } from 'node:path'; + +const root = resolve(process.argv[2] ? '.' : '.'); +const port = Number(process.argv[2]) || 8765; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', +}; + +createServer(async (req, res) => { + try { + const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); + const file = join(root, pathname.endsWith('/') ? join(pathname, 'index.html') : pathname); + const data = await readFile(file); + res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream' }); + res.end(data); + } catch { + res.writeHead(404); + res.end('not found'); + } +}).listen(port, '127.0.0.1', () => { + console.log(`serving http://127.0.0.1:${port}/`); +}); diff --git a/tests/ai.test.js b/tests/ai.test.js new file mode 100644 index 0000000..e9214a9 --- /dev/null +++ b/tests/ai.test.js @@ -0,0 +1,63 @@ +/* AI 行为测试:node --test tests/ai.test.js */ +const { test } = require('node:test'); +const assert = require('node:assert'); +const E = require('../js/engine.js'); +const AI = require('../js/ai.js'); + +function setStones(game, color, coords) { + for (const [x, y] of coords) game.board[y][x] = color; +} + +test('空盘首着落天元', () => { + const g = E.createGame(); + assert.deepStrictEqual(AI.nextMove(g, E.BLACK), { x: 7, y: 7 }); +}); + +test('能一手取胜时直接取胜', () => { + const g = E.createGame(); + // 白方横向四连(x=4..7, y=5),左端被黑封堵,唯一成五点是 (8,5) + setStones(g, E.WHITE, [[4, 5], [5, 5], [6, 5], [7, 5]]); + setStones(g, E.BLACK, [[3, 5]]); + assert.deepStrictEqual(AI.nextMove(g, E.WHITE), { x: 8, y: 5 }); +}); + +test('对方冲四时必防守', () => { + const g = E.createGame(); + // 黑方横向四连(x=3..6, y=9),左端 (2,9) 已被白封堵,白必须在 (7,9) 挡 + setStones(g, E.BLACK, [[3, 9], [4, 9], [5, 9], [6, 9]]); + setStones(g, E.WHITE, [[2, 9]]); + assert.deepStrictEqual(AI.nextMove(g, E.WHITE), { x: 7, y: 9 }); +}); + +test('对方活三时封挡端点', () => { + const g = E.createGame(); + // 黑方两端开阔的活三,白应落在 (5,7) 或 (9,7) 之一 + setStones(g, E.BLACK, [[6, 7], [7, 7], [8, 7]]); + const mv = AI.nextMove(g, E.WHITE); + assert.ok( + (mv.x === 5 && mv.y === 7) || (mv.x === 9 && mv.y === 7), + `期望封挡 (5,7) 或 (9,7),实际落在 (${mv.x},${mv.y})` + ); +}); + +test('己方活三优先于防守对方眠二', () => { + const g = E.createGame(); + // 白有活二 (4,8)(5,8),黑只有零散两子;白应继续连子而非远端防守 + setStones(g, E.WHITE, [[4, 8], [5, 8]]); + setStones(g, E.BLACK, [[10, 3], [3, 11]]); + const mv = AI.nextMove(g, E.WHITE); + const nearOwn = Math.abs(mv.x - 4) + Math.abs(mv.y - 8) <= 4 + || Math.abs(mv.x - 5) + Math.abs(mv.y - 8) <= 4; + assert.ok(nearOwn, `期望在己方活二附近行棋,实际落在 (${mv.x},${mv.y})`); +}); + +test('返回的点永远是空位', () => { + const g = E.createGame(); + setStones(g, E.BLACK, [[7, 7], [8, 8], [6, 8]]); + setStones(g, E.WHITE, [[7, 8], [8, 6], [6, 6]]); + g.currentPlayer = E.WHITE; + for (let i = 0; i < 20; i++) { + const mv = AI.nextMove(g); + assert.strictEqual(g.board[mv.y][mv.x], E.EMPTY); + } +}); diff --git a/tests/engine.test.js b/tests/engine.test.js new file mode 100644 index 0000000..566631a --- /dev/null +++ b/tests/engine.test.js @@ -0,0 +1,129 @@ +/* 规则引擎测试:node --test tests/ */ +const { test } = require('node:test'); +const assert = require('node:assert'); +const E = require('../js/engine.js'); + +// 直接往棋盘上摆子,用于构造测试局面(绕过轮流落子) +function setStones(game, color, coords) { + for (const [x, y] of coords) game.board[y][x] = color; +} + +test('黑先并且落子后交替行棋', () => { + const g = E.createGame(); + assert.strictEqual(g.currentPlayer, E.BLACK); + const r = E.place(g, 7, 7); + assert.strictEqual(r.ok, true); + assert.strictEqual(g.currentPlayer, E.WHITE); + E.place(g, 8, 8); + assert.strictEqual(g.currentPlayer, E.BLACK); +}); + +test('横向五连判胜', () => { + const g = E.createGame(); + setStones(g, E.BLACK, [[3, 7], [4, 7], [5, 7], [6, 7]]); + g.currentPlayer = E.BLACK; + const r = E.place(g, 7, 7); + assert.strictEqual(r.win, true); + assert.strictEqual(g.winner, E.BLACK); + assert.strictEqual(g.gameOver, true); + assert.deepStrictEqual(g.winningLine, [ + { x: 3, y: 7 }, { x: 4, y: 7 }, { x: 5, y: 7 }, { x: 6, y: 7 }, { x: 7, y: 7 }, + ]); +}); + +test('纵向五连判胜', () => { + const g = E.createGame(); + setStones(g, E.WHITE, [[7, 3], [7, 4], [7, 5], [7, 6]]); + g.currentPlayer = E.WHITE; + const r = E.place(g, 7, 7); + assert.strictEqual(r.win, true); + assert.strictEqual(g.winner, E.WHITE); +}); + +test('斜向(左上到右下)五连判胜', () => { + const g = E.createGame(); + setStones(g, E.BLACK, [[3, 3], [4, 4], [5, 5], [6, 6]]); + g.currentPlayer = E.BLACK; + const r = E.place(g, 7, 7); + assert.strictEqual(r.win, true); +}); + +test('斜向(左下到右上)五连判胜', () => { + const g = E.createGame(); + setStones(g, E.BLACK, [[3, 10], [4, 9], [5, 8], [6, 7]]); + g.currentPlayer = E.BLACK; + const r = E.place(g, 7, 6); + assert.strictEqual(r.win, true); + assert.strictEqual(g.winningLine.length, 5); +}); + +test('长连(六连)同样判胜', () => { + const g = E.createGame(); + setStones(g, E.BLACK, [[3, 5], [4, 5], [5, 5], [6, 5], [8, 5]]); + g.currentPlayer = E.BLACK; + const r = E.place(g, 7, 5); + assert.strictEqual(r.win, true); + assert.strictEqual(g.winningLine.length, 6); +}); + +test('四连不判胜', () => { + const g = E.createGame(); + setStones(g, E.BLACK, [[3, 7], [4, 7], [5, 7], [6, 7]]); + g.currentPlayer = E.BLACK; + const r = E.place(g, 9, 9); // 与四连无关的落子 + assert.strictEqual(r.win, undefined); + assert.strictEqual(g.gameOver, false); +}); + +test('非法落子被拒绝:已占用', () => { + const g = E.createGame(); + E.place(g, 7, 7); + const r = E.place(g, 7, 7); + assert.strictEqual(r.ok, false); +}); + +test('非法落子被拒绝:越界', () => { + const g = E.createGame(); + assert.strictEqual(E.place(g, -1, 0).ok, false); + assert.strictEqual(E.place(g, 0, -1).ok, false); + assert.strictEqual(E.place(g, 15, 0).ok, false); + assert.strictEqual(E.place(g, 0, 15).ok, false); +}); + +test('对局结束后拒绝继续落子', () => { + const g = E.createGame(); + setStones(g, E.BLACK, [[3, 7], [4, 7], [5, 7], [6, 7]]); + g.currentPlayer = E.BLACK; + E.place(g, 7, 7); + const r = E.place(g, 10, 10); + assert.strictEqual(r.ok, false); + assert.strictEqual(r.reason, '游戏已结束'); +}); + +test('棋盘下满判平局', () => { + const g = E.createGame(); + // 用 (x + 2y) mod 4 的双色填充,任意方向连续同色不超过 2,不会误判五连 + for (let y = 0; y < E.SIZE; y++) { + for (let x = 0; x < E.SIZE; x++) { + if (x === 14 && y === 14) continue; + const v = ((x + 2 * y) % 4 + 4) % 4; + g.board[y][x] = v === 0 || v === 1 ? E.BLACK : E.WHITE; + } + } + g.currentPlayer = E.BLACK; + const r = E.place(g, 14, 14); + assert.strictEqual(r.draw, true); + assert.strictEqual(g.gameOver, true); + assert.strictEqual(g.winner, 0); +}); + +test('落子历史被完整记录', () => { + const g = E.createGame(); + E.place(g, 7, 7); + E.place(g, 8, 8); + E.place(g, 7, 8); + assert.strictEqual(g.moveHistory.length, 3); + assert.deepStrictEqual(g.moveHistory[0], { x: 7, y: 7, player: E.BLACK }); + assert.deepStrictEqual(g.moveHistory[1], { x: 8, y: 8, player: E.WHITE }); + assert.deepStrictEqual(g.moveHistory[2], { x: 7, y: 8, player: E.BLACK }); +});