From d5dc14f3a93c5b9032ae9e4542394e2b6038e08d Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Sat, 3 Jun 2017 15:15:18 -0400 Subject: [PATCH 01/43] Rework test.html into appropriate test/ files This also fixes a bug with the return value of `playAt` for a move which is illegal by ko. --- Makefile | 2 +- README.md | 4 +- package.json | 2 +- src/ruleset.js | 2 +- test.html | 1516 ------------------------------ test/client-test.js | 16 +- test/game-handicap-test.js | 409 ++++++++ test/game-ko-test.js | 172 ++++ test/game-scoring-test.js | 261 +++++ test/game-seki-detection-test.js | 639 +++++++++++++ test/game-test.js | 254 ++--- test/helpers.js | 14 + test/svg-renderer-test.js | 142 +++ 13 files changed, 1783 insertions(+), 1650 deletions(-) delete mode 100644 test.html create mode 100644 test/game-handicap-test.js create mode 100644 test/game-ko-test.js create mode 100644 test/game-scoring-test.js create mode 100644 test/game-seki-detection-test.js create mode 100644 test/helpers.js create mode 100644 test/svg-renderer-test.js diff --git a/Makefile b/Makefile index 0160182..69ca140 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ clean: rm -rf build tmp lib test: all - npm test && phantomjs phantomjs-test.js && eslint src + npm test && eslint src build/$(name).js: build $(compiled_js) cat copyright_header.txt \ diff --git a/README.md b/README.md index 68a5ac6..682659b 100644 --- a/README.md +++ b/README.md @@ -291,9 +291,7 @@ The game is set to run on a fixed 9x9 board. # Running tests -For end-to-end tests on a real board, open [`test.html`](https://aprescott.github.io/tenuki.js/test.html) in your browser. If the tests all pass, you'll see "PASS" shown with black stones. - -For other tests, use `npm test`. +Run tests with `npm test`. # Developing diff --git a/package.json b/package.json index 0683acd..a789c75 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Tenuki is a web-based board and JavaScript library for the game of go/baduk/weiqi.", "main": "index.js", "scripts": { - "test": "mocha --require source-map-support/register" + "test": "mocha --require source-map-support/register --require test/helpers.js" }, "repository": { "type": "git", diff --git a/src/ruleset.js b/src/ruleset.js index b2d729c..d6ffe0b 100644 --- a/src/ruleset.js +++ b/src/ruleset.js @@ -31,7 +31,7 @@ Ruleset.prototype = { if (this.koRule === "simple") { const koPoint = boardState.koPoint; - isKoViolation = koPoint && koPoint.y === y && koPoint.x === x; + isKoViolation = Boolean(koPoint) && koPoint.y === y && koPoint.x === x; } else { const newState = boardState.playAt(y, x, color); const boardStates = existingStates; diff --git a/test.html b/test.html deleted file mode 100644 index 6126d62..0000000 --- a/test.html +++ /dev/null @@ -1,1516 +0,0 @@ - - - - - - -
- - - - diff --git a/test/client-test.js b/test/client-test.js index ae870a3..db5f57e 100644 --- a/test/client-test.js +++ b/test/client-test.js @@ -1,3 +1,4 @@ +var helpers = require("./helpers.js"); var expect = require("chai").expect; var tenuki = require("../index.js"); var Client = tenuki.Client; @@ -9,19 +10,8 @@ var nullClientHooks = { }; describe("Client", function() { - var JSDOM = require("jsdom").JSDOM; - var window = new JSDOM('
').window; - var document = window.document; - - global["document"] = document; - global["window"] = window; - // we don't really need this for these tests. - window.requestAnimationFrame = function() {}; - global["navigator"] = { userAgent: "node.js" }; - global["HTMLElement"] = global["window"].HTMLElement; - - afterEach(function() { - document.querySelector("#test-board").innerHTML = ""; + beforeEach(function() { + helpers.generateNewTestBoard() }); describe("setup", function() { diff --git a/test/game-handicap-test.js b/test/game-handicap-test.js new file mode 100644 index 0000000..80e897d --- /dev/null +++ b/test/game-handicap-test.js @@ -0,0 +1,409 @@ +var expect = require("chai").expect; +var tenuki = require("../index.js"); +var Game = tenuki.Game; + +describe("handicap stones", function() { + describe("default placement on 19x19", function() { + it("is at the correct hoshi points", function() { + var game = new Game(); + game.setup(); + + expect(game.isBlackPlaying()).to.be.true; + + var o = { + 2: [ + [3, 15], + [15, 3] + ], + 3: [ + [3, 15], + [15, 3], + [15, 15] + ], + 4: [ + [3, 3], + [3, 15], + [15, 3], + [15, 15] + ], + 5: [ + [3, 3], + [3, 15], + [9, 9], + [15, 3], + [15, 15] + ], + 6: [ + [3, 3], + [9, 3], + [15, 3], + [3, 15], + [9, 15], + [15, 15] + ], + 7: [ + [3, 3], + [3, 15], + [9, 3], + [9, 9], + [9, 15], + [15, 3], + [15, 15] + ], + 8: [ + [3, 3], + [3, 9], + [3, 15], + [9, 3], + [9, 15], + [15, 3], + [15, 9], + [15, 15] + ], + 9: [ + [3, 3], + [3, 9], + [3, 15], + [9, 3], + [9, 9], + [9, 15], + [15, 3], + [15, 9], + [15, 15] + ] + }; + + Object.keys(o).forEach(k => { + var game = new Game(); + var handicapStoneCount = Number(k); + game.setup({ handicapStones: handicapStoneCount }); + + var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); + expect(nonEmptyPoints.length).to.equal(o[k].length); + o[k].forEach(([y, x]) => { + var realValue = game.intersectionAt(y, x).value; + expect(realValue, "expected " + y + "-" + x + " to be black, but it's " + realValue).to.equal("black"); + }); + }); + }); + }); + + describe("default placement on 13x13", function() { + it("is at the correct hoshi points", function() { + var game = new Game(); + game.setup({ boardSize: 13 }); + + expect(game.isBlackPlaying()).to.be.true; + + var o = { + 2: [ + [3, 9], + [9, 3] + ], + 3: [ + [3, 9], + [9, 3], + [9, 9] + ], + 4: [ + [3, 3], + [3, 9], + [9, 3], + [9, 9] + ], + 5: [ + [3, 3], + [3, 9], + [6, 6], + [9, 3], + [9, 9] + ], + 6: [ + [3, 3], + [6, 3], + [9, 3], + [3, 9], + [6, 9], + [9, 9] + ], + 7: [ + [3, 3], + [3, 9], + [6, 3], + [6, 6], + [6, 9], + [9, 3], + [9, 9] + ], + 8: [ + [3, 3], + [3, 6], + [3, 9], + [6, 3], + [6, 9], + [9, 3], + [9, 6], + [9, 9] + ], + 9: [ + [3, 3], + [3, 6], + [3, 9], + [6, 3], + [6, 6], + [6, 9], + [9, 3], + [9, 6], + [9, 9] + ] + }; + + Object.keys(o).forEach(k => { + var game = new Game(); + var handicapStoneCount = Number(k); + game.setup({ boardSize: 13, handicapStones: handicapStoneCount }); + + var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); + + expect(nonEmptyPoints.length).to.equal(o[k].length); + o[k].forEach(([y, x]) => { + var realValue = game.intersectionAt(y, x).value; + expect(realValue, "expected " + y + "-" + x + " to be black, but it's " + realValue).to.equal("black"); + }); + }); + }); + }); + + describe("default placement on 9x9", function() { + it("is at the correct hoshi points", function() { + var game = new Game(); + game.setup({ boardSize: 9 }); + + expect(game.isBlackPlaying()).to.be.true; + + var o = { + 2: [ + [2, 6], + [6, 2] + ], + 3: [ + [2, 6], + [6, 2], + [6, 6] + ], + 4: [ + [2, 2], + [2, 6], + [6, 2], + [6, 6] + ], + 5: [ + [2, 2], + [2, 6], + [4, 4], + [6, 2], + [6, 6] + ], + 6: [ + [2, 2], + [4, 2], + [6, 2], + [2, 6], + [4, 6], + [6, 6] + ], + 7: [ + [2, 2], + [2, 6], + [4, 2], + [4, 4], + [4, 6], + [6, 2], + [6, 6] + ], + 8: [ + [2, 2], + [2, 4], + [2, 6], + [4, 2], + [4, 6], + [6, 2], + [6, 4], + [6, 6] + ], + 9: [ + [2, 2], + [2, 4], + [2, 6], + [4, 2], + [4, 4], + [4, 6], + [6, 2], + [6, 4], + [6, 6] + ] + }; + + Object.keys(o).forEach(k => { + var game = new Game(); + var handicapStoneCount = Number(k); + game.setup({ boardSize: 9, handicapStones: handicapStoneCount }); + + var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); + + expect(nonEmptyPoints.length).to.equal(o[k].length); + o[k].forEach(([y, x]) => { + var realValue = game.intersectionAt(y, x).value; + expect(realValue, "expected " + y + "-" + x + " to be black, but it's " + realValue).to.equal("black"); + }); + }); + }); + }); + + describe("handicap scoring", function() { + it("counts each handicap stone in area scoring but not territory", function() { + [ + { + scoring: "territory", + handicap: 5, + initialScore: { white: 0, black: 361 - 5 }, + expectedScore: { white: 0, black: 0 } + }, + { + scoring: "area", + handicap: 5, + initialScore: { white: 0, black: 361 }, + expectedScore: { white: 2, black: 1+5 } }, + { + scoring: "equivalence", + handicap: 5, + initialScore: { white: 1, black: 361+2 }, + expectedScore: { white: 2+2, black: 1+5+2 } + } + ].forEach(function({ handicap, scoring, initialScore, expectedScore }) { + var game = new Game(); + game.setup({ handicapStones: handicap, scoring: scoring }); + + game.pass(); // w + game.pass(); // b + + if (scoring === "equivalence") { + game.pass(); // w + } + + expect(game.score().white).to.equal(initialScore["white"]); + expect(game.score().black).to.equal(initialScore["black"]); + + game.undo(); + game.undo(); + + if (scoring === "equivalence") { + game.undo(); + } + + game.playAt(1, 1); // w + game.pass(); // b + game.playAt(1, 2); // w + game.playAt(1, 3); // b + + game.pass(); // w + game.pass(); // b + + if (scoring === "equivalence") { + game.pass(); // w + } + + expect(game.score().white).to.equal(expectedScore["white"]); + expect(game.score().black).to.equal(expectedScore["black"]); + }); + }) + }); + + describe("free handicap placement", function() { + it("is off by default", function() { + var game = new Game(); + game.setup({ handicapStones: 2 }); + + game.playAt(18, 18); + + var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); + expect(nonEmptyPoints.length).to.equal(3); + + expect(nonEmptyPoints[0].value).to.equal("black"); + expect(nonEmptyPoints[0].y).to.equal(3); + expect(nonEmptyPoints[0].x).to.equal(15); + + expect(nonEmptyPoints[1].value).to.equal("black"); + expect(nonEmptyPoints[1].y).to.equal(15); + expect(nonEmptyPoints[1].x).to.equal(3); + + expect(nonEmptyPoints[2].value).to.equal("white"); + expect(nonEmptyPoints[2].y).to.equal(18); + expect(nonEmptyPoints[2].x).to.equal(18); + }); + + it("allows black to place stones for the first n moves", function() { + var game = new Game(); + game.setup({ handicapStones: 2, freeHandicapPlacement: true }); + + var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); + expect(nonEmptyPoints.length).to.equal(0); + + expect(game.currentPlayer()).to.equal("black"); + game.playAt(5, 5); + expect(game.currentPlayer()).to.equal("black"); + game.playAt(6, 6); + expect(game.currentPlayer()).to.equal("white"); + game.playAt(7, 7); + + nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); + expect(nonEmptyPoints.length).to.equal(3); + + expect(nonEmptyPoints[0].value).to.equal("black"); + expect(nonEmptyPoints[0].y).to.equal(5); + expect(nonEmptyPoints[0].x).to.equal(5); + + expect(nonEmptyPoints[1].value).to.equal("black"); + expect(nonEmptyPoints[1].y).to.equal(6); + expect(nonEmptyPoints[1].x).to.equal(6); + + expect(nonEmptyPoints[2].value).to.equal("white"); + expect(nonEmptyPoints[2].y).to.equal(7); + expect(nonEmptyPoints[2].x).to.equal(7); + }); + + it("is undoable", function() { + var game = new Game(); + game.setup({ handicapStones: 2, freeHandicapPlacement: true }); + + expect(game.currentPlayer()).to.equal("black"); + game.playAt(5, 5); + expect(game.currentPlayer()).to.equal("black"); + game.playAt(6, 6); + expect(game.currentPlayer()).to.equal("white"); + game.undo(); + expect(game.currentPlayer()).to.equal("black"); + game.undo(); + expect(game.currentPlayer()).to.equal("black"); + + var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); + expect(nonEmptyPoints.length).to.equal(0); + + game.playAt(5, 5); + expect(game.currentPlayer()).to.equal("black"); + game.playAt(6, 6); + expect(game.currentPlayer()).to.equal("white"); + + nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); + expect(nonEmptyPoints.length).to.equal(2); + + expect(nonEmptyPoints[0].value).to.equal("black"); + expect(nonEmptyPoints[0].y).to.equal(5); + expect(nonEmptyPoints[0].x).to.equal(5); + + expect(nonEmptyPoints[1].value).to.equal("black"); + expect(nonEmptyPoints[1].y).to.equal(6); + expect(nonEmptyPoints[1].x).to.equal(6); + }); + }); +}); diff --git a/test/game-ko-test.js b/test/game-ko-test.js new file mode 100644 index 0000000..459e655 --- /dev/null +++ b/test/game-ko-test.js @@ -0,0 +1,172 @@ +var expect = require("chai").expect; +var tenuki = require("../index.js"); +var Game = tenuki.Game; + +describe("ko restriction", function() { + it("prevents simple ko by default", function() { + var game = new Game(); + game.setup(); + + expect(game.playAt(3, 3)).to.be.true; // b + expect(game.playAt(3, 2)).to.be.true; // w + expect(game.playAt(4, 2)).to.be.true; // b + expect(game.playAt(4, 1)).to.be.true; // w + expect(game.playAt(5, 3)).to.be.true; // b + expect(game.playAt(5, 2)).to.be.true; // w + expect(game.playAt(4, 4)).to.be.true; // b + expect(game.playAt(4, 3)).to.be.true; // w + + expect(game.intersectionAt(4, 2).isEmpty()).to.be.true; + expect(game.playAt(4, 2)).to.be.false; + expect(game.isIllegalAt(4, 2)).to.be.true; + + expect(game.playAt(10, 10)).to.be.true; // b + + expect(game.isIllegalAt(4, 2)).to.be.false; // w + expect(game.playAt(4, 2)).to.be.true; + }); + + it("is removed by a pass", function() { + var game = new Game(); + game.setup(); + + expect(game.playAt(3, 3)).to.be.true; // b + expect(game.playAt(3, 2)).to.be.true; // w + expect(game.playAt(4, 2)).to.be.true; // b + expect(game.playAt(4, 1)).to.be.true; // w + expect(game.playAt(5, 3)).to.be.true; // b + expect(game.playAt(5, 2)).to.be.true; // w + expect(game.playAt(4, 4)).to.be.true; // b + expect(game.playAt(4, 3)).to.be.true; // w + + expect(game.intersectionAt(4, 2).isEmpty()).to.be.true; + expect(game.playAt(4, 2)).to.be.false; + expect(game.isIllegalAt(4, 2)).to.be.true; + + expect(game.pass()).to.be.true; // b + + expect(game.isIllegalAt(4, 2)).to.be.false; // w + expect(game.playAt(4, 2)).to.be.true; + }); + + it("is undone by an undo", function() { + var game = new Game(); + game.setup(); + + expect(game.playAt(0, 0)).to.be.true; // b + expect(game.playAt(0, 1)).to.be.true; // w + expect(game.playAt(1, 1)).to.be.true; // b + expect(game.playAt(1, 0)).to.be.true; // w + expect(game.playAt(0, 2)).to.be.true; // b + expect(game.playAt(1, 2)).to.be.true; // w + expect(game.playAt(0, 0)).to.be.true; // b + + expect(game.intersectionAt(0, 1).isEmpty()).to.be.true; + expect(game.isIllegalAt(0, 1)).to.be.true; + + game.undo(); + + expect(game.intersectionAt(0, 1).isEmpty()).to.be.false; + expect(game.intersectionAt(0, 1).isWhite()).to.be.true; + }); + + describe("with positional superko rules", function() { + it("prevents repeating a previous position", function() { + var game = new Game(); + game.setup({ koRule: "superko" }); + + // cycle for ● repeatedly losing 2 stones + // ┌─●─┬─○─● + // ●─○─○─○─● + // ●─┼─○─●─● + // ○─○─○─●─┼ + // ●─●─●─●─┼ + game.playAt(0, 3); // b + game.playAt(0, 4); // w + game.playAt(1, 3); // b + game.playAt(1, 4); // w + game.playAt(1, 2); // b + game.playAt(2, 4); // w + game.playAt(1, 1); // b + game.playAt(2, 3); // w + game.playAt(2, 2); // b + game.playAt(3, 3); // w + game.playAt(3, 2); // b + game.playAt(4, 3); // w + game.playAt(3, 1); // b + game.playAt(4, 2); // w + game.playAt(3, 0); // b + game.playAt(4, 1); // w + game.playAt(0, 8); // b tenuki + game.playAt(4, 0); // w + game.playAt(1, 8); // b tenuki + game.playAt(0, 1); // w + game.playAt(2, 8); // b tenuki + game.playAt(1, 0); // w + game.playAt(3, 8); // b tenuki + game.playAt(2, 0); // w + game.playAt(4, 8); // b tenuki -- (*) + game.playAt(0, 2); // w + + expect(game.playAt(0, 0)).to.be.true; // b + + expect(game.playAt(0, 1)).to.be.false; // w -- this move is not allowed with superko since it repeats (*) + expect(game.isIllegalAt(0, 1)).to.be.true; + expect(game.intersectionAt(0, 1).value).to.equal("empty"); + expect(game.currentPlayer()).to.equal("white"); + expect(game.currentState().playedPoint.y).to.equal(0); + expect(game.currentState().playedPoint.x).to.equal(0); + }); + + it("prevents repetition with a triple ko", function() { + var game = new Game(); + game.setup({ koRule: "superko" }); + + var ponnukiOffsets = [ + [-1, 1], + [0, 0], + [1, 1], + [0, 2] + ]; + + // ko points + [ + [4, 4], // ko A + [4, 9], // ko B + [7, 6] // ko C (*) + ].forEach(([koY, koX]) => { + ponnukiOffsets.forEach(([yOffset, xOffset]) => { + // b + expect(game.playAt(koY + yOffset, koX + xOffset)).to.be.true; + // w (capturing the ko for the last offset) + expect(game.playAt(koY + yOffset, koX + xOffset - 1)).to.be.true; + }); + }); + + // black recaptures ko A + expect(game.playAt(4, 4)).to.be.true; // b + // white tenuki + expect(game.playAt(2, 7)).to.be.true; // w + + // 3 kos, clockwise: (A) black, (B) white, (C) white + + expect(game.playAt(4, 9)).to.be.true; // b: ko B -> black + expect(game.playAt(4, 5)).to.be.true; // w: ko A -> white + expect(game.playAt(7, 6)).to.be.true; // b: ko C -> black + expect(game.playAt(4, 10)).to.be.true; // w: ko B -> white + expect(game.playAt(4, 4)).to.be.true; // b: ko A -> black + + // kos are now: (A) black, (B) white, (C) black + + // not allowed, since it would change ko C to white, leading to a repetition of (*) + expect(game.playAt(7, 7)).to.be.false; + expect(game.isIllegalAt(7, 7)).to.be.true; + expect(game.intersectionAt(7, 7).value).to.equal("empty"); + expect(game.intersectionAt(7, 6).value).to.equal("black"); + expect(game.currentPlayer()).to.equal("white"); + expect(game.currentState().playedPoint.y).to.equal(4); + expect(game.currentState().playedPoint.x).to.equal(4); + }); + }); +}); + diff --git a/test/game-scoring-test.js b/test/game-scoring-test.js new file mode 100644 index 0000000..14d3325 --- /dev/null +++ b/test/game-scoring-test.js @@ -0,0 +1,261 @@ +var expect = require("chai").expect; +var tenuki = require("../index.js"); +var Game = tenuki.Game; + +var run = function(scoring) { + var game = new Game(); + game.setup({ scoring: scoring }); + + // divide the board down the middle, black on the right + game.playAt(0, 9); // b + game.playAt(0, 8); // w + game.playAt(1, 9); // b + game.playAt(1, 8); // w + game.playAt(2, 9); // b + game.playAt(2, 8); // w + game.playAt(3, 9); // b + game.playAt(3, 8); // w + game.playAt(4, 9); // b + game.playAt(4, 8); // w + game.playAt(5, 9); // b + game.playAt(5, 8); // w + game.playAt(6, 9); // b + game.playAt(6, 8); // w + game.playAt(7, 9); // b + game.playAt(7, 8); // w + game.playAt(8, 9); // b + game.playAt(8, 8); // w + game.playAt(9, 9); // b + game.playAt(9, 8); // w + game.playAt(10, 9); // b + game.playAt(10, 8); // w + game.playAt(11, 9); // b + game.playAt(11, 8); // w + game.playAt(12, 9); // b + game.playAt(12, 8); // w + game.playAt(13, 9); // b + game.playAt(13, 8); // w + game.playAt(14, 9); // b + game.playAt(14, 8); // w + game.playAt(15, 9); // b + game.playAt(15, 8); // w + game.playAt(16, 9); // b + game.playAt(16, 8); // w + game.playAt(17, 9); // b + game.playAt(17, 8); // w + game.playAt(18, 9); // b + game.playAt(18, 8); // w + game.pass(); // b + game.pass(); // w + + expect(game.isOver()).to.be.true; + + return game; +}; + +var setupCaptures = function(game) { + expect(game.currentState().blackStonesCaptured).to.equal(0); + expect(game.currentState().whiteStonesCaptured).to.equal(0); + + // play in the corner so white captures a stone and leaves 2 black stones dead + game.playAt(0, 0); // b + game.playAt(0, 1); // w + game.playAt(1, 1); // b + + expect(game.currentState().blackStonesCaptured).to.equal(0); + expect(game.currentState().whiteStonesCaptured).to.equal(0); + + game.playAt(1, 0); // w + + expect(game.currentState().blackStonesCaptured).to.equal(1); + expect(game.currentState().whiteStonesCaptured).to.equal(0); + + game.playAt(0, 2); // b + + game.pass(); // w + game.pass(); // b +}; + +describe("scoring rules", function() { + describe("area scoring", function() { + it("counts area as territory plus stones played, plus captures, with eyes in seki counted", function() { + var game = run("area"); + + expect(game.score().black).to.equal((9 + 1)*19); + expect(game.score().white).to.equal(9*19); + + game.undo(); + game.undo(); + + setupCaptures(game); + + expect(game.isOver()).to.be.true; + + // territory + stones on the board + expect(game.score().black).to.equal(10*19 + 2); + // 1 point of territory + 2 stones played inside + expect(game.score().white).to.equal(1 + 1*19 + 2); + + // mark dead stones + game.toggleDeadAt(1, 1); + game.toggleDeadAt(0, 2); + + // 2 dead stones are now ignored because they're marked dead + expect(game.score().black).to.equal(10*19); + // rectangle territory, 8*19 + // plus 1*19 for the stones on the board + // 2 white stones played inside the territory, IGNORED + // 1 captured black stone, IGNORED + // 2 dead stones, IGNORED + expect(game.score().white).to.equal(9*19); + }); + }); + + describe("equivalence scoring", function() { + it("counts as area, plus pass stones per player", function() { + var game = run("equivalence"); + + expect(game.score().black).to.equal((9 + 1)*19 + 1); + expect(game.score().white).to.equal(9*19 + 1); + + game.undo(); + game.undo(); + + setupCaptures(game); + + // pass once more for equivalence scoring + expect(game.pass()).to.be.true; + + expect(game.isOver()).to.be.true; + + // territory + stones on the board + 2 pass stones + expect(game.score().black).to.equal(10*19 + 2 + 2); + // 1 point of territory + 2 stones played inside + 1 pass stone + expect(game.score().white).to.equal(1 + 1*19 + 2 + 1); + + // mark dead stones + game.toggleDeadAt(1, 1); + game.toggleDeadAt(0, 2); + + // 2 dead stones are now ignored because they're marked dead + // plus 2 pass stones + expect(game.score().black).to.equal(10*19 + 2); + // rectangle territory, 8*19 + // plus 1*19 for the stones on the board + // 2 white stones played inside the territory, IGNORED + // 1 captured black stone, IGNORED + // 2 dead stones, IGNORED + // 1 pass stone + expect(game.score().white).to.equal(9*19 + 1); + }); + + // TODO: this requirement is incorrect in its implementation! + it("requires one final pass by white", function() { + var game = new Game(); + game.setup({ scoring: "equivalence" }); + + game.pass(); // b + game.pass(); // w + + expect(game.isOver()).to.be.true; + + game = new Game(); + game.setup({ scoring: "equivalence" }); + + game.pass(); // b + game.pass(); // w + game.playAt(1, 1); // b + game.pass(); // w + game.pass(); // b + + expect(game.isOver()).to.be.false; + + game.pass(); + + expect(game.isOver()).to.be.true; + }); + }); + + describe("territory scoring", function() { + it("counts only territory, excluding stones played, plus captures, ", function() { + var game = run("territory"); + + expect(game.score().black).to.equal(9*19); + expect(game.score().white).to.equal(8*19); + + game.undo(); + game.undo(); + + setupCaptures(game); + + expect(game.isOver()).to.be.true; + + // territory + captured stones, but with ambiguous territory remaining + expect(game.score().black).to.equal(9*19); + // 1 captured stone + // note the top-left corner is ignored as part of neutral point filling + // for seki calculations + expect(game.score().white).to.equal(1); + + // mark dead stones + game.toggleDeadAt(1, 1); + game.toggleDeadAt(0, 2); + + expect(game.score().black).to.equal(9*19); + // rectangle territory, 8*19 + // 2 white stones played inside the territory, -2 + // 1 captured black stone, +1, + // 2 dead stones, +2 + expect(game.score().white).to.equal(8*19 - 2 + 1 + 2); + }); + }); + + describe("toggling multi-stone groups as dead", function() { + it("treats the whole group as dead when a single stone is marked", function() { + var game = new Game(); + game.setup(); + + game.playAt(0, 9); // b + game.playAt(0, 8); // w + game.playAt(1, 9); // b + game.pass(); + game.pass(); + + // mark the 2-stone black group as dead + expect(game.deadStones().length).to.equal(0); + + game.toggleDeadAt(0, 9); + + expect(game.deadStones().length).to.equal(2); + expect(game.deadStones()[0].y).to.equal(0); + expect(game.deadStones()[0].x).to.equal(9); + expect(game.deadStones()[1].y).to.equal(1); + expect(game.deadStones()[1].x).to.equal(9); + + expect(game.score().black).to.equal(0); + expect(game.score().white).to.equal(19*19 - 1 + 2); + + // unmark it dead + game.toggleDeadAt(0, 9); + + expect(game.deadStones().length).to.equal(0); + expect(game.score().black).to.equal(0); + expect(game.score().white).to.equal(0); + }); + }); + + describe("territory marking where two stones are alive on the board with nothing else", function() { + it("leads to no territory being marked", function() { + var game = new Game(); + game.setup(); + + game.playAt(0, 9); // b + game.playAt(0, 10); // w + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(0); + expect(game.score().white).to.equal(0); + }); + }); +}); diff --git a/test/game-seki-detection-test.js b/test/game-seki-detection-test.js new file mode 100644 index 0000000..7947ca5 --- /dev/null +++ b/test/game-seki-detection-test.js @@ -0,0 +1,639 @@ +var expect = require("chai").expect; +var tenuki = require("../index.js"); +var Game = tenuki.Game; + +describe("seki detection", function() { + it("does not count a false eye in seki as territory", function() { + var game = new Game(); + game.setup(); + + // ┌─○─┬─●─○─┬─ + // ●─○─●─●─○─┼─ + // ├─●─●─○─┼─┼─ + // ●─●─○─┼─┼─┼─ + // ○─○─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─ + game.playAt(0, 1); // b ○ + game.playAt(1, 0); // w ● + game.playAt(1, 1); // b + game.playAt(2, 1); // w + game.playAt(0, 4); // b + game.playAt(0, 3); // w + game.playAt(1, 4); // b + game.playAt(1, 3); // w + game.playAt(2, 3); // b + game.playAt(1, 2); // w + game.playAt(3, 2); // b + game.playAt(2, 2); // w + game.playAt(4, 1); // b + game.playAt(3, 1); // w + game.playAt(4, 0); // b + game.playAt(3, 0); // w + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(342); + expect(game.score().white).to.equal(0); + }); + + it("does not count two false eyes in a seki as territory", function() { + var game = new Game(); + game.setup(); + + // ┌─○─┬─●─○─┬─┬─┬─ + // ●─○─●─●─○─┼─┼─┼─ + // ├─●─┼─●─○─┼─┼─┼─ + // ●─●─●─○─┼─○─┼─┼─ + // ○─○─○─○─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─ + game.playAt(0, 1); // b + game.playAt(1, 0); // w + game.playAt(1, 1); // b + game.playAt(2, 1); // w + game.playAt(0, 4); // b + game.playAt(1, 2); // w + game.playAt(1, 4); // b + game.playAt(1, 3); // w + game.playAt(2, 4); // b + game.playAt(0, 3); // w + game.playAt(3, 3); // b + game.playAt(2, 3); // w + game.playAt(3, 5); // b + game.playAt(3, 2); // w + game.playAt(4, 3); // b + game.playAt(3, 1); // w + game.playAt(4, 2); // b + game.playAt(3, 0); // w + + game.playAt(4, 1); // b + game.pass(); // w + game.playAt(4, 0); // b + + game.pass(); + game.pass(); + + expect(game.score().white).to.equal(0) + }); + + it("does not count seki with two 1-eyed groups", function() { + var game = new Game(); + game.setup({ boardSize: 9 }); + + // ┌─○─┬─●─┬─●─○─┬─○ + // ○─○─○─●─●─●─○─○─┤ + // ●─●─●─○─○─○─○─┼─○ + // ●─┼─●─○─┼─┼─┼─┼─┤ + // ├─●─●─○─┼─┼─┼─┼─┤ + // ●─●─○─○─┼─┼─┼─┼─┤ + // ○─○─○─┼─┼─┼─┼─┼─┤ + // ├─┼─┼─┼─┼─┼─┼─┼─┤ + // └─┴─┴─┴─┴─┴─┴─┴─┘ + game.playAt(1, 0); + game.playAt(2, 0); + game.playAt(1, 1); + game.playAt(2, 1); + game.playAt(0, 1); + game.playAt(2, 2); + game.playAt(1, 2); + game.playAt(1, 3); + game.playAt(2, 3); + game.playAt(0, 3); + game.playAt(2, 4); + game.playAt(1, 4); + game.playAt(2, 5); + game.playAt(1, 5); + game.playAt(2, 6); + game.playAt(0, 5); + game.playAt(1, 6); + game.pass(); + game.playAt(0, 6); + game.playAt(3, 2); + game.playAt(1, 7); + game.playAt(4, 2); + game.playAt(0, 8); + game.playAt(4, 1); + game.playAt(2, 8); + game.playAt(5, 1); + game.playAt(3, 3); + game.playAt(5, 0); + game.playAt(4, 3); + game.playAt(3, 0); + game.playAt(5, 3); + game.pass(); + game.playAt(5, 2); + game.pass(); + game.playAt(6, 2); + game.pass(); + game.playAt(6, 1); + game.pass(); + game.playAt(6, 0); + + game.pass(); + game.pass(); + + expect(game.score().white).to.equal(2) + expect(game.score().black).to.equal(42) + }); + + it("merges territories across 'thick' boundaries and skips what would otherwise be seki", function() { + var game = new Game(); + game.setup(); + + // ┌─○─○─┬─○─●─┬─┬─ + // ○─○─○─○─○─●─┼─┼─ + // ●─●─●─●─●─●─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─ + game.playAt(0, 1); + game.playAt(2, 1); + game.playAt(1, 1); + game.playAt(2, 0); + game.playAt(1, 0); + game.playAt(2, 2); + game.playAt(1, 2); + game.playAt(2, 3); + game.playAt(0, 2); + game.playAt(2, 4); + game.playAt(1, 3); + game.playAt(2, 5); + game.playAt(1, 4); + game.playAt(1, 5); + game.playAt(0, 4); + game.playAt(0, 5); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(2) + expect(game.score().white).to.equal(343) + }); + + it("does _not_ ignore false-looking eyes in groups that are alive after filling those false-looking eyes", function() { + var game = new Game(); + game.setup(); + + // ┌─○─┬─○─┬─○─┬─○─●─┬─┬─ + // ○─○─○─●─○─○─○─○─●─┼─┼─ + // ●─●─●─●─●─●─●─●─●─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + game.playAt(0, 1); + game.playAt(2, 1); + game.playAt(1, 1); + game.playAt(2, 2); + game.playAt(1, 2); + game.playAt(1, 3); + game.playAt(0, 3); + game.playAt(2, 3); + game.playAt(1, 0); + game.playAt(2, 0); + game.playAt(1, 4); + game.playAt(2, 4); + game.playAt(1, 5); + game.playAt(2, 5); + game.playAt(0, 5); + game.playAt(2, 6); + game.playAt(1, 6); + game.playAt(2, 7); + game.playAt(1, 7); + game.playAt(2, 8); + game.playAt(0, 7); + game.playAt(1, 8); + game.pass() + game.playAt(0, 8); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(4); + }); + + it("does not ignore false eyes that should be filled in under correct play if the sequence is not sufficiently played out", function() { + var game = new Game(); + game.setup(); + + // ┌─○─┬─○─┬─┬─○─●─┬─┬─ + // ○─○─○─●─○─○─○─●─┼─┼─ + // ●─●─●─●─●─●─●─●─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + game.playAt(1, 0); + game.playAt(2, 0); + game.playAt(1, 1); + game.playAt(2, 1); + game.playAt(0, 1); + game.playAt(2, 2); + game.playAt(1, 2); + game.playAt(1, 3); + game.playAt(0, 3); + game.playAt(2, 4); + game.playAt(1, 4); + game.playAt(2, 3); + game.playAt(1, 5); + game.playAt(2, 5); + game.playAt(1, 6); + game.playAt(2, 6); + game.playAt(0, 6); + game.playAt(1, 7); + game.pass(); + game.playAt(0, 7); + game.pass(); + game.playAt(2, 7); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(4); + }); + + it("ignores false eyes which will disappear due to direct atari, after filling neutral points, which can affect the status of live groups", function() { + var game = new Game(); + game.setup(); + + // ┌─○─○─┬─┬─○─┬─┬─○─┬─┬─○─┬─●─┬─ + // ●─●─●─○─○─●─○─○─●─○─○─●─○─●─┼─ + // ├─┼─┼─●─●─●─●─●─●─●─●─●─●─●─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + game.playAt(0, 1); + game.playAt(1, 0); + game.playAt(0, 2); + game.playAt(1, 1); + game.playAt(1, 3); + game.playAt(1, 2); + game.playAt(1, 4); + game.playAt(2, 3); + game.playAt(0, 5); + game.playAt(2, 4); + game.playAt(1, 6); + game.playAt(2, 5); + game.playAt(1, 7); + game.playAt(1, 5); + game.playAt(0, 8); + game.playAt(2, 6); + game.playAt(1, 9); + game.playAt(2, 7); + game.playAt(1, 10); + game.playAt(2, 8); + game.playAt(0, 11); + game.playAt(1, 8); + game.playAt(1, 12); + game.playAt(2, 9); + game.pass(); + game.playAt(2, 10); + game.pass(); + game.playAt(2, 11); + game.pass(); + game.playAt(1, 11); + game.pass(); + game.playAt(2, 12); + game.pass(); + game.playAt(2, 13); + game.pass(); + game.playAt(1, 13); + game.pass(); + game.playAt(0, 13); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(0) + }); + + it("ignores false eyes that are the last remaining points after filling dame", function() { + var game = new Game(); + game.setup(); + + // ┌─○─○─┬─┬─○─┬─○─┬─○─●─┬─┬─┬─ + // ●─●─●─○─○─○─○─○─○─○─●─┼─┼─┼─ + // ├─┼─┼─●─●─●─●─●─●─●─●─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + game.playAt(0, 1); + game.playAt(1, 0); + game.playAt(0, 2); + game.playAt(1, 1); + game.playAt(1, 3); + game.playAt(1, 2); + game.playAt(1, 4); + game.playAt(2, 3); + game.playAt(1, 5); + game.playAt(2, 4); + game.playAt(1, 6); + game.playAt(2, 5); + game.playAt(1, 7); + game.playAt(2, 6); + game.playAt(1, 8); + game.playAt(2, 7); + game.playAt(1, 9); + game.playAt(2, 8); + game.playAt(0, 9); + game.playAt(2, 9); + game.playAt(0, 7); + game.playAt(2, 10); + game.playAt(0, 5); + game.playAt(1, 10); + game.pass(); + game.playAt(0, 10); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(3) + }); + + it("ignores false eyes in the corner", function() { + var game = new Game(); + game.setup({ boardSize: 9 }); + + game.playAt(1, 0); + game.playAt(2, 0); + game.playAt(0, 1); + game.playAt(1, 1); + game.playAt(1, 2); + game.playAt(2, 2); + game.playAt(1, 3); + game.playAt(2, 3); + game.playAt(1, 4); + game.playAt(2, 4); + game.playAt(1, 5); + game.playAt(2, 5); + game.playAt(1, 6); + game.playAt(2, 6); + game.playAt(1, 7); + game.playAt(2, 7); + game.playAt(1, 8); + game.playAt(2, 8); + game.playAt(0, 7); + game.playAt(2, 1); + game.playAt(0, 4); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(4) + }); + + it("counts eyepoints as territory for groups separated by bamboo-type connections", function() { + var game = new Game(); + game.setup(); + + var run = function(game) { + // ●─●─●─●─●─●─●─●─●─┬─ + // ●─○─○─○─┼─○─○─○─●─┼─ + // ●─○─┼─○─┼─○─┼─○─●─┼─ + // ●─○─○─○─┼─○─○─○─●─┼─ + // ●─●─●─●─●─●─●─●─●─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─ + game.playAt(1, 1); + game.playAt(0, 1); + game.playAt(2, 1); + game.playAt(0, 0); + game.playAt(1, 2); + game.playAt(1, 0); + game.playAt(1, 3); + game.playAt(2, 0); + game.playAt(3, 1); + game.playAt(3, 0); + game.playAt(3, 2); + game.playAt(4, 0); + game.playAt(3, 3); + game.playAt(4, 1); + game.playAt(2, 3); + game.playAt(4, 2); + game.playAt(1, 5); + game.playAt(4, 3); + game.playAt(2, 5); + game.playAt(4, 4); + game.playAt(3, 5); + game.playAt(4, 5); + game.playAt(3, 6); + game.playAt(4, 6); + game.playAt(3, 7); + game.playAt(4, 7); + game.playAt(2, 7); + game.playAt(4, 8); + game.playAt(1, 7); + game.playAt(3, 8); + game.playAt(1, 6); + game.playAt(2, 8); + game.pass(); + game.playAt(1, 8); + game.pass(); + game.playAt(0, 8); + game.pass(); + game.playAt(0, 7); + game.pass(); + game.playAt(0, 6); + game.pass(); + game.playAt(0, 5); + game.pass(); + game.playAt(0, 4); + game.pass(); + game.playAt(0, 3); + game.pass(); + game.playAt(0, 2); + }; + + run(game); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(2); + + // test the same thing, but now by symmetry against white + // to test the alternating pattern behavior + + game = new Game(); + game.setup(); + + game.pass(); + run(game); + + game.pass(); + game.pass(); + + expect(game.score().white).to.equal(2); + }); + + it("ignores territory for two 1-eyed groups that can't connect out to live groups", function() { + var game = new Game(); + game.setup({ boardSize: 9 }); + + // ●─┬─●─○─┬─┬─○─●─┐ + // ├─●─●─○─┼─┼─○─●─● + // ●─●─┼─○─┼─┼─○─●─┤ + // ●─○─○─○─┼─┼─○─●─┤ + // ●─┼─●─○─┼─┼─○─●─● + // ●─┼─●─○─○─○─○─●─● + // ○─○─○─●─●─●─○─○─● + // ├─○─┼─●─┼─●─┼─┼─● + // ○─○─●─●─●─●─○─○─● + var run = function(game) { + game.playAt(8, 0); + game.playAt(8, 2); + game.playAt(8, 1); + game.playAt(8, 3); + game.playAt(7, 1); + game.playAt(7, 3); + game.playAt(6, 1); + game.playAt(6, 3); + game.playAt(6, 0); + game.playAt(6, 4); + game.playAt(6, 2); + game.playAt(6, 5); + game.playAt(8, 6); + game.playAt(7, 5); + game.playAt(8, 7); + game.playAt(8, 5); + game.playAt(6, 6); + game.playAt(8, 8); + game.playAt(6, 7); + game.playAt(7, 8); + game.playAt(5, 6); + game.playAt(8, 4); + game.playAt(5, 5); + game.playAt(6, 8); + game.playAt(5, 4); + game.playAt(5, 8); + game.playAt(5, 3); + game.playAt(5, 2); + game.playAt(4, 3); + game.playAt(4, 2); + game.playAt(3, 3); + game.playAt(5, 0); + game.playAt(3, 2); + game.playAt(4, 0); + game.playAt(3, 1); + game.playAt(3, 0); + game.pass(); + game.playAt(2, 0); + game.pass(); + game.playAt(4, 8); + game.pass(); + game.playAt(4, 7); + game.pass(); + game.playAt(3, 7); + game.pass(); + game.playAt(2, 7); + game.pass(); + game.playAt(1, 7); + game.pass(); + game.playAt(0, 7); + game.pass(); + game.playAt(1, 8); + game.playAt(4, 6); + game.playAt(2, 1); + game.playAt(3, 6); + game.playAt(1, 1); + game.playAt(2, 6); + game.playAt(0, 0); + game.playAt(1, 6); + game.playAt(0, 2); + game.playAt(0, 6); + game.playAt(1, 2); + game.playAt(2, 3); + game.pass(); + game.playAt(1, 3); + game.pass(); + game.playAt(0, 3); + game.playAt(5, 7); + }; + + run(game); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(10); + expect(game.score().white).to.equal(5); + + // test the same thing, but now by symmetry against white + // to test the alternating pattern behavior + + game = new Game(); + game.setup({ boardSize: 9 }); + + game.pass(); + run(game); + + game.pass(); + game.pass(); + + expect(game.score().white).to.equal(10); + expect(game.score().black).to.equal(5); + }); + + it("ignores eyes in seki, but accounts for dead stone marking correctly", function() { + var game = new Game(); + game.setup(); + + // ┌─○─●─┬─●─○─┬─┬─ + // ○─┼─●─●─●─○─┼─┼─ + // ●─●─●─○─○─○─┼─┼─ + // ○─○─○─○─┼─┼─┼─┼─ + // ├─┼─┼─┼─┼─┼─┼─┼─ + game.playAt(0, 1) // b + game.playAt(0, 2) // w + game.playAt(1, 0) // b + game.playAt(1, 2) // w + game.playAt(0, 5) // b + game.playAt(1, 3) // w + game.playAt(1, 5) // b + game.playAt(1, 4) // w + game.playAt(2, 5) // b + game.playAt(0, 4) // w + game.playAt(2, 4) // b + game.playAt(2, 2) // w + game.playAt(2, 3) // b + game.playAt(2, 1) // w + game.playAt(3, 3) // b + game.playAt(2, 0) // w + game.playAt(3, 2) // b + game.playAt(10, 10) // w tenuki + game.playAt(3, 1) // b + game.playAt(10, 11) // w tenuki + game.playAt(3, 0) // b + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(0); + expect(game.score().white).to.equal(0); + + // mark seki white group dead + game.toggleDeadAt(2, 2); + + expect(game.score().black).to.equal(11 + 8); + expect(game.score().white).to.equal(0); + + // mark the outer white group dead + game.toggleDeadAt(10, 10); + + expect(game.score().white).to.equal(0); + + // 8 dead white stones double-counted as territory + 3 empty points + // + 2 outer dead stones double-counted as territory + all remaining empty points + expect(game.score().black).to.equal(8*2 + 3 + 2*2 + 337) + + // unmark white dead + game.toggleDeadAt(2, 2); + game.toggleDeadAt(10, 10); + + expect(game.score().black).to.equal(0); + expect(game.score().white).to.equal(0); + + // mark both black stones dead + game.toggleDeadAt(0, 1); + game.toggleDeadAt(1, 0); + + expect(game.score().black).to.equal(0); + + // 2 dead black stones double-counted as territory + 3 empty points + expect(game.score().white).to.equal(2*2 + 3) + }); +}); diff --git a/test/game-test.js b/test/game-test.js index ee976fd..e0f215e 100644 --- a/test/game-test.js +++ b/test/game-test.js @@ -49,10 +49,99 @@ describe("Game", function() { game.setup(); game.playAt(5, 5); - expect(game.intersectionAt(5, 5).value).to.eq("black"); + expect(game.intersectionAt(5, 5).value).to.equal("black"); game.playAt(5, 6); - expect(game.intersectionAt(5, 6).value).to.eq("white"); + expect(game.intersectionAt(5, 6).value).to.equal("white"); + }); + }); + + describe("capturing", function() { + it("removes black stones from the board", function() { + var game = new Game(); + game.setup(); + + game.playAt(0, 0); // b + game.playAt(0, 1); // w + game.playAt(1, 1); // b + game.playAt(1, 0); // w, capturing + + expect(game.intersectionAt(0, 0).isEmpty()).to.be.true; + expect(game.currentState().blackStonesCaptured).to.equal(1) + expect(game.currentState().whiteStonesCaptured).to.equal(0) + }); + + it("removes white stones from the board", function() { + var game = new Game(); + game.setup(); + + game.pass(); // b + game.playAt(0, 0); // w + game.playAt(0, 1); // b + game.playAt(1, 1); // w + game.playAt(1, 0); // b, capturing + + expect(game.intersectionAt(0, 0).isEmpty()).to.be.true; + expect(game.currentState().blackStonesCaptured).to.equal(0) + expect(game.currentState().whiteStonesCaptured).to.equal(1) + }); + + it("removes multi-stone groups from the board", function() { + var game = new Game(); + game.setup(); + + game.playAt(0, 0); // b + game.playAt(0, 1); // w + game.playAt(1, 0); // b + game.playAt(1, 1); // w + game.playAt(5, 5); // b tenuki + game.playAt(2, 0); // w, capturing + + expect(game.intersectionAt(0, 0).isEmpty()).to.be.true; + expect(game.intersectionAt(1, 0).isEmpty()).to.be.true; + + expect(game.currentState().blackStonesCaptured).to.equal(2) + expect(game.currentState().whiteStonesCaptured).to.equal(0) + }); + + it("removes multi-stone groups that share a liberty, counting unique spaces", function() { + var game = new Game(); + game.setup(); + + game.playAt(0, 1); // b + game.playAt(0, 2); // w + game.playAt(1, 1); // b + game.playAt(1, 2); // w + game.playAt(1, 0); // b + game.playAt(2, 1); // w + game.playAt(2, 2); // b + game.playAt(2, 0); // w + game.playAt(3, 3); // b + game.playAt(0, 0); // w + + expect(game.currentState().blackStonesCaptured).to.equal(3) + expect(game.currentState().whiteStonesCaptured).to.equal(0) + }); + }); + + describe("undo", function() { + it("removes the last played stone", function() { + var game = new Game(); + game.setup(); + + game.playAt(5, 10); // b + game.playAt(5, 11); // w + game.playAt(5, 12); // b + + expect(game.currentPlayer()).to.equal("white"); + expect(game.intersectionAt(5, 12).value).to.equal("black"); + + game.undo(); + + expect(game.currentPlayer()).to.equal("black"); + expect(game.intersectionAt(5, 10).value).to.equal("black"); + expect(game.intersectionAt(5, 11).value).to.equal("white"); + expect(game.intersectionAt(5, 12).value).to.equal("empty"); }); }); @@ -103,6 +192,8 @@ describe("Game", function() { game.setup(); expect(game.handicapStones).to.equal(0); + expect(game.currentPlayer()).to.equal("black"); + var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); expect(nonEmptyPoints.length).to.equal(0); }); @@ -117,17 +208,6 @@ describe("Game", function() { expect(nonEmptyPoints.length).to.equal(h); expect(game.currentPlayer()).to.equal("white"); }); - - var game = new Game(); - game.setup({ handicapStones: 2 }); - expect(game.handicapStones).to.equal(2); - var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); - expect(nonEmptyPoints.length).to.equal(2); - expect(nonEmptyPoints[0].value).to.equal("black"); - expect(nonEmptyPoints[0].y).to.equal(3); - expect(nonEmptyPoints[0].x).to.equal(15); - expect(nonEmptyPoints[1].y).to.equal(15); - expect(nonEmptyPoints[1].x).to.equal(3); }); it("does not allow invalid handicap stone values", function() { @@ -140,7 +220,10 @@ describe("Game", function() { it("does not allow handicap stones on non-standard sizes", function() { var game = new Game(); expect(function() { game.setup({ boardSize: 19, handicapStones: 2 }); }).to.not.throw(Error); - expect(function() { game.setup({ boardSize: 17, handicapStones: 2 }); }).to.throw(Error, "Handicap stones not supported on sizes other than 9x9, 13x13 and 19x19"); + + [3, 5, 7, 11, 15, 17, 21].forEach(b => { + expect(function() { game.setup({ boardSize: b, handicapStones: 2 }); }).to.throw(Error, "Handicap stones not supported on sizes other than 9x9, 13x13 and 19x19"); + }); }); it("treats handicap stone positions as illegal moves", function() { @@ -151,115 +234,31 @@ describe("Game", function() { }); }); - describe("free handicap placement", function() { - it("is off by default", function() { - var game = new Game(); - game.setup({ handicapStones: 2 }); - - game.playAt(18, 18); - - var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); - expect(nonEmptyPoints.length).to.equal(3); - - expect(nonEmptyPoints[0].value).to.equal("black"); - expect(nonEmptyPoints[0].y).to.equal(3); - expect(nonEmptyPoints[0].x).to.equal(15); - - expect(nonEmptyPoints[1].value).to.equal("black"); - expect(nonEmptyPoints[1].y).to.equal(15); - expect(nonEmptyPoints[1].x).to.equal(3); - - expect(nonEmptyPoints[2].value).to.equal("white"); - expect(nonEmptyPoints[2].y).to.equal(18); - expect(nonEmptyPoints[2].x).to.equal(18); - }); - - it("allows black to place stones for the first n moves", function() { - var game = new Game(); - game.setup({ handicapStones: 2, freeHandicapPlacement: true }); - - var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); - expect(nonEmptyPoints.length).to.equal(0); - - expect(game.currentPlayer()).to.equal("black"); - game.playAt(5, 5); - expect(game.currentPlayer()).to.equal("black"); - game.playAt(6, 6); - expect(game.currentPlayer()).to.equal("white"); - game.playAt(7, 7); - - nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); - expect(nonEmptyPoints.length).to.equal(3); - - expect(nonEmptyPoints[0].value).to.equal("black"); - expect(nonEmptyPoints[0].y).to.equal(5); - expect(nonEmptyPoints[0].x).to.equal(5); - - expect(nonEmptyPoints[1].value).to.equal("black"); - expect(nonEmptyPoints[1].y).to.equal(6); - expect(nonEmptyPoints[1].x).to.equal(6); - - expect(nonEmptyPoints[2].value).to.equal("white"); - expect(nonEmptyPoints[2].y).to.equal(7); - expect(nonEmptyPoints[2].x).to.equal(7); - }); - - it("is undoable", function() { + describe("komi", function() { + it("does not add komi by default", function() { var game = new Game(); - game.setup({ handicapStones: 2, freeHandicapPlacement: true }); - - expect(game.currentPlayer()).to.equal("black"); - game.playAt(5, 5); - expect(game.currentPlayer()).to.equal("black"); - game.playAt(6, 6); - expect(game.currentPlayer()).to.equal("white"); - game.undo(); - expect(game.currentPlayer()).to.equal("black"); - game.undo(); - expect(game.currentPlayer()).to.equal("black"); - - var nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); - expect(nonEmptyPoints.length).to.equal(0); - - game.playAt(5, 5); - expect(game.currentPlayer()).to.equal("black"); - game.playAt(6, 6); - expect(game.currentPlayer()).to.equal("white"); + game.setup(); - nonEmptyPoints = game.currentState().intersections.filter(i => !i.isEmpty()); - expect(nonEmptyPoints.length).to.equal(2); + game.pass(); + game.pass(); - expect(nonEmptyPoints[0].value).to.equal("black"); - expect(nonEmptyPoints[0].y).to.equal(5); - expect(nonEmptyPoints[0].x).to.equal(5); + expect(game.isOver()).to.be.true; - expect(nonEmptyPoints[1].value).to.equal("black"); - expect(nonEmptyPoints[1].y).to.equal(6); - expect(nonEmptyPoints[1].x).to.equal(6); + expect(game.score().black).to.equal(0); + expect(game.score().white).to.equal(0); }); - }); - describe("komi", function() { - var game = new Game(); - game.setup(); - - game.pass(); - game.pass(); - - expect(game.isOver()).to.be.true; - - expect(game.score().black).to.equal(0); - expect(game.score().white).to.equal(0); + it("supports integer and non-integer komi", function() { + [0.5, 7, 7.5, 100].forEach(function(komiValue) { + var game = new Game(); + game.setup({ komi: komiValue }); - [0.5, 7, 7.5, 100].forEach(function(komiValue) { - var game = new Game(); - game.setup({ komi: komiValue }); + game.pass(); + game.pass(); - game.pass(); - game.pass(); - - expect(game.score().black).to.equal(0); - expect(game.score().white).to.equal(komiValue); + expect(game.score().black).to.equal(0); + expect(game.score().white).to.equal(komiValue); + }); }); }); @@ -292,4 +291,29 @@ describe("Game", function() { expect(game.currentState().playedPoint.y).to.equal(2); }); }); + + describe("suicide restrictions", function() { + it("prevents suicide", function() { + var game = new Game(); + game.setup(); + + // in the corner + expect(game.playAt(0, 1)).to.be.true; // b + expect(game.playAt(0, 2)).to.be.true; // w + expect(game.playAt(1, 0)).to.be.true; // b + expect(game.playAt(2, 0)).to.be.true; // w + expect(game.playAt(15, 15)).to.be.true; // b tenuki + expect(game.playAt(1, 1)).to.be.true; // w + + expect(game.playAt(0, 0)).to.be.false; + expect(game.isIllegalAt(0, 0)).to.be.true; + expect(game.intersectionAt(0, 0).isEmpty()).to.be.true; + expect(game.isBlackPlaying()).to.be.true; + + expect(game.playAt(9, 9)).to.be.true; // b tenuki + expect(game.playAt(0, 0)).to.be.true; // w + + expect(game.intersectionAt(0, 0).isWhite()).to.be.true; + }); + }); }); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..3795d78 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,14 @@ +var JSDOM = require("jsdom").JSDOM; +var window = new JSDOM('').window; +var document = window.document; + +global["document"] = document; +global["window"] = window; +// we don't really need this for these tests. +window.requestAnimationFrame = function() {}; +global["navigator"] = { userAgent: "node.js" }; +global["HTMLElement"] = global["window"].HTMLElement; + +exports.generateNewTestBoard = function() { + document.querySelector("body").innerHTML = '
'; +} diff --git a/test/svg-renderer-test.js b/test/svg-renderer-test.js new file mode 100644 index 0000000..b9cdb6e --- /dev/null +++ b/test/svg-renderer-test.js @@ -0,0 +1,142 @@ +var helpers = require("./helpers.js"); +var expect = require("chai").expect; +var tenuki = require("../index.js"); +var Game = tenuki.Game; +var utils = tenuki.utils; + +describe("SVG renderer", function() { + beforeEach(function() { + helpers.generateNewTestBoard(); + }); + + describe("click handling", function() { + it("updates board state", function() { + var testBoardElement = document.querySelector("#test-board"); + + var game = new Game(testBoardElement); + game.setup({ boardSize: 5 }); + + expect(testBoardElement.querySelectorAll(".intersection.empty").length).to.equal(25); + + testBoardElement.querySelectorAll(".intersection")[0*5 + 3].click(); + + expect(game.intersectionAt(0, 3).isBlack()).to.be.true; + expect(game.isWhitePlaying()).to.be.true; + + expect(testBoardElement.querySelectorAll(".intersection.empty").length).to.equal(24); + }); + }); + + describe("board state rendering", function() { + it("starts with an empty board", function() { + var testBoardElement = document.querySelector("#test-board"); + + var game = new Game(testBoardElement); + game.setup({ boardSize: 5 }); + + expect(testBoardElement.querySelectorAll(".intersection").length).to.equal(25); + expect(testBoardElement.querySelectorAll(".intersection.empty").length).to.equal(25); + }); + + it("is bound to moves on the game", function() { + var testBoardElement = document.querySelector("#test-board"); + + var game = new Game(testBoardElement); + game.setup({ boardSize: 5 }); + + game.playAt(3, 2); + expect(testBoardElement.querySelectorAll(".intersection").length).to.equal(25); + expect(testBoardElement.querySelectorAll(".intersection.black").length).to.equal(1); + expect(testBoardElement.querySelectorAll(".intersection.white").length).to.equal(0); + expect(utils.hasClass(testBoardElement.querySelectorAll(".intersection")[3*5 + 2], "played")).to.be.true; + expect(utils.hasClass(testBoardElement.querySelectorAll(".intersection")[3*5 + 2], "black")).to.be.true; + + game.playAt(2, 4); + expect(testBoardElement.querySelectorAll(".intersection").length).to.equal(25); + expect(testBoardElement.querySelectorAll(".intersection.black").length).to.equal(1); + expect(testBoardElement.querySelectorAll(".intersection.white").length).to.equal(1); + expect(utils.hasClass(testBoardElement.querySelectorAll(".intersection")[3*5 + 2], "played")).to.be.false; + expect(utils.hasClass(testBoardElement.querySelectorAll(".intersection")[3*5 + 2], "black")).to.be.true; + }); + + it("undoes state correctly", function() { + var testBoardElement = document.querySelector("#test-board"); + + var game = new Game(testBoardElement); + game.setup({ boardSize: 5 }); + + game.playAt(3, 2); + game.undo(); + + expect(testBoardElement.querySelectorAll(".intersection").length).to.equal(25); + expect(testBoardElement.querySelectorAll(".intersection.empty").length).to.equal(25); + }); + + it("removes captured stones", function() { + var testBoardElement = document.querySelector("#test-board"); + + var game = new Game(testBoardElement); + game.setup({ boardSize: 5 }); + + game.playAt(0, 0); + game.playAt(0, 1); + game.playAt(3, 3); + + expect(utils.hasClass(testBoardElement.querySelectorAll(".intersection")[0], "black")).to.be.true; + + game.playAt(1, 0); + + expect(utils.hasClass(testBoardElement.querySelectorAll(".intersection")[0], "black")).to.be.false; + }); + }); + + describe("number of lines", function() { + [3, 5, 7, 9, 11, 13, 15, 17, 19].forEach(b => { + it(`is correct for ${b}x${b}`, function() { + var testBoardElement = document.querySelector("#test-board"); + + var game = new Game(testBoardElement); + game.setup({ boardSize: b }); + + expect(testBoardElement.querySelectorAll(".lines").length).to.equal(1); + expect(testBoardElement.querySelectorAll(".line-box").length).to.equal((b - 1) * (b - 1)); + }); + }); + }) + + describe("number of hoshi points", function() { + var cases = { + 3: 1, + 4: 0, + 5: 1, + 6: 0, + 7: 4, + 8: 4, + 9: 9, + 10: 4, + 11: 9, + 12: 4, + 13: 9, + 14: 4, + 15: 9, + 16: 4, + 17: 9, + 18: 4, + 19: 9 + }; + + Object.keys(cases).forEach(k => { + var expectedNumber = cases[k]; + var b = Number(k); + + it(`is the correct number for ${b}x${b}`, function() { + var testBoardElement = document.querySelector("#test-board"); + + var game = new Game(testBoardElement); + game.setup({ boardSize: b }); + + expect(testBoardElement.querySelectorAll("circle.hoshi").length).to.equal(expectedNumber); + }); + }); + }) +}); From 1a444e9883a22d2f0047f6aa34e82b2eafe31caf Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Sat, 3 Jun 2017 15:18:26 -0400 Subject: [PATCH 02/43] Update the CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a001393..f70d259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Next version -* ... +* `playAt` now returns `false` for a move which is illegal on the basis of ko. Previously it incorrectly returned `null`. # v0.2.2 From 27b1c18354a074005456cb978871b997ba8535ae Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Sat, 3 Jun 2017 15:18:47 -0400 Subject: [PATCH 03/43] Test Node 8.x explicitly --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index a5ef9aa..02b2c4b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ cache: - node_modules node_js: - "node" + - "8" # latest 8.x - "7" # latest 7.x - "7.0" - "6" # latest 6.x From e57ad6d697d144a10904d432609b9367ca514a8e Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Sat, 3 Jun 2017 15:26:02 -0400 Subject: [PATCH 04/43] Make the positional superko setting explicit by name --- CHANGELOG.md | 1 + README.md | 4 ++-- src/ruleset.js | 2 +- test/game-ko-test.js | 6 +++--- test/game-test.js | 1 + 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f70d259..2bfe66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Next version +* Change `"superko"` as a ko setting to `"positional-superko"` to be explicit. * `playAt` now returns `false` for a move which is illegal on the basis of ko. Previously it incorrectly returned `null`. # v0.2.2 diff --git a/README.md b/README.md index 682659b..57cf1e3 100644 --- a/README.md +++ b/README.md @@ -210,14 +210,14 @@ The default ko rule is the simple variant: immediately recreating the previous b ```js game.setup({ - koRule: "superko" // default is "simple" + koRule: "positional-superko" // default is "simple" }) ``` Valid ko rule values are: * `"simple"` — Immediately recreating the previous board position is illegal. - * `"superko"` — Recreating any previous position is illegal. (Also known as "positional superko".) + * `"positional-superko"` — Recreating any previous position is illegal. # Usage outside of a browser diff --git a/src/ruleset.js b/src/ruleset.js index d6ffe0b..abcb5b4 100644 --- a/src/ruleset.js +++ b/src/ruleset.js @@ -1,6 +1,6 @@ const VALID_KO_OPTIONS = [ "simple", - "superko" + "positional-superko" ]; const Ruleset = function({ koRule }) { diff --git a/test/game-ko-test.js b/test/game-ko-test.js index 459e655..7a495fc 100644 --- a/test/game-ko-test.js +++ b/test/game-ko-test.js @@ -73,7 +73,7 @@ describe("ko restriction", function() { describe("with positional superko rules", function() { it("prevents repeating a previous position", function() { var game = new Game(); - game.setup({ koRule: "superko" }); + game.setup({ koRule: "positional-superko" }); // cycle for ● repeatedly losing 2 stones // ┌─●─┬─○─● @@ -110,7 +110,7 @@ describe("ko restriction", function() { expect(game.playAt(0, 0)).to.be.true; // b - expect(game.playAt(0, 1)).to.be.false; // w -- this move is not allowed with superko since it repeats (*) + expect(game.playAt(0, 1)).to.be.false; // w -- this move is not allowed with positional superko since it repeats (*) expect(game.isIllegalAt(0, 1)).to.be.true; expect(game.intersectionAt(0, 1).value).to.equal("empty"); expect(game.currentPlayer()).to.equal("white"); @@ -120,7 +120,7 @@ describe("ko restriction", function() { it("prevents repetition with a triple ko", function() { var game = new Game(); - game.setup({ koRule: "superko" }); + game.setup({ koRule: "positional-superko" }); var ponnukiOffsets = [ [-1, 1], diff --git a/test/game-test.js b/test/game-test.js index e0f215e..d5ebb72 100644 --- a/test/game-test.js +++ b/test/game-test.js @@ -29,6 +29,7 @@ describe("Game", function() { expect(function() { game.setup({ boardSize: 19, koRule: "Simple" }); }).to.throw(Error, "Unknown ko rule: Simple"); expect(function() { game.setup({ boardSize: 19, koRule: "SIMPLE" }); }).to.throw(Error, "Unknown ko rule: SIMPLE"); + expect(function() { game.setup({ boardSize: 19, koRule: "superko" }); }).to.throw(Error, "Unknown ko rule: superko"); expect(function() { game.setup({ boardSize: 19, koRule: "positional" }); }).to.throw(Error, "Unknown ko rule: positional"); expect(function() { game.setup({ boardSize: 19, koRule: "gibberish" }); }).to.throw(Error, "Unknown ko rule: gibberish"); }); From 34866d1d0d9159bb25f1f04b144bd01e95b0e6d6 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Sat, 3 Jun 2017 17:17:05 -0400 Subject: [PATCH 05/43] End the game after 2 passes even under equivalence scoring rules The previous implementation of equivalence scoring was incorrect. 3 passes are not required to end the game if the 2nd pass is by black. Instead, the end of the game is _signaled_ by 2 consecutive passes, and an agreement phase begins. After that agreement phase is over, then white may be required to hand over one final pass stone. If there are any disputes, then white's 3rd and final "pass" never happens. This commit stops adding a 3rd consecutive white pass to the board state and moves the "final white pass" logic into the scoring step. --- CHANGELOG.md | 1 + README.md | 48 +++++++++++++++++++++++++++++++++++---- src/game.js | 15 +++--------- src/scorer.js | 15 +++++++++++- test/game-scoring-test.js | 19 ++++++---------- 5 files changed, 68 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfe66e..a20fa57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Next version * Change `"superko"` as a ko setting to `"positional-superko"` to be explicit. +* Two consecutive passes will now always end the game under equivalence scoring. Previously, the requirement that white "pass" last was implemented as a game-ending requirement. Now, the game will always end after 2 passes, with the score handling the extra white pass stone to represent a "pass" move. (#30) * `playAt` now returns `false` for a move which is illegal on the basis of ko. Previously it incorrectly returned `null`. # v0.2.2 diff --git a/README.md b/README.md index 57cf1e3..e52b181 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,9 @@ game.setup({ }); ``` -# Configuring scoring and komi +# Configuring scoring -The default scoring is territory scoring. The scoring can be given as part of the `setup()` options: +The default scoring method is territory scoring. The scoring rule is configured by `setup()`: ```js game.setup({ @@ -191,10 +191,48 @@ game.setup({ Valid scoring types are: - * `"area"` — Area scoring. - * `"territory"` — Territory scoring. + * `"area"` + * `"territory"` + * `"equivalence"` -The default komi value is 0. To alter the value of white's score, pass `komi`: +## Area scoring + +The score for each player under area scoring is the sum of two values: + +* The number of stones on the board. +* The number of points of territory. + +Eyes in seki count as territory under area scoring. + +## Territory scoring + +The score for each player under territory scoring is simply the number of points of territory. + +Eyes in seki _do not_ count as points of territory. + +When territory scoring is in use, a simple detection algorithm attempts to correctly ignore each of the following as not-territory: + +1. Neutral points, consisting of intersections surrounded by neither player. +2. Intersections which would be the point of capture for a group in atari, after filling in neutral points. +3. Eyes in seki. + +Counting eyes in seki relies on a way of determining whether a group of stones is in seki. Tenuki detects seki by counting eyes, after filling in other neutral points. + +The more neutral points exist on the board, the more likely it is that seki detection will fail in some way. + +_It is strongly recommended that you fill in all neutral points before passing at the end of a game._ + +## Equivalence scoring + +An explanation of equivalence scoring can be found at the [Sensei's Library Wiki](http://senseis.xmp.net/?EquivalenceScoring), and a longer explanation of the equivalence can be found in a [commentary appendix to the AGA Rules](https://www.cs.cmu.edu/~wjh/go/rules/AGA.commentary.html). + +In short, equivalence scoring implements pass stones, plus the requirement that white make one final pass prior to scoring, which makes the score from counting by area equivalent to the score from counting by territory. + +Note that using equivalence scoring does _not_ change how the game ends. The game will end with 2 consecutive passes, even if black makes the 2nd pass. The final white pass stone handed to black is implemented in Tenuki by the `game.score()` function, not a move by a player. + +# Komi + +The default komi value is 0. To alter the value of white's score, pass `komi` to `setup()`: ```js game.setup({ diff --git a/src/game.js b/src/game.js index aa6b1eb..7d879be 100644 --- a/src/game.js +++ b/src/game.js @@ -71,8 +71,6 @@ Game.prototype = { throw new Error("Unknown renderer: " + renderer); } - this._whiteMustPassLast = this._scorer.usingPassStones(); - this._ruleset = new Ruleset({ koRule: koRule }); @@ -212,17 +210,10 @@ Game.prototype = { return false; } - if (this._whiteMustPassLast) { - const finalMove = this._moves[this._moves.length - 1]; - const previousMove = this._moves[this._moves.length - 2]; - - return finalMove.pass && previousMove.pass && finalMove.color === "white"; - } else { - const finalMove = this._moves[this._moves.length - 1]; - const previousMove = this._moves[this._moves.length - 2]; + const finalMove = this._moves[this._moves.length - 1]; + const previousMove = this._moves[this._moves.length - 2]; - return finalMove.pass && previousMove.pass; - } + return finalMove.pass && previousMove.pass; }, toggleDeadAt: function(y, x) { diff --git a/src/scorer.js b/src/scorer.js index 870f273..d7eca6a 100644 --- a/src/scorer.js +++ b/src/scorer.js @@ -174,8 +174,21 @@ Scorer.prototype = { result.white += this._komi; if (this._usePassStones) { + // Under equivalence scoring, 2 consecutive passes signals(!) the end of the + // game, but just prior to the end of the game, white must make one final + // pass move if the game didn't end on a white pass. + // + // However, instead of creating a 3rd consecutive pass in the board state, + // white's additional pass stone is handled by the scoring mechanism alone. + // The idea is that, under any game resumption, the additional white pass + // stone must not exist, so we shouldn't add it. + // + // NOTE: the final result should rely on this scoring function. Any calculations + // using raw board state pass stone numbers may be off by 1 in favor of black. + const needsFinalWhitePassStone = game.currentState().color !== "white"; + return { - black: result.black + game.currentState().whitePassStones, + black: result.black + game.currentState().whitePassStones + (needsFinalWhitePassStone ? 1 : 0), white: result.white + game.currentState().blackPassStones }; } else { diff --git a/test/game-scoring-test.js b/test/game-scoring-test.js index 14d3325..5c0b300 100644 --- a/test/game-scoring-test.js +++ b/test/game-scoring-test.js @@ -123,9 +123,6 @@ describe("scoring rules", function() { setupCaptures(game); - // pass once more for equivalence scoring - expect(game.pass()).to.be.true; - expect(game.isOver()).to.be.true; // territory + stones on the board + 2 pass stones @@ -149,8 +146,7 @@ describe("scoring rules", function() { expect(game.score().white).to.equal(9*19 + 1); }); - // TODO: this requirement is incorrect in its implementation! - it("requires one final pass by white", function() { + it("adds one final pass stone by white if necessary", function() { var game = new Game(); game.setup({ scoring: "equivalence" }); @@ -158,26 +154,25 @@ describe("scoring rules", function() { game.pass(); // w expect(game.isOver()).to.be.true; + expect(game.score().black).to.equal(1); + expect(game.score().white).to.equal(1); game = new Game(); game.setup({ scoring: "equivalence" }); - game.pass(); // b - game.pass(); // w game.playAt(1, 1); // b game.pass(); // w game.pass(); // b - expect(game.isOver()).to.be.false; - - game.pass(); - expect(game.isOver()).to.be.true; + // area + extra pass stone + expect(game.score().black).to.equal(19*19 + 1 + 1); + expect(game.score().white).to.equal(1); }); }); describe("territory scoring", function() { - it("counts only territory, excluding stones played, plus captures, ", function() { + it("counts only territory, excluding stones played, plus captures", function() { var game = run("territory"); expect(game.score().black).to.equal(9*19); From a55c5f0b975836554e7355548efa4297b545fe21 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Sat, 3 Jun 2017 17:28:19 -0400 Subject: [PATCH 06/43] Update the README to clarify territory scoring --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e52b181..4c6a4ea 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ Eyes in seki count as territory under area scoring. ## Territory scoring -The score for each player under territory scoring is simply the number of points of territory. +The score for each player under territory scoring is the number of points of territory, plus opponent stones you captured. Eyes in seki _do not_ count as points of territory. From 3a28980cf28db5ff694ad0aa1c406d05b2745eab Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Sat, 3 Jun 2017 18:36:58 -0400 Subject: [PATCH 07/43] Remove the now-unnecessary phantomjs-test.js file --- phantomjs-test.js | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 phantomjs-test.js diff --git a/phantomjs-test.js b/phantomjs-test.js deleted file mode 100644 index 063782c..0000000 --- a/phantomjs-test.js +++ /dev/null @@ -1,24 +0,0 @@ -var page = require("webpage").create(); -var fs = require("fs"); - -page.onError = function(msg, trace) { - var msgStack = ['PHANTOM ERROR: ' + msg]; - if (trace && trace.length) { - msgStack.push('TRACE:'); - trace.forEach(function(t) { - msgStack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function +')' : '')); - }); - } - console.error(msgStack.join('\n')); - phantom.exit(1); -}; - -page.open("file://" + fs.absolute("test.html"), function(status) { - if (status == "success") { - console.log("Tests pass in PhantomJS."); - phantom.exit(); - } else { - console.log("Failed to open test.html page. (Status: " + status + ")"); - phantom.exit(1); - } -}); From 3763069733ed07e29c1ee74aef8e5ba953fb6b2b Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Thu, 15 Jun 2017 22:20:41 -0400 Subject: [PATCH 08/43] Throw errors if intersectionAt is given values outside the board --- CHANGELOG.md | 1 + src/board-state.js | 8 ++++++++ test/game-test.js | 25 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a20fa57..eca6372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * Change `"superko"` as a ko setting to `"positional-superko"` to be explicit. * Two consecutive passes will now always end the game under equivalence scoring. Previously, the requirement that white "pass" last was implemented as a game-ending requirement. Now, the game will always end after 2 passes, with the score handling the extra white pass stone to represent a "pass" move. (#30) * `playAt` now returns `false` for a move which is illegal on the basis of ko. Previously it incorrectly returned `null`. +* `intersectionAt` will now throw an error if given intersection values are not on the board. # v0.2.2 diff --git a/src/board-state.js b/src/board-state.js index 8821d58..96f4423 100644 --- a/src/board-state.js +++ b/src/board-state.js @@ -156,6 +156,14 @@ BoardState.prototype = { }, intersectionAt: function(y, x) { + if (y >= this.boardSize || x >= this.boardSize) { + throw new Error(`Intersection at (${y}, ${x}) would be outside the board`) + } + + if (y < 0 || x < 0) { + throw new Error(`Intersection position cannot be negative, but was given (4, -1)`) + } + return this.intersections[y*this.boardSize + x]; }, diff --git a/test/game-test.js b/test/game-test.js index d5ebb72..6f946d4 100644 --- a/test/game-test.js +++ b/test/game-test.js @@ -57,6 +57,31 @@ describe("Game", function() { }); }); + describe("intersectionAt", function() { + it("returns the intersection for the given point", function() { + var game = new Game(); + game.setup(); + + expect(game.intersectionAt(4, 5).value).to.equal("empty"); + expect(game.intersectionAt(4, 5).y).to.equal(4); + expect(game.intersectionAt(4, 5).x).to.equal(5); + + game.playAt(4, 5); + game.playAt(5, 6); + + expect(game.intersectionAt(4, 5).value).to.equal("black"); + expect(game.intersectionAt(5, 6).value).to.equal("white"); + }); + + it("errors when trying to retrieve values outside of the board", function() { + var game = new Game(); + game.setup({ boardSize: 9 }); + + expect(function() { game.intersectionAt(5, 14); }).to.throw(Error, "Intersection at (5, 14) would be outside the board"); + expect(function() { game.intersectionAt(4, -1); }).to.throw(Error, "Intersection position cannot be negative, but was given (4, -1)"); + }); + }); + describe("capturing", function() { it("removes black stones from the board", function() { var game = new Game(); From 06c353302438a4f5727293bd7043ee3372f97026 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Thu, 15 Jun 2017 22:40:52 -0400 Subject: [PATCH 09/43] Make the linter happy again --- src/board-state.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/board-state.js b/src/board-state.js index 96f4423..6cce583 100644 --- a/src/board-state.js +++ b/src/board-state.js @@ -157,11 +157,11 @@ BoardState.prototype = { intersectionAt: function(y, x) { if (y >= this.boardSize || x >= this.boardSize) { - throw new Error(`Intersection at (${y}, ${x}) would be outside the board`) + throw new Error(`Intersection at (${y}, ${x}) would be outside the board`); } if (y < 0 || x < 0) { - throw new Error(`Intersection position cannot be negative, but was given (4, -1)`) + throw new Error(`Intersection position cannot be negative, but was given (4, -1)`); } return this.intersections[y*this.boardSize + x]; From 78e44e5fc29231b14a6ba37ec0ad3fde58086f33 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Thu, 15 Jun 2017 23:10:41 -0400 Subject: [PATCH 10/43] Make setup() more strict about passing undefined or null values --- CHANGELOG.md | 1 + src/game.js | 21 +++++++++++++++------ test/game-test.js | 8 ++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eca6372..ac418ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Two consecutive passes will now always end the game under equivalence scoring. Previously, the requirement that white "pass" last was implemented as a game-ending requirement. Now, the game will always end after 2 passes, with the score handling the extra white pass stone to represent a "pass" move. (#30) * `playAt` now returns `false` for a move which is illegal on the basis of ko. Previously it incorrectly returned `null`. * `intersectionAt` will now throw an error if given intersection values are not on the board. +* `setup` will throw an error for valid option keys but where the value is given as null or undefined. This is to prevent defaults from unknowingly being used. # v0.2.2 diff --git a/src/game.js b/src/game.js index 7d879be..4762b73 100644 --- a/src/game.js +++ b/src/game.js @@ -32,6 +32,20 @@ const Game = function(boardElement) { }; Game.prototype = { + _validateOptions: function(options) { + for (let key in options) { + if (options.hasOwnProperty(key)) { + if (VALID_GAME_OPTIONS.indexOf(key) < 0) { + throw new Error("Unrecognized game option: " + key); + } + + if (typeof options[key] === "undefined" || options[key] === null) { + throw new Error(`Game option ${key} must not be set as null or undefined`); + } + } + } + }, + _configureOptions: function({ boardSize = this._defaultBoardSize, komi = 0, handicapStones = 0, freeHandicapPlacement = false, scoring = this._defaultScoring, koRule = this._defaultKoRule, renderer = this._defaultRenderer } = {}) { if (typeof boardSize !== "number") { throw new Error("Board size must be a number, but was: " + typeof boardSize); @@ -87,12 +101,7 @@ Game.prototype = { }, setup: function(options = {}) { - for (let key in options) { - if (options.hasOwnProperty(key) && VALID_GAME_OPTIONS.indexOf(key) < 0) { - throw new Error("Unrecognized game option: " + key); - } - } - + this._validateOptions(options); this._configureOptions(options); if (this._boardElement) { diff --git a/test/game-test.js b/test/game-test.js index 6f946d4..cd5bc3c 100644 --- a/test/game-test.js +++ b/test/game-test.js @@ -33,6 +33,14 @@ describe("Game", function() { expect(function() { game.setup({ boardSize: 19, koRule: "positional" }); }).to.throw(Error, "Unknown ko rule: positional"); expect(function() { game.setup({ boardSize: 19, koRule: "gibberish" }); }).to.throw(Error, "Unknown ko rule: gibberish"); }); + + it("does not allow any explicitly-given values which are null or undefined", function() { + var game = new Game(); + + expect(function() { game.setup({boardSize: undefined}) }).to.throw(Error, "Game option boardSize must not be set as null or undefined"); + expect(function() { game.setup({boardSize: null}) }).to.throw(Error, "Game option boardSize must not be set as null or undefined"); + expect(function() { game.setup({koRule: null}) }).to.throw(Error, "Game option koRule must not be set as null or undefined"); + }); }); describe("playAt", function() { From 11930d83845dcf6fd8222d40693af61b56955078 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Mon, 3 Jul 2017 23:06:14 -0400 Subject: [PATCH 11/43] Slightly refactor the renderers to distinguish setup vs insertion --- src/dom-renderer.js | 46 ++++++------- src/renderer.js | 7 +- src/svg-renderer.js | 154 ++++++++++++++++++++++---------------------- 3 files changed, 107 insertions(+), 100 deletions(-) diff --git a/src/dom-renderer.js b/src/dom-renderer.js index 3422dee..b088c45 100644 --- a/src/dom-renderer.js +++ b/src/dom-renderer.js @@ -22,31 +22,29 @@ DOMRenderer.prototype._setup = function(boardState) { }; DOMRenderer.prototype.generateBoard = function(boardState) { - const renderer = this; - const boardElement = this.boardElement; - const zoomContainer = renderer.zoomContainer; + const contentsContainer = utils.createElement("div"); - utils.appendElement(zoomContainer, utils.createElement("div", { class: "lines horizontal" })); - utils.appendElement(zoomContainer, utils.createElement("div", { class: "lines vertical" })); - utils.appendElement(zoomContainer, utils.createElement("div", { class: "hoshi-points" })); - utils.appendElement(zoomContainer, utils.createElement("div", { class: "intersections" })); + utils.appendElement(contentsContainer, utils.createElement("div", { class: "lines horizontal" })); + utils.appendElement(contentsContainer, utils.createElement("div", { class: "lines vertical" })); + utils.appendElement(contentsContainer, utils.createElement("div", { class: "hoshi-points" })); + utils.appendElement(contentsContainer, utils.createElement("div", { class: "intersections" })); Renderer.hoshiPositionsFor(boardState.boardSize).forEach(h => { const hoshi = utils.createElement("div", { class: "hoshi" }); - hoshi.style.left = (h.left * (renderer.INTERSECTION_GAP_SIZE + 1)) + "px"; - hoshi.style.top = (h.top * (renderer.INTERSECTION_GAP_SIZE + 1)) + "px"; + hoshi.style.left = (h.left * (this.INTERSECTION_GAP_SIZE + 1)) + "px"; + hoshi.style.top = (h.top * (this.INTERSECTION_GAP_SIZE + 1)) + "px"; - utils.appendElement(boardElement.querySelector(".hoshi-points"), hoshi); + utils.appendElement(contentsContainer.querySelector(".hoshi-points"), hoshi); }); for (let y = 0; y < boardState.boardSize; y++) { const horizontalLine = utils.createElement("div", { class: "line horizontal" }); horizontalLine.setAttribute("data-left-gutter", boardState.yCoordinateFor(y)); - utils.appendElement(boardElement.querySelector(".lines.horizontal"), horizontalLine); + utils.appendElement(contentsContainer.querySelector(".lines.horizontal"), horizontalLine); const verticalLine = utils.createElement("div", { class: "line vertical" }); verticalLine.setAttribute("data-top-gutter", boardState.xCoordinateFor(y)); - utils.appendElement(boardElement.querySelector(".lines.vertical"), verticalLine); + utils.appendElement(contentsContainer.querySelector(".lines.vertical"), verticalLine); for (let x = 0; x < boardState.boardSize; x++) { const intersectionElement = utils.createElement("div", { class: "intersection empty" }); @@ -56,30 +54,32 @@ DOMRenderer.prototype.generateBoard = function(boardState) { intersectionElement.setAttribute("data-position-x", x); intersectionElement.setAttribute("data-position-y", y); - intersectionElement.style.left = (x * (renderer.INTERSECTION_GAP_SIZE + 1)) + "px"; - intersectionElement.style.top = (y * (renderer.INTERSECTION_GAP_SIZE + 1)) + "px"; + intersectionElement.style.left = (x * (this.INTERSECTION_GAP_SIZE + 1)) + "px"; + intersectionElement.style.top = (y * (this.INTERSECTION_GAP_SIZE + 1)) + "px"; - utils.appendElement(boardElement.querySelector(".intersections"), intersectionElement); + utils.appendElement(contentsContainer.querySelector(".intersections"), intersectionElement); - renderer.grid[y] = renderer.grid[y] || []; - renderer.grid[y][x] = intersectionElement; + this.grid[y] = this.grid[y] || []; + this.grid[y][x] = intersectionElement; this.addIntersectionEventListeners(intersectionElement, y, x); } } // prevent the text-selection cursor - utils.addEventListener(boardElement.querySelector(".lines.horizontal"), "mousedown", function(e) { + utils.addEventListener(contentsContainer.querySelector(".lines.horizontal"), "mousedown", function(e) { e.preventDefault(); }); - utils.addEventListener(boardElement.querySelector(".lines.vertical"), "mousedown", function(e) { + utils.addEventListener(contentsContainer.querySelector(".lines.vertical"), "mousedown", function(e) { e.preventDefault(); }); - boardElement.querySelector(".lines.horizontal").style.width = ((renderer.INTERSECTION_GAP_SIZE * (boardState.boardSize - 1)) + boardState.boardSize) + "px"; - boardElement.querySelector(".lines.horizontal").style.height = ((renderer.INTERSECTION_GAP_SIZE * (boardState.boardSize - 1)) + boardState.boardSize) + "px"; - boardElement.querySelector(".lines.vertical").style.width = ((renderer.INTERSECTION_GAP_SIZE * (boardState.boardSize - 1)) + boardState.boardSize) + "px"; - boardElement.querySelector(".lines.vertical").style.height = ((renderer.INTERSECTION_GAP_SIZE * (boardState.boardSize - 1)) + boardState.boardSize) + "px"; + contentsContainer.querySelector(".lines.horizontal").style.width = ((this.INTERSECTION_GAP_SIZE * (boardState.boardSize - 1)) + boardState.boardSize) + "px"; + contentsContainer.querySelector(".lines.horizontal").style.height = ((this.INTERSECTION_GAP_SIZE * (boardState.boardSize - 1)) + boardState.boardSize) + "px"; + contentsContainer.querySelector(".lines.vertical").style.width = ((this.INTERSECTION_GAP_SIZE * (boardState.boardSize - 1)) + boardState.boardSize) + "px"; + contentsContainer.querySelector(".lines.vertical").style.height = ((this.INTERSECTION_GAP_SIZE * (boardState.boardSize - 1)) + boardState.boardSize) + "px"; + + return contentsContainer; }; DOMRenderer.prototype.setIntersectionClasses = function(intersectionEl, intersection, classes) { diff --git a/src/renderer.js b/src/renderer.js index c3658f7..c01914b 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -129,7 +129,12 @@ Renderer.prototype = { throttle("resize", "optimizedResize"); - this.generateBoard(boardState); + const specificRendererBoard = this.generateBoard(boardState, { + hasCoordinates: this.hasCoordinates, + smallerStones: this.smallerStones, + texturedStones: this.texturedStones + }); + utils.appendElement(zoomContainer, specificRendererBoard); renderer.computeSizing(); diff --git a/src/svg-renderer.js b/src/svg-renderer.js index 130a28e..a8712e8 100644 --- a/src/svg-renderer.js +++ b/src/svg-renderer.js @@ -9,19 +9,11 @@ const SVGRenderer = function(boardElement, { hooks, options }) { SVGRenderer.prototype = Object.create(Renderer.prototype); SVGRenderer.prototype.constructor = SVGRenderer; -SVGRenderer.prototype.generateBoard = function(boardState) { - const renderer = this; - const zoomContainer = renderer.zoomContainer; - +const constructSVG = function(renderer, boardState, { hasCoordinates, smallerStones, texturedStones }) { const svg = utils.createSVGElement("svg"); - renderer.svgElement = svg; - const defs = utils.createSVGElement("defs"); utils.appendElement(svg, defs); - renderer.blackGradientID = utils.randomID("black-gradient"); - renderer.whiteGradientID = utils.randomID("white-gradient"); - const blackGradient = utils.createSVGElement("radialGradient", { attributes: { id: renderer.blackGradientID, @@ -67,9 +59,10 @@ SVGRenderer.prototype.generateBoard = function(boardState) { const contentsContainer = utils.createSVGElement("g", { attributes: { class: "contents", - transform: `translate(${this.MARGIN}, ${this.MARGIN})` + transform: `translate(${renderer.MARGIN}, ${renderer.MARGIN})` } }); + utils.appendElement(svg, contentsContainer); const lines = utils.createSVGElement("g", { attributes: { @@ -82,10 +75,10 @@ SVGRenderer.prototype.generateBoard = function(boardState) { for (let x = 0; x < boardState.boardSize - 1; x++) { const lineBox = utils.createSVGElement("rect", { attributes: { - y: y * (this.INTERSECTION_GAP_SIZE + 1) - 0.5, - x: x * (this.INTERSECTION_GAP_SIZE + 1) - 0.5, - width: this.INTERSECTION_GAP_SIZE + 1, - height: this.INTERSECTION_GAP_SIZE + 1, + y: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, + x: x * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, + width: renderer.INTERSECTION_GAP_SIZE + 1, + height: renderer.INTERSECTION_GAP_SIZE + 1, class: "line-box" } }); @@ -101,8 +94,8 @@ SVGRenderer.prototype.generateBoard = function(boardState) { const hoshi = utils.createSVGElement("circle", { attributes: { class: "hoshi", - cy: h.top * (this.INTERSECTION_GAP_SIZE + 1) - 0.5, - cx: h.left * (this.INTERSECTION_GAP_SIZE + 1) - 0.5, + cy: h.top * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, + cx: h.left * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, r: 2 } }); @@ -113,43 +106,41 @@ SVGRenderer.prototype.generateBoard = function(boardState) { const intersections = utils.createSVGElement("g", { attributes: { class: "intersections" }}); utils.appendElement(contentsContainer, intersections); - if (this.hasCoordinates) { + if (hasCoordinates) { const coordinateContainer = utils.createSVGElement("g", { attributes: { class: "coordinates", - transform: `translate(${this.MARGIN}, ${this.MARGIN})` + transform: `translate(${renderer.MARGIN}, ${renderer.MARGIN})` } }); for (let y = 0; y < boardState.boardSize; y++) { - if (this.hasCoordinates) { - // TODO: 16 is for the rendered height _on my browser_. not reliable... - - [16/2 + 1 - (16 + 16/2 + 16/(2*2) + 16/(2*2*2)), 16/2 + 1 + (16 + 16/2) + (boardState.boardSize - 1)*(this.INTERSECTION_GAP_SIZE + 1)].forEach(verticalOffset => { - utils.appendElement(coordinateContainer, utils.createSVGElement("text", { - text: boardState.xCoordinateFor(y), - attributes: { - "text-anchor": "middle", - y: verticalOffset - 0.5, - x: y * (this.INTERSECTION_GAP_SIZE + 1) - 0.5 - } - })); - }); - - - [-1*(16 + 16/2 + 16/(2*2)), (16 + 16/2 + 16/(2*2)) + (boardState.boardSize - 1)*(this.INTERSECTION_GAP_SIZE + 1)].forEach(horizontalOffset => { - utils.appendElement(coordinateContainer, utils.createSVGElement("text", { - text: boardState.yCoordinateFor(y), - attributes: { - "text-anchor": "middle", - y: y * (this.INTERSECTION_GAP_SIZE + 1) - 0.5 + 16/(2*2), - x: horizontalOffset - 0.5 - } - })); - }); - - utils.appendElement(svg, coordinateContainer); - } + // TODO: 16 is for the rendered height _on my browser_. not reliable... + + [16/2 + 1 - (16 + 16/2 + 16/(2*2) + 16/(2*2*2)), 16/2 + 1 + (16 + 16/2) + (boardState.boardSize - 1)*(renderer.INTERSECTION_GAP_SIZE + 1)].forEach(verticalOffset => { + utils.appendElement(coordinateContainer, utils.createSVGElement("text", { + text: boardState.xCoordinateFor(y), + attributes: { + "text-anchor": "middle", + y: verticalOffset - 0.5, + x: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5 + } + })); + }); + + + [-1*(16 + 16/2 + 16/(2*2)), (16 + 16/2 + 16/(2*2)) + (boardState.boardSize - 1)*(renderer.INTERSECTION_GAP_SIZE + 1)].forEach(horizontalOffset => { + utils.appendElement(coordinateContainer, utils.createSVGElement("text", { + text: boardState.yCoordinateFor(y), + attributes: { + "text-anchor": "middle", + y: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5 + 16/(2*2), + x: horizontalOffset - 0.5 + } + })); + }); + + utils.appendElement(svg, coordinateContainer); } } @@ -160,6 +151,8 @@ SVGRenderer.prototype.generateBoard = function(boardState) { class: "intersection" } }); + intersectionGroup.setAttribute("data-intersection-y", y); + intersectionGroup.setAttribute("data-intersection-x", x); utils.appendElement(intersections, intersectionGroup); const intersectionInnerContainer = utils.createSVGElement("g", { @@ -171,37 +164,33 @@ SVGRenderer.prototype.generateBoard = function(boardState) { const intersectionBox = utils.createSVGElement("rect", { attributes: { - y: y * (this.INTERSECTION_GAP_SIZE + 1) - this.INTERSECTION_GAP_SIZE/2 - 0.5, - x: x * (this.INTERSECTION_GAP_SIZE + 1) - this.INTERSECTION_GAP_SIZE/2 - 0.5, - width: this.INTERSECTION_GAP_SIZE, - height: this.INTERSECTION_GAP_SIZE + y: y * (renderer.INTERSECTION_GAP_SIZE + 1) - renderer.INTERSECTION_GAP_SIZE/2 - 0.5, + x: x * (renderer.INTERSECTION_GAP_SIZE + 1) - renderer.INTERSECTION_GAP_SIZE/2 - 0.5, + width: renderer.INTERSECTION_GAP_SIZE, + height: renderer.INTERSECTION_GAP_SIZE } }); utils.appendElement(intersectionInnerContainer, intersectionBox); - let stoneRadius = this.INTERSECTION_GAP_SIZE / 2; + let stoneRadius = renderer.INTERSECTION_GAP_SIZE / 2; - if (this.smallerStones) { + if (smallerStones) { stoneRadius -= 1; } const stoneAttributes = { class: "stone", - cy: y * (this.INTERSECTION_GAP_SIZE + 1) - 0.5, - cx: x * (this.INTERSECTION_GAP_SIZE + 1) - 0.5, - width: this.INTERSECTION_GAP_SIZE + 1, - height: this.INTERSECTION_GAP_SIZE + 1, + cy: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, + cx: x * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, r: stoneRadius }; - if (this.texturedStones) { + if (texturedStones) { utils.appendElement(intersectionInnerContainer, utils.createSVGElement("circle", { attributes: { class: "stone-shadow", cy: stoneAttributes["cy"] + 2, cx: stoneAttributes["cx"], - width: stoneAttributes["width"], - height: stoneAttributes["height"], r: stoneRadius } })); @@ -215,10 +204,8 @@ SVGRenderer.prototype.generateBoard = function(boardState) { utils.appendElement(intersectionInnerContainer, utils.createSVGElement("circle", { attributes: { class: "marker", - cy: y * (this.INTERSECTION_GAP_SIZE + 1) - 0.5, - cx: x * (this.INTERSECTION_GAP_SIZE + 1) - 0.5, - width: this.INTERSECTION_GAP_SIZE + 1, - height: this.INTERSECTION_GAP_SIZE + 1, + cy: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, + cx: x * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, r: 4.5 } })); @@ -226,8 +213,8 @@ SVGRenderer.prototype.generateBoard = function(boardState) { utils.appendElement(intersectionInnerContainer, utils.createSVGElement("rect", { attributes: { class: "ko-marker", - y: y * (this.INTERSECTION_GAP_SIZE + 1) - 6 - 0.5, - x: x * (this.INTERSECTION_GAP_SIZE + 1) - 6 - 0.5, + y: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 6 - 0.5, + x: x * (renderer.INTERSECTION_GAP_SIZE + 1) - 6 - 0.5, width: 12, height: 12 } @@ -236,25 +223,40 @@ SVGRenderer.prototype.generateBoard = function(boardState) { utils.appendElement(intersectionInnerContainer, utils.createSVGElement("rect", { attributes: { class: "territory-marker", - y: y * (this.INTERSECTION_GAP_SIZE + 1) - 6, - x: x * (this.INTERSECTION_GAP_SIZE + 1) - 6, + y: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 6, + x: x * (renderer.INTERSECTION_GAP_SIZE + 1) - 6, width: 11, height: 11 } })); - - this.grid[y] = this.grid[y] || []; - this.grid[y][x] = intersectionGroup; - - this.addIntersectionEventListeners(intersectionGroup, y, x); } } - utils.appendElement(svg, contentsContainer); - utils.appendElement(zoomContainer, svg); + return svg; +}; + +SVGRenderer.prototype.generateBoard = function(boardState, { hasCoordinates, smallerStones, texturedStones }) { + this.blackGradientID = utils.randomID("black-gradient"); + this.whiteGradientID = utils.randomID("white-gradient"); + + const svg = constructSVG(this, boardState, { hasCoordinates, smallerStones, texturedStones }); + + this.svgElement = svg; + this.svgElement.setAttribute("height", this.BOARD_LENGTH); + this.svgElement.setAttribute("width", this.BOARD_LENGTH); + + const intersectionGroups = svg.querySelectorAll(".intersection"); + intersectionGroups.forEach((intersectionGroup) => { + const y = Number(intersectionGroup.getAttribute("data-intersection-y")); + const x = Number(intersectionGroup.getAttribute("data-intersection-x")); + + this.grid[y] = this.grid[y] || []; + this.grid[y][x] = intersectionGroup; + + this.addIntersectionEventListeners(intersectionGroup, y, x); + }); - renderer.svgElement.setAttribute("height", renderer.BOARD_LENGTH); - renderer.svgElement.setAttribute("width", renderer.BOARD_LENGTH); + return svg; }; SVGRenderer.prototype.computeSizing = function() { From 639a27d9b9703265921b8060f3ac350a266e3441 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Mon, 3 Jul 2017 23:22:00 -0400 Subject: [PATCH 12/43] Bring grid setup back into the main intersection loop This gets rid of a slowdown introduced by the separation. --- src/svg-renderer.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/svg-renderer.js b/src/svg-renderer.js index a8712e8..ed54b83 100644 --- a/src/svg-renderer.js +++ b/src/svg-renderer.js @@ -229,6 +229,11 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto height: 11 } })); + + renderer.grid[y] = renderer.grid[y] || []; + renderer.grid[y][x] = intersectionGroup; + + renderer.addIntersectionEventListeners(intersectionGroup, y, x); } } @@ -245,17 +250,6 @@ SVGRenderer.prototype.generateBoard = function(boardState, { hasCoordinates, sma this.svgElement.setAttribute("height", this.BOARD_LENGTH); this.svgElement.setAttribute("width", this.BOARD_LENGTH); - const intersectionGroups = svg.querySelectorAll(".intersection"); - intersectionGroups.forEach((intersectionGroup) => { - const y = Number(intersectionGroup.getAttribute("data-intersection-y")); - const x = Number(intersectionGroup.getAttribute("data-intersection-x")); - - this.grid[y] = this.grid[y] || []; - this.grid[y][x] = intersectionGroup; - - this.addIntersectionEventListeners(intersectionGroup, y, x); - }); - return svg; }; From 8079c7cb260271323666570f2c5ba8fa5b641552 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Tue, 4 Jul 2017 11:38:47 -0400 Subject: [PATCH 13/43] Roll Game/Client setup() into constructor arguments --- CHANGELOG.md | 3 +- README.md | 31 ++++---- examples/example.html | 3 +- examples/example_fuzzy_placement.html | 3 +- examples/example_multiboard.html | 6 +- examples/example_with_simple_controls.html | 3 +- ...ample_with_simple_controls_and_gutter.html | 3 +- src/client.js | 14 ++-- src/game.js | 11 ++- test-server/server.js | 3 +- test/client-test.js | 28 ++++--- test/game-handicap-test.js | 28 +++---- test/game-ko-test.js | 9 +-- test/game-scoring-test.js | 11 +-- test/game-seki-detection-test.js | 22 +----- test/game-test.js | 75 ++++++------------- test/svg-renderer-test.js | 21 ++---- 17 files changed, 102 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac418ca..9010538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ * Two consecutive passes will now always end the game under equivalence scoring. Previously, the requirement that white "pass" last was implemented as a game-ending requirement. Now, the game will always end after 2 passes, with the score handling the extra white pass stone to represent a "pass" move. (#30) * `playAt` now returns `false` for a move which is illegal on the basis of ko. Previously it incorrectly returned `null`. * `intersectionAt` will now throw an error if given intersection values are not on the board. -* `setup` will throw an error for valid option keys but where the value is given as null or undefined. This is to prevent defaults from unknowingly being used. +* `Game` creation will throw an error for option keys which are valid, but where the value is given as null or undefined. This is to prevent defaults from unknowingly being used. +* Calling `setup()` on a `Game` is removed. Setup should now happen in the constructor, e.g., `new Game({ element: el, boardSize: 13 })`. Similarly for `Client`. # v0.2.2 diff --git a/README.md b/README.md index 4c6a4ea..91540da 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ It's also possible to clone this repo and run `make` against the latest commit, # Simple usage -Create a new `tenuki.Game` instance with a DOM element, then call `setup()`, which displays the board itself and configures click handlers on each intersection: +Create a new `tenuki.Game` instance, which displays the board itself and configures click handlers on each intersection: ```html @@ -71,8 +71,7 @@ Create a new `tenuki.Game` instance with a DOM element, then call `setup()`, whi ``` @@ -93,7 +92,7 @@ For a textured board, add the class `tenuki-board-textured`: For fuzzy stone placement, pass `fuzzyStonePlacement: true` as a game option: ```js -game.setup({ +new tenuki.Game({ fuzzyStonePlacement: true }); ``` @@ -150,12 +149,12 @@ The default renderer uses SVG to display the board. If this is a problem, you ca # Board sizes other than 19x19 -You can pass a second argument to `new tenuki.Game` to specify the board size. If no size is given, the default of 19 is used. All sizes between 1x1 and 19x19 should work. Sizes above 19x19 will error and won't render. +You can pass a `boardSize` option to specify the board size. If no size is given, the default of 19 is used. All sizes between 1x1 and 19x19 should work. Sizes above 19x19 will error and won't render. ```js -var game = new tenuki.Game(boardElement); -// use a 13x13 board -game.setup({ +var game = new tenuki.Game({ + element: boardElement, + // use a 13x13 board boardSize: 13 }); ``` @@ -165,7 +164,7 @@ game.setup({ Handicap stones (2 through 9) are supported on sizes 9x9, 13x13 and 19x19. ```js -game.setup({ +new tenuki.Game({ handicapStones: 5 }); ``` @@ -173,7 +172,7 @@ game.setup({ By default, handicap placement is fixed at the traditional star points. To allow free handicap placement, set `freeHandicapPlacement: true`: ```js -game.setup({ +new tenuki.Game({ handicapStones: 5, freeHandicapPlacement: true }); @@ -181,10 +180,10 @@ game.setup({ # Configuring scoring -The default scoring method is territory scoring. The scoring rule is configured by `setup()`: +The default scoring method is territory scoring. The scoring rule is configured as a setup option: ```js -game.setup({ +new tenuki.Game({ scoring: "area" // default is "territory" }); ``` @@ -232,10 +231,10 @@ Note that using equivalence scoring does _not_ change how the game ends. The gam # Komi -The default komi value is 0. To alter the value of white's score, pass `komi` to `setup()`: +The default komi value is 0. To alter the value of white's score, specify `komi` as an option: ```js -game.setup({ +new tenuki.Game({ komi: 6.5 }); ``` @@ -247,7 +246,7 @@ Komi is not automatically chosen based on the scoring type. The default ko rule is the simple variant: immediately recreating the previous board position is not allowed. Superko is also supported with the `koRule` configuration option: ```js -game.setup({ +new tenuki.Game({ koRule: "positional-superko" // default is "simple" }) ``` @@ -264,7 +263,6 @@ The full browser environment is not required in order to use the representation ```js var Game = require("tenuki").Game; game = new Game(); -game.setup(); ``` From there, the JavaScript interface is the same as in a browser console: @@ -303,7 +301,6 @@ This is useful if you want to update some other state: ```js var game = new tenuki.Game(boardElement); -game.setup(); game.callbacks.postRender = function(game) { if (game.currentState().pass) { diff --git a/examples/example.html b/examples/example.html index 418e07a..787c3a2 100644 --- a/examples/example.html +++ b/examples/example.html @@ -18,6 +18,5 @@ diff --git a/examples/example_fuzzy_placement.html b/examples/example_fuzzy_placement.html index 0a4cc19..a37dbd4 100644 --- a/examples/example_fuzzy_placement.html +++ b/examples/example_fuzzy_placement.html @@ -28,8 +28,7 @@ diff --git a/examples/example_with_simple_controls.html b/examples/example_with_simple_controls.html index 89cd773..dc6007d 100644 --- a/examples/example_with_simple_controls.html +++ b/examples/example_with_simple_controls.html @@ -28,8 +28,7 @@ +
@@ -85,7 +85,7 @@ For a textured board, add the class `tenuki-board-textured`:
``` - + # Fuzzy stone placement diff --git a/copyright_header.txt b/copyright_header.txt index 2b72ae8..a0b199b 100644 --- a/copyright_header.txt +++ b/copyright_header.txt @@ -1,5 +1,5 @@ /*! - * tenuki v0.2.2 (https://github.com/aprescott/tenuki.js) + * Tenuki v0.2.2 (https://github.com/aprescott/tenuki) * Copyright © 2016 Adam Prescott. * Licensed under the MIT license. */ diff --git a/package.json b/package.json index a789c75..000e4f8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/aprescott/tenuki.js.git" + "url": "git+https://github.com/aprescott/tenuki.git" }, "keywords": [ "go", @@ -25,9 +25,9 @@ "author": "Adam Prescott", "license": "MIT", "bugs": { - "url": "https://github.com/aprescott/tenuki.js/issues" + "url": "https://github.com/aprescott/tenuki/issues" }, - "homepage": "https://github.com/aprescott/tenuki.js#readme", + "homepage": "https://github.com/aprescott/tenuki#readme", "devDependencies": { "babel-cli": "^6.7.7", "babel-preset-es2015": "*", From d61cfe33213cdbd0dec09a3bd352ca5acda83c48 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Tue, 11 Jul 2017 17:57:14 -0400 Subject: [PATCH 21/43] Correct some overly broad dotjs dropping --- README.md | 2 +- test-server/server.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ec39ec0..51ca79f 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Create a new `tenuki.Game` instance, which displays the board itself and configu ```html - +
diff --git a/test-server/server.js b/test-server/server.js index 4eaa02a..7a93b66 100644 --- a/test-server/server.js +++ b/test-server/server.js @@ -15,7 +15,7 @@ app.set('etag', false); // turn off etags which affects caching app.use(bodyParser.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded app.use(bodyParser.json()); // for parsing application/json -var tenuki = require("../build/tenuki"); +var tenuki = require("../build/tenuki.js"); var Game = tenuki.Game; var game = new Game({ boardSize: 9 }); From 90f72f04bd8b6df1a5ce9eacba8eed7d14f8f469 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 13:34:21 -0700 Subject: [PATCH 22/43] Improve dead stone marking performance with many mergeable regions Marking a single stone as dead can take several seconds when that stone sits inside of a region which is mergeable with many others as part of a single group. By caching the boundary stone calculations the time is reduced from many seconds to being nearly instant. --- CHANGELOG.md | 1 + src/region.js | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bce7085..0fe51a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * `playAt` and `intersectionAt` will now throw an error if given intersection values are not on the board. * `Game` creation will throw an error for option keys which are valid, but where the value is given as null or undefined. This is to prevent defaults from unknowingly being used. * Calling `setup()` on a `Game` is removed. Setup should now happen in the constructor, e.g., `new Game({ element: el, boardSize: 13 })`. Similarly for `Client`. +* Dead stone marking is now faster in cases where there are many groups. # v0.2.2 diff --git a/src/region.js b/src/region.js index a440ecb..9924b31 100644 --- a/src/region.js +++ b/src/region.js @@ -4,6 +4,8 @@ const Region = function(boardState, intersections) { this.boardState = boardState; this.intersections = intersections; + this._computed = {}; + Object.freeze(this); }; @@ -109,18 +111,30 @@ Region.prototype = { }, boundaryStones: function() { + if (this._computed.boundaryStones) { + return this._computed.boundaryStones; + } + if (!this.isEmpty()) { throw new Error("Attempted to obtain boundary stones for non-empty region"); } - return this.exterior().filter(i => !i.sameColorAs(this.intersections[0])); + this._computed.boundaryStones = this.exterior().filter(i => !i.sameColorAs(this.intersections[0])); + + return this._computed.boundaryStones; }, expandedBoundaryStones: function() { + if (this._computed.expandedBoundaryStones) { + return this._computed.expandedBoundaryStones; + } + const boundaryStones = this.boundaryStones(); const regions = Region.allFor(this.boardState).filter(r => r.intersections.some(i => boundaryStones.indexOf(i) > -1)); - return utils.flatMap(regions, r => r.intersections); + this._computed.expandedBoundaryStones = utils.flatMap(regions, r => r.intersections); + + return this._computed.expandedBoundaryStones; }, lengthOfTerritoryBoundary: function() { From dfae9263cebc97ab0a57ac8880826314d7a9407c Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 14:52:13 -0700 Subject: [PATCH 23/43] Add a test against region checking code on the edge of the board This should have been included in c4997bf8cb76d468c33e4968d79d465c972bbe8f which corrected a bug with overly strict index checking. --- test/region-test.js | 91 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 test/region-test.js diff --git a/test/region-test.js b/test/region-test.js new file mode 100644 index 0000000..bcf26a2 --- /dev/null +++ b/test/region-test.js @@ -0,0 +1,91 @@ +var expect = require("chai").expect; +var tenuki = require("../index.js"); +var Game = tenuki.Game; +var Region = require("../lib/region.js").default; +var helpers = require("./helpers"); + +describe("Region", function() { + describe("containsSquareFour", function() { + it("handles checking regions containing intersections at the edge of a board", function() { + var game = new Game({ boardSize: 5 }); + + // ┌─○─●─┬─┐ + // ├─○─●─●─┤ + // ├─┼─○─●─┤ + // ○─○─○─●─● + // ○─○─●─●─┘ + + game.playAt(0, 1); + game.playAt(0, 2); + game.playAt(1, 1); + game.playAt(1, 2); + game.playAt(2, 2); + game.playAt(2, 3); + game.playAt(3, 1); + game.playAt(3, 3); + game.playAt(3, 2); + game.playAt(4, 3); + game.playAt(3, 0); + game.playAt(4, 2); + game.playAt(4, 1); + game.playAt(1, 3); + game.playAt(4, 0); + game.playAt(3, 4); + + [ + [[0, 0], false], + [[3, 0], true], + [[4, 4], false], + [[3, 3], false] + ].forEach(([[y, x], expectedResult]) => { + var region = Region.allFor(game.currentState()).find(r => { + return r.intersections.some(i => i.y == y && i.x == x); + }); + + expect(region.containsSquareFour()).to.equal(expectedResult); + }); + }); + }); + + describe("containsCurvedFour", function() { + it("handles checking regions containing intersections at the edge of a board", function() { + var game = new Game({ boardSize: 5 }); + + // ┌─○─●─┬─┐ + // ├─○─●─●─┤ + // ├─┼─○─●─┤ + // ○─○─○─●─● + // ○─○─●─●─┘ + + game.playAt(0, 1); + game.playAt(0, 2); + game.playAt(1, 1); + game.playAt(1, 2); + game.playAt(2, 2); + game.playAt(2, 3); + game.playAt(3, 1); + game.playAt(3, 3); + game.playAt(3, 2); + game.playAt(4, 3); + game.playAt(3, 0); + game.playAt(4, 2); + game.playAt(4, 1); + game.playAt(1, 3); + game.playAt(4, 0); + game.playAt(3, 4); + + [ + [[0, 0], true], + [[3, 0], true], + [[3, 3], true], + [[4, 4], false] + ].forEach(([[y, x], expectedResult]) => { + var region = Region.allFor(game.currentState()).find(r => { + return r.intersections.some(i => i.y == y && i.x == x); + }); + + expect(region.containsCurvedFour()).to.equal(expectedResult); + }); + }); + }); +}); From 054c7f9b98cbbef393896add1186b3e8f9646200 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 14:54:01 -0700 Subject: [PATCH 24/43] Allow playing, passing, and marking dead stones without a board render This allows more fine-grained control over, e.g., playing many moves in a sequence and then rendering after all of them have been played. --- README.md | 2 ++ src/game.js | 18 ++++++++++------ test/game-test.js | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 51ca79f..75f7093 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,8 @@ Note that all functions which take two integer coordinates (`y` and `x`) are mea * `score()` returns scoring information, e.g., `{ black: 150, white: 130 }`. Only useful if `isOver()` is `true`, since proper scoring requires dead stone marking at the end of the game. Scoring is dependent on the scoring rules in use. * `undo()`: undo the most recent move. +When rendering to a HTML board element, changing the game state will re-render the board. `pass`, `playAt`, `markDeadAt`, `unmarkDeadAt` and `toggleDeadAt` all support `{ render: false }` as an option, which will skip the board rendering step. To manually render the board with `render: false`, call `game.render()` explicitly. + # Post-render callbacks There is a configurable callback, `postRender`, which is fired each time the board is rendered, e.g., after every move. diff --git a/src/game.js b/src/game.js index 6d42d88..4b72d19 100644 --- a/src/game.js +++ b/src/game.js @@ -187,7 +187,7 @@ Game.prototype = { return this.currentState().moveNumber; }, - playAt: function(y, x) { + playAt: function(y, x, { render = true } = {}) { if (this.isIllegalAt(y, x)) { return false; } @@ -201,12 +201,14 @@ Game.prototype = { this._moves.push(newState); - this.render(); + if (render) { + this.render(); + } return true; }, - pass: function() { + pass: function({ render = true } = {}) { if (this.isOver()) { return false; } @@ -214,7 +216,9 @@ Game.prototype = { const newState = this.currentState().playPass(this.currentPlayer()); this._moves.push(newState); - this.render(); + if (render) { + this.render(); + } return true; }, @@ -230,7 +234,7 @@ Game.prototype = { return finalMove.pass && previousMove.pass; }, - toggleDeadAt: function(y, x) { + toggleDeadAt: function(y, x, { render = true } = {}) { if (this.intersectionAt(y, x).isEmpty()) { return; } @@ -245,7 +249,9 @@ Game.prototype = { } }); - this.render(); + if (render) { + this.render(); + } return true; }, diff --git a/test/game-test.js b/test/game-test.js index 74d00cc..23ef214 100644 --- a/test/game-test.js +++ b/test/game-test.js @@ -54,6 +54,38 @@ describe("Game", function() { game.playAt(5, 6); expect(game.intersectionAt(5, 6).value).to.equal("white"); }); + + it("allows skipping a call to render", function() { + var game = new Game(); + + let calledRender = false; + game.render = function() { + calledRender = true; + } + + game.playAt(5, 5, { render: false }); + expect(calledRender).to.be.false; + + game.playAt(5, 6); + expect(calledRender).to.be.true; + }); + }); + + describe("pass", function() { + it("allows skipping a call to render", function() { + var game = new Game(); + + let calledRender = false; + game.render = function() { + calledRender = true; + } + + game.pass({ render: false }); + expect(calledRender).to.be.false; + + game.pass(); + expect(calledRender).to.be.true; + }); }); describe("intersectionAt", function() { @@ -205,6 +237,27 @@ describe("Game", function() { expect(game.score()).to.deep.equal({ black: 0, white: 0 }); }); + + it("allows skipping a call to render", function() { + var game = new Game(); + + game.playAt(5, 5); + game.playAt(5, 6); + + game.pass(); + game.pass(); + + let calledRender = false; + game.render = function() { + calledRender = true; + } + + game.toggleDeadAt(5, 5, { render: false }); + expect(calledRender).to.be.false; + + game.toggleDeadAt(5, 6); + expect(calledRender).to.be.true; + }); }); describe("handicap stones", function() { From c9e5fc94533905ae79630826143aad354420b9a3 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 14:54:01 -0700 Subject: [PATCH 25/43] Skip rendering territory unless necessary --- src/renderer.js | 2 +- test/game-scoring-test.js | 19 +++++++++++++++++-- test/renderer-test.js | 35 ++++++++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/renderer.js b/src/renderer.js index c01914b..9a857d2 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -498,7 +498,7 @@ Renderer.prototype = { } } - if (territory) { + if (deadStones.length > 0 || territory.black.length > 0 || territory.white.length > 0) { this.renderTerritory(territory, deadStones); } }, diff --git a/test/game-scoring-test.js b/test/game-scoring-test.js index 163b1f7..d36fcec 100644 --- a/test/game-scoring-test.js +++ b/test/game-scoring-test.js @@ -235,8 +235,8 @@ describe("scoring rules", function() { }); }); - describe("territory marking where two stones are alive on the board with nothing else", function() { - it("leads to no territory being marked", function() { + describe("territory marking with whole-board edge cases", function() { + it("scores two live stones of opposing color, with nothing else, as no points", function() { var game = new Game(); game.playAt(0, 9); // b @@ -247,5 +247,20 @@ describe("scoring rules", function() { expect(game.score().black).to.equal(0); expect(game.score().white).to.equal(0); }); + + it("scores two dead stones of opposing color, with nothing else, as 1 point each under territory scoring", function() { + var game = new Game(); + + game.playAt(0, 9); // b + game.playAt(0, 10); // w + game.pass(); + game.pass(); + + game.toggleDeadAt(0, 9); + game.toggleDeadAt(0, 10); + + expect(game.score().black).to.equal(1); + expect(game.score().white).to.equal(1); + }); }); }); diff --git a/test/renderer-test.js b/test/renderer-test.js index 6cb8e06..adc6d82 100644 --- a/test/renderer-test.js +++ b/test/renderer-test.js @@ -10,13 +10,13 @@ describe("renderer", function() { }); ["svg", "dom"].forEach(renderer => { - [ - "simple", - "positional-superko", - "situational-superko", - "natural-situational-superko" - ].forEach(koRule => { - describe(renderer, function() { + describe(renderer, function() { + [ + "simple", + "positional-superko", + "situational-superko", + "natural-situational-superko" + ].forEach(koRule => { describe("ko markers", function() { it(`displays a ko marker for regular illegal ko moves under ${koRule}`, function() { var testBoardElement = document.querySelector("#test-board"); @@ -74,6 +74,27 @@ describe("renderer", function() { }); }); }); + + describe("dead stone marking", function() { + it("marks two dead stones of opposing color, with nothing else, as dead", function() { + var testBoardElement = document.querySelector("#test-board"); + + var game = new Game({ element: testBoardElement, boardSize: 13, renderer: renderer }); + + game.playAt(0, 5); // b + game.playAt(0, 6); // w + game.pass(); + game.pass(); + + expect(testBoardElement.querySelectorAll(".intersection.dead").length).to.equal(0); + + game.toggleDeadAt(0, 5); + expect(testBoardElement.querySelectorAll(".intersection.dead").length).to.equal(1); + + game.toggleDeadAt(0, 6); + expect(testBoardElement.querySelectorAll(".intersection.dead").length).to.equal(2); + }); + }); }); }); }); From 0d674f44e79365c2fe08506c62d87b86b48add3c Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 14:54:01 -0700 Subject: [PATCH 26/43] Improve board resizing performance with a requestAnimationFrame wrapper --- src/renderer.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer.js b/src/renderer.js index 9a857d2..17959bc 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -136,10 +136,12 @@ Renderer.prototype = { }); utils.appendElement(zoomContainer, specificRendererBoard); - renderer.computeSizing(); + window.requestAnimationFrame(() => { + // we'll potentially be zooming on touch devices + zoomContainer.style.willChange = "transform"; - // we'll potentially be zooming on touch devices - zoomContainer.style.willChange = "transform"; + renderer.computeSizing(); + }); window.addEventListener("optimizedResize", () => { renderer.computeSizing(); From aa0954fc18270d59f69f964a3240c980db31fa11 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 14:54:01 -0700 Subject: [PATCH 27/43] Cache SVG line elements across similar boards on a single page This helps a page load faster when there are many boards included. --- src/svg-renderer.js | 56 ++++++++++++++++++++++++++++----------------- src/utils.js | 8 +++++++ 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/svg-renderer.js b/src/svg-renderer.js index ed54b83..475e20b 100644 --- a/src/svg-renderer.js +++ b/src/svg-renderer.js @@ -9,7 +9,11 @@ const SVGRenderer = function(boardElement, { hooks, options }) { SVGRenderer.prototype = Object.create(Renderer.prototype); SVGRenderer.prototype.constructor = SVGRenderer; +const CACHED_CONSTRUCTED_LINES = {}; + const constructSVG = function(renderer, boardState, { hasCoordinates, smallerStones, texturedStones }) { + const cacheKey = [boardState.boardSize, hasCoordinates, smallerStones, texturedStones].toString(); + const svg = utils.createSVGElement("svg"); const defs = utils.createSVGElement("defs"); utils.appendElement(svg, defs); @@ -64,29 +68,38 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto }); utils.appendElement(svg, contentsContainer); - const lines = utils.createSVGElement("g", { - attributes: { - class: "lines" - } - }); - utils.appendElement(contentsContainer, lines); + let lines; - for (let y = 0; y < boardState.boardSize - 1; y++) { - for (let x = 0; x < boardState.boardSize - 1; x++) { - const lineBox = utils.createSVGElement("rect", { - attributes: { - y: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, - x: x * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, - width: renderer.INTERSECTION_GAP_SIZE + 1, - height: renderer.INTERSECTION_GAP_SIZE + 1, - class: "line-box" - } - }); + if (CACHED_CONSTRUCTED_LINES[cacheKey]) { + lines = utils.clone(CACHED_CONSTRUCTED_LINES[cacheKey]); + } else { + lines = utils.createSVGElement("g", { + attributes: { + class: "lines" + } + }); + + for (let y = 0; y < boardState.boardSize - 1; y++) { + for (let x = 0; x < boardState.boardSize - 1; x++) { + const lineBox = utils.createSVGElement("rect", { + attributes: { + y: y * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, + x: x * (renderer.INTERSECTION_GAP_SIZE + 1) - 0.5, + width: renderer.INTERSECTION_GAP_SIZE + 1, + height: renderer.INTERSECTION_GAP_SIZE + 1, + class: "line-box" + } + }); - utils.appendElement(lines, lineBox); + utils.appendElement(lines, lineBox); + } } + + CACHED_CONSTRUCTED_LINES[cacheKey] = lines; } + utils.appendElement(contentsContainer, lines); + const hoshiPoints = utils.createSVGElement("g", { attributes: { class: "hoshi" }}); utils.appendElement(contentsContainer, hoshiPoints); @@ -103,9 +116,6 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto utils.appendElement(hoshiPoints, hoshi); }); - const intersections = utils.createSVGElement("g", { attributes: { class: "intersections" }}); - utils.appendElement(contentsContainer, intersections); - if (hasCoordinates) { const coordinateContainer = utils.createSVGElement("g", { attributes: { @@ -144,6 +154,8 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto } } + const intersections = utils.createSVGElement("g", { attributes: { class: "intersections" }}); + for (let y = 0; y < boardState.boardSize; y++) { for (let x = 0; x < boardState.boardSize; x++) { const intersectionGroup = utils.createSVGElement("g", { @@ -237,6 +249,8 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto } } + utils.appendElement(contentsContainer, intersections); + return svg; }; diff --git a/src/utils.js b/src/utils.js index 374fcd9..bdfe60b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -19,6 +19,10 @@ export default { return `${prefix}-${str}`; }, + clone: function(element) { + return element.cloneNode(true); + }, + createElement: function(elementName, options) { const element = document.createElement(elementName); @@ -65,6 +69,10 @@ export default { }, removeClass: function(el, className) { + if (!this.hasClass(el, className)) { + return; + } + if (el.classList && el.classList.remove) { el.classList.remove(className); return; From a4570c7ff8f62620993ba98cd6fe7bc2643f067a Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 14:54:01 -0700 Subject: [PATCH 28/43] Provide non-toggling dead stone marking functions This is useful in a context where concurrent users are editing dead stones. If the only primitive is toggling, then the user interactions are racey. --- README.md | 2 +- src/game.js | 28 ++++++++++++++--- test/game-test.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 75f7093..f953e79 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ Note that all functions which take two integer coordinates (`y` and `x`) are mea * `pass()`: passes for the current player. * `playAt(y, x)`: attempts to play a stone at `(y, x)` for the current player. If the move is illegal (because of ko, suicide, etc.), then nothing will happen. Returns `true` if the move is successful, otherwise `false`. * `isOver()`: returns `true` if the most recent 2 moves were passes, indicating the game is over, otherwise `false`. -* `toggleDeadAt(y, x)`: sets the group of stones at `(y, x)` to be dead as part of marking territory. Only useful if `isOver()` is `true`. +* `markDeadAt(y, x)`, `unmarkDeadAt(y, x)` and `toggleDeadAt(y, x)`: set the group of stones at `(y, x)` to dead or undead as part of marking territory. Only useful if the game is over. * `score()` returns scoring information, e.g., `{ black: 150, white: 130 }`. Only useful if `isOver()` is `true`, since proper scoring requires dead stone marking at the end of the game. Scoring is dependent on the scoring rules in use. * `undo()`: undo the most recent move. diff --git a/src/game.js b/src/game.js index 4b72d19..c4fb4dd 100644 --- a/src/game.js +++ b/src/game.js @@ -234,18 +234,36 @@ Game.prototype = { return finalMove.pass && previousMove.pass; }, + markDeadAt: function(y, x, { render = true } = {}) { + if (this._isDeadAt(y, x)) { + return true; + } + + return this._setDeadStatus(y, x, true, { render }); + }, + + unmarkDeadAt: function(y, x, { render = true } = {}) { + if (!this._isDeadAt(y, x)) { + return true; + } + + return this._setDeadStatus(y, x, false, { render }); + }, + toggleDeadAt: function(y, x, { render = true } = {}) { + return this._setDeadStatus(y, x, !this._isDeadAt(y, x), { render }); + }, + + _setDeadStatus: function(y, x, markingDead, { render = true } = {}) { if (this.intersectionAt(y, x).isEmpty()) { return; } - const alreadyDead = this._isDeadAt(y, x); - this.currentState().groupAt(y, x).forEach(intersection => { - if (alreadyDead) { - this._deadPoints = this._deadPoints.filter(dead => !(dead.y === intersection.y && dead.x === intersection.x)); - } else { + if (markingDead) { this._deadPoints.push({ y: intersection.y, x: intersection.x }); + } else { + this._deadPoints = this._deadPoints.filter(dead => !(dead.y === intersection.y && dead.x === intersection.x)); } }); diff --git a/test/game-test.js b/test/game-test.js index 23ef214..2570980 100644 --- a/test/game-test.js +++ b/test/game-test.js @@ -217,6 +217,83 @@ describe("Game", function() { }); }); + describe("markDeadAt", function() { + it("allows skipping a call to render", function() { + var game = new Game(); + + game.playAt(5, 5); + game.playAt(5, 6); + + game.pass(); + game.pass(); + + let calledRender = false; + game.render = function() { + calledRender = true; + } + + game.markDeadAt(5, 5, { render: false }); + expect(calledRender).to.be.false; + + game.markDeadAt(5, 6); + expect(calledRender).to.be.true; + }); + }); + + describe("unmarkDeadAt", function() { + it("allows skipping a call to render", function() { + var game = new Game(); + + game.playAt(5, 5); + game.playAt(5, 6); + + game.pass(); + game.pass(); + + game.markDeadAt(5, 5); + game.markDeadAt(5, 6); + + let calledRender = false; + game.render = function() { + calledRender = true; + } + + game.unmarkDeadAt(5, 5, { render: false }); + expect(calledRender).to.be.false; + + game.unmarkDeadAt(5, 6); + expect(calledRender).to.be.true; + }); + }); + + describe("dead stone marking with markDeadAt and unmarkDeadAt", function() { + it("mark stones as dead as part of the scoring calculation", function() { + var game = new Game(); + + game.playAt(5, 5); + game.playAt(5, 6); + + game.pass(); + game.pass(); + + expect(game.score()).to.deep.equal({ black: 0, white: 0 }); + + expect(game.markDeadAt(5, 6)).to.equal.true; + expect(game.score()).to.deep.equal({ black: 361, white: 0 }); + + // no change + expect(game.markDeadAt(5, 6)).to.equal.true; + expect(game.score()).to.deep.equal({ black: 361, white: 0 }); + + expect(game.unmarkDeadAt(5, 6)).to.equal.true; + expect(game.score()).to.deep.equal({ black: 0, white: 0 }); + + // no change + expect(game.unmarkDeadAt(5, 6)).to.equal.true; + expect(game.score()).to.deep.equal({ black: 0, white: 0 }); + }); + }); + describe("toggleDeadAt", function() { it("toggles stones dead as part of the scoring calculation", function() { var game = new Game(); From c4dd82b1d53dd88f567e3dbe3d1faed21322d32e Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 14:54:01 -0700 Subject: [PATCH 29/43] Update CHANGELOG for performance-related changes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe51a4..15cf069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ * `Game` creation will throw an error for option keys which are valid, but where the value is given as null or undefined. This is to prevent defaults from unknowingly being used. * Calling `setup()` on a `Game` is removed. Setup should now happen in the constructor, e.g., `new Game({ element: el, boardSize: 13 })`. Similarly for `Client`. * Dead stone marking is now faster in cases where there are many groups. +* `playAt`, `pass` and `toggleDeadAt` now accept `{ render: false }` as an argument, which skips board rendering. This allows, e.g., playing N moves without rendering the board, then manually rendering once each has been played. +* Some general improvements to board rendering. # v0.2.2 From ce6c5df2d85b5cf2470dba27cc197f6b65491509 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 16:46:19 -0700 Subject: [PATCH 30/43] Mark and unmark dead stones in bulk based on regions Before, if two unconnected black stones were sitting in white's territory, you had to mark them as dead completely independently. Now, marking one stone as dead will automatically mark both stones as dead since they're both sitting in the same region. --- CHANGELOG.md | 1 + src/game.js | 18 +++++++++-- test/game-scoring-test.js | 3 -- test/game-seki-detection-test.js | 1 - test/game-test.js | 51 ++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cf069..2e70a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Dead stone marking is now faster in cases where there are many groups. * `playAt`, `pass` and `toggleDeadAt` now accept `{ render: false }` as an argument, which skips board rendering. This allows, e.g., playing N moves without rendering the board, then manually rendering once each has been played. * Some general improvements to board rendering. +* Dead stone marking now happens in bulk based on regions. # v0.2.2 diff --git a/src/game.js b/src/game.js index c4fb4dd..ff51d6a 100644 --- a/src/game.js +++ b/src/game.js @@ -255,11 +255,25 @@ Game.prototype = { }, _setDeadStatus: function(y, x, markingDead, { render = true } = {}) { - if (this.intersectionAt(y, x).isEmpty()) { + const selectedIntersection = this.intersectionAt(y, x); + + if (selectedIntersection.isEmpty()) { return; } - this.currentState().groupAt(y, x).forEach(intersection => { + const chosenDead = []; + + const [candidates] = this.currentState().partitionTraverse(selectedIntersection, intersection => { + return intersection.isEmpty() || intersection.sameColorAs(selectedIntersection); + }); + + candidates.forEach(sameColorOrEmpty => { + if (!sameColorOrEmpty.isEmpty()) { + chosenDead.push(sameColorOrEmpty); + } + }); + + chosenDead.forEach(intersection => { if (markingDead) { this._deadPoints.push({ y: intersection.y, x: intersection.x }); } else { diff --git a/test/game-scoring-test.js b/test/game-scoring-test.js index d36fcec..42ddde5 100644 --- a/test/game-scoring-test.js +++ b/test/game-scoring-test.js @@ -97,7 +97,6 @@ describe("scoring rules", function() { // mark dead stones game.toggleDeadAt(1, 1); - game.toggleDeadAt(0, 2); // 2 dead stones are now ignored because they're marked dead expect(game.score().black).to.equal(10*19); @@ -131,7 +130,6 @@ describe("scoring rules", function() { // mark dead stones game.toggleDeadAt(1, 1); - game.toggleDeadAt(0, 2); // 2 dead stones are now ignored because they're marked dead // plus 2 pass stones @@ -191,7 +189,6 @@ describe("scoring rules", function() { // mark dead stones game.toggleDeadAt(1, 1); - game.toggleDeadAt(0, 2); expect(game.score().black).to.equal(9*19); // rectangle territory, 8*19 diff --git a/test/game-seki-detection-test.js b/test/game-seki-detection-test.js index 8cf8d36..4e87397 100644 --- a/test/game-seki-detection-test.js +++ b/test/game-seki-detection-test.js @@ -615,7 +615,6 @@ describe("seki detection", function() { // mark both black stones dead game.toggleDeadAt(0, 1); - game.toggleDeadAt(1, 0); expect(game.score().black).to.equal(0); diff --git a/test/game-test.js b/test/game-test.js index 2570980..da183f2 100644 --- a/test/game-test.js +++ b/test/game-test.js @@ -292,6 +292,57 @@ describe("Game", function() { expect(game.unmarkDeadAt(5, 6)).to.equal.true; expect(game.score()).to.deep.equal({ black: 0, white: 0 }); }); + + it("sets dead stone state in bulk based on the region of the chosen stone", function() { + var game = new Game({ boardSize: 9 }); + + // ┌─┬─○─●─┬─┬─┬─┬─┐ + // ○─○─○─●─┼─┼─┼─┼─┤ + // ├─●─○─●─┼─┼─┼─┼─┤ + // ●─○─○─●─┼─┼─┼─┼─┤ + // ●─┼─○─●─┼─┼─┼─┼─┤ + // ├─┼─○─●─┼─┼─┼─┼─┤ + // ├─┼─○─●─┼─┼─┼─┼─┤ + // ├─┼─○─●─┼─┼─┼─┼─┤ + // └─┴─○─●─┴─┴─┴─┴─┘ + game.playAt(0, 2); + game.playAt(0, 3); + game.playAt(1, 2); + game.playAt(1, 3); + game.playAt(2, 2); + game.playAt(2, 3); + game.playAt(3, 2); + game.playAt(3, 3); + game.playAt(4, 2); + game.playAt(4, 3); + game.playAt(5, 2); + game.playAt(5, 3); + game.playAt(6, 2); + game.playAt(6, 3); + game.playAt(7, 2); + game.playAt(7, 3); + game.playAt(8, 2); + game.playAt(8, 3); + game.playAt(1, 1); + game.playAt(4, 0); + game.playAt(1, 0); + game.playAt(2, 1); + game.playAt(3, 1); + game.playAt(3, 0); + + game.pass(); + game.pass(); + + expect(game.score().black).to.equal(0); + + game.markDeadAt(2, 1); + + expect(game.score().black).to.equal(18); + + game.unmarkDeadAt(4, 0); + + expect(game.score().black).to.equal(0); + }); }); describe("toggleDeadAt", function() { From 26536023c3b651dd111068b93f5e3c3542e7a26a Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 18:04:26 -0700 Subject: [PATCH 31/43] Fix missing DOM renderer opacity for dead stones --- scss/dom-renderer.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scss/dom-renderer.scss b/scss/dom-renderer.scss index 7ead9e2..a6e4d8e 100644 --- a/scss/dom-renderer.scss +++ b/scss/dom-renderer.scss @@ -290,7 +290,8 @@ $board-margin: $base-margin + $gutter-margin; background: black; } - &.tenuki-board .intersection.empty.hovered .stone { + &.tenuki-board .intersection.empty.hovered .stone, + &.tenuki-board .intersection.dead .stone { opacity: 0.5; } From cb08a2dd4093ab4b15c44d88387c1e92012de9a9 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 18:29:26 -0700 Subject: [PATCH 32/43] Lighten the color scheme --- CHANGELOG.md | 1 + scss/dom-renderer.scss | 14 +++++++++----- scss/renderer-cancel-button.scss | 2 +- scss/svg-renderer.scss | 11 ++++++----- src/svg-renderer.js | 16 ++++++++-------- 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e70a9d..7b55e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * `playAt`, `pass` and `toggleDeadAt` now accept `{ render: false }` as an argument, which skips board rendering. This allows, e.g., playing N moves without rendering the board, then manually rendering once each has been played. * Some general improvements to board rendering. * Dead stone marking now happens in bulk based on regions. +* Board and stone colors have been lightened. # v0.2.2 diff --git a/scss/dom-renderer.scss b/scss/dom-renderer.scss index a6e4d8e..066e85f 100644 --- a/scss/dom-renderer.scss +++ b/scss/dom-renderer.scss @@ -14,7 +14,7 @@ $board-margin: $base-margin + $gutter-margin; } &.tenuki-board .tenuki-inner-container { - background: rgb(226, 188, 106); + background: rgb(235, 201, 138); } &.tenuki-board .tenuki-zoom-container { @@ -292,7 +292,7 @@ $board-margin: $base-margin + $gutter-margin; &.tenuki-board .intersection.empty.hovered .stone, &.tenuki-board .intersection.dead .stone { - opacity: 0.5; + opacity: 0.7; } &.tenuki-board .intersection.ko .stone { @@ -347,9 +347,13 @@ $board-margin: $base-margin + $gutter-margin; box-shadow: 0px 1.5px 0px rgba(62, 62, 62, 0.38); } + &.tenuki-board-textured .intersection.dead .stone { + box-shadow: 0px 1.5px 0px rgba(62, 62, 62, 0.38 / 2); + } + &.tenuki-board-textured .occupied.black .stone, &.tenuki-board-textured .empty.hovered.black .stone { - border-color: hsl(0, 0%, 20%); + border-color: hsl(0, 0%, 28%); } &.tenuki-board-textured .occupied.white .stone, @@ -369,10 +373,10 @@ $board-margin: $base-margin + $gutter-margin; } &.tenuki-board-textured .occupied.black .stone:before { - background: radial-gradient(circle at 50% 0px, #848484, hsl(0, 0%, 20%) 50%); + background: radial-gradient(circle at 50% 15%, hsl(0, 0%, 38%), #39363D 50%); } &.tenuki-board-textured .occupied.white .stone:before { - background: radial-gradient(circle at 50% 0px, #FFFFFF, #DDDDDD 70%); + background: radial-gradient(circle at 50% 15%, #FFFFFF, #fafdfc 70%); } } diff --git a/scss/renderer-cancel-button.scss b/scss/renderer-cancel-button.scss index 53a8681..c274f78 100644 --- a/scss/renderer-cancel-button.scss +++ b/scss/renderer-cancel-button.scss @@ -28,7 +28,7 @@ position: absolute; z-index: 1; bottom: 48px; - background: rgba(226, 188, 106, 0.81); + background: rgba(235, 201, 138, 0.81); border-radius: 50%; width: 100px; height: 100px; diff --git a/scss/svg-renderer.scss b/scss/svg-renderer.scss index d7448a8..13869eb 100644 --- a/scss/svg-renderer.scss +++ b/scss/svg-renderer.scss @@ -27,7 +27,7 @@ } &.tenuki-board .tenuki-inner-container { - background-color: rgb(225, 188, 106); + background-color: rgb(235, 201, 138); } &.tenuki-board .tenuki-zoom-container { @@ -51,8 +51,9 @@ } &.tenuki-board .intersection.hovered .stone, - &.tenuki-board .intersection.dead .stone { - opacity: 0.5; + &.tenuki-board .intersection.dead .stone, + &.tenuki-board-textured .intersection.white .stone-shadow { + opacity: 0.7; } &.tenuki-board .intersection.dead.white .stone, @@ -101,12 +102,12 @@ &.tenuki-board-textured .intersection.black .stone, &.tenuki-board-textured .intersection.dead.black .stone, &.tenuki-board-textured .intersection.hovered.black .stone { - stroke: hsl(0, 0%, 20%); + stroke: hsl(0, 0%, 28%); } &.tenuki-board-textured .intersection.black .stone-shadow, &.tenuki-board-textured .intersection.white .stone-shadow { - fill: hsla(0, 0%, 24%, 0.38); + fill: hsla(0, 0%, 24%, 0.38 / 2); } &.tenuki-board-textured .intersection.hovered.white .stone-shadow, diff --git a/src/svg-renderer.js b/src/svg-renderer.js index 475e20b..18c015b 100644 --- a/src/svg-renderer.js +++ b/src/svg-renderer.js @@ -21,20 +21,20 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto const blackGradient = utils.createSVGElement("radialGradient", { attributes: { id: renderer.blackGradientID, - cy: "0", - r: "55%" + cy: "15%", + r: "50%" } }); utils.appendElement(blackGradient, utils.createSVGElement("stop", { attributes: { offset: "0%", - "stop-color": "#848484" + "stop-color": "hsl(0, 0%, 38%)" } })); utils.appendElement(blackGradient, utils.createSVGElement("stop", { attributes: { offset: "100%", - "stop-color": "hsl(0, 0%, 20%)" + "stop-color": "#39363D" } })); utils.appendElement(defs, blackGradient); @@ -42,20 +42,20 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto const whiteGradient = utils.createSVGElement("radialGradient", { attributes: { id: renderer.whiteGradientID, - cy: "0", - r: "70%" + cy: "15%", + r: "50%" } }); utils.appendElement(whiteGradient, utils.createSVGElement("stop", { attributes: { offset: "0%", - "stop-color": "white" + "stop-color": "#FFFFFF" } })); utils.appendElement(whiteGradient, utils.createSVGElement("stop", { attributes: { offset: "100%", - "stop-color": "#DDDDDD" + "stop-color": "#fafdfc" } })); utils.appendElement(defs, whiteGradient); From a64a5602bbbbf1eb99cfa0a76b530b8ba2009423 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 19:24:13 -0700 Subject: [PATCH 33/43] Fix some stone shadow mismatches between the DOM and SVG renderers --- scss/svg-renderer.scss | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scss/svg-renderer.scss b/scss/svg-renderer.scss index 13869eb..014a88c 100644 --- a/scss/svg-renderer.scss +++ b/scss/svg-renderer.scss @@ -52,7 +52,7 @@ &.tenuki-board .intersection.hovered .stone, &.tenuki-board .intersection.dead .stone, - &.tenuki-board-textured .intersection.white .stone-shadow { + &.tenuki-board .intersection.dead .stone-shadow { opacity: 0.7; } @@ -107,6 +107,10 @@ &.tenuki-board-textured .intersection.black .stone-shadow, &.tenuki-board-textured .intersection.white .stone-shadow { + fill: hsla(0, 0%, 24%, 0.38); + } + + &.tenuki-board-textured .intersection.dead .stone-shadow { fill: hsla(0, 0%, 24%, 0.38 / 2); } From bb60d5af9e722f494b5f959c848343de452e8b8f Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 19:50:09 -0700 Subject: [PATCH 34/43] Separate opacity styling for dead vs. hovered stone states --- scss/dom-renderer.scss | 4 ++++ scss/svg-renderer.scss | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/scss/dom-renderer.scss b/scss/dom-renderer.scss index 066e85f..e0a7432 100644 --- a/scss/dom-renderer.scss +++ b/scss/dom-renderer.scss @@ -292,6 +292,10 @@ $board-margin: $base-margin + $gutter-margin; &.tenuki-board .intersection.empty.hovered .stone, &.tenuki-board .intersection.dead .stone { + opacity: 0.5; + } + + &.tenuki-board-textured .intersection.dead .stone { opacity: 0.7; } diff --git a/scss/svg-renderer.scss b/scss/svg-renderer.scss index 014a88c..78b96b3 100644 --- a/scss/svg-renderer.scss +++ b/scss/svg-renderer.scss @@ -53,6 +53,11 @@ &.tenuki-board .intersection.hovered .stone, &.tenuki-board .intersection.dead .stone, &.tenuki-board .intersection.dead .stone-shadow { + opacity: 0.5; + } + + &.tenuki-board-textured .intersection.dead .stone, + &.tenuki-board-textured .intersection.dead .stone-shadow { opacity: 0.7; } From 110cd96a04843071d2fd083310ab9029be2f9184 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 20:01:00 -0700 Subject: [PATCH 35/43] Use stones with a gradient and shadow by default --- CHANGELOG.md | 1 + README.md | 12 ++-- examples/screenshots/board-textured.png | Bin 20608 -> 0 bytes examples/screenshots/board.png | Bin 13171 -> 0 bytes examples/screenshots/territory-scoring.png | Bin 42341 -> 0 bytes scss/dom-renderer.scss | 54 ++++++++--------- scss/svg-renderer.scss | 64 ++++++++++----------- src/renderer.js | 11 ++-- src/svg-renderer.js | 12 ++-- 9 files changed, 77 insertions(+), 77 deletions(-) delete mode 100644 examples/screenshots/board-textured.png delete mode 100644 examples/screenshots/board.png delete mode 100644 examples/screenshots/territory-scoring.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b55e6e..4324360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Some general improvements to board rendering. * Dead stone marking now happens in bulk based on regions. * Board and stone colors have been lightened. +* `.tenuki-board-textured` is now the default. To use flat stone styling with no gradients or shadows, set `.tenuki-board-flat` as the class name. # v0.2.2 diff --git a/README.md b/README.md index f953e79..bb8f451 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,6 @@ The JavaScript engine is not dependent on the renderer and works stand-alone. Yo The go board interface is intended to be a robust, functional component that can be embedded in a web page. By using the JavaScript API you could then build your own custom controls for undo/pass/etc. - - Features: * Simple ko and superko. @@ -77,16 +75,14 @@ Create a new `tenuki.Game` instance, which displays the board itself and configu There are no other dependencies. -# Textured styling +# Flat stone styling -For a textured board, add the class `tenuki-board-textured`: +For a completely flat board with no stone shadows or gradients, add the class `tenuki-board-flat`: ```html -
+
``` - - # Fuzzy stone placement For fuzzy stone placement, pass `fuzzyStonePlacement: true` as a game option: @@ -97,7 +93,7 @@ new tenuki.Game({ }); ``` -When enabled, stones are set to textured styling, and played stones will be randomly placed slightly off-center. If stones overlap after placement, existing stones are bumped out of the way. +When enabled, played stones will be randomly placed slightly off-center. If stones overlap after placement, existing stones are bumped out of the way. # Auto-scaling and responsiveness diff --git a/examples/screenshots/board-textured.png b/examples/screenshots/board-textured.png deleted file mode 100644 index e18f28f52eaf5dc349fcec772c0fb47c2a176650..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20608 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
  • 43I%sITxc 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 diff --git a/examples/screenshots/territory-scoring.png b/examples/screenshots/territory-scoring.png deleted file mode 100644 index 195a09fd446058c4e0c9bde8bbec3a59195859a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42341 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 diff --git a/scss/dom-renderer.scss b/scss/dom-renderer.scss index e0a7432..b53d6b6 100644 --- a/scss/dom-renderer.scss +++ b/scss/dom-renderer.scss @@ -169,64 +169,64 @@ $board-margin: $base-margin + $gutter-margin; height: $intersection-gap + 1px; } - &.tenuki-board-textured, - &.tenuki-board-textured .tenuki-inner-container, - &.tenuki-board-textured .tenuki-zoom-container, - &.tenuki-board-textured .intersections { + &.tenuki-board-nonflat, + &.tenuki-board-nonflat .tenuki-inner-container, + &.tenuki-board-nonflat .tenuki-zoom-container, + &.tenuki-board-nonflat .intersections { /* scale(1) is for the same iOS 9 reason as above */ transform: scale(1) translate3d(0, 0, 0); } - &.tenuki-board-textured.tenuki-smaller-stones .intersection { + &.tenuki-board-nonflat.tenuki-smaller-stones .intersection { width: $intersection-gap + 1px - 2px; height: $intersection-gap + 1px - 2px; border: 1px solid transparent; } - &.tenuki-board-textured.tenuki-fuzzy-placement .occupied { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .occupied { transition: 0.1s margin; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.played { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.played { transition: none; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-up { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-up { margin-top: -1px; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-upup { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-upup { margin-top: -2px; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-down { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-down { margin-top: 1px; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-downdown { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-downdown { margin-top: 2px; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-none { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-none { } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-left { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-left { margin-left: -1px; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-leftleft { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-leftleft { margin-left: -2px; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-right { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-right { margin-left: 1px; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-rightright { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-rightright { margin-left: 2px; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-none { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-none { } &.tenuki-board .intersection, @@ -295,7 +295,7 @@ $board-margin: $base-margin + $gutter-margin; opacity: 0.5; } - &.tenuki-board-textured .intersection.dead .stone { + &.tenuki-board-nonflat .intersection.dead .stone { opacity: 0.7; } @@ -347,25 +347,25 @@ $board-margin: $base-margin + $gutter-margin; background: white; } - &.tenuki-board-textured .occupied .stone { + &.tenuki-board-nonflat .occupied .stone { box-shadow: 0px 1.5px 0px rgba(62, 62, 62, 0.38); } - &.tenuki-board-textured .intersection.dead .stone { + &.tenuki-board-nonflat .intersection.dead .stone { box-shadow: 0px 1.5px 0px rgba(62, 62, 62, 0.38 / 2); } - &.tenuki-board-textured .occupied.black .stone, - &.tenuki-board-textured .empty.hovered.black .stone { + &.tenuki-board-nonflat .occupied.black .stone, + &.tenuki-board-nonflat .empty.hovered.black .stone { border-color: hsl(0, 0%, 28%); } - &.tenuki-board-textured .occupied.white .stone, - &.tenuki-board-textured .empty.hovered.white .stone { + &.tenuki-board-nonflat .occupied.white .stone, + &.tenuki-board-nonflat .empty.hovered.white .stone { border-color: #DEDEDE; } - &.tenuki-board-textured .occupied .stone:before { + &.tenuki-board-nonflat .occupied .stone:before { content: ""; position: absolute; top: 0; @@ -376,11 +376,11 @@ $board-margin: $base-margin + $gutter-margin; z-index: 2; } - &.tenuki-board-textured .occupied.black .stone:before { + &.tenuki-board-nonflat .occupied.black .stone:before { background: radial-gradient(circle at 50% 15%, hsl(0, 0%, 38%), #39363D 50%); } - &.tenuki-board-textured .occupied.white .stone:before { + &.tenuki-board-nonflat .occupied.white .stone:before { background: radial-gradient(circle at 50% 15%, #FFFFFF, #fafdfc 70%); } } diff --git a/scss/svg-renderer.scss b/scss/svg-renderer.scss index 78b96b3..089e473 100644 --- a/scss/svg-renderer.scss +++ b/scss/svg-renderer.scss @@ -3,9 +3,9 @@ user-select: none; cursor: default; - &.tenuki-board-textured svg, - &.tenuki-board-textured .tenuki-zoom-container, - &.tenuki-board-textured .intersections { + &.tenuki-board-nonflat svg, + &.tenuki-board-nonflat .tenuki-zoom-container, + &.tenuki-board-nonflat .intersections { transform: translate3d(0, 0, 0); } @@ -56,8 +56,8 @@ opacity: 0.5; } - &.tenuki-board-textured .intersection.dead .stone, - &.tenuki-board-textured .intersection.dead .stone-shadow { + &.tenuki-board-nonflat .intersection.dead .stone, + &.tenuki-board-nonflat .intersection.dead .stone-shadow { opacity: 0.7; } @@ -99,83 +99,83 @@ visibility: visible; } - &.tenuki-board-textured .intersection.hovered.black .stone, - &.tenuki-board-textured .intersection.hovered.white .stone { + &.tenuki-board-nonflat .intersection.hovered.black .stone, + &.tenuki-board-nonflat .intersection.hovered.white .stone { filter: none !important; } - &.tenuki-board-textured .intersection.black .stone, - &.tenuki-board-textured .intersection.dead.black .stone, - &.tenuki-board-textured .intersection.hovered.black .stone { + &.tenuki-board-nonflat .intersection.black .stone, + &.tenuki-board-nonflat .intersection.dead.black .stone, + &.tenuki-board-nonflat .intersection.hovered.black .stone { stroke: hsl(0, 0%, 28%); } - &.tenuki-board-textured .intersection.black .stone-shadow, - &.tenuki-board-textured .intersection.white .stone-shadow { + &.tenuki-board-nonflat .intersection.black .stone-shadow, + &.tenuki-board-nonflat .intersection.white .stone-shadow { fill: hsla(0, 0%, 24%, 0.38); } - &.tenuki-board-textured .intersection.dead .stone-shadow { + &.tenuki-board-nonflat .intersection.dead .stone-shadow { fill: hsla(0, 0%, 24%, 0.38 / 2); } - &.tenuki-board-textured .intersection.hovered.white .stone-shadow, - &.tenuki-board-textured .intersection.hovered.black .stone-shadow { + &.tenuki-board-nonflat .intersection.hovered.white .stone-shadow, + &.tenuki-board-nonflat .intersection.hovered.black .stone-shadow { fill: none; } - &.tenuki-board-textured .intersection.white .stone, - &.tenuki-board-textured .intersection.dead.white .stone, - &.tenuki-board-textured .intersection.hovered.white .stone { + &.tenuki-board-nonflat .intersection.white .stone, + &.tenuki-board-nonflat .intersection.dead.white .stone, + &.tenuki-board-nonflat .intersection.hovered.white .stone { stroke: #DEDEDE; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.occupied, - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.occupied .intersection-inner-container { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.occupied, + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.occupied .intersection-inner-container { transition: 0.1s transform; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.played, - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.played .intersection-inner-container { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.played, + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.played .intersection-inner-container { transition: none; } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-up { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-up { transform: translateY(-1px); } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-upup { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-upup { transform: translateY(-2px); } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-down { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-down { transform: translateY(1px); } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-downdown { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-downdown { transform: translateY(2px); } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.v-shift-none { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.v-shift-none { } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-left .intersection-inner-container { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-left .intersection-inner-container { transform: translateX(-1px); } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-leftleft .intersection-inner-container { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-leftleft .intersection-inner-container { transform: translateX(-2px); } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-right .intersection-inner-container { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-right .intersection-inner-container { transform: translateX(1px); } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-rightright .intersection-inner-container { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-rightright .intersection-inner-container { transform: translateX(2px); } - &.tenuki-board-textured.tenuki-fuzzy-placement .intersection.h-shift-none .intersection-inner-container { + &.tenuki-board-nonflat.tenuki-fuzzy-placement .intersection.h-shift-none .intersection-inner-container { } .coordinates { diff --git a/src/renderer.js b/src/renderer.js index 17959bc..e2507ab 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -14,12 +14,15 @@ const Renderer = function(boardElement, { hooks, options }) { if (this._options["fuzzyStonePlacement"]) { utils.addClass(boardElement, "tenuki-fuzzy-placement"); - utils.addClass(boardElement, "tenuki-board-textured"); + utils.removeClass(boardElement, "tenuki-board-flat"); + utils.addClass(boardElement, "tenuki-board-nonflat"); this.smallerStones = true; } - if (utils.hasClass(boardElement, "tenuki-board-textured")) { - this.texturedStones = true; + this.flatStones = utils.hasClass(boardElement, "tenuki-board-flat"); + + if (!this.flatStones) { + utils.addClass(boardElement, "tenuki-board-nonflat"); } }; @@ -132,7 +135,7 @@ Renderer.prototype = { const specificRendererBoard = this.generateBoard(boardState, { hasCoordinates: this.hasCoordinates, smallerStones: this.smallerStones, - texturedStones: this.texturedStones + flatStones: this.flatStones }); utils.appendElement(zoomContainer, specificRendererBoard); diff --git a/src/svg-renderer.js b/src/svg-renderer.js index 18c015b..5eeb749 100644 --- a/src/svg-renderer.js +++ b/src/svg-renderer.js @@ -11,8 +11,8 @@ SVGRenderer.prototype.constructor = SVGRenderer; const CACHED_CONSTRUCTED_LINES = {}; -const constructSVG = function(renderer, boardState, { hasCoordinates, smallerStones, texturedStones }) { - const cacheKey = [boardState.boardSize, hasCoordinates, smallerStones, texturedStones].toString(); +const constructSVG = function(renderer, boardState, { hasCoordinates, smallerStones, flatStones }) { + const cacheKey = [boardState.boardSize, hasCoordinates, smallerStones, flatStones].toString(); const svg = utils.createSVGElement("svg"); const defs = utils.createSVGElement("defs"); @@ -197,7 +197,7 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto r: stoneRadius }; - if (texturedStones) { + if (!flatStones) { utils.appendElement(intersectionInnerContainer, utils.createSVGElement("circle", { attributes: { class: "stone-shadow", @@ -254,11 +254,11 @@ const constructSVG = function(renderer, boardState, { hasCoordinates, smallerSto return svg; }; -SVGRenderer.prototype.generateBoard = function(boardState, { hasCoordinates, smallerStones, texturedStones }) { +SVGRenderer.prototype.generateBoard = function(boardState, { hasCoordinates, smallerStones, flatStones }) { this.blackGradientID = utils.randomID("black-gradient"); this.whiteGradientID = utils.randomID("white-gradient"); - const svg = constructSVG(this, boardState, { hasCoordinates, smallerStones, texturedStones }); + const svg = constructSVG(this, boardState, { hasCoordinates, smallerStones, flatStones }); this.svgElement = svg; this.svgElement.setAttribute("height", this.BOARD_LENGTH); @@ -285,7 +285,7 @@ SVGRenderer.prototype.setIntersectionClasses = function(intersectionEl, intersec intersectionEl.setAttribute("class", classes.join(" ")); } - if (this.texturedStones) { + if (!this.flatStones) { if (intersection.isEmpty()) { intersectionEl.querySelector(".stone").setAttribute("style", ""); } else { From 7ac312c91250f3822329c45d16cda5d555c9a068 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 20:09:17 -0700 Subject: [PATCH 36/43] General README updates The screenshots and GIFs will only become stale! --- README.md | 9 --------- examples/example.html | 2 +- examples/example_fuzzy_placement.html | 2 +- examples/example_multiboard.html | 2 +- examples/example_with_simple_controls.html | 2 +- examples/example_with_simple_controls_and_gutter.html | 2 +- 6 files changed, 5 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bb8f451..0845114 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,6 @@ For live examples, see `examples/`, or view them on GitHub: These examples are also set up to work on mobile/touch displays, because the board is set to fit within the browser window. -Here are some screenshots and GIFs: - -* [Top-right corner of the board, with some stones](https://i.imgur.com/w6D33pf.png) -* [GIF: Desktop gameplay on a full-sized board](https://i.imgur.com/7cQkoaf.gif) -* [GIF: Desktop gameplay with a smaller board](https://i.imgur.com/N0YgjJD.gif) -* [GIF: Touch-screen gameplay on a mobile device (zooming)](https://i.imgur.com/7UDNTJM.gif) -* [GIF: Touch-screen gameplay (dragging)](https://i.imgur.com/YaIZIkV.gif) -* [GIF: Pinch-zooming yourself on a mobile device](https://i.imgur.com/wvsifZg.gif) - # Installation If you use npm, then `npm install tenuki`. Otherwise, you can download the files you need from [the latest release](https://github.com/aprescott/tenuki/releases). diff --git a/examples/example.html b/examples/example.html index 787c3a2..c41940f 100644 --- a/examples/example.html +++ b/examples/example.html @@ -11,7 +11,7 @@
    -

    This is an example showing a minimal board with no extra controls.

    +

    This is an example showing the default board.

    diff --git a/examples/example_fuzzy_placement.html b/examples/example_fuzzy_placement.html index a37dbd4..fffb4b6 100644 --- a/examples/example_fuzzy_placement.html +++ b/examples/example_fuzzy_placement.html @@ -11,7 +11,7 @@
    -

    This is an example board with fuzzy stone placement, where stones are randomly placed slightly off-center and push other stones out of the way. The button controls (e.g., undo) are not part of the Tenuki library — you can configure and style them however you like.

    +

    This is an example board with fuzzy stone placement, where stones are randomly placed slightly off-center and push other stones out of the way. The button controls are not part of Tenuki, but are an example of the sort of controls you can build yourself.

    diff --git a/examples/example_multiboard.html b/examples/example_multiboard.html index d3221a0..0d27acb 100644 --- a/examples/example_multiboard.html +++ b/examples/example_multiboard.html @@ -22,7 +22,7 @@
    -

    This is an example showing that each board is isolated and self-contained. Moves made on one board are independent from another.

    +

    Multiple boards on a single page are isolated and self-contained. Moves made on one board are independent from other boards.

    diff --git a/examples/example_with_simple_controls.html b/examples/example_with_simple_controls.html index dc6007d..fe0133d 100644 --- a/examples/example_with_simple_controls.html +++ b/examples/example_with_simple_controls.html @@ -11,7 +11,7 @@
    -

    This is an example board with some simple controls. The button controls (e.g., undo) are not part of the Tenuki library — you can configure and style them however you like.

    +

    This is an example board with some simple controls. The button controls are not part of Tenuki, but are an example of the sort of controls you can build yourself.

    diff --git a/examples/example_with_simple_controls_and_gutter.html b/examples/example_with_simple_controls_and_gutter.html index b403ae7..b3d6ee3 100644 --- a/examples/example_with_simple_controls_and_gutter.html +++ b/examples/example_with_simple_controls_and_gutter.html @@ -11,7 +11,7 @@
    -

    This is an example board with some simple controls, with coordinate markers. The button controls (e.g., undo) are not part of the Tenuki library — you can configure and style them however you like.

    +

    This is an example board with some simple controls, with coordinate markers. The button controls are not part of Tenuki, but are an example of the sort of controls you can build yourself.

    From a90e58ac84e78f2e72957d9bfbd44bbb8f6df65a Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Wed, 9 Aug 2017 20:20:22 -0700 Subject: [PATCH 37/43] Some general README content updates --- README.md | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0845114..87cf2c7 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,22 @@ The JavaScript engine is not dependent on the renderer and works stand-alone. Yo The go board interface is intended to be a robust, functional component that can be embedded in a web page. By using the JavaScript API you could then build your own custom controls for undo/pass/etc. -Features: +The game engine supports playing an entire game and has various features and configuration settings: - * Simple ko and superko. - * Pass. - * Undo. + * Both Simple ko and superko rules. * Handicap stones, with or without free placement. * End-game functionality: dead stone marking and scoring. * Different scoring rules: territory, area, equivalence (with pass stones). * Komi. * Seki detection for territory scoring rules. - * Support for playing against a server. - * For the visual interface: - - Built-in mobile support for touch devices and small screens. - - Automatic board resizing and responsiveness. - - Optional coordinate markers for points A19 through T1. - - Optional fuzzy stone placement with collision movement animations. + +The board UI also has its own features: + + * A responsive UI for comfortably playing on touch devices and small screens. + * Automatic board resizing to fit the layout. + * Ko point markers. + * Optional coordinate markers for points A19 through T1. + * Optional fuzzy stone placement with collision movement animations. # Live examples @@ -304,17 +304,6 @@ game.callbacks.postRender = function(game) { }; ``` -# Using `Client` to play against a server - -The `Game` interface is only useful for local play. For play against a remote server, there is the `Client` interface. - -For an example of how to use `Client`, the `test-server/` directory contains a demo client and server setup. - - * `cd test-server/` and run `node server.js`. - * Open `test-server/client.html` in a browser. - -The game is set to run on a fixed 9x9 board. - # Running tests Run tests with `npm test`. From 5b1f28baf6ece606ab4bb13e1d44207d5b15551c Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Sat, 19 Aug 2017 17:00:57 -0400 Subject: [PATCH 38/43] Lighten the darker line/hoshi/coordinate board color --- scss/dom-renderer.scss | 9 ++++++--- scss/svg-renderer.scss | 11 +++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/scss/dom-renderer.scss b/scss/dom-renderer.scss index b53d6b6..04de902 100644 --- a/scss/dom-renderer.scss +++ b/scss/dom-renderer.scss @@ -3,6 +3,9 @@ $gutter-margin: $intersection-gap - 3px; $base-margin: $intersection-gap - 10px; $board-margin: $base-margin + $gutter-margin; +$board-lighter-color: hsl(41, 36%, 44%); +$board-darker-color: hsl(41, 36%, 40%); + .tenuki-dom-renderer { &.tenuki-board { position: relative; @@ -47,7 +50,7 @@ $board-margin: $base-margin + $gutter-margin; } &.tenuki-board .line { - background: rgb(135, 113, 63); + background: $board-lighter-color; float: left; position: relative; } @@ -80,7 +83,7 @@ $board-margin: $base-margin + $gutter-margin; /* gutter margin, less 5px */ line-height: $gutter-margin - 5px; font-family: sans-serif; - color: rgb(135, 113, 63); + color: $board-darker-color; display: block; font-size: 14px; } @@ -145,7 +148,7 @@ $board-margin: $base-margin + $gutter-margin; width: calc(2 * 2px + 1px); height: calc(2 * 2px + 1px); border-radius: 50%; - background: rgb(135, 113, 63); + background: $board-darker-color; position: absolute; } diff --git a/scss/svg-renderer.scss b/scss/svg-renderer.scss index 089e473..14ed064 100644 --- a/scss/svg-renderer.scss +++ b/scss/svg-renderer.scss @@ -1,3 +1,6 @@ +$board-lighter-color: hsl(41, 36%, 44%); +$board-darker-color: hsl(41, 36%, 40%); + .tenuki-svg-renderer { -webkit-user-select: none; user-select: none; @@ -17,13 +20,13 @@ } &.tenuki-board .line-box { - stroke: rgb(135, 113, 63); + stroke: $board-lighter-color; fill: transparent; } &.tenuki-board .hoshi { - stroke: rgb(135, 113, 63); - fill: rgb(135, 113, 63); + stroke: $board-darker-color; + fill: $board-darker-color; } &.tenuki-board .tenuki-inner-container { @@ -179,7 +182,7 @@ } .coordinates { - fill: rgb(135, 113, 63); + fill: $board-darker-color; font-family: sans-serif; font-size: 14px; } From cd159de42ad8f6707c5e50a74b45873d114490d9 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Mon, 9 Jul 2018 15:56:41 -0400 Subject: [PATCH 39/43] Add lockfiles to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 6c43ab4..0728817 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /*.sublime-* /build /tmp +/package-lock.json +/yarn.lock From 18f93bd97cb4571886fb6bf364c119963552367d Mon Sep 17 00:00:00 2001 From: Michael Dougherty Date: Thu, 31 May 2018 13:09:53 -0700 Subject: [PATCH 40/43] Make sure SVG anchor url lacks hash --- src/svg-renderer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/svg-renderer.js b/src/svg-renderer.js index 5eeb749..c1f33ac 100644 --- a/src/svg-renderer.js +++ b/src/svg-renderer.js @@ -289,7 +289,8 @@ SVGRenderer.prototype.setIntersectionClasses = function(intersectionEl, intersec if (intersection.isEmpty()) { intersectionEl.querySelector(".stone").setAttribute("style", ""); } else { - intersectionEl.querySelector(".stone").setAttribute("style", "fill: url(" + window.location + "#" + this[intersection.value + "GradientID"] + ")"); + const base = window.location.href.split('#')[0]; + intersectionEl.querySelector(".stone").setAttribute("style", "fill: url(" + base + "#" + this[intersection.value + "GradientID"] + ")"); } } }; From d063789d60ac1fdf7cc458cfe7bf4f6e864346a6 Mon Sep 17 00:00:00 2001 From: Viktor Linder Date: Fri, 12 Apr 2019 11:34:39 +0200 Subject: [PATCH 41/43] Use browserify 8 at most to not break the build. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 000e4f8..c9f7c64 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "babel-preset-es2015": "*", "babelify": "*", "body-parser": "^1.15.1", - "browserify": "*", + "browserify": "8", "chai": "^3.5.0", "clean-css-cli": "*", "eslint": "*", From 0b572a19cb6df515d3f14720a3a8cef2eee9c286 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Fri, 19 Apr 2019 20:23:06 -0400 Subject: [PATCH 42/43] Update the CHANGELOG with the SVG renderer URI fragment fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4324360..6ec38d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * Dead stone marking now happens in bulk based on regions. * Board and stone colors have been lightened. * `.tenuki-board-textured` is now the default. To use flat stone styling with no gradients or shadows, set `.tenuki-board-flat` as the class name. +* Allow the SVG renderer to function properly when the URL has a URI fragment / hash. (#38, maackle) # v0.2.2 From 5b226a734ca361756ab0bee2baae5a25a2956662 Mon Sep 17 00:00:00 2001 From: Adam Prescott Date: Fri, 19 Apr 2019 20:43:13 -0400 Subject: [PATCH 43/43] Version 0.3.1 --- CHANGELOG.md | 4 ++++ copyright_header.txt | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ec38d6..46f2e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Next version +* ... + +# v0.3.1 + * Change `"superko"` as a ko setting to `"positional-superko"` to be explicit. * Support situational and natural situational superko variants. (#31) * Two consecutive passes will now always end the game under equivalence scoring. Previously, the requirement that white "pass" last was implemented as a game-ending requirement. Now, the game will always end after 2 passes, with the score handling the extra white pass stone to represent a "pass" move. (#30) diff --git a/copyright_header.txt b/copyright_header.txt index a0b199b..3c9c32a 100644 --- a/copyright_header.txt +++ b/copyright_header.txt @@ -1,5 +1,5 @@ /*! - * Tenuki v0.2.2 (https://github.com/aprescott/tenuki) - * Copyright © 2016 Adam Prescott. + * Tenuki v0.3.1 (https://github.com/aprescott/tenuki) + * Copyright © 2016-2019 Adam Prescott. * Licensed under the MIT license. */ diff --git a/package.json b/package.json index c9f7c64..f20153e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tenuki", - "version": "0.2.2", + "version": "0.3.1", "description": "Tenuki is a web-based board and JavaScript library for the game of go/baduk/weiqi.", "main": "index.js", "scripts": {