diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 56be55d9..c2a5851f 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -158,6 +158,7 @@ "queensland": "Create long open corridors between your pieces to score points. Easy, right?", "query": "A connection game on an Alquerque board. Place a piece on an eight-way intersection, or two pieces on four-way intersections.", "quincunx": "A Decktet tableau-building game where the grid gets built as you go. Each play scores points (sometimes negative points). A game consists of as many rounds as there are players. Highest accumulated score wins.", + "rampart": "An annihilation game where players grow from a predetermined set of groups.", "razzle": "Razzle Dazzle, also called Knight Moves, is a fast-paced game, designed to recreate as much of the experience of Ultimate Frisbee as possible while keeping the rules and gameplay simple and elegant. Two players try to move their ball to the opposite side of the board, using their 5 pieces as passing instruments. A player may either pass the ball or move a piece like a knight in a game of Chess.", "realm": "Realm is played on a unique 12x12 board with only two types of mobile pieces. The goal is to capture more territory than your opponent by blocking and immobilizing their pieces. A few simple rules let you generate more pieces, which leads to ever-evolving tactics.", "renju": "An asymmetric five-in-a-row game where the first player is not allowed to create double open threes, double fours, or overlines. Competitive openings like Taraguchi-10 and Soosõrv-8 are implemented here.", @@ -1929,6 +1930,24 @@ "description": "When present, if the card you played creates or extends a continuous line (including diagonal) of four or more cards that share the same suit, or that forms a 2x2 square of cards that share a suit, score 10 points for each line and square." } }, + "rampart": { + "#setup": { + "name": "Standard setup" + }, + "custom": { + "name": "Custom setup (unrated)", + "description": "The players can create arbitrary setups. Should only be done with good communication. These games cannot be rated." + }, + "#board": { + "name": "11x11 board" + }, + "13x13": { + "name": "13x13 board" + }, + "hex7": { + "name": "Hexagonal board (base-7)" + } + }, "realm": { "capturedBases": { "description": "The number of captured bases becomes a second tie breaker.", @@ -4857,6 +4876,13 @@ "INITIAL_INSTRUCTIONS": "Select a card from your hand to place on the board.", "PARTIAL": "Provide the destination." }, + "rampart": { + "BAD_PLACEMENT": "One of the cells you're trying to place a piece on is either nonexistent or occupied.", + "INITIAL_INSTRUCTIONS_play": "Select an empty cell to place a piece or a dead enemy piece to remove the groups.", + "INITIAL_INSTRUCTIONS_setup": "Select any empty cell to place a piece or an existing friendly piece to remove it.", + "PARTIAL_PLACE": "Continue adding and removing pieces until you're done, then click the Complete Move button.", + "PLACE_ONE": "You must place at least one piece." + }, "razzle": { "BAD_DIRECTION": "You can only move the ball orthogonally or diagonally. The move from {{from}} to {{to}} is invalid.", "INCOMPLETE_BALL_MOVE": "You must keep moving the ball, because this position repeats an earlier position and would therefore not be valid.", @@ -5005,13 +5031,13 @@ "INVALID_MOVE": "You may only place a piece where it will flip at least one of your opponent's pieces." }, "rootbound": { - "BAD_SECOND_MOVE": "You must start by making a second group as soon as possible.", - "CLAIMED_CELL": "You may not place a piece into a claimed territory.", "FIRST_MOVE_INSTRUCTIONS": "Place a piece anywhere on the board.", + "SECOND_MOVE_INSTRUCTIONS": "Place two pieces on empty spaces. You must make a second group as soon as possible.", + "THIRD_MOVE_INSTRUCTIONS": "Place one or two pieces on empty spaces. You must make a second group as soon as possible.", "INITIAL_INSTRUCTIONS": "Place one or two pieces on an unclaimed empty space. Your pieces may not form a small triangle nor can you grow a group in a straight line from an existing piece.", + "BAD_SECOND_MOVE": "You must start by making a second group as soon as possible.", + "CLAIMED_CELL": "You may not place a piece into a claimed territory.", "RAPID_GROWTH": "You cannot grow a group in a straight line from an existing piece in a single turn.", - "SECOND_MOVE_INSTRUCTIONS": "Place one or two pieces on empty spaces. You must make a second group as soon as possible.", - "TOO_FEW_GROUPS": "You cannot end a turn with only one group unless it claims territory.", "TOO_MANY_NEIGHBORS": "Your pieces cannot form a small triangle." }, "saltire": { diff --git a/package-lock.json b/package-lock.json index f3dcde23..488989e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4058,7 +4058,6 @@ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4123,7 +4122,6 @@ "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.32.0", "@typescript-eslint/types": "8.32.0", @@ -4553,7 +4551,6 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4612,7 +4609,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5311,7 +5307,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -7122,7 +7117,6 @@ "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7761,7 +7755,6 @@ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -8944,8 +8937,7 @@ "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/graphology-utils": { "version": "2.5.2", @@ -10512,7 +10504,6 @@ "integrity": "sha512-ek8NRg/OPvS9ISOJNWNAz5vZcpYacWNFDWNJjj5OXsc6YuKacfey6wF04cXz/tOJIVrZ2nGSkHpAY5qKtF6ISg==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d": "^1.0.2", "duration": "^0.2.2", @@ -13880,7 +13871,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14335,7 +14325,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14665,7 +14654,6 @@ "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -14714,7 +14702,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -15060,7 +15047,6 @@ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -15292,7 +15278,6 @@ "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/games/arimaa.ts b/src/games/arimaa.ts index 73b834d0..625ba6ed 100644 --- a/src/games/arimaa.ts +++ b/src/games/arimaa.ts @@ -490,11 +490,12 @@ export class ArimaaGame extends GameBase { // clicking an occupied cell after selecting a piece to place if (lastmove.length === 0) { const idx = steps.findIndex(([pc,,f,]) => pc === piece![0] && f === cell); - if (idx === -1) { - throw new Error("This should never happen"); + if (idx >= 0) { + steps.splice(idx, 1); + newmove = steps.map(([pc, p, f,]) => `${p === 1 ? pc : pc.toLowerCase()}${f}`).join(","); + } else { + newmove = stub; } - steps.splice(idx, 1); - newmove = steps.map(([pc, p, f,]) => `${p === 1 ? pc : pc.toLowerCase()}${f}`).join(","); } else { newmove = stub; } @@ -524,7 +525,7 @@ export class ArimaaGame extends GameBase { } } - // console.log(`About to validate ${newmove}`); + // console.log(`About to validate '${newmove}'`); let result = this.validateMove(newmove) as IClickResult; if (! result.valid) { result.move = move; @@ -556,6 +557,11 @@ export class ArimaaGame extends GameBase { if (m.length === 0) { result.valid = true; result.complete = -1; + // if in the setup phase, we need canrender, otherwise don't + result.canrender = false; + if (!this.variants.includes("eee") && this.stack.length <= 2) { + result.canrender = true; + } result.canrender = true; result.message = i18next.t("apgames:validation.arimaa.INITIAL_INSTRUCTIONS", {context: (this.hands !== undefined && this.hands[this.currplayer - 1].length > 0) ? "place" : "play"}); return result; @@ -896,49 +902,51 @@ export class ArimaaGame extends GameBase { } } - // because we don't have a move list to fall back on, - // we do some basic validation as we go and throw on errors - // but we don't go so far as to validate pushes and pulls here - this.results = []; const initial = this.clone(); // used to triple check that the board state changes const lastmove: string[] = []; - const steps = m.split(",").filter(Boolean).map(mv => ArimaaGame.baseMove(mv)); - for (let i = 0; i < steps.length; i++) { - const [pc, owner, from, to] = steps[i]; - // placement - if (this.hands !== undefined && this.hands[this.currplayer - 1].length > 0) { - if (from !== undefined) { - this.board.set(from, [pc, this.currplayer]); - this.results.push({type: "place", what: pc, where: from}); - // update hand - if (!this.variants.includes("free")) { - this.hands![this.currplayer - 1].splice(this.hands![this.currplayer - 1].indexOf(pc), 1); + if (m.length > 0) { + // because we don't have a move list to fall back on, + // we do some basic validation as we go and throw on errors + // but we don't go so far as to validate pushes and pulls here + this.results = []; + const steps = m.split(",").filter(Boolean).map(mv => ArimaaGame.baseMove(mv)); + for (let i = 0; i < steps.length; i++) { + const [pc, owner, from, to] = steps[i]; + // placement + if (this.hands !== undefined && this.hands[this.currplayer - 1].length > 0) { + if (from !== undefined) { + this.board.set(from, [pc, this.currplayer]); + this.results.push({type: "place", what: pc, where: from}); + // update hand + if (!this.variants.includes("free")) { + this.hands![this.currplayer - 1].splice(this.hands![this.currplayer - 1].indexOf(pc), 1); + } + lastmove.push(`${this.currplayer === 1 ? pc : pc.toLowerCase()}${from}`); + } else if (i !== steps.length - 1) { + throw new Error("Invalid placement detected in the middle of the move."); } - lastmove.push(`${this.currplayer === 1 ? pc : pc.toLowerCase()}${from}`); - } else if (i !== steps.length - 1) { - throw new Error("Invalid placement detected in the middle of the move."); } - } - // movement - else { - if (from !== undefined && to !== undefined) { - const moved = this.board.get(from)!; - this.board.set(to, moved); - this.board.delete(from); - this.results.push({type: "move", from, to}); - // check traps - let parenthetical = ""; - for (const trap of traps) { - if (this.board.has(trap) && this.isAlone(trap)) { - const [trapPc, trapOwner] = this.board.get(trap)!; - this.board.delete(trap); - this.results.push({type: "destroy", what: trapOwner === 1 ? trapPc : trapPc.toLowerCase(), where: trap}); - parenthetical = `(x${trapOwner === 1 ? trapPc : trapPc.toLowerCase()}${trap})`; + // movement + else { + if (from !== undefined && to !== undefined) { + const moved = this.board.get(from)!; + this.board.set(to, moved); + this.board.delete(from); + this.results.push({type: "move", from, to}); + // check traps + let parenthetical = ""; + for (const trap of traps) { + if (this.board.has(trap) && this.isAlone(trap)) { + const [trapPc, trapOwner] = this.board.get(trap)!; + this.board.delete(trap); + this.results.push({type: "destroy", what: trapOwner === 1 ? trapPc : trapPc.toLowerCase(), where: trap}); + parenthetical = `(x${trapOwner === 1 ? trapPc : trapPc.toLowerCase()}${trap})`; + } } + lastmove.push(`${owner === 1 ? pc : pc.toLowerCase()}${from}${to}${parenthetical}`); + } else if (i !== steps.length - 1) { + throw new Error("Invalid move detected in the middle of the move."); } - lastmove.push(`${owner === 1 ? pc : pc.toLowerCase()}${from}${to}${parenthetical}`); - } else if (i !== steps.length - 1) { - throw new Error("Invalid move detected in the middle of the move."); } } } @@ -970,7 +978,7 @@ export class ArimaaGame extends GameBase { // clear hands when both are empty if ( (this.hands !== undefined && this.hands[0].length === 0 && this.hands[1].length === 0) || - (this.variants.includes("free") && this.stack.length ===2) + (this.variants.includes("free") && this.stack.length === 2) ) { this.hands = undefined; } diff --git a/src/games/frogger.ts b/src/games/frogger.ts index f5955ea4..682b9e9f 100644 --- a/src/games/frogger.ts +++ b/src/games/frogger.ts @@ -413,6 +413,11 @@ export class FroggerGame extends GameBase { return cards; } + private getFullHand(player: playerid): string[] { + //Returns concatenated hidden and visible hands for player playerId. + return this.hands[player - 1].concat(this.closedhands[player - 1]); + } + private getNextBack(from: string): string[] { //Walk back through the board until we find a free column. //Return an array of all available cells in that column. @@ -492,7 +497,9 @@ export class FroggerGame extends GameBase { let points: string[] = []; const card = Card.deserialize(cardId); if (card === undefined) { - throw new Error(`Could not deserialize the card ${cardId} in getNextForwardsFromCard.`); + //If you can see this, the front end is trying to generate moves + // for the next player from his hidden hand cards. Be forgiving... + return points; } const suits = card.suits.map(s => s.uid); @@ -853,7 +860,8 @@ export class FroggerGame extends GameBase { } public moves(player?: playerid): string[] { - //Used for the autopasser. Not a full move list. + //Used for the autopasser. + //Not a full move list, but better than just one random single move. if (this.gameover) { return []; } @@ -867,12 +875,65 @@ export class FroggerGame extends GameBase { return ["pass"]; } - return [this.randomMove()]; + //Make a list of all possible first moves (out of the three allowed). + let moves:string[] = []; + if (this.checkBlocked()) { + //Note the market must be populated at this point. + moves = this.market.map(card => card + "//"); + return moves; + } + + //List frogs. + let firstFrog = ""; + const freeFrogs: string[] = []; + for (let row = 1; row < this.rows; row++) { + for (let col = 0; col < this.columns - 1; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { + const frog = this.board.get(cell)!; + if ( frog.charAt(1) === this.currplayer.toString() ) { + if (col === 0) + firstFrog = cell; + else + freeFrogs.push(cell); + } + } + } + } + + //Get backward moves. + freeFrogs.forEach( from => { + const toArray = this.getNextBack(from); + toArray.forEach( to => { + moves.push(`${from}-${to}`); //the non-profit move + this.getWhiteMarket(to).forEach( card => moves.push(`${from}-${to},${card}`) ); + }); + }); + + //Get forward moves. + //Now we can use a first frog. + if (firstFrog !== "") + freeFrogs.push(firstFrog); + + const amalgahand = this.getFullHand(this.currplayer); + amalgahand.forEach( card => { + freeFrogs.forEach( from => { + let targets:string[] = this.getNextForwardsForCard(from, card); + //Can sometimes get duplicate targets, so uniquify. + targets = [...new Set(targets)]; + targets.forEach( to => moves.push(`${card}:${from}-${to}`) ); + }); + }); + + return moves; } public randomMove(): string { //We return only one, legal move, for testing purposes. + //Now that there's a move list we could use it, + // but these results are weighted so keeping them. + if (this.gameover) { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); } @@ -913,7 +974,7 @@ export class FroggerGame extends GameBase { if ( handcard ) { //hop forward - const card = this.randomElement( this.closedhands[this.currplayer - 1].concat(this.hands[this.currplayer - 1]) ); + const card = this.randomElement( this.getFullHand(this.currplayer) ); //Card shouldn't be invisible but if it is we need to give up gracefully. if (card === "") { return "hidden"; @@ -925,6 +986,7 @@ export class FroggerGame extends GameBase { let to; const cardObj = Card.deserialize(card); if (cardObj === undefined) { + //Already handled the hidden case, so something else is wrong. throw new Error(`Could not deserialize the card ${card} in randomMove.`); } @@ -1113,10 +1175,12 @@ export class FroggerGame extends GameBase { //Internal autocompletion. let automove = result.autocomplete; result = this.validateMove(automove) as IClickResult; + //console.log("first autocomplete", automove); //A double auto-completion may be needed. if (result.autocomplete !== undefined) { automove = result.autocomplete; result = this.validateMove(automove) as IClickResult; + //console.log("double autocompleting", automove); } result.move = automove; } else { @@ -1266,14 +1330,14 @@ export class FroggerGame extends GameBase { //Check cards. //(The case remaining with no card is falling back at no profit.) if (subIFM.card) { - if (subIFM.forward && (cloned.closedhands[cloned.currplayer - 1].concat(cloned.hands[cloned.currplayer - 1])).indexOf(subIFM.card) < 0 ) { + if (subIFM.forward && cloned.getFullHand(cloned.currplayer).indexOf(subIFM.card) < 0 ) { //Bad hand card. result.valid = false; result.message = i18next.t("apgames:validation.frogger.NO_SUCH_HAND_CARD", {card: subIFM.card}); return result; } else if (!subIFM.forward && cloned.market.indexOf(subIFM.card) < 0 ) { //Bad card? Unless... - if ( (cloned.closedhands[cloned.currplayer - 1].concat(cloned.hands[cloned.currplayer - 1])).indexOf(subIFM.card) > -1 ) { + if ( cloned.getFullHand(cloned.currplayer).indexOf(subIFM.card) > -1 ) { //The player clicked on a hand card to start the next move. //We use autocompletion to patch up this case. result.valid = true; @@ -1349,7 +1413,9 @@ export class FroggerGame extends GameBase { result.message = i18next.t("apgames:validation.frogger.PLACE_NEXT"); //Internal autocompletion: - const targets:string[] = subIFM.forward ? cloned.getNextForwardsForCard(subIFM.from, subIFM.card!) : this.getNextBack(subIFM.from); + let targets:string[] = subIFM.forward ? cloned.getNextForwardsForCard(subIFM.from, subIFM.card!) : cloned.getNextBack(subIFM.from); + //Can sometimes get duplicate targets, so uniquify. + targets = [...new Set(targets)]; if (targets.length === 1) { result.autocomplete = m + targets[0] + (subIFM.forward ? "/" : ""); } @@ -1794,7 +1860,7 @@ export class FroggerGame extends GameBase { if (this._highlight.indexOf(card.uid) > -1) { glyph = card.toGlyph({border: true, fill: { func: "flatten", - fg: "_context_strokes", + fg: "_context_labels", bg: "_context_background", opacity: 0.2 }}); @@ -1843,7 +1909,7 @@ export class FroggerGame extends GameBase { }, { text: count.toString(), - colour: "_context_strokes", + colour: "_context_labels", scale: 0.66 } ] @@ -1886,7 +1952,7 @@ export class FroggerGame extends GameBase { // build pieces areas const areas: AreaPieces[] = []; for (let p = 1; p <= this.numplayers; p++) { - const hand = this.closedhands[p-1].concat(this.hands[p-1]); + const hand = this.getFullHand(p as playerid); if (hand.length > 0) { areas.push({ type: "pieces", @@ -1926,10 +1992,13 @@ export class FroggerGame extends GameBase { // create an area for all invisible cards (if there are any cards left) const hands = this.hands.map(h => [...h]); - const visibleCards = [...this.getBoardCards(), ...hands.flat(), ...this.market, ...this.discards].map(uid => Card.deserialize(uid)); + const closedhands = this.closedhands.map(h => [...h]).flat().filter(c => c !== ""); + const visibleCards = [...this.getBoardCards(), ...hands.flat(), ...this.market, ...this.discards, ...closedhands].map(uid => Card.deserialize(uid)); + if (visibleCards.includes(undefined)) { throw new Error("Could not deserialize one of the cards. This should never happen!"); } + const remaining = allcards.sort(cardSortAsc).filter(c => visibleCards.find(cd => cd!.uid === c.uid) === undefined).map(c => "c" + c.uid) as [string, ...string[]] if (remaining.length > 0) { areas.push({ diff --git a/src/games/index.ts b/src/games/index.ts index e5c2ab65..1c551e66 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -201,7 +201,6 @@ import { AssemblyGame, IAssemblyState } from "./assembly"; import { PaintbucketGame, IPaintbucketState } from "./paintbucket"; import { C1Game, IC1State } from "./c1"; import { BloqueoGame, IBloqueoState } from "./bloqueo"; -import { StormCGame, IStormCState } from "./stormc"; import { PilastriGame, IPilastriState } from "./pilastri"; import { TessellaGame, ITessellaState } from "./tessella"; import { GorogoGame, IGorogoState } from "./gorogo"; @@ -218,6 +217,7 @@ import { LascaGame, ILascaState } from "./lasca"; import { EmergoGame, IEmergoState } from "./emergo"; import { FroggerGame, IFroggerState } from "./frogger"; import { ArimaaGame, IArimaaState } from "./arimaa"; +import { RampartGame, IRampartState } from "./rampart"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -422,7 +422,6 @@ export { PaintbucketGame, IPaintbucketState, C1Game, IC1State, BloqueoGame, IBloqueoState, - StormCGame, IStormCState, PilastriGame, IPilastriState, TessellaGame, ITessellaState, GorogoGame, IGorogoState, @@ -439,6 +438,7 @@ export { EmergoGame, IEmergoState, FroggerGame, IFroggerState, ArimaaGame, IArimaaState, + RampartGame, IRampartState, }; const games = new Map(); // Manually add each game to the following array [ @@ -546,9 +547,9 @@ const games = new Map { if (games.has(g.gameinfo.uid)) { throw new Error("Another game with the UID '" + g.gameinfo.uid + "' has already been used. Duplicates are not allowed."); @@ -962,8 +963,6 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new C1Game(...args); case "bloqueo": return new BloqueoGame(...args); - case "stormc": - return new StormCGame(...args); case "pilastri": return new PilastriGame(...args); case "tessella": @@ -996,6 +995,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new FroggerGame(args[0], ...args.slice(1)); case "arimaa": return new ArimaaGame(...args); + case "rampart": + return new RampartGame(...args); } return; } diff --git a/src/games/rampart.ts b/src/games/rampart.ts new file mode 100644 index 00000000..ec286509 --- /dev/null +++ b/src/games/rampart.ts @@ -0,0 +1,576 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, RowCol } from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { HexTriGraph, reviver, SquareOrthGraph, UserFacingError } from "../common"; +import i18next from "i18next"; +import { IGraph } from "../common/graphs"; +import { connectedComponents } from "graphology-components"; + +export type playerid = 1|2; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + board: Map; + lastmove?: string; +}; + +export interface IRampartState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +export class RampartGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Rampart", + uid: "rampart", + playercounts: [2], + version: "20260106", + dateAdded: "2023-12-20", + // i18next.t("apgames:descriptions.rampart") + description: "apgames:descriptions.rampart", + urls: [ + "https://boardgamegeek.com/boardgame/133923/rampart", + "https://boardgamegeek.com/boardgame/134259/hex-rampart", + ], + people: [ + { + type: "designer", + name: "Corey Clark", + urls: ["https://boardgamegeek.com/boardgamedesigner/38921/corey-clark"], + apid: "d1cd6092-7429-4241-826b-bbc157d08d93", + }, + { + type: "coder", + name: "Aaron Dalton (Perlkönig)", + urls: [], + apid: "124dd3ce-b309-4d14-9c8e-856e56241dfe", + }, + ], + variants: [ + { uid: "13x13", group: "board" }, + { uid: "hex7", group: "board" }, + { uid: "custom", group: "setup", unrated: true }, + ], + categories: ["goal>annihilate", "mechanic>place", "mechanic>capture", "board>shape>rect", "board>connect>rect", "board>shape>hex", "board>connect>hex", "components>simple>1per"], + flags: ["experimental", "automove"] + }; + + public numplayers = 2; + public currplayer: playerid = 1; + public board!: Map; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + + constructor(state?: IRampartState | string, variants?: string[]) { + super(); + if (state === undefined) { + if (variants !== undefined) { + this.variants = [...variants]; + } + let board = new Map(); + if (!this.variants.includes("custom")) { + if (this.variants.includes("13x13")) { + board = new Map([ + ["c13", 2], ["g13", 2], ["k13", 2], + ["c9", 2], ["g9", 2], ["k9", 2], + ["c5", 2], ["g5", 2], ["k5", 2], + ["c1", 2], ["g1", 2], ["k1", 2], + ["a11", 1], ["e11", 1], ["i11", 1], ["m11", 1], + ["a7", 1], ["e7", 1], ["i7", 1], ["m7", 1], + ["a3", 1], ["e3", 1], ["i3", 1], ["m3", 1], + ]); + } else if (this.variants.includes("hex7")) { + board = new Map([ + ["c1", 2], ["c8", 2], ["d6", 2], ["e11", 2], + ["f2", 2], ["h4", 2], ["i8", 2], ["m3", 2], ["l6", 2], + ["a5", 1], ["b3", 1], ["e4", 1], ["f9", 1], + ["h11", 1], ["i1", 1], ["j5", 1], ["k2", 1], ["k9", 1], + ]); + } + // otherwise default 11x11 + else { + board = new Map([ + ["a11", 2], ["e11", 2], ["i11", 2], + ["c9", 1], ["g9", 1], ["k9", 1], + ["a7", 2], ["e7", 2], ["i7", 2], + ["c5", 1], ["g5", 1], ["k5", 1], + ["a3", 2], ["e3", 2], ["i3", 2], + ["c1", 1], ["g1", 1], ["k1", 1], + ]); + } + } + + const fresh: IMoveState = { + _version: RampartGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IRampartState; + } + if (state.game !== RampartGame.gameinfo.uid) { + throw new Error(`The Rampart engine cannot process a game of '${state.game}'.`); + } + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = [...state.variants]; + this.stack = [...state.stack]; + } + this.load(); + } + + public load(idx = -1): RampartGame { + if (idx < 0) { + idx += this.stack.length; + } + if ( (idx < 0) || (idx >= this.stack.length) ) { + throw new Error("Could not load the requested state from the stack."); + } + + const state = this.stack[idx]; + this.results = [...state._results]; + this.currplayer = state.currplayer; + this.board = new Map(state.board); + this.lastmove = state.lastmove; + return this; + } + + public get graph(): IGraph { + if (this.variants.includes("hex7")) { + return new HexTriGraph(7, 13); + } else if (this.variants.includes("13x13")) { + return new SquareOrthGraph(13, 13); + } else { + return new SquareOrthGraph(11, 11); + } + } + + public moves(player?: playerid): string[] { + if (this.gameover) { return []; } + if (player === undefined) { + player = this.currplayer; + } + const opp = player === 1 ? 2 : 1; + const moves: string[] = []; + + // ignore setup + if (this.variants.includes("custom") && this.stack.length <= 2) { + return []; + } + + const g = this.graph; + // placements first + const mine = [...this.board.entries()].filter(([,p]) => p === player).map(([cell,]) => cell); + const uniques = new Set(); + for (const cell of mine) { + const ns = g.neighbours(cell); + for (const n of ns) { + if (!this.board.has(n)) { + uniques.add(n); + } + } + } + moves.push(...uniques); + + // captures + const theirs = [...this.board.entries()].filter(([,p]) => p === opp).map(([cell,]) => cell); + for (const cell of theirs) { + if (this.isDead(cell, g)) { + moves.push(`x${cell}`); + } + } + + if (moves.length === 0) { + moves.push("pass"); + } + return moves.sort((a,b) => a.localeCompare(b)) + } + + public isDead(cell: string, g?: IGraph): boolean { + if (!this.board.has(cell)) { + return false; + } + const player = this.board.get(cell)!; + const opp = player === 1 ? 2 : 1; + if (g === undefined) { + g = this.graph; + } + let surrounded = true; + const surrBy = new Set(); + for (const n of g.neighbours(cell)) { + if (!this.board.has(n)) { + surrounded = false; + break; + } else { + surrBy.add(this.board.get(n)!); + } + } + if (surrounded) { + // in default 11x11, one of the surrounding pieces must be opposing + // all `board` variants need to be in the following array + if (!["13x13", "hex7"].some(v => this.variants.includes(v))) { + if (surrBy.has(opp)) { + return true; + } + return false; + } + // everything else, it just needs to be surrounded + else { + return surrounded; + } + } + return false; + } + + public randomMove(): string { + const moves = this.moves(); + return moves[Math.floor(Math.random() * moves.length)]; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + try { + const g = this.graph; + const cell = g.coords2algebraic(col, row); + let newmove = ""; + // setup first + if (this.variants.includes("custom") && this.stack.length <= 2) { + const moves = new Set(move.split(",").filter(Boolean)); + if (!moves.has(cell)) { + moves.add(cell); + } else { + moves.delete(cell); + } + newmove = [...moves].join(","); + } + // regular play + else { + // empty cell + if (!this.board.has(cell)) { + newmove = cell; + } + // occupied enemy + else if (this.board.get(cell) !== this.currplayer) { + newmove = `x${cell}`; + } + } + + const result = this.validateMove(newmove) as IClickResult; + if (! result.valid) { + result.move = move; + } else { + result.move = newmove; + } + return result; + } catch (e) { + return { + move, + valid: false, + message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) + } + } + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation.rampart.INITIAL_INSTRUCTIONS", {context: (this.variants.includes("custom") && this.stack.length <= 2) ? "setup": "play"}); + return result; + } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + const g = this.graph; + + // setup + if (this.variants.includes("custom") && this.stack.length <= 2) { + const allcells = new Set(g.listCells() as string[]); + const moves = [...new Set(m.split(",").filter(Boolean))]; + if (moves.some(cell => this.board.has(cell)) || moves.some(cell => !allcells.has(cell))) { + result.valid = false; + result.message = i18next.t("apgames:validation.rampart.BAD_PLACEMENT"); + return result; + } + // you must place at least one + if (moves.length === 0) { + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation.rampart.PLACE_ONE"); + return result; + } + // otherwise, always 0 + result.valid = true; + result.complete = 0; + result.canrender = true; + result.message = i18next.t("apgames:validation.rampart.PARTIAL_PLACE"); + return result; + } + // regular play + else { + const allMoves = this.moves(); + if (m === "pass") { + if (! allMoves.includes("pass")) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALID_PASS") + return result; + } else { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + } + if (! allMoves.includes(m)) { + result.valid = false; + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m}); + return result; + } + + // we're good + result.valid = true; + result.complete = 1; + result.canrender = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + } + + public move(m: string, {partial = false, trusted = false} = {}): RampartGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + let result; + if (! trusted) { + result = this.validateMove(m); + if (! result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message) + } + // all partial moves should still be in the move list + const allmoves = this.moves(); + if ( (! partial) && (allmoves.length > 0) && (! this.moves().includes(m)) ) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) + } + } + if (m === "") { return this; } + + this.results = []; + // setup first + if (this.variants.includes("custom") && this.stack.length <= 2) { + const moves = new Set(m.split(",").filter(Boolean)); + for (const cell of moves) { + this.board.set(cell, this.currplayer); + this.results.push({type: "place", where: cell}); + } + } + // regular play + else { + if (m === "pass") { + this.results = [{ type: "pass" }]; + } else { + // captures + if (m.startsWith("x")) { + const cell = m.substring(1); + const g = this.graph.graph; + const opp = this.currplayer === 1 ? 2 : 1; + const theirs = new Set([...this.board.entries()].filter(([,p]) => p === opp).map(([cell,]) => cell)); + for (const node of [...g.nodes()]) { + if (!theirs.has(node)) { + g.dropNode(node); + } + } + const conn = connectedComponents(g); + const capped = conn.find(grp => grp.includes(cell)); + if (capped === undefined) { + throw new Error(`Could not find a group that contains the cell ${cell}.`); + } + capped.forEach(cell => { + this.board.delete(cell); + this.results.push({type: "capture", where: cell}); + }); + } + // placements + else { + this.board.set(m, this.currplayer); + this.results.push({type: "place", where: m}); + } + } + } + + if (partial) { return this; } + + // update currplayer + this.lastmove = m; + let newplayer = (this.currplayer as number) + 1; + if (newplayer > this.numplayers) { + newplayer = 1; + } + this.currplayer = newplayer as playerid; + + this.checkEOG(); + this.saveState(); + return this; + } + + protected checkEOG(): RampartGame { + // don't do any checks in setup mode + if (this.variants.includes("custom") && this.stack.length <= 2) { + return this; + } + + const one = [...this.board.entries()].filter(([,p]) => p === 1).map(([cell,]) => cell); + const two = [...this.board.entries()].filter(([,p]) => p === 2).map(([cell,]) => cell); + if (one.length === 0) { + this.gameover = true; + this.winner = [2]; + } else if (two.length === 0) { + this.gameover = true; + this.winner = [1]; + } + + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(): IRampartState { + return { + game: RampartGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + } + + public moveState(): IMoveState { + return { + _version: RampartGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + board: new Map(this.board), + }; + } + + public render(): APRenderRep { + // Build piece string + const g = this.graph; + const deadRed: RowCol[] = []; + const deadBlue: RowCol[] = []; + + let pstr = ""; + for (const row of g.listCells(true) as string[][]) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (const cell of row) { + if (this.board.has(cell)) { + const contents = this.board.get(cell)!; + if (contents === 1) { + pieces.push("A"); + if (this.isDead(cell)) { + const [x, y] = g.algebraic2coords(cell); + deadRed.push({col: x, row: y}); + } + } else { + pieces.push("B"); + if (this.isDead(cell)) { + const [x, y] = g.algebraic2coords(cell); + deadBlue.push({col: x, row: y}); + } + } + } else { + pieces.push("-"); + } + } + pstr += pieces.join(""); + } + + // Build rep + const rep: APRenderRep = { + board: (this.variants.includes("13x13")) ? { + style: "vertex", + width: 13, + height: 13, + } + : this.variants.includes("hex7") ? { + style: "hex-of-hex", + minWidth: 7, + maxWidth: 13, + } : { + style: "vertex", + width: 11, + height: 11, + }, + legend: { + A: { name: "piece", colour: 1 }, + B: { name: "piece", colour: 2 }, + }, + pieces: pstr + }; + + // Add annotations + rep.annotations = []; + + // add dots on dead stones + if (deadRed.length > 0 || deadBlue.length > 0) { + if (deadRed.length > 0) { + rep.annotations.push({type: "dots", targets: deadRed as [RowCol, ...RowCol[]], colour: 2}); + } + if (deadBlue.length > 0) { + rep.annotations.push({type: "dots", targets: deadBlue as [RowCol, ...RowCol[]], colour: 1}); + } + } + + if (this.results.length > 0) { + for (const move of this.results) { + if (move.type === "place") { + const [x, y] = g.algebraic2coords(move.where!); + rep.annotations.push({type: "enter", targets: [{row: y, col: x}]}); + } + if (move.type === "capture") { + const [x, y] = g.algebraic2coords(move.where!); + rep.annotations.push({type: "exit", targets: [{row: y, col: x}]}); + } + } + } + + return rep; + } + + public status(): string { + let status = super.status(); + + if (this.variants !== undefined) { + status += "**Variants**: " + this.variants.join(", ") + "\n\n"; + } + + return status; + } + + public clone(): RampartGame { + return new RampartGame(this.serialize()); + } +} diff --git a/src/games/rootbound.ts b/src/games/rootbound.ts index 6606abec..40edef5f 100644 --- a/src/games/rootbound.ts +++ b/src/games/rootbound.ts @@ -368,7 +368,9 @@ export class RootBoundGame extends GameBase { } const validFirstMoves = (this.listCells() as string[]).filter(c => !prohibitedCells.includes(c) && this.isValidPlacement(player!, c)).sort(); - moves.push(...validFirstMoves); + if (this.stack.length !== 2) { + moves.push(...validFirstMoves); + } if (this.stack.length > 1) { @@ -450,6 +452,8 @@ export class RootBoundGame extends GameBase { result.complete = -1; if (this.stack.length > 3) { result.message = i18next.t("apgames:validation.rootbound.INITIAL_INSTRUCTIONS"); + } else if (this.stack.length > 2) { + result.message = i18next.t("apgames:validation.rootbound.THIRD_MOVE_INSTRUCTIONS"); } else if (this.stack.length > 1) { result.message = i18next.t("apgames:validation.rootbound.SECOND_MOVE_INSTRUCTIONS"); } else { @@ -459,8 +463,14 @@ export class RootBoundGame extends GameBase { } const moves = this.moves(); + const cells: string[] = m.split(","); + + if (this.stack.length < 4 && m === "pass") { + result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m}); + return result; + } + if (m !== "pass") { - const cells: string[] = m.split(","); if (cells.length > 2) { result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m}); return result; @@ -502,7 +512,6 @@ export class RootBoundGame extends GameBase { } if (cells.length === 2 && this.getGraph().neighbours(cells[0]).includes(cells[1])) { - const boardClone = deepclone(this.board) as Map; boardClone.set(cells[0], [this.currplayer, 10000]); const neighbors = this.getGraph().neighbours(cells[1]).filter(c => boardClone.has(c) && boardClone.get(c)![0] === this.currplayer); @@ -514,8 +523,8 @@ export class RootBoundGame extends GameBase { } } - if (this.stack.length === 2 && this.getGraph().neighbours(cells[0]).includes(cells[1]) || - this.stack.length === 3 && this.getGraph().neighbours(cells[0]).includes(cells[1]) && this.getEmptyNeighborsOfGroup(0).includes(cells[1])) { + if ((this.stack.length === 2 && this.getGraph().neighbours(cells[0]).includes(cells[1])) || + (this.stack.length === 3 && this.getGraph().neighbours(cells[0]).includes(cells[1]) && this.getEmptyNeighborsOfGroup(0).includes(cells[1]))) { result.message = i18next.t("apgames:validation.rootbound.BAD_SECOND_MOVE"); return result; } @@ -527,16 +536,28 @@ export class RootBoundGame extends GameBase { } } - if (moves.includes(m)) { + if (moves.includes(m) || (this.stack.length === 2 && cells.length === 1 && m !== "pass")) { result.valid = true; result.canrender = true; - result.message = i18next.t("apgames:validation._general.VALID_MOVE"); - const cells: string[] = m.split(","); - if (this.stack.length > 1 && cells.length === 1 && m !== "pass") { + if ((this.stack.length > 3 && (cells.length === 2 || m === "pass")) + || (this.stack.length > 1 && cells.length === 2) + || (this.stack.length === 1 && cells.length === 1)) { + result.complete = 1; + } else if (this.stack.length > 2 && cells.length === 1) { result.complete = 0; + } else if (this.stack.length === 2 && cells.length === 1) { + result.complete = -1; + } + + if (this.stack.length > 3) { + result.message = i18next.t("apgames:validation.rootbound.INITIAL_INSTRUCTIONS"); + } else if (this.stack.length > 2) { + result.message = i18next.t("apgames:validation.rootbound.THIRD_MOVE_INSTRUCTIONS"); + } else if (this.stack.length > 1) { + result.message = i18next.t("apgames:validation.rootbound.SECOND_MOVE_INSTRUCTIONS"); } else { - result.complete = 1; + result.message = i18next.t("apgames:validation.rootbound.FIRST_MOVE_INSTRUCTIONS"); } } @@ -579,7 +600,7 @@ export class RootBoundGame extends GameBase { return this; } - public move(m: string, {trusted = false} = {}): RootBoundGame { + public move(m: string, {partial = false, trusted = false} = {}): RootBoundGame { if (m === "") return this; if (this.gameover) { @@ -590,11 +611,13 @@ export class RootBoundGame extends GameBase { m = m.toLowerCase(); m = m.replace(/\s+/g, ""); + let complete = false; if (!trusted) { const result = this.validateMove(m); if (!result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message); } + complete = result.complete !== undefined && result.complete >= 0; } if (m === "pass") { @@ -636,6 +659,8 @@ export class RootBoundGame extends GameBase { claimedRegions = this.computeClaimedRegions(); } + if (partial || !complete) return this; + // update currplayer this.lastmove = m; this.currplayer = ((this.currplayer as number) % this.numplayers) + 1 as PlayerId; diff --git a/src/games/stormc.ts b/src/games/stormc.ts deleted file mode 100644 index 9abb7141..00000000 --- a/src/games/stormc.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; -import { APGamesInformation } from "../schemas/gameinfo"; -import { APRenderRep, RowCol } from "@abstractplay/renderer/src/schemas/schema"; -import { APMoveResult } from "../schemas/moveresults"; -import { Direction, reviver, SquareDirectedGraph, UserFacingError } from "../common"; -import i18next from "i18next"; -// eslint-disable-next-line @typescript-eslint/no-require-imports -const deepclone = require("rfdc/default"); - -type playerid = 1 | 2; - -interface IMoveState extends IIndividualState { - currplayer: playerid; - board: Map; - lastmove?: string; -} - -export interface IStormCState extends IAPGameState { - winner: playerid[]; - stack: Array; -}; - -export class StormCGame extends GameBase { - public static readonly gameinfo: APGamesInformation = { - name: "Storm Clouds", - uid: "stormc", - playercounts: [2], - version: "20250422", - dateAdded: "2025-03-21", - // i18next.t("apgames:descriptions.stormc") - description: "apgames:descriptions.stormc", - urls: ["https://boardgamegeek.com/boardgame/429340/storm-clouds"], - people: [ - { - type: "designer", - name: "Corey Clark", - urls: ["https://boardgamegeek.com/boardgamedesigner/38921/corey-clark"], - apid: "d1cd6092-7429-4241-826b-bbc157d08d93", - }, - { - type: "coder", - name: "Aaron Dalton (Perlkönig)", - urls: [], - apid: "124dd3ce-b309-4d14-9c8e-856e56241dfe", - }, - ], - variants: [ - ], - categories: ["goal>annihilate", "mechanic>move", "mechanic>capture", "mechanic>asymmetry", "board>shape>rect", "board>connect>rect", "components>simple>1per"], - flags: ["experimental", "automove"], - }; - - public coords2algebraic(x: number, y: number): string { - return this.graph.coords2algebraic(x, y); - } - public algebraic2coords(cell: string): [number, number] { - return this.graph.algebraic2coords(cell); - } - - public numplayers = 2; - public currplayer!: playerid; - public board!: Map; - public gameover = false; - public winner: playerid[] = []; - public stack!: Array; - public results: Array = []; - public variants: string[] = []; - private dots: string[] = []; - - constructor(state?: IStormCState | string, variants?: string[]) { - super(); - if (state === undefined) { - if (variants !== undefined) { - this.variants = [...variants]; - } - const board = new Map([ - ["a8", 1],["a7", 1],["a6", 1],["a5", 1],["a4", 1],["a3", 1], - ["b8", 1],["b7", 1],["b6", 1],["b5", 1],["b4", 1],["b3", 1], - ["c1", 2],["d1", 2],["e1", 2],["f1", 2],["g1", 2],["h1", 2], - ["c2", 2],["d2", 2],["e2", 2],["f2", 2],["g2", 2],["h2", 2], - ]); - const fresh: IMoveState = { - _version: StormCGame.gameinfo.version, - _results: [], - _timestamp: new Date(), - currplayer: 1, - board, - }; - this.stack = [fresh]; - } else { - if (typeof state === "string") { - state = JSON.parse(state, reviver) as IStormCState; - } - if (state.game !== StormCGame.gameinfo.uid) { - throw new Error(`The StormC game code cannot process a game of '${state.game}'.`); - } - this.gameover = state.gameover; - this.winner = [...state.winner]; - this.variants = state.variants; - this.stack = [...state.stack]; - } - this.load(); - } - - public load(idx = -1): StormCGame { - if (idx < 0) { - idx += this.stack.length; - } - if ( (idx < 0) || (idx >= this.stack.length) ) { - throw new Error("Could not load the requested state from the stack."); - } - - const state = this.stack[idx]; - if (state === undefined) { - throw new Error(`Could not load state index ${idx}`); - } - this.results = [...state._results]; - this.currplayer = state.currplayer; - this.board = new Map([...state.board.entries()]); - this.lastmove = state.lastmove; - return this; - } - - protected get boardSize(): number { - return 8; - } - - private get graph(): SquareDirectedGraph { - return new SquareDirectedGraph(this.boardSize, this.boardSize); - } - - public moves(player?: playerid): string[] { - if (this.gameover) { return []; } - if (player === undefined) { - player = this.currplayer; - } - - const moves: string[] = []; - - const g = this.graph; - const mine = [...this.board.entries()].filter(([,v]) => v === player).map(([k,]) => k); - const nonCapDirs: Direction[] = player === 1 ? ["N", "NE", "E", "SE"] : ["E", "NE", "N", "NW"]; - const capDirs: Direction[] = player === 1 ? ["S", "SW", "W", "NW"] : ["SE", "S", "SW", "W"]; - for (const start of mine) { - // noncapturing - for (const dir of nonCapDirs) { - const ray = g.ray(start, dir); - if (ray.length > 0) { - const next = ray[0]; - if (!this.board.has(next)) { - moves.push(`${start}-${next}`); - } - } - } - // capturing - for (const dir of capDirs) { - const ray = g.ray(start, dir); - const occ = ray.find(c => this.board.has(c)); - if (occ !== undefined) { - const contents = this.board.get(occ)!; - if (contents !== player) { - moves.push(`${start}x${occ}`) - } - } - } - } - - if (moves.length === 0) { - moves.push("pass"); - } - return moves.sort((a,b) => a.localeCompare(b)); - } - - public randomMove(): string { - const moves = this.moves(); - return moves[Math.floor(Math.random() * moves.length)]; - } - - public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { - try { - const cell = this.coords2algebraic(col, row); - let newmove: string; - // empty move, selecting a piece - if (move === "") { - newmove = cell; - } - // otherwise, continuation - else { - if (this.board.has(cell)) { - const contents = this.board.get(cell); - // if own pawn, reset - if (contents === this.currplayer) { - newmove = cell; - } else { - const [start,] = move.split(/[-x]/); - newmove = `${start}x${cell}`; - } - } else { - const [start,] = move.split(/[-x]/); - newmove = `${start}-${cell}`; - } - } - - // autocomplete - const matches = this.moves().filter(mv => mv.startsWith(newmove)); - if (matches.length === 1) { - newmove = matches[0]; - } - - const result = this.validateMove(newmove) as IClickResult; - if (!result.valid) { - result.move = move; - } else { - result.move = newmove; - } - return result; - } catch (e) { - return { - move, - valid: false, - message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message}) - } - } - } - - public validateMove(m: string): IValidationResult { - const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")}; - - m = m.toLowerCase(); - m = m.replace(/\s+/g, ""); - - if (m.length === 0) { - result.valid = true; - result.complete = -1; - result.canrender = true; - result.message = i18next.t("apgames:validation.stormc.INITIAL_INSTRUCTIONS"); - return result; - } - - const allMoves = this.moves(); - if (allMoves.includes(m)) { - // we're good - result.valid = true; - result.complete = 1; - result.message = i18next.t("apgames:validation._general.VALID_MOVE"); - return result; - } - // otherwise look for partials - else { - const matches = allMoves.filter(mv => mv.startsWith(m)); - if (matches.length > 0) { - result.valid = true; - result.complete = -1; - result.canrender = true; - result.message = i18next.t("apgames:validation.stormc.PARTIAL"); - return result; - } else { - if (m.length === 2) { - result.valid = false; - result.message = i18next.t("apgames:validation._general.NO_MOVES", {where: m}); - return result; - } else { - result.valid = false; - result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m}); - return result; - } - } - } - } - - public move(m: string, {partial = false, trusted = false} = {}): StormCGame { - if (this.gameover) { - throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); - } - - m = m.toLowerCase(); - m = m.replace(/\s+/g, ""); - const allMoves = this.moves(); - if (!trusted) { - const result = this.validateMove(m); - if (!result.valid) { - throw new UserFacingError("VALIDATION_GENERAL", result.message); - } - if (!partial && !allMoves.includes(m)) { - throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m})) - } - } - - this.results = []; - this.dots = []; - - if (partial) { - if (m.length === 2) { - const matches = new Set(allMoves.filter(mv => mv.startsWith(m)).map(mv => { - const [,end] = mv.split(/[-x]/); - return end; - })); - this.dots = [...matches]; - } - return this; - } - - if (m === "pass") { - this.results.push({ type: "pass", who: this.currplayer }); - } else { - const [from, to] = m.split(/[-x]/); - this.board.delete(from); - this.board.set(to, this.currplayer); - this.results.push({type: "move", from, to}); - if (m.includes("x")) { - this.results.push({type: "capture", where: to}); - } - } - - this.lastmove = m; - this.currplayer = this.currplayer % 2 + 1 as playerid; - this.checkEOG(); - this.saveState(); - return this; - } - - protected checkEOG(): StormCGame { - // game only ends if one player has no pieces on the board - const count1 = [...this.board.values()].filter(p => p === 1).length; - const count2 = [...this.board.values()].filter(p => p === 2).length; - if (count1 === 0) { - this.gameover = true; - this.winner = [2]; - } else if (count2 === 0) { - this.gameover = true; - this.winner = [1]; - } - - if (this.gameover) { - this.results.push({type: "eog"}); - this.results.push({type: "winners", players: [...this.winner]}); - } - return this; - } - - public state(): IStormCState { - return { - game: StormCGame.gameinfo.uid, - numplayers: 2, - variants: this.variants, - gameover: this.gameover, - winner: [...this.winner], - stack: [...this.stack], - }; - } - - protected moveState(): IMoveState { - return { - _version: StormCGame.gameinfo.version, - _results: [...this.results], - _timestamp: new Date(), - currplayer: this.currplayer, - lastmove: this.lastmove, - board: new Map([...this.board.entries()]), - }; - } - - public render(): APRenderRep { - const g = this.graph; - // Build piece string - const pstr: string[][] = []; - const cells = g.listCells(true) as string[][]; - for (const row of cells) { - const pieces: string[] = []; - for (const cell of row) { - if (this.board.has(cell)) { - const owner = this.board.get(cell)!; - if (owner === 1) { - pieces.push("A") - } else { - pieces.push("B"); - } - } else { - pieces.push("-"); - } - } - pstr.push(pieces); - } - // Build rep - const rep: APRenderRep = { - board: { - style: "squares-checkered", - width: this.boardSize, - height: this.boardSize, - }, - legend: { - A: [{ name: "piece", colour: 1 }], - B: [{ name: "piece", colour: 2 }], - }, - pieces: pstr.map(p => p.join("")).join("\n"), - }; - - // Add annotations - if (this.results.length > 0) { - rep.annotations = []; - for (const move of this.results) { - if (move.type === "move") { - const [fx, fy] = g.algebraic2coords(move.from); - const [tx, ty] = g.algebraic2coords(move.to); - rep.annotations.push({ type: "move", targets: [{ row: fy, col: fx }, { row: ty, col: tx }] }); - } else if (move.type === "capture") { - const targets: RowCol[] = []; - for (const m of move.where!.split(", ")) { - const [x, y] = g.algebraic2coords(m); - targets.push({row: y, col: x}); - } - rep.annotations.push({type: "exit", targets: targets as [RowCol, ...RowCol[]]}); - } - } - } - - // add dots - if (this.dots.length > 0) { - if (!("annotations" in rep)) { - rep.annotations = []; - } - const coords: RowCol[] = []; - for (const dot of this.dots) { - const [x, y] = this.algebraic2coords(dot); - coords.push({row: y, col: x}); - } - rep.annotations!.push({type: "dots", targets: coords as [RowCol, ...RowCol[]]}); - } - - return rep; - } - - public status(): string { - let status = super.status(); - - if (this.variants !== undefined) { - status += "**Variants**: " + this.variants.join(", ") + "\n\n"; - } - - return status; - } - - // public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { - // let resolved = false; - // switch (r.type) { - // case "capture": - // node.push(i18next.t("apresults:CAPTURE.group", { player, count: r.count, cells: r.where })); - // resolved = true; - // break; - // case "pass": - // node.push(i18next.t("apresults:PASS.forced", { player })); - // resolved = true; - // break; - // } - // return resolved; - // } - - public clone(): StormCGame { - return Object.assign(new StormCGame(), deepclone(this) as StormCGame); - } -} diff --git a/test/games/frogger.test.ts b/test/games/frogger.test.ts index 5ef3fd5e..10384400 100644 --- a/test/games/frogger.test.ts +++ b/test/games/frogger.test.ts @@ -394,10 +394,35 @@ describe("Frogger", () => { //Special autocompletion case to reparse a "bad" handleClick result. expect(g.validateMove("TMLY:a3-d3/d3-c2,6LK")).to.have.deep.property("autocomplete", "TMLY:a3-d3/d3-c2/6LK:"); - expect(g.validateMove("TMLY:a3-d3/d3-c2/c2-b3,6LK")).to.have.deep.property("autocomplete", "TMLY:a3-d3/d3-c2/c2-b3/6LK:"); + //Same result when passing through handleClick. + expect(g.handleClick("TMLY:a3-d3/d3-c2", -1, -1, "c6LK")).to.have.deep.property("move", "TMLY:a3-d3/d3-c2/6LK:"); + //OK to autocorrect to a bad value b/c during the game it gets revalidated. + expect(g.validateMove("TMLY:a3-d3/d3-c2/c2-b3,6LK")).to.have.deep.property("autocomplete", "TMLY:a3-d3/d3-c2/c2-b3/6LK:"); expect(g.validateMove("TMLY:a3-d3/d3-c2/c2-b3/6LK:")).to.have.deep.property("valid", false); - + //Same result when passing through handleClick. + expect(g.handleClick("TMLY:a3-d3/d3-c2/c2-b3", -1, -1, "c6LK")).to.have.deep.property("move", "TMLY:a3-d3/d3-c2/c2-b3/6LK:"); + + }); + + it ("Double autocompletes", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["continuous","advanced"],"gameover":false,"winner":[],"stack":[{"_version":"20251229","_results":[],"_timestamp":"2025-12-30T15:56:10.049Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","PMYK"],["c4","PMSL"],["d4","1M"],["e4","PVLY"],["f4","NL"],["g4","PSVK"],["h4","NK"],["i4","1Y"],["j4","3SK"],["k4","NS"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["","","",""],["8MS","7ML","9VY","8YK"]],"hands":[[],[]],"market":["6LK","6SY","9LK"],"discards":[],"nummoves":3},{"_version":"20251229","_results":[{"type":"move","from":"a3","to":"e3","what":"NV","how":"forward"},{"type":"move","from":"e3","to":"g3","what":"2SY","how":"forward"},{"type":"move","from":"g3","to":"l3","what":"1V","how":"forward"}],"_timestamp":"2025-12-31T20:57:34.367Z","currplayer":2,"lastmove":"NV:a3-e3/2SY:e3-g3/1V:g3-l3/","board":{"dataType":"Map","value":[["b4","PMYK"],["c4","PMSL"],["d4","1M"],["e4","PVLY"],["f4","NL"],["g4","PSVK"],["h4","NK"],["i4","1Y"],["j4","3SK"],["k4","NS"],["a3","X1-5"],["a2","X2-6"],["l3","X1-1"]]},"closedhands":[[""],["8MS","7ML","9VY","8YK"]],"hands":[[],[]],"market":["6LK","6SY","9LK"],"discards":["NV","2SY","1V"],"nummoves":3},{"_version":"20251229","_results":[{"type":"move","from":"a2","to":"b2","what":"8YK","how":"forward"},{"type":"move","from":"a2","to":"e3","what":"9VY","how":"forward"},{"type":"move","from":"e3","to":"d3","what":"6SY","how":"back"},{"type":"deckDraw"}],"_timestamp":"2026-01-01T03:11:03.580Z","currplayer":1,"lastmove":"8YK:a2-b2/9VY:a2-e3/e3-d3,6SY/","board":{"dataType":"Map","value":[["b4","PMYK"],["c4","PMSL"],["d4","1M"],["e4","PVLY"],["f4","NL"],["g4","PSVK"],["h4","NK"],["i4","1Y"],["j4","3SK"],["k4","NS"],["a3","X1-5"],["a2","X2-4"],["l3","X1-1"],["b2","X2"],["d3","X2"]]},"closedhands":[[""],["8MS","7ML"]],"hands":[[],["6SY"]],"market":["4YK","4VL","3LY"],"discards":["NV","2SY","1V","8YK","9VY"],"nummoves":3},{"_version":"20251229","_results":[{"type":"move","from":"a3","to":"b1","what":"1K","how":"forward"},{"type":"eject","from":"b2","to":"a2","what":"a Crown or Ace"},{"type":"move","from":"b1","to":"a3","what":"4VL","how":"back"},{"type":"deckDraw"}],"_timestamp":"2026-01-01T13:48:53.017Z","currplayer":2,"lastmove":"1K:a3-b1/b1-a3,4VL/","board":{"dataType":"Map","value":[["b4","PMYK"],["c4","PMSL"],["d4","1M"],["e4","PVLY"],["f4","NL"],["g4","PSVK"],["h4","NK"],["i4","1Y"],["j4","3SK"],["k4","NS"],["a3","X1-5"],["a2","X2-5"],["l3","X1-1"],["d3","X2"]]},"closedhands":[[],["8MS","7ML"]],"hands":[["4VL"],["6SY"]],"market":["NM","8VL","5YK"],"discards":["NV","2SY","1V","8YK","9VY","1K"],"nummoves":3}]}`); + + //Setup, with testing of single completes. + expect(g.handleClick("8MS:d3-g3/", -1, -1, "c7ML")).to.have.deep.property("move", "8MS:d3-g3/7ML:"); + + expect(g.validateMove("8MS:d3-g3/7ML:g3-")).to.have.deep.property("autocomplete", "8MS:d3-g3/7ML:g3-l2/"); + //Same result when passing through handleClick. + expect(g.handleClick("8MS:d3-g3/7ML:", 1, 6, "X2")).to.have.deep.property("move", "8MS:d3-g3/7ML:g3-l2/"); + + g.move("8MS:d3-g3/7ML:g3-l2/"); + + //The actual double autocomplete. + expect(g.validateMove("4VL:")).to.have.deep.property("autocomplete", "4VL:a3-"); + expect(g.validateMove("4VL:a3-")).to.have.deep.property("autocomplete", "4VL:a3-c1/"); + //The double validation happens in handleClick. + expect(g.handleClick("", -1, -1, "c4VL")).to.have.deep.property("move", "4VL:a3-c1/"); + });