diff --git a/locales/en/apgames.json b/locales/en/apgames.json index d08982f2..38eba58a 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -84,6 +84,7 @@ "four": "A game in which four shapes in four colours are placed in a virtual grid according to four constraints. Be the last player able to place.", "fourinarow": "A simple game of getting four (or more) in a row with the twist: there is gravity.", "frames": "A simple \"get into the head of your opponent\" game where both players simultaneously select an empty cell on a Go board. The placements define a rectangle. The player with the most stones within that rectangle gets a point. Play to a certain number of points.", + "frogger": "A racing game for the Decktet inspired by the eponymous videogame and the classic boardgame Cartagena. Move your frogs along a dangerous path to reach home first.", "furl": "Either furl up a row of your discs, or unfurl a stack. If you have a disc in your end row at the start of your turn, you win.", "garden": "Two wizards share a garden plot and try to harvest valuable reagents. Both are sharing the same pool of seeds. Planting a seed changes the plants around it. Form four in a row to harvest. Most harvests wins!", "gess": "In Gess, the 'pieces' are 3x3 patterns stones. A player can use any 3×3 portion of the board that contains at least one of their stones and none of their opponent's stones. If a 3x3 piece moves in such a way that it overlaps one or more stones (of either player), all stones that the piece moves onto are captured. Each player must maintain one piece that is a ring of eight stones around an empty square at all times. If one player is in a position where they no longer have such a ring anywhere on the board for any reason, they lose.", @@ -235,6 +236,7 @@ "emu": "More information on the Decktet system can be found on the [official Decktet website](https://www.decktet.com). Cards in players' hands are hidden from observers, and they are hidden from opponents until the deck is empty, at which point the players have perfect information, so the hands are revealed. Cards drawn from the discard pile are also always visible to opponents.", "entropy": "In this implementation, the players play two games simultaneously but with a single shared stream of randomized pieces. Each player places a piece on their *opponent's* Order board and then makes a move on their *own* Order board; players thus act as both Order and Chaos at the same time. The player with the greatest score wins! Since both players had the exact same placement choices, this provides the cleanest measure of relative skill.", "exxit": "Translations of the rules tend to omit certain nuances. This implementation conforms with the original French edition of the rules.\n\nBecause the board is built out as you play in irregular shapes, the hexes are labelled numerically instead of algebraically. This ensures that the labels don't change as the map grows.", + "frogger": "As in other Decktet games at Abstract Play, the deck is displayed at the bottom of the board and includes both cards in the deck and unknown cards in other players' hands. After the first hand, all cards are drawn from the open market, so hands are then open. The discards pile is also displayed.\n\nDue to how randomization works at Abstract Play, forced passes are needed for a player to refill the market in the middle of his turn. These are handled by the server, but a couple of variants for the market have also been added to avoid forced passing.\n\nThe Crocodiles variant is by Jorge Arroyo, the translator of the English rules. The Advanced rules and other minor variants are by P. D. Magnus; they appear in The Decktet Book, where the game is called Xing.", "garden": "To make it very clear what happened on a previous turn, each move is displayed over four separate boards. The first board shows the game after the piece was first placed. The second board shows the state after adjacent pieces were flipped. The third board shows any harvests. The fourth board is the final game state and is where you make your moves.\n\nIn our implementation, black is always the \"tome\" or tie-breaker colour. The last player to harvest black will have a `0.1` after their score.", "gyges": "The goal squares are adjacent to all the cells in the back row. The renderer cannot currently handle \"floating\" cells.", "homeworlds": "The win condition is what's called \"Sinister Homeworlds.\" You only win by defeating the opponent to your left. If someone else does that, the game continues, but your left-hand opponent now shifts clockwise. For example, in a four-player game, if I'm South, then I win if I eliminate West. But if the North player ends up eliminating West, the game continues, but now my left-hand opponent is North.", @@ -1158,6 +1160,36 @@ "name": "Swap-5 opening" } }, + "frogger": { + "#market": { + "description": "The market does not refill until the next player's turn.", + "name": "No refills" + }, + "advanced": { + "description": "The advanced game has a shorter track anda more restrictive movement rule.", + "name": "Advanced" + }, + "continuous": { + "description": "The market is smaller but refills after each turn.", + "name": "Continuous market" + }, + "courtpawns": { + "description": "For variety, the roles of pawns and courts may be reversed.", + "name": "Courts for Pawns" + }, + "courts": { + "description": "The four courts are added to the draw deck. When playing a court, advance a frog to the next available spot in your choice of the three suits (even in the advanced game). When swapping courts for pawns, pawns will be added instead.", + "name": "Courts" + }, + "crocodiles": { + "description": "Add hungry crocodiles to the pawn cards. Crocodiles advance at the end of each round, sending any frog on their new space back to the Excuse. This variant is intended for a two-player game, but you may add crocodiles at any player count.", + "name": "Crocodiles" + }, + "refills": { + "description": "The market may be refilled during a player's turn, by splitting their actions over two turns.", + "name": "Refills" + } + }, "gliss": { "#board": { "name": "16x16 board" @@ -4066,6 +4098,47 @@ "frames": { "INITIAL_INSTRUCTIONS": "Click an empty cell to add a piece to the board." }, + "frogger": { + "ADDITIONAL_INSTRUCTIONS": "You may make up to three moves. Choose another card or frog to start another move, or press the complete move button to finish your turn.", + "CARD_FIRST": "Please select a hand card before moving a frog forward.", + "CARD_NEXT": "You may click on a market card to add it to your hand, or submit your move.", + "CARD_NEXT_OR": "You may click on a market card to add it to your hand, or start another move.", + "INITIAL_INSTRUCTIONS": "Click a card in your hand to use to move a frog forward.", + "LATER_INSTRUCTIONS": "Click a card in your hand to use to move a frog forward, or click a frog to move it back (and possibly draw a market card).", + "INVALID_MARKET_CARD": "Your chosen market card must not include the suit your frog landed on.", + "INVALID_FROG": "You may only move your own frogs.", + "INVALID_HOP_FORWARD": "When advancing, your frog must land on the first available spot of a given suit.", + "INVALID_HOP_FORWARD_ADVANCED": "Under the advanced rules, when playing a two-suited card from your hand, your frog must land on the first available spot of either suit (not your choice). If both suits are present on the same board card (that is, in the same column), you may choose either spot.", + "INVALID_HOP_BACKWARD": "When hopping back, your frog must land on the nearest available card. If there is more than one spot available in that column, you may choose any one of them.", + "INVALID_MOVE": "The move '{{move}}' could not be parsed.", + "INVALID_NON-MOVE": "Unless you're blocked, you must move a frog on each of your moves.", + "LABEL_MARKET": "Market cards", + "LABEL_REMAINING": "Cards in deck", + "LABEL_DISCARDS": "Discard pile", + "LABEL_STASH": "Player {{playerNum}}'s hand", + "MISPLACED_REFILL": "Please request a refill before you move a frog backward.", + "MUST_HOP_FORWARD": "When using a hand card, your frog must hop forward.", + "MUST_MOVE": "Your frog must move to a different card/column.", + "MUST_PASS": "You must pass to allow another player to complete their turn.", + "NO_CHOICE_BLOCKED": "If, at the start of your turn, you have no hand cards and none of your frogs can move back, you must draw a single card from the market and end your turn.", + "NO_MOVE_BLOCKED": "You cannot move after claiming a card for being blocked.", + "NO_PASSING": "There is no passing as such in Frogger. If you are blocked, you must draw a single card from the market.", + "NO_REFILLS": "Refilling the market is not allowed in this variant.", + "NO_RETURN": "Frogs may not leave home once they've reached it.", + "NO_SUCH_HAND_CARD": "The card \"{{card}}\" does not appear to be in your hand.", + "NO_SUCH_MARKET_CARD": "The card \"{{card}}\" does not appear to be in the market.", + "NOT_BLOCKED": "You're not blocked, so you must move back before claiming a market card.", + "OCCUPIED": "Only one frog per suit space.", + "OFF_BOARD": "Frogs must be placed on locations with suits, at home, or at the Excuse.", + "OFFSIDES": "Please click on your desired suit space, not the informational icons on the top row.", + "PIECE_NEXT": "Click on a frog to move it forward or backwards.", + "PLACE_NEXT": "Click on a new location for your frog.", + "TOO_HOPPY": "You may not make more than {{count}} moves on your turn.", + "TOO_EARLY_FOR_REFILL": "Please submit your refill request before making your remaining moves.", + "TOO_LATE_FOR_BLOCKED": "You do not count as blocked if you've already moved on this turn.", + "TOO_LATE_FOR_REFILL": "There is no need to force a market refill after your third move. It will refill automatically after your turn." + }, + "furl": { "CAPTURE4UNFURL": "You signalled a capture unfurl move, but {{to}} not is occupied by the opponent. Use \">\" to denote non-capture unfurl.", "FURL4UNFURL": "You signalled a furl move with \"<\", but the selected stack has a height of {{size}}, which is greater than 1.", @@ -5448,7 +5521,7 @@ } }, "INITIAL_UNDO": "You can't undo the initial starting position of the game.", - "MOVES_GAMEOVER": "You cannot make moves in concluded games", + "MOVES_GAMEOVER": "You cannot make moves in concluded games.", "MOVES_INVALID": "The move '{{move}}' is invalid. Please check the rules and try again.", "MOVES_SIMULTANEOUS_PARTIAL": "In simultaneous games, both players' moves must be submitted at the same time." } diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 918d6572..77318a9f 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -22,6 +22,7 @@ "ANNOUNCE": { "biscuit": "The following cards were in opposing hands: {{cards}}.", "deckfish": "{{player}} was unable to move and must pass from now on.", + "frogger": "{{player}} chose to refill the market. Other players must pass so he can take his {{moves}} remaining move(s).", "quincunx": "Player {{playerNum}} had the following cards left in their hand: {{cards}}.", "stawvs": "{{player}} was unable to move and must pass from now on. Their pieces remain on the following pyramids: {{pyramids}}." }, @@ -134,6 +135,7 @@ "fnap_col": "{{player}} claimed column {{where}}.", "fnap_fnap": "{{player}} has claimed the FNAP token.", "fnap_row": "{{player}} claimed row {{where}}.", + "frogger": "{{player}} was blocked and drew {{card}} from the market.", "jacynth": "{{player}} exerted influence at {{where}}.", "logger": "{{player}} claimed a protestor from the tree felled at {{where}}.", "majorities_line": "{{player}} claimed the line {{where}}.", @@ -167,12 +169,14 @@ "biscuit": "The next player was unable to play any cards in their hand, so they drew a card.", "emu_deck": "{{player}} drew a face-down card from the deck.", "emu_discard": "{{player}} drew the {{what}} from the top of the discard pile.", + "frogger": "The market was refilled.", "quincunx_one": "{{player}} drew {{count}} card.", "quincunx_other": "{{player}} drew {{count}} cards." }, "DECLARE": { "biscuit": "{{player}} has gone out!", "emu": "Year {{year}} has ended.", + "frogger": "The crocodiles advanced.", "mchess": "{{player}} has \"called the clock.\" If no capture is made in the next seven turns, the game will end and be scored.", "renju": "{{player}} has declared {{count}} tentative fifths." }, @@ -214,6 +218,8 @@ "EJECT": { "assembly": "The production line moved forward {{distance}} spaces.", "deckfish": "{{player}} pushed a piece from {{from}} to {{to}}.", + "frogger_card": "{{player}} played an Ace or Crown and returned a frog from {{from}} to {{to}}.", + "frogger_croc": "The frog at {{from}} was eaten by crocodiles (and returned to {{to}}).", "penguin_ball": "{{player}} kicked the ball from {{from}} to {{to}}.", "penguin_penguin": "A penguin was pushed from {{from}} to {{to}}." }, @@ -295,6 +301,8 @@ "fightopia_2": "{{player}} moved a tank from {{from}} to {{to}}.", "fightopia_4": "{{player}} moved their giant from {{from}} to {{to}}.", "fightopia_pivot": "{{player}} pivoted a tank from {{from}} to {{to}}.", + "frogger_back": "{{player}} moved a frog back from {{from}} to {{to}}, and drew {{card}} from the market.", + "frogger_forward": "{{player}} used {{card}} to move a frog forward from {{from}} to {{to}}.", "gess_one": "{{player}} moved a 3x3 piece containing {{count}} stone from {{from}} to {{to}}.", "gess_other": "{{player}} moved a 3x3 piece containing {{count}} stones from {{from}} to {{to}}.", "hexentafl_king": "{{player}} moved the king from {{from}} to {{to}}.", @@ -337,6 +345,7 @@ "deckfish": "{{player}} was unable to move.", "entropy": "{{player}} chose not to move any pieces this turn.", "forced": "{{player}} was forced to pass.", + "frogger": "{{player}} passed to permit a market refill.", "pie": "{{player}} accepted the komi offer and will continue playing second.", "pigs": "{{player}} idles.", "simple": "{{player}} passed.", diff --git a/src/games/frogger.ts b/src/games/frogger.ts new file mode 100644 index 00000000..adc58f91 --- /dev/null +++ b/src/games/frogger.ts @@ -0,0 +1,1921 @@ +import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base"; +import { APGamesInformation } from "../schemas/gameinfo"; +import { APRenderRep, AreaPieces, Glyph, MarkerFlood, MarkerGlyph, RowCol} from "@abstractplay/renderer/src/schemas/schema"; +import { APMoveResult } from "../schemas/moveresults"; +import { reviver, UserFacingError } from "../common"; +import i18next from "i18next"; +import { Card, Deck, cardSortAsc, cardsBasic, cardsExtended, suits } from "../common/decktet"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const deepclone = require("rfdc/default"); + +export type playerid = 1|2|3|4|5; +export type Suit = "M"|"S"|"V"|"L"|"Y"|"K"; +const suitOrder = ["M","S","V","L","Y","K"]; + +export interface IMoveState extends IIndividualState { + currplayer: playerid; + skipto?: playerid; + board: Map; + closedhands: string[][]; + hands: string[][]; + market: string[]; + discards: string[]; + nummoves: number; + lastmove?: string; +}; + +export interface IFroggerState extends IAPGameState { + winner: playerid[]; + stack: Array; +}; + +interface ILegendObj { + [key: string]: Glyph|[Glyph, ...Glyph[]]; +} + +interface IFrogMove { + forward: boolean; + card?: string; + from?: string; + to?: string; + refill: boolean; + incomplete: boolean; + valid: boolean; +} + +export class FroggerGame extends GameBase { + public static readonly gameinfo: APGamesInformation = { + name: "Frogger", + uid: "frogger", + playercounts: [2,3,4,5], + version: "20251229", + dateAdded: "2025-12-29", + // i18next.t("apgames:descriptions.frogger") + description: "apgames:descriptions.frogger", + // i18next.t("apgames:notes.frogger") + notes: "apgames:notes.frogger", + urls: [ + "http://wiki.decktet.com/game:frogger", + "https://boardgamegeek.com/boardgame/41859/frogger", + ], + people: [ + { + type: "designer", + name: "José Carlos de Diego Guerrero", + urls: ["http://www.labsk.net"], + }, + { + type: "coder", + name: "mcd", + urls: ["https://mcdemarco.net/games/"], + apid: "4bd8317d-fb04-435f-89e0-2557c3f2e66c", + }, + ], + variants: [ + { uid: "advanced" }, //see Xing in The Decktet Book + { uid: "crocodiles" }, //see the comments on the Decktet Wiki + { uid: "courts" }, //include courts in the draw deck + { uid: "courtpawns" }, //courts for pawns + { uid: "#market" }, //i.e., no refills + { uid: "refills", group: "market", default: true }, //the official rule + { uid: "continuous", group: "market" }, //continuous small refills + ], + categories: ["goal>evacuate", "mechanic>move", "mechanic>bearoff", "mechanic>block", "mechanic>random>setup", "mechanic>random>play", "board>shape>rect", "board>connect>rect", "components>decktet", "other>2+players"], + flags: ["autopass", "custom-buttons", "custom-randomization", "random-start", "experimental"], + }; + public coords2algebraic(x: number, y: number): string { + return GameBase.coords2algebraic(x, y, this.rows); + } + public algebraic2coords(cell: string): [number, number] { + return GameBase.algebraic2coords(cell, this.rows); + } + + public numplayers = 2; + public currplayer: playerid = 1; + public skipto?: playerid|undefined; + public board!: Map; + public closedhands: string[][] = []; + public hands: string[][] = []; + public market: string[] = []; + public discards: string[] = []; + public nummoves = 3; + public gameover = false; + public winner: playerid[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + private rows: number = 3; + private columns: number = 14; //12 cards plus two end columns + private pawnrank: string = "P"; + private courtrank: string = "T"; + private marketsize: number = 6; + private deck!: Deck; + private suitboard!: Map; + + constructor(state: number | IFroggerState | string, variants?: string[]) { + super(); + if (typeof state === "number") { + this.numplayers = state; + if (variants !== undefined) { + this.variants = [...variants]; + } + + if (this.variants.includes("courtpawns")) { + this.pawnrank = "T"; + this.courtrank = "P"; + } + + // init deck + const cards = [...cardsBasic]; + const deck = new Deck(cards); + deck.shuffle(); + + if (this.variants.includes("advanced")) + this.columns = 12; //10 cards plus two end columns + + //const boardCard = [...cardsExtended.filter(c=> c.rank.uid === "0")]; + const boardDeckCards = [...cardsExtended.filter(c => c.rank.uid === this.pawnrank)].concat(deck.draw(this.columns - 6)); + const boardDeck = new Deck(boardDeckCards); + boardDeck.shuffle(); + + // init board + this.rows = Math.max(3, this.numplayers) + 1; + + if (this.variants.includes("continuous")) + this.marketsize = 3; + + const board = new Map(); + const suitboard = new Map(); + + //add cards + for (let col = 1; col < this.columns - 1; col++) { + const [card] = boardDeck.draw(); + const cell = this.coords2algebraic(col, 0); + board.set(cell, card.uid); + + //Set suits. + const suits = card.suits.map(s => s.uid); + for (let s = 0; s < suits.length; s++) { + const cell = this.coords2algebraic(col, s + 1); + suitboard.set(cell,suits[s]); + } + + //Add crocodiles. Crocodiles are player 0. + if (this.variants.includes("crocodiles")) { + if (card.rank.uid === this.pawnrank) { + const cell = this.coords2algebraic(col, 1); + board.set(cell, "X0"); + } + } + } + + //add player pieces, which are Xs to not conflict with Pawns + for (let row = 1; row <= this.numplayers; row++) { + const cell = this.coords2algebraic(0, row); + board.set(cell, "X" + row.toString() + "-6"); + } + + if (this.variants.includes("courts")) { + const courtDeckCards = [...cardsExtended.filter(c => c.rank.uid === this.courtrank)]; + // note that .add() autoshuffles. + courtDeckCards.forEach( card => deck.add(card.uid) ); + } + + // init market and hands + const closedhands: string[][] = []; + const hands: string[][] = []; + for (let i = 0; i < this.numplayers; i++) { + closedhands.push([...deck.draw(4).map(c => c.uid)]); + hands.push([]); + } + const market: string[] = [...deck.draw(this.marketsize).map(c => c.uid)]; + + const fresh: IMoveState = { + _version: FroggerGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + board, + closedhands, + hands, + market, + discards: [], + nummoves: 3 + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IFroggerState; + } + if (state.game !== FroggerGame.gameinfo.uid) { + throw new Error(`The Frogger engine cannot process a game of '${state.game}'.`); + } + this.numplayers = state.numplayers; + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + + } + this.load(); + } + + public load(idx = -1): FroggerGame { + 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.skipto = state.skipto; + this.board = new Map(state.board); + this.closedhands = state.closedhands.map(h => [...h]); + this.hands = state.hands.map(h => [...h]); + this.market = [...state.market]; + this.discards = [...state.discards]; + this.nummoves = state.nummoves; + this.lastmove = state.lastmove; + + if (this.variants.includes("advanced")) + this.columns = 12; + if (this.variants.includes("courtpawns")) { + this.pawnrank = "T"; + this.courtrank = "P"; + } + + this.rows = Math.max(3, this.numplayers) + 1; + + if (this.variants.includes("continuous")) + this.marketsize = 3; + + //Separate model of the suited part of the board. + this.suitboard = new Map(); + const scards = this.getBoardCards(); + for (let col = 1; col < this.columns - 1; col++) { + const suits = this.getSuits(scards[col - 1]); + for (let s = 1; s < suits.length + 1; s++) { + const cell = this.coords2algebraic(col, s); + this.suitboard.set(cell,suits[s-1]); + } + } + + //The deck is reset every time you load + const cards = [...cardsBasic]; + if (this.variants.includes("courts")) { + cards.push(...cardsExtended.filter(c => c.rank.uid === this.courtrank)); + } + //Some board cards, for removal. + cards.push(...cardsExtended.filter(c => c.rank.uid === this.pawnrank)); + + this.deck = new Deck(cards); + + //Remove cards from the deck that are on the board, in the market, or in known hands. + this.getBoardCards().forEach( uid => + this.deck.remove(uid) + ); + + //We track the initial closed hand for hiding because all subsequent draws are open. + //Note that even if closehands become known logically, they remain hidden until played. + for (const hand of this.closedhands) { + for (const uid of hand) { + if (uid !== "") { + this.deck.remove(uid); + } + } + } + for (const hand of this.hands) { + for (const uid of hand) { + this.deck.remove(uid); + } + } + + for (const uid of this.market) { + this.deck.remove(uid); + } + for (const uid of this.discards) { + this.deck.remove(uid); + } + + this.deck.shuffle(); + + return this; + } + + private checkBlocked(): boolean { + //A player is blocked if their hand is empty, all frogs are already at the Excuse or home, + // and it's the beginning of their turn. + + //This function checks the hand and frog conditions; + // it's the responsibility of the caller to check the turn condition. + if (this.closedhands[this.currplayer - 1].length > 0 || this.hands[this.currplayer - 1].length > 0) + return false; + if (this.countColumnFrogs() + this.countColumnFrogs(true) < 6) + return false; + return true; + } + + private checkNextBack(from: string, to: string): boolean { + //Checks a frog has backed up to the correct spot + // by checking its spot against the array of all allowed spots + // (using a function also used in random moves). + const correctBacks: string[] = this.getNextBack(from); + return (correctBacks.indexOf(to) > -1); + } + + private checkNextForward(from: string, to: string, card: string): boolean { + //Checks a frog has moved forward to the correct spot + // by checking its against the allowed spot, + // using the function for generating random moves. + + //The advanced case is handled inside this function, unlike in getNextForward. + const cardObj = Card.deserialize(card)!; + + if (this.variants.includes("advanced") && cardObj.rank.uid !== this.courtrank) { + //In the advanced game, courts still function like regular game cards, + // but number cards do not. + // (Crowns and aces are a degenerate case that can use either function.) + return this.checkNextForwardAdvanced(from, to, card); + } + + const suits = this.getSuits(card); + + for (let s = 0; s < suits.length; s++) { + //In the base game, you can use any suit off the card, + // so we return true if we find a good one. + const suitto = this.getNextForward(from, suits[s]); + if (to === suitto) + return true; + } + + //Otherwise, the move didn't land on any of the legal next spots. + return false; + } + + private checkNextForwardAdvanced(from: string, to: string, card: string): boolean { + //Checks that {to} is the next available cell under the advanced game movement restriction. + //Assumes you are not passing in a Court, but handles Aces and Crowns. + + //Uses getNextForwardAdvanced to get the array of legal values for {to}. + const suits = this.getSuits(card); + const options = this.getNextForwardAdvanced(from, suits); + return (options.indexOf(to) > -1); + } + + private countColumnFrogs(home?: boolean): number { + //Returns number of currplayer's frogs in the start (false/undefined) or home (true) column. + let col = 0; + if (home) + col = this.columns - 1; + + const cell = this.coords2algebraic(col, this.currplayer as number); + if (!this.board.has(cell)) + return 0; + const piece = this.board.get(cell)!; + const parts = piece.split("-"); + if (parts.length < 2) + throw new Error(`The piece at "${cell}" was malformed. This should never happen.`); + else + return parseInt(parts[1],10); + } + + private getBoardCards(): string[] { + //Returns the top row of cards for various purposes. + const cards: string[] = []; + for (let col = 1; col < this.columns - 1; col++) { + const cell = this.coords2algebraic(col, 0); + const uid = this.board.get(cell)!; + cards.push(uid); + } + return cards; + } + + 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. + //Used in random move generation and move validation. + const fromX = this.algebraic2coords(from)[0]; + + if ( fromX === 0 ) { + throw new Error("Could not back up from the Excuse. This should never happen."); + } + + for (let c = fromX - 1; c > 0; c--) { + const cells = []; + for (let r = 1; r < this.rows; r++) { + const cell = this.coords2algebraic(c, r); + if ( !this.board.has(cell) && this.suitboard.has(cell) ) + cells.push(cell); + } + if (cells.length > 0) + return cells; + } + const startCell = this.coords2algebraic(0, this.currplayer); + return [startCell]; + } + + private getNextForward(from: string, suit: string): string { + //Get the next available cell by suit. + //Used in random move generation and move validation. + const homecell = this.coords2algebraic(this.columns - 1, this.currplayer); + + const fromX = this.algebraic2coords(from)[0]; + + if ( fromX === this.columns - 1 ) { + throw new Error("Could not go forward from home. This should never happen."); + } + + for (let c = fromX + 1; c < this.columns; c++) { + if (c === this.columns - 1) { + return homecell; + } + for (let r = 1; r < this.rows; r++) { + const cell = this.coords2algebraic(c, r); + if ( !this.board.has(cell) && this.suitboard.has(cell) && this.suitboard.get(cell) === suit ) + return cell; + } + } + + //You shouldn't be here! + throw new Error(`Something went wrong looking for the next suited cell.`); + } + + private getNextForwardAdvanced(from: string, suits: string[]): string[] { + //Get the next available cell under the advanced game movement restriction. + //Assumes you are not passing in a Court, but handles Aces and Crowns. + //Used in random move generation and move validation. + //In certain rare cases you have a choice, so this function returns an array of cells. + + const to1 = this.getNextForward(from, suits[0]); + if (suits.length === 1) + return [to1]; + + const to2 = this.getNextForward(from, suits[1]); + + const col1 = this.algebraic2coords(to1)[0]; + const col2 = this.algebraic2coords(to2)[0]; + + if (col1 === col2) + return [to1,to2]; + else if (col1 < col2) + return [to1]; + else + return [to2]; + } + + private getSuits(cardId: string): string[] { + const card = Card.deserialize(cardId)!; + const suits = card.suits.map(s => s.uid); + return suits; + } + + private getUnsuitedCells(): string[] { + //Return those board cells that aren't on suitboard but are playable. + const uncells: string[] = []; + for (let row = 1; row <= this.numplayers; row++) { + const startcell = this.coords2algebraic(0, row); + const homecell = this.coords2algebraic(this.columns - 1, row); + uncells.push(startcell); + uncells.push(homecell); + } + return uncells; + } + + private modifyFrogStack(cell: string, increment: boolean): void { + //Handle the process of incrementing and decrementing the frog stacks + // in the start and home columns. + //It's the responsibility of the caller to validate the arguments. + const [cellX, cellY] = this.algebraic2coords(cell); + + if (! this.board.has(cell) ) { + if ( (cellX === this.columns - 1 || cellX === 0) && increment ) { + //The special case of the first frog home, + // or the first frog returning to the empty Excuse. + this.board.set(cell, "X" + cellY + "-1"); + } else { + throw new Error(`Stack not found at "${cell}" in modifyFrogStack.`); + } + return; + } + + const oldFrog = this.board.get(cell)!; + const player = oldFrog.charAt(1); + const oldFrogCount = parseInt(oldFrog.split("-")[1], 10); + const newFrogCount = increment ? oldFrogCount + 1 : oldFrogCount - 1 ; + + if (newFrogCount === 0) { + this.board.delete(cell); + } else { + const newFrogStack = "X" + player + "-" + newFrogCount.toString(); + this.board.set(cell, newFrogStack); + } + + } + + private moveFrog(from: string, to: string): void { + //Frog adjustments are complicated by frog piles and crocodiles. + const frog = this.board.get(from)!; + const fromX = this.algebraic2coords(from)[0]; + const toX = this.algebraic2coords(to)[0]; + const singleFrog = "X" + frog.charAt(1); + + if (fromX > 0 && toX > 0 && toX < this.columns - 1) { + this.board.set(to, singleFrog); + this.board.delete(from); + } else { + //Unsetting the old: + if (fromX === 0) { + this.modifyFrogStack(from, false); + } else { + //Normal delete. + this.board.delete(from); + } + + //Setting the new: + if ( toX === 0 || toX === this.columns - 1 ) { + this.modifyFrogStack(to, true); + } else { + this.board.set(to, singleFrog); + } + } + } + + private moveFrogToExcuse(from: string): string { + //Wrapper for moveFrog that determines the correct Excuse row, + // because bounced frogs don't necessarily belong to currplayer. + const frog = this.board.get(from)!; + const row = parseInt(frog.charAt(1),10); + const to = this.coords2algebraic(0, row); + this.moveFrog(from, to); + return to; + } + + private moveNeighbors(cell: string, cardId: string): string[][] { + //Tests for bouncing condition and if so, moves other frogs off your lily pad. + //Returns a list of who, if anyone, was bounced. + const bounced: string[][] = []; + + //Bouncing occurs when an Ace or Crown was played, not a number card or a Court. + const card = Card.deserialize(cardId)!; + const rank = card.rank.name; + if ( rank === "Crown" || rank === "Ace" ) { + + //The bounce process. + const col = this.algebraic2coords(cell)[0]; + + if (col === 0) { + throw new Error("Trying to bounce frogs off the Excuse. This should never happen!"); + } else if (col === this.columns - 1) { + //Can't bounce here. + return bounced; + } + + for (let row = 1; row < this.rows; row++) { + const bouncee = this.coords2algebraic(col, row); + //Don't bounce self or crocodiles. + if ( bouncee !== cell && this.board.has(bouncee) && this.board.get(bouncee) !== "X0" ) { + const to = this.moveFrogToExcuse(bouncee)!; + bounced.push([bouncee, to]); + } + } + } + return bounced; + } + + public parseMove(submove: string): IFrogMove { + //Parse a string into an IFrogMove object. + //Does only structural validation. + + //Because the Excuse does not appear in moves, + // the card format is: + const cardex = /^(\d?[A-Z]{1,2}||[A-Z]{2,4})$/; + //The cell format is: + const cellex = /^[a-n][1-5]$/; + //A regex to check for illegal characters (except !) is: + const illegalChars = /[^A-Za-n1-9:,-]/; + + //The move format is one of: + // handcard:from-to a regular move forward + // from-to,marketcard a productive move backward + // from-to,marketcard! a productive move backward, request refill + // from-to a move backward but no market card taken + // marketcard// a whole (blocked) turn to draw a marketcard + + let mv, from, to, card; + + const ifm: IFrogMove = { + incomplete: false, + forward: false, + refill: false, + valid: true + } + + //To ignore the empty move or passes, we pass out the meaningless defaults. + if (submove === "" || submove === "pass") + return ifm; + + //Setting refill (in variant with refill button). + if (submove.indexOf("!") > - 1) { + submove = submove.split("!")[0]; + ifm.refill = true; + } + + //Check for legal characters, after trimming the single legal !. + if (illegalChars.test(submove)) { + ifm.valid = false; + return ifm; + } + + //A partial move that can't be submitted is set to incomplete. + + //Next, split the string on card punctuation: + if (submove.split(/:|,/).length > 2 || submove.split("-").length > 2) { + //console.log("malformed move string"); + ifm.valid = false; + } else if (submove.indexOf(":") > -1) { + //Card followed by move is a forward move. + [card, mv] = submove.split(":"); + ifm.card = card; + ifm.forward = true; + //may be incomplete depending on parse of to. + } else if (submove.indexOf(",") > -1) { + //Move followed by card is a backwards move. + [mv, card] = submove.split(","); + if (card) { + ifm.card = card; + } + //In this case mv is required, so check it now. + if ( !mv || mv.split("-").length < 2 || mv.split("-").indexOf("") > -1 ) + ifm.valid = false; + } else if (submove.indexOf("-") > -1) { + //Raw move is a unproductive or partial backwards move. + mv = submove; + //may be incomplete depending on parse of to. + } else if (/\d/.test(submove.charAt(1))) { + //A cell has a second digit that's numeric; a card wouldn't. + mv = submove; + //From alone is a partial move so... + ifm.incomplete = true; + } else { + //... or a card. A card alone is a blocked move or a partial move. + ifm.card = submove; + mv = ""; + //We aren't validating here so give it the benefit of the doubt and don't mark incomplete. + } + + //Setting from, to, and remaining completes. + if (mv) { + [from, to] = mv.split("-"); + ifm.from = from; + if (to) + ifm.to = to; + else + ifm.incomplete = true; + } else { + //If we were waiting to parse a move, we didn't find it. + if (ifm.forward === true) + ifm.incomplete = true; + //else if (ifm.forward === false && ifm.card) + } + + if (ifm.card && !cardex.test(ifm.card)) { + //console.log("malformed card ",ifm.card); + ifm.valid = false; + } + if (ifm.from && !cellex.test(ifm.from)) { + //console.log("malformed cell ",ifm.from); + ifm.valid = false; + } + if (ifm.to && !cellex.test(ifm.to)) { + //console.log("malformed cell ",ifm.to); + ifm.valid = false; + } + + return ifm; + } + + private popCrocs(): string[][] { + //Moves the crocodiles and their victims. + //Returns a list of the victims for logging and rendering. + const victims: string[][] = []; + for (let col = 1; col < this.columns - 1; col++) { + // check for pawn column using the suit board + if ( this.suitboard.has(this.coords2algebraic(col, 3)) ) { + //We have a croc's column; we could go looking for its row + // but we can also just derive it from the stack length. + const crocRow = (((this.stack.length / this.numplayers) - 1) % 3) + 1; + const victimRow = (crocRow % 3) + 1; + const crocFrom = this.coords2algebraic(col, crocRow); + const victimFrom = this.coords2algebraic(col, victimRow); + if ( this.board.has(victimFrom) ) { + const victimTo = this.moveFrogToExcuse(victimFrom); + victims.push([victimFrom, victimTo]); + } + // regardless of squashed frogs, we move the crocodile + this.moveFrog(crocFrom, victimFrom); + } + } + return victims; + } + + private popHand(card: string): void { + if (! this.removeCard(card, this.hands[this.currplayer - 1])) + this.removeCard(card, this.closedhands[this.currplayer - 1]); + this.discards.push(card); + } + + private popMarket(card: string): boolean { + //Remove the drawn card. + //For convenience, also tests for an empty market. + this.removeCard(card, this.market); + this.hands[this.currplayer - 1].push(card); + + return (this.market.length === 0); + } + + private randomElement(array: string[]): string { + //Return a random element from an array. + //Used for random moves. + const index = Math.floor(Math.random() * array.length); + return array[index]; + } + + private refillMarket(): boolean { + //Fills the market regardless of current size. + //Shuffles the discards when necessary. + //Refill variant behavior is mostly handled by the caller, + + //May be called when the market is already full (in the continuous variant). + if (this.market.length === this.marketsize) + return false; + + //First, try to draw what we need from the deck. + const toDraw = Math.min(this.marketsize, this.deck.size); + this.market = [...this.deck.draw(toDraw).map(c => c.uid)]; + + //If we didn't fill the market, shuffle the discards. + if (this.market.length < this.marketsize) { + //Return the discards to the deck and shuffle. + this.discards.forEach( card => { + this.deck.add(card); + }); + this.discards = []; + this.deck.shuffle(); + + //Draw the rest. + for (let n = this.market.length; n < this.marketsize; n++) { + const [card] = this.deck.draw(); + this.market.push(card.uid); + } + } + return true; + } + + private removeCard(card: string, arr: string[]): boolean { + //Remove a card from an array. + //It's up to the caller to put the card somewhere else. + const index = arr.indexOf(card); + if (index > -1) { + arr.splice(index, 1); + return true; + } //else... + return false; + } + + public moves(player?: playerid): string[] { + //Used for the autopasser. Not a full move list. + if (this.gameover) { + return []; + } + + if (player === undefined) { + player = this.currplayer; + } + + if (this.skipto !== undefined && this.skipto !== this.currplayer ) { + //Passing for market hiding. + return ["pass"]; + } + + return [this.randomMove()]; + } + + public randomMove(): string { + //We return only one, legal move, for testing purposes. + + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + //Refill/skipto case. Not reachable from the move list, but useful for testing. + if ( this.variants.includes("refills") && this.skipto !== undefined && this.skipto !== this.currplayer ) + return "pass"; + + if (this.checkBlocked()) { + const marketCard = this.randomElement(this.market); + return marketCard + "/"; + } + + //Flip a coin about what to do (if there's an option). + let handcard = ( Math.random() < 0.66 ); + //But... + if ( this.closedhands[this.currplayer - 1].length === 0 && this.hands[this.currplayer - 1].length === 0 ) + handcard = false; + if ( this.countColumnFrogs() + this.countColumnFrogs(true) === 6 ) + handcard = true; + + //Pick an appropriate frog for hopping forward or back, randomly. + //Need a frog that can move, so skip the home row. + const frarray = []; + for (let row = 1; row < this.rows; row++) { + for (let col = 0; col < this.columns - 1; col++) { + if ( col === 0 && !handcard ) + continue; + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { + const frog = this.board.get(cell)!; + if ( frog.charAt(1) === this.currplayer.toString() ) + frarray.push(cell); + } + } + } + const from = this.randomElement(frarray); + + if ( handcard ) { + //hop forward + const card = this.randomElement( this.closedhands[this.currplayer - 1].concat(this.hands[this.currplayer - 1]) ); + const suits = this.getSuits(card); + const suit = this.randomElement(suits); + + let to; + const cardObj = Card.deserialize(card)!; + if (this.variants.includes("advanced") && cardObj.rank.uid !== this.courtrank) { + //Courts next forward normally in the advanced game. + //Aces and Crowns do, too, but this function handles them. + to = this.randomElement(this.getNextForwardAdvanced(from, suits)); + } else + to = this.getNextForward(from, suit); + + return `${card}:${from}-${to}`; + + } else { + //fall back. + + const toArray = this.getNextBack(from); + const to = this.randomElement(toArray); + const toX = this.algebraic2coords(to)[0]; + let card; + if (toX === 0) { + //Can choose any market card. + card = this.randomElement(this.market); + } else { + //Filter the market by the forbidden suit. + const suit = this.suitboard.get(to); + const whiteMarket: string[] = []; + + this.market.forEach(card => { + const suits = this.getSuits(card); + if (suits.indexOf(suit!) < 0) + whiteMarket.push(card); + }); + + if (whiteMarket.length > 0) + card = this.randomElement(whiteMarket); + } + + if ( card ) + return `${from}-${to},${card}`; + else + return `${from}-${to}`; + } + + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + + if (this.gameover) { + return { + valid: false, + complete: -1, + move: "", + message: i18next.t("apgames:MOVES_GAMEOVER") + }; + } + + try { + + if ( this.variants.includes("refills") && this.skipto && this.skipto !== this.currplayer ) { + //All clicks are bad clicks. We don't bother with a pass button + // because the back end should have autopassed you. + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.MUST_PASS") + } + } + + let newmove = ""; + + const moves = move.split("/"); + + const isFirstMove = (moves.length === 1); + const isLastMove = (moves.length === this.nummoves); + + const currmove = moves[moves.length - 1]; + const currIFM = this.parseMove(currmove); + + if (moves.length > this.nummoves) { + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.TOO_HOPPY", {count: this.nummoves}) + } + } + + if (row < 0 && col < 0) { + //clicking on a hand or market card or refill button + + if (isFirstMove && currmove === "") { + //Refill is not possible here. + + // starting the first move (forward) or possibly the blocked option + if (this.checkBlocked()) { + //The blocked case (spending your entire turn to draw a market card) + newmove = `${piece!.substring(1)}//`; + } else { + newmove = `${piece!.substring(1)}:`; + } + } else if (currmove === "") { + //Deal with the refill button. + if (this.variants.includes("refills") && piece === "refill") { + newmove = `${move.slice(0,-1)}!/`; + } else { + // starting another move (forward). + newmove = `${move}${piece!.substring(1)}:`; + } + } else if (currIFM.from && currIFM.to === undefined) { + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.PLACE_NEXT") + } + } else if (currIFM.card && currIFM.from === undefined) { + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.PIECE_NEXT") + } + } else if (currIFM.to && currIFM.forward === false && currIFM.card === undefined) { + //Hopefully picking a market card. + if (this.variants.includes("refills") && piece === "refill") { + //This is not an appropriate time to click the refill button. + if ( moves.length === this.nummoves) { + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.TOO_LATE_FOR_REFILL") + } + } else { + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.MISPLACED_REFILL") + } + } + } else { + newmove = `${move},${piece!.substring(1)}/`; + } + } + + } else { + + //Clicking on the board. + + if (row === 0) { + //The top row is not allowed. + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.OFFSIDES") + } + } + + const cell = this.coords2algebraic(col, row); + + if (currmove === "" || currIFM.from === undefined) { + //Piece picking cases, so need a piece. + if ( piece === undefined || piece === "" ) { + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.PIECE_NEXT") + } + } else { + //picked a piece to move. + if (move) + newmove += `${move}${cell}-`; + else + newmove += `${cell}-`; + } + } else if (currIFM.to === undefined) { + //picked the target. Don't check occupied (piece) here because it's complicated. + if (currIFM.card) { + //picked the target and finished the move forward. + newmove = `${move}${cell}/`; + } else { + //picked the target but not a market card. + newmove = `${move}${cell}`; + } + } else if (currIFM.incomplete === false && !isLastMove) { //complete > -1 && !isLastMove) { + //Finished a hop forward or an unproductive hop back, + // so can start a new move (back). + newmove = `${move}/${cell}-`; + } else { + //Getting hoppy. + return { + move, + valid: false, + message: i18next.t("apgames:validation.frogger.TOO_HOPPY", {count: this.nummoves}) + } + } + } + + 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 (this.gameover) { + if (m.length === 0) { + result.message = ""; + } else { + result.message = i18next.t("apgames:MOVES_GAMEOVER"); + } + return result; + } + + m = m.replace(/\s+/g, ""); + const blocked = this.checkBlocked(); + + if (m.length === 0) { + result.valid = true; + result.complete = -1; + + if ( blocked ) + result.message = i18next.t("apgames:validation.frogger.NO_CHOICE_BLOCKED"); + else if ( this.stack.length > this.numplayers ) + result.message = i18next.t("apgames:validation.frogger.LATER_INSTRUCTIONS") + else + result.message = i18next.t("apgames:validation.frogger.INITIAL_INSTRUCTIONS") + return result; + } + + if (m === "pass") { + //May only pass in some refill situations. + if ( this.variants.includes("refills") && this.skipto !== undefined ) { + + // && this.skipto !== this.currplayer + //You must pass if you're not the player being skipped to. + + // && this.skipto === this.currplayer && this.nummoves < 3 + //But you also may pass if you were skipped to + // and it's your supplemental refill turn, + // because you already made at least one move in the main turn. + + result.valid = true; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + result.complete = 1; + return result; + } else { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.NO_PASSING"); + return result; + } + } + + const cloned: FroggerGame = Object.assign(new FroggerGame(this.numplayers, [...this.variants]), deepclone(this) as FroggerGame); + + let allcomplete = false; + const moves: string[] = m.split("/"); + + if (moves[moves.length - 1] === "") { + //Trim the dummy move and mark all complete. + //Could also test that the last character of m is a /. + moves.length--; + allcomplete = true; + } + + if (moves.length > this.nummoves) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.TOO_HOPPY", {count: this.nummoves}); + return result; + } + + for (let s = 0; s < moves.length; s++) { + const submove = moves[s]; + + const subIFM = cloned.parseMove(submove); + if (subIFM.valid === false) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.INVALID_MOVE", {move: submove}); + return result; + } + + //Check blocked first. + if (blocked) { + if (subIFM.forward || subIFM.from || ! subIFM.card) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.NO_CHOICE_BLOCKED"); + return result; + } else if (s > 0) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.TOO_LATE_FOR_BLOCKED"); + return result; + } else if (moves.length > 1 && moves.join("") !== submove) { + //Checks for future moves that aren't the empty move. + //(We only trimmed one empty move, not all of them.) + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.NO_MOVE_BLOCKED"); + return result; + } else if (cloned.market.indexOf(subIFM.card) === -1) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.NO_SUCH_MARKET_CARD"); + return result; + } else { + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + } + + //Check and set refill. + if (subIFM.refill) { + if (! cloned.variants.includes("refills") ) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.NO_REFILLS"); + return result; + } else if ( s === 2 ) {//refilling only happens in the original 3-move sequence, before the third move. + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.TOO_LATE_FOR_REFILL"); + return result; + } else if ( s < moves.length - 1 ) {//refill request needs to end the sequence + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.TOO_EARLY_FOR_REFILL"); + return result; + } // else refill = true; + } + + let complete = false; + + //Check if we need to parse this as a partial move. + if (s < moves.length - 1 || allcomplete) + complete = true; + + //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 ) { + //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. + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.NO_SUCH_MARKET_CARD", {card: subIFM.card}); + return result; + } + } + + //Check moves. + //There is no case remaining without moves, except partials. + if ( ! subIFM.from ) { + if ( ! complete ) { + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.frogger.PIECE_NEXT"); + return result; + } else { + //Reachable if an unblocked player submits the blocked move. + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.INVALID_MOVE", {move: submove}); + return result; + } + } + + //Check frog. + //(Once we have a move from, we have a frog.) + const frog = cloned.board.get(subIFM.from!); + + //Check frog existence and ownership. + if (!frog || frog!.charAt(1)! !== cloned.currplayer.toString() ) { + //Bad frog. + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.INVALID_FROG"); + return result; + } + + //Check frog location. + //(Frogs cannot leave home.) + const fromX = cloned.algebraic2coords(subIFM.from)[0]; + if (fromX === cloned.columns - 1) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.NO_RETURN"); + return result; + } + + if ( ! subIFM.to ) { + if ( ! complete ) { + result.valid = true; + result.complete = -1; + result.message = i18next.t("apgames:validation.frogger.PLACE_NEXT"); + return result; + } else { + //malformed, no longer reachable. + throw new Error("Received malformed IFMove from parser. This should never happen!"); + } + } + + //Check target location is on the board. + if ( !cloned.suitboard.has(subIFM.to) && cloned.getUnsuitedCells().indexOf(subIFM.to) < 0 ) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.OFF_BOARD"); + return result; + } + //The source location was tested for frogs so must have been on the board. + + //On to to testing. + const toX = cloned.algebraic2coords(subIFM.to)[0]; + + //It's my interpretation of the rules that you must change cards on a move, + // not just change space, but I'm not 100% sure about that. + if (fromX === toX) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.MUST_MOVE"); + return result; + } + + //Test the move direction (determined from move structure) against the actual cells provided. + if (subIFM.forward && toX < fromX) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.MUST_HOP_FORWARD"); + return result; + } else if (!subIFM.forward && toX > fromX) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.CARD_FIRST"); + return result; + } + + //Moving back tests. + if (!subIFM.forward) { + if ( !cloned.checkNextBack(subIFM.from, subIFM.to)) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.INVALID_HOP_BACKWARD"); + return result; + } + if (toX > 0 && subIFM.card) { + const suit = cloned.suitboard.get(subIFM.to)!; + const suits = cloned.getSuits(subIFM.card); + if (suits.indexOf(suit) > -1) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.INVALID_MARKET_CARD"); + return result; + } + } else if (subIFM.card) { + // When backing up to start you can pick any market card. + // We already checked it was in the market. + } else if (!complete && cloned.market.length > 0) { + // No card. May be a partial move. + result.valid = true; + result.complete = -1; + result.canrender = true; + if (s < cloned.nummoves - 1) + result.message = i18next.t("apgames:validation.frogger.CARD_NEXT_OR"); + else + result.message = i18next.t("apgames:validation.frogger.CARD_NEXT"); + return result; + } + } + + //Moving forward tests. + if (subIFM.forward && !cloned.checkNextForward(subIFM.from, subIFM.to, subIFM.card!)) { + result.valid = false; + if (cloned.variants.includes("advanced")) + result.message = i18next.t("apgames:validation.frogger.INVALID_HOP_FORWARD_ADVANCED"); + else + result.message = i18next.t("apgames:validation.frogger.INVALID_HOP_FORWARD"); + return result; + } + + if (s < moves.length - 1) { + //Passed all tests so make the submove (for validating the rest of the move). + //Card adjustments. + if (subIFM.forward) { + cloned.popHand(subIFM.card!); + //console.log(subIFM.card, " in ", cloned.hands[cloned.currplayer - 1] , cloned.closedhands[cloned.currplayer - 1]); + + //Also pop other frogs if it's a crown or ace. + cloned.moveNeighbors(subIFM.to,subIFM.card!); + } else if (subIFM.card) { + //marketEmpty = + cloned.popMarket(subIFM.card); + } + + if (subIFM.from && subIFM.to) { + //Frog adjustments, complicated by frog piles. + cloned.moveFrog(subIFM.from, subIFM.to); + } + + } else if ( s === moves.length - 1 ) { + //Pass completion status to outside. + allcomplete = complete; + } + } + + //Really really done. + result.valid = true; + result.complete = allcomplete ? 1 : 0; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + public move(m: string, {trusted = false, partial = false} = {}): FroggerGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + + //m = m.toLowerCase(); + m = m.replace(/\s+/g, ""); + if (! trusted) { + const result = this.validateMove(m); + if (! result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message) + } + } + + this.results = []; + let marketEmpty = false; + let refill = false; + let remaining: number; + + if ( m === "pass") { + this.results.push({type: "pass"}); + + //Passes only happen in the context of the refill option. + //Besides the forced passes, the original player can pass + // once he sees the market and (perhaps) doesn't like it. + //In that case, we need to clean up (below). + + } else { + + const moves = m.split("/"); + + for (let s = 0; s < moves.length; s++) { + const submove = moves[s]; + if ( submove === "" ) + continue; + + const subIFM = this.parseMove(submove); + + if (subIFM.refill) + refill = true; +/* + + if (submove.indexOf(":") > -1) { + //Card followed by move is a hand card. + [ca, mv] = submove.split(":"); + handcard = true; + } else if (submove.indexOf(",") > -1) { + //Move followed by card is a backwards move. + [mv, ca] = submove.split(","); + } else if (submove.indexOf("-") > -1) { + //Raw move is a unproductive or partial backwards move. + [from, to] = submove.split("-"); + // nocard = true; + } else { + //Raw card must be a blocked move or a partial. + ca = submove; + if (!partial) + this.results.push({type: "claim", what: ca}); + } + + if ( mv ) + [from, to] = mv!.split("-"); +*/ + //Make the submove. + //Possible card adjustments. + if (subIFM.forward && subIFM.card) { + this.popHand(subIFM.card); + this.results.push({type: "move", from: subIFM.from!, to: subIFM.to!, what: subIFM.card!, how: "forward"}); + const bounced = this.moveNeighbors(subIFM.to!,subIFM.card!); + bounced.forEach( ([from, to]) => { + this.results.push({type: "eject", from: from, to: to, what: "a Crown or Ace"}); + }); + } else if (subIFM.card) { + marketEmpty = this.popMarket(subIFM.card); + if (subIFM.from) { + this.results.push({type: "move", from: subIFM.from!, to: subIFM.to!, what: subIFM.card!, how: "back"}); + } + } else { + this.results.push({type: "move", from: subIFM.from!, to: subIFM.to!, what: "no card", how: "back"}); + } + + if (subIFM.from && subIFM.to) { + this.moveFrog(subIFM.from,subIFM.to); + } + + if (refill) { + remaining = 2 - s; + break; + } + } + } + + if (partial) { return this; } + + if (refill) { + //Set skipto and nummoves. + //Don't progress crocodiles. + //Skip to my lou. + //After the new turn, update nummoves, skipto, and crocs. + this.results.push({type: "announce", payload: [remaining!]}); + this.skipto = this.currplayer; + this.nummoves = remaining!; + } else { + + //If this was the refill turn, unset skipto and nummoves, + // regardless of whether currplayer passed or moved. + if ( this.variants.includes("refills") && this.skipto !== undefined && this.skipto === this.currplayer) { + this.skipto = undefined; + this.nummoves = 3; + } + + //update crocodiles if croccy + if (this.variants.includes("crocodiles") && this.currplayer as number === this.numplayers && !this.skipto) { + this.results.push({type: "declare"}); + //Advance the crocodiles. + const victims = this.popCrocs(); + //Memorialize any victims. + victims.forEach( ([from, to]) => { + this.results.push({type: "eject", from: from, to: to, what: "crocodiles"}); + }); + } + + } + + //update market if necessary + if (marketEmpty || this.variants.includes("continuous")) { + const refilled = this.refillMarket(); + if (refilled) + this.results.push({type: "deckDraw"}); + } + + // 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(): FroggerGame { + if ( this.countColumnFrogs(true) === 6 ) { + this.gameover = true; + this.winner.push(this.currplayer); + } + + if (this.gameover) { + this.results.push( + {type: "eog"}, + {type: "winners", players: [...this.winner]} + ); + } + return this; + } + + public state(opts?: {strip?: boolean, player?: number}): IFroggerState { + const state: IFroggerState = { + game: FroggerGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack] + }; + if (opts !== undefined && opts.strip) { + state.stack = state.stack.map(mstate => { + for (let p = 1; p <= this.numplayers; p++) { + if (p === opts.player) { continue; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + mstate.closedhands[p-1] = mstate.closedhands[p-1].map(c => ""); + } + return mstate; + }); + } + return state; + } + + public moveState(): IMoveState { + return { + _version: FroggerGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + skipto: this.skipto, + lastmove: this.lastmove, + board: new Map(this.board), + closedhands: this.closedhands.map(h => [...h]), + hands: this.hands.map(h => [...h]), + market: [...this.market], + discards: [...this.discards], + nummoves: this.nummoves, + }; + } + + public render(): APRenderRep { + //Taken from the decktet sheet. + const suitColors = ["#c7c8ca","#e08426","#6a9fcc","#bc8a5d","#6fc055","#d6dd40"]; + + // Build piece string. + let pstr = ""; + for (let row = 0; row < this.rows; row++) { + if (pstr.length > 0) { + pstr += "\n"; + } + const pieces: string[] = []; + for (let col = 0; col < this.columns; col++) { + const cell = this.coords2algebraic(col, row); + + if (this.board.has(cell)) { + if (row === 0) + pieces.push("c" + this.board.get(cell)!); + else + pieces.push(this.board.get(cell)!); + } else { + pieces.push("-"); + } + + } + + pstr += pieces.join(","); + } + + //Also build blocked sting. + const blocked = []; + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.columns; col++) { + const cell = this.coords2algebraic(col, row); + if (row === 0) { + //Blocking not working here? Probably because they're pieces. + blocked.push({col: col, row: row} as RowCol); + } else if (col === 0 || col === this.columns - 1) { + if (row > this.numplayers) + blocked.push({col: col, row: row} as RowCol); + } else if (! this.suitboard.has(cell) ) { + blocked.push({col: col, row: row} as RowCol); + } + } + } + + // build claimed markers + const markers: (MarkerFlood|MarkerGlyph)[] = []; + + markers.push({ + type: "glyph", + glyph: "start", + points: [{row: 0, col: 0}], + }); + markers.push({ + type: "glyph", + glyph: "home", + points: [{row: 0, col: this.columns - 1}], + }); + + // add flood markers for the end column + const points = []; + for (let r = 0; r < this.numplayers; r++) { + const row = this.rows - 2 - r; + points.push({col: 0, row: row} as RowCol); + points.push({col: this.columns - 1, row: row} as RowCol); + } + markers.push({ + type: "flood", + colour: "_context_fill_", + opacity: 0.03, + points: points as [RowCol, ...RowCol[]], + }); + + //Need card info on all cards. + const allcards = [...cardsBasic]; + allcards.push(...cardsExtended.filter(c => c.rank.uid === this.pawnrank)); + if (this.variants.includes("courts")) + allcards.push(...cardsExtended.filter(c => c.rank.uid === this.courtrank)); + + + //add flood and suit markers for the active spaces + for (let col = 1; col < this.columns - 1; col++) { + const cell = this.coords2algebraic(col,0); + const cardObj = Card.deserialize(this.board.get(cell)!); + const suits = cardObj!.suits; + + let shadeRow = 1; + suits.forEach(suit => { + const color = suitColors[suitOrder.indexOf(suit.uid)]; + markers.push({ + type: "flood", + colour: color, + opacity: 0.33, + points: [{row: shadeRow, col: col}], + }); + markers.push({ + type: "glyph", + glyph: suit.uid, + points: [{row: shadeRow, col: col}], + }); + shadeRow++; + }); + } + + // build legend of ALL cards + const legend: ILegendObj = {}; + for (const card of allcards) { + legend["c" + card.uid] = card.toGlyph(); + } + + const excuses = [...cardsExtended.filter(c => c.rank.uid === "0")]; + + // add glyph for unknown cards + legend["cUNKNOWN"] = { + name: "piece-square-borderless", + colour: { + func: "flatten", + fg: "_context_fill", + bg: "_context_background", + opacity: 0.5, + }, + } + + legend["start"] = excuses[0].toGlyph(); + + //Home symbol for the last column. + legend["home"] = { + name: "streetcar-house", + scale: 0.75 + }; + + //Player pieces. + for (let player = 1; player <= this.numplayers; player++) { + + legend["X" + player] = { + name: "piece", + colour: player, + scale: 0.75 + } + + //The XP-1 token is used in the first and last rows. + for (let count = 1; count <= 6; count++) { + legend["X" + player + "-" + count] = [ + { + name: "piece", + colour: player, + scale: 0.75 + }, + { + text: count.toString(), + colour: "_context_strokes", + scale: 0.66 + } + ] + } + } + + if (this.variants.includes("crocodiles")) { + legend["X0"] = [ + { + name: "piece-borderless", + colour: "_context_background", + scale: 0.85, + opacity: 0.55 + }, + { + text: "\u{1F40A}", + scale: 0.85 + } + ] + } + + if (this.variants.includes("refills")) { + legend["refill"] = [ + { + text: "\u{1F504}", + scale: 1.25 + } + ] + } + + //Suit glyphs. + for (const suit of suits) { + legend[suit.uid] = { + name: suit.glyph, + scale: 1, + opacity: 0.33 + } + }; + + // 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]); + if (hand.length > 0) { + areas.push({ + type: "pieces", + pieces: hand.map(c => "c" + (c === "" ? "UNKNOWN" : c)) as [string, ...string[]], + label: i18next.t("apgames:validation.frogger.LABEL_STASH", {playerNum: p}) || `P${p} Hand`, + spacing: 0.5, + ownerMark: p + }); + } + } + + if (this.market.length > 0) { + areas.push({ + type: "pieces", + pieces: this.market.map(c => "c" + c) as [string, ...string[]], + label: i18next.t("apgames:validation.frogger.LABEL_MARKET") || "Market", + spacing: 0.375, + }); + } else if ( this.variants.includes("refills") ) { + areas.push({ + type: "pieces", + pieces: ["refill"], + label: i18next.t("apgames:validation.frogger.LABEL_MARKET") || "Market", + spacing: 0.375, + }); + } + + if (this.discards.length > 0) { + areas.push({ + type: "pieces", + pieces: this.discards.map(c => "c" + c) as [string, ...string[]], + label: i18next.t("apgames:validation.frogger.LABEL_DISCARDS") || "Discards", + spacing: 0.25, + width: this.columns + 2, + }); + } + + // 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)); + 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({ + type: "pieces", + label: i18next.t("apgames:validation.frogger.LABEL_REMAINING") || "Cards in deck", + spacing: 0.25, + width: this.columns + 2, + pieces: remaining, + }); + } + + // Build rep + const rep: APRenderRep = { + options: ["hide-labels-half"], + board: { + style: "squares", + width: this.columns, + height: this.rows, + tileHeight: 1, + tileWidth: 1, + tileSpacing: 0.1, + strokeOpacity: 0, + blocked: blocked as [RowCol, ...RowCol[]], + markers, + }, + legend, + pieces: pstr, + areas, + }; + + //console.log(rep); + + // Add annotations + if (this.results.length > 0) { + rep.annotations = []; + for (const move of this.results) { + if (move.type === "move") { + const [fromX, fromY] = this.algebraic2coords(move.from!); + const [toX, toY] = this.algebraic2coords(move.to!); + if (move.how === "back") + rep.annotations.push({type: "move", style: "dashed", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + else + rep.annotations.push({type: "move", targets: [{row: fromY, col: fromX}, {row: toY, col: toX}]}); + } else if (move.type === "claim") { + //TODO: cross off the market card when partial? + } else if (move.type === "eject") { + const [fromX, fromY] = this.algebraic2coords(move.from!); + const [toX, toY] = this.algebraic2coords(move.to!); + if (move.what === "crocodiles") { + rep.annotations.push({type: "eject", targets: [{row: fromY, col: fromX},{row: toY, col: toX}], opacity: 0.9, colour: "#FE019A"}); + rep.annotations.push({type: "exit", targets: [{row: fromY, col: fromX}], occlude: false, colour: "#FE019A"}); + } else { + rep.annotations.push({type: "eject", targets: [{row: fromY, col: fromX},{row: toY, col: toX}]}); + rep.annotations.push({type: "exit", targets: [{row: fromY, col: fromX}]}); + } + } + } + } + + 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 "announce": + node.push(i18next.t("apresults:ANNOUNCE.frogger", {player, moves: (r.payload as string[]).join("") })); + resolved = true; + break; + case "claim": + node.push(i18next.t("apresults:CLAIM.frogger", {player, card: r.what})); + resolved = true; + break; + case "deckDraw": + node.push(i18next.t("apresults:DECKDRAW.frogger")); + resolved = true; + break; + case "declare": + node.push(i18next.t("apresults:DECLARE.frogger")); + resolved = true; + break; + case "eject": + if (r.what === "crocodiles") { + node.push(i18next.t("apresults:EJECT.frogger_croc", {player, from: r.from, to: r.to})); + } else { + node.push(i18next.t("apresults:EJECT.frogger_card", {player, from: r.from, to: r.to})); + } + resolved = true; + break; + case "move": + if (r.how === "forward") { + node.push(i18next.t("apresults:MOVE.frogger_forward", {player, from: r.from, to: r.to, card: r.what})); + } else if (r.how === "back") { + node.push(i18next.t("apresults:MOVE.frogger_back", {player, from: r.from, to: r.to, card: r.what})); + } else { + node.push(i18next.t("apresults:MOVE.frogger_blocked", {player, card: r.what})); + } + resolved = true; + break; + case "pass": + node.push(i18next.t("apresults:PASS.frogger", {player})); + resolved = true; + break; + case "eog": + node.push(i18next.t("apresults:EOG.frogger", {player})); + resolved = true; + break; + } + return resolved; + } + + public clone(): FroggerGame { + + return Object.assign(new FroggerGame(this.numplayers), deepclone(this) as FroggerGame); + } +} diff --git a/src/games/index.ts b/src/games/index.ts index 773aede0..4bf3308b 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -217,6 +217,7 @@ import { SunspotGame, ISunspotState } from "./sunspot"; import { StawvsGame, IStawvsState } from "./stawvs"; import { LascaGame, ILascaState } from "./lasca"; import { EmergoGame, IEmergoState } from "./emergo"; +import { FroggerGame, IFroggerState } from "./frogger"; export { APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState, @@ -436,6 +437,7 @@ export { StawvsGame, IStawvsState, LascaGame, ILascaState, EmergoGame, IEmergoState, + FroggerGame, IFroggerState, }; const games = new Map(); // Manually add each game to the following array [ @@ -544,7 +548,7 @@ 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."); @@ -988,6 +992,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu return new LascaGame(...args); case "emergo": return new EmergoGame(...args); + case "frogger": + return new FroggerGame(args[0], ...args.slice(1)); } return; } diff --git a/test/games/frogger.test.ts b/test/games/frogger.test.ts new file mode 100644 index 00000000..6d5d687f --- /dev/null +++ b/test/games/frogger.test.ts @@ -0,0 +1,338 @@ +/* tslint:disable:no-unused-expression */ + +import "mocha"; +import { expect } from "chai"; +import { FroggerGame } from '../../src/games'; + +describe("Frogger", () => { + const g = new FroggerGame(2); + it ("Parses single moves", () => { + // parsing good moves + expect(g.parseMove("8MS:a3-b3")).to.deep.equal({ + card: "8MS", + forward: true, + from: "a3", + incomplete: false, + to: "b3", + refill: false, + valid: true + }); + expect(g.parseMove("c2-b3,8MS")).to.deep.equal({ + card: "8MS", + forward: false, + from: "c2", + incomplete: false, + to: "b3", + refill: false, + valid: true + }); + expect(g.parseMove("c2-b3,1M!")).to.deep.equal({ + card: "1M", + forward: false, + from: "c2", + incomplete: false, + to: "b3", + refill: true, + valid: true + }); + expect(g.parseMove("c2")).to.deep.equal({ + forward: false, + from: "c2", + incomplete: true, + refill: false, + valid: true + }); + expect(g.parseMove("8MS")).to.deep.equal({ + card: "8MS", + forward: false, + incomplete: false, + refill: false, + valid: true + }); + }); + + it ("Does character validation on parse", () => { + const g = new FroggerGame(2); + expect(g.parseMove("7MSaaa:c5")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS:n5-n6!")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS:n5-o4")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS:b5-cc4!")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS:b55-c4")).to.have.deep.property("valid", false); + expect(g.parseMove("7MSPQRS:b5-c4!")).to.have.deep.property("valid", false); + expect(g.parseMove("M7SLST")).to.have.deep.property("valid", false); + expect(g.parseMove("d2-e2,7MSPQRS")).to.have.deep.property("valid", false); + }); + + it ("Does structural validation on parse", () => { + const g = new FroggerGame(2); + expect(g.parseMove("7MS:m5-m6,6MS!")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS,m5-m6,6MS!")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS:m5-m6:6MS")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS;m5*m6")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS;m5-n6")).to.have.deep.property("valid", false); + expect(g.parseMove("7MS-m5")).to.have.deep.property("valid", false); + expect(g.parseMove("c2-,1M!")).to.have.deep.property("valid", false); + expect(g.parseMove("c2,1M!")).to.have.deep.property("valid", false); + expect(g.parseMove("c2-d2-e2")).to.have.deep.property("valid", false); + }); + + it ("Does character validation on validate", () => { + const g = new FroggerGame(2); + expect(g.validateMove("7MSaaa:c5")).to.have.deep.property("valid", false); + expect(g.validateMove("7MS:n5-n6!")).to.have.deep.property("valid", false); + expect(g.validateMove("7MS:n5-o4")).to.have.deep.property("valid", false); + expect(g.validateMove("7MS:b5-cc4!")).to.have.deep.property("valid", false); + expect(g.validateMove("7MS:b55-c4")).to.have.deep.property("valid", false); + expect(g.validateMove("7MSPQRS:b5-c4!")).to.have.deep.property("valid", false); + expect(g.validateMove("M7SLST")).to.have.deep.property("valid", false); + expect(g.validateMove("d2-e2,7MSPQRS")).to.have.deep.property("valid", false); + }); + + it ("Does structural validation on validate", () => { + const g = new FroggerGame(2); + expect(g.validateMove("8MS:m2-n3,9MS!")).to.have.deep.property("valid", false); + expect(g.validateMove("8MS,m2-n3,9MS!")).to.have.deep.property("valid", false); + expect(g.validateMove("8MS:m2-n3:9MS")).to.have.deep.property("valid", false); + expect(g.validateMove("8MS;m2*n3")).to.have.deep.property("valid", false); + expect(g.validateMove("8MS;m2-n3")).to.have.deep.property("valid", false); + expect(g.validateMove("8MS-m5")).to.have.deep.property("valid", false); + expect(g.validateMove("c2-,1M!")).to.have.deep.property("valid", false); + expect(g.validateMove("c2,1M!")).to.have.deep.property("valid", false); + expect(g.validateMove("c2-d2-e2")).to.have.deep.property("valid", false); + }); + + it ("Does semantic validation on validate", () => { + const g = new FroggerGame(2); + //This is only semantic in that non-existent cards won't be in anyone's hand. + expect(g.validateMove("7MS:b2-c3")).to.have.deep.property("valid", false); + //Non-existent cards also won't be in the market. + expect(g.validateMove("d2-c3,9MV")).to.have.deep.property("valid", false); + //Set up a 2p base game, so cells a4-n4 are disallowed. + expect(g.validateMove("8MS:f2-g4")).to.have.deep.property("valid", false); + //Cells a5-n5 are off the board. + expect(g.validateMove("8MS:f2-g5")).to.have.deep.property("valid", false); + /* //Structural issue with a refill request (default refill variant on). + expect(g.validateMove("f2-e3,1M!//")).to.have.deep.property("valid", false); + //Structural issue with a refill request (default refill variant on). + expect(g.validateMove("f2-e3,1M!/e3-b1,2MK")).to.have.deep.property("valid", false); + //Structural issue with a refill request (default refill variant on). + expect(g.validateMove("f2-e3,1M!")).to.have.deep.property("valid", false);*/ + }); + + it ("Handles multi-part moves", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":[],"gameover":false,"winner":[],"stack":[{"_version":"20251220","_results":[],"_timestamp":"2025-12-27T20:25:46.174Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","PVLY"],["c4","4YK"],["d4","2MK"],["e4","9VY"],["f4","7VY"],["g4","PMYK"],["h4","5YK"],["i4","6MV"],["j4","PSVK"],["k4","PMSL"],["l4","NV"],["m4","1L"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["4VL","2SY","1Y","8YK"],["NY","6SY","1K","5ML"]],"hands":[[],[]],"market":["6LK","3MV","1S","1V","7SK","9LK"],"discards":[],"nummoves":3}]}`); + + expect(g.validateMove("8YK:a3-c2/c2-b2,1S/b2-a3,7MK/")).to.have.deep.property("valid", false); //Not a real card. + expect(g.validateMove("8YK:a3-c2/c2-d2,1S/b2-a3,7SK/")).to.have.deep.property("valid", false); //Wrong direction. + expect(g.validateMove("8YK:a3-c2/c2-b2,1S!/b2-a3,7SK/")).to.have.deep.property("valid", false); //Can't refill. + expect(g.validateMove("8YK:a3-c2/c2-b2,1S/b2-a3,7SK/")).to.have.deep.property("valid", true); //A legal sequence. + g.move("8YK:a3-c2/c2-b2,1S/b2-a3,7SK/"); + expect(g.validateMove("8YK:a3-c2/c2-b2,1S/b2-a3,7SK/")).to.have.deep.property("valid", false); //No longer legal. + + expect(g.validateMove("6SY:a2-j3/1K:j3-n2!/")).to.have.deep.property("valid", false); //Can't refill. + expect(g.validateMove("6SY:a2-j3/1K:j3-n1/")).to.have.deep.property("valid", false); //Other player's home invasion. + expect(g.validateMove("6SY:a2-j3/1K:j3-p2/")).to.have.deep.property("valid", false); //Off the board. + expect(g.validateMove("6SY:a2-j3/1K:j3-n0/")).to.have.deep.property("valid", false); //Offsides. + expect(g.validateMove("6SY:a2-j3/1K:j3-n2/")).to.have.deep.property("valid", true); //A legal sequence. + g.move("6SY:a2-j3/1K:j3-n2/"); + expect(g.validateMove("6SY:a2-j3/1K:j3-n2/")).to.have.deep.property("valid", false); //No longer legal. + + expect(g.validateMove("2SY:a3-j3/NY:j3-n3/1S:a3-j3/")).to.have.deep.property("valid", false); //Steal other player's card. + expect(g.validateMove("2SY:a3-j3/4VL:j3-n3/1S:a3-j3/")).to.have.deep.property("valid", false); //Use own wrong card. + expect(g.validateMove("2SY:a3-j3/2SY:j3-n3/1S:a3-j3/")).to.have.deep.property("valid", false); //Steal own discard. + expect(g.validateMove("2SY:a3-j3/6SY:j3-n3/1S:a3-j3/")).to.have.deep.property("valid", false); //Steal other discard. + expect(g.validateMove("2SY:a3-j3/3VY:j3-n3/1S:a3-j3/")).to.have.deep.property("valid", false); //Steal deck card. + expect(g.validateMove("2SY:a3-j3/1Y:j3-n3/3SK:a3-j3/")).to.have.deep.property("valid", false); //Steal another deck card. + expect(g.validateMove("2SY:a3-j3/1Y:j3-n3/1S:a3-j3/")).to.have.deep.property("valid", true); //A legal sequence. + g.move("2SY:a3-j3/1Y:j3-n3/1S:a3-j3/"); + expect(g.validateMove("2SY:a3-j3/1Y:j3-n3/1S:a3-j3/")).to.have.deep.property("valid", false); //No longer legal. + }); + + it ("Handles the blocked position", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":[],"gameover":false,"winner":[],"stack":[{"_version":"20251220","_results":[],"_timestamp":"2025-12-27T20:25:46.174Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","PVLY"],["c4","4YK"],["d4","2MK"],["e4","9VY"],["f4","7VY"],["g4","PMYK"],["h4","5YK"],["i4","6MV"],["j4","PSVK"],["k4","PMSL"],["l4","NV"],["m4","1L"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["4VL","2SY","1Y","8YK"],["NY","6SY","1K","5ML"]],"hands":[[],[]],"market":["6LK","3MV","1S","1V","7SK","9LK"],"discards":[],"nummoves":3}]}`); + g.move("8YK:a3-c2/c2-b2,1S/b2-a3,7SK/"); + g.move("6SY:a2-j3/1K:j3-n2/"); + g.move("2SY:a3-j3/1Y:j3-n3/1S:a3-j3/"); + + //Some setup to block player 1. + expect(g.validateMove("5ML:a2-d3")).to.have.deep.property("valid", true); //A legal sequence. + g.move("5ML:a2-d3"); + expect(g.validateMove("4VL:j3-k1/7SK:k1-n3/")).to.have.deep.property("valid", true); //A legal sequence. + g.move("4VL:j3-k1/7SK:k1-n3/"); + expect(g.validateMove("NY:a2-b1")).to.have.deep.property("valid", true); //A legal sequence. + g.move("NY:a2-b1"); + + expect(g.validateMove("a1")).to.have.deep.property("valid", false); //Partial frog start. + expect(g.validateMove("a1-e1")).to.have.deep.property("valid", false); //Bad frog forward. + expect(g.validateMove("e1-a1")).to.have.deep.property("valid", false); //Bad frog back. + expect(g.validateMove("n1")).to.have.deep.property("valid", false); //Partial home frog start. + expect(g.validateMove("n1-m1")).to.have.deep.property("valid", false); //No return from home. + + expect(g.validateMove("NY//")).to.have.deep.property("valid", false); //Steal other player's card. + expect(g.validateMove("4VL//")).to.have.deep.property("valid", false); //Use own wrong card. + expect(g.validateMove("2SY//")).to.have.deep.property("valid", false); //Steal own discard. + expect(g.validateMove("6SY//")).to.have.deep.property("valid", false); //Steal other discard. + expect(g.validateMove("3LY//")).to.have.deep.property("valid", false); //Steal deck card. + expect(g.validateMove("6LY//")).to.have.deep.property("valid", false); //Invent a card. + + expect(g.validateMove("1V//")).to.have.deep.property("valid", true); //A legal sequence (market card). + expect(g.validateMove("1V/")).to.have.deep.property("valid", true); //A legal sequence (market card). + expect(g.validateMove("1V")).to.have.deep.property("valid", true); //A legal sequence (market card). + g.move("1V"); + + expect(g.validateMove("3MV//")).to.have.deep.property("valid", false); //Player 2 isn't blocked. + + }); + + it ("Enacts a double bounce", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":[],"gameover":false,"winner":[],"stack":[{"_version":"20251220","_results":[],"_timestamp":"2025-12-27T20:25:46.174Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","PVLY"],["c4","4YK"],["d4","2MK"],["e4","9VY"],["f4","7VY"],["g4","PMYK"],["h4","5YK"],["i4","6MV"],["j4","PSVK"],["k4","PMSL"],["l4","NV"],["m4","1L"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["4VL","2SY","1Y","8YK"],["NY","6SY","1K","5ML"]],"hands":[[],[]],"market":["6LK","3MV","1S","1V","7SK","9LK"],"discards":[],"nummoves":3}]}`); + g.move("8YK:a3-c2/c2-b2,1S/b2-a3,7SK/"); + g.move("6SY:a2-j3/1K:j3-n2/"); + g.move("2SY:a3-j3/1Y:j3-n3/1S:a3-j3/"); + g.move("5ML:a2-d3"); + g.move("4VL:j3-k1/7SK:k1-n3/"); + g.move("NY:a2-b1"); + g.move("1V"); + + //set up the bounce + expect(g.validateMove("d3-c3,3MV/c3-b2")).to.have.deep.property("valid", true); + g.move("d3-c3,3MV/c3-b2"); + + //Check + expect(g.board.get("a2")).to.equal("X2-3"); + expect(g.validateMove("1V:a3-b3/")).to.have.deep.property("valid", true); //The bouncing move. + g.move("1V:a3-b3/"); + expect(g.board.get("a2")).to.equal("X2-5"); + }); + + it ("Implements crocodiles and continuous market variants", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["crocodiles","continuous"],"gameover":false,"winner":[],"stack":[{"_version":"20251220","_results":[],"_timestamp":"2025-12-28T02:33:17.187Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","PMSL"],["b3","X0"],["c4","PSVK"],["c3","X0"],["d4","NV"],["e4","PVLY"],["e3","X0"],["f4","9VY"],["g4","8MS"],["h4","PMYK"],["h3","X0"],["i4","NL"],["j4","1Y"],["k4","2MK"],["l4","2VL"],["m4","8VL"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["1M","7SK","1L","1K"],["5SV","4MS","3MV","9LK"]],"hands":[[],[]],"market":["6SY","6LK","4VL"],"discards":[],"nummoves":3}]}`); + expect(g.board.get("b3")).to.equal("X0"); + expect(g.board.get("c3")).to.equal("X0"); + expect(g.board.get("e3")).to.equal("X0"); + expect(g.board.get("h3")).to.equal("X0"); + expect(g.market.length).to.equal(3); + + expect(g.validateMove("1M:a3-g3/g3-f3,6LK/6LK:f3-i3/")).to.have.deep.property("valid", true); //A legal sequence. + g.move("1M:a3-g3/g3-f3,6LK/6LK:f3-i3/"); + expect(g.board.get("b3")).to.equal("X0"); //Crocodiles haven't moved. + expect(g.market.length).to.equal(3); + + expect(g.board.get("a2")).to.equal("X2-6"); + expect(g.validateMove("5SV:a2-c2!/")).to.have.deep.property("valid", false); //No manual refills allowed. + expect(g.validateMove("5SV:a2-c2/")).to.have.deep.property("valid", true); //About to get chomped. + g.move("5SV:a2-c2/"); + //expect(g.board.get("a2")).to.equal("X2-5"); //The move happens, but too fast to test before chomping. + expect(g.board.get("b2")).to.equal("X0"); //Crocodiles have moved. End of round 1. + expect(g.board.get("c2")).to.equal("X0"); + expect(g.board.get("e2")).to.equal("X0"); + expect(g.board.get("h2")).to.equal("X0"); + expect(g.board.get("a2")).to.equal("X2-6"); //Chomped frog is back where we started. + expect(g.market.length).to.equal(3); + + //Cycle croc and possibly market using random moves. + g.move(g.randomMove()); + expect(g.board.get("c2")).to.equal("X0"); //Crocodiles haven't moved. + expect(g.market.length).to.equal(3); + + g.move(g.randomMove()); + expect(g.board.get("c1")).to.equal("X0"); //Crocodiles have moved. End of round 2. + expect(g.market.length).to.equal(3); + + //Cycle crocs to top and possibly market using random moves. + g.move(g.randomMove()); + expect(g.board.get("e1")).to.equal("X0"); //Crocodiles haven't moved. + expect(g.market.length).to.equal(3); + + g.move(g.randomMove()); + expect(g.board.get("e3")).to.equal("X0"); //Crocodiles have moved. End of round 3. + expect(g.market.length).to.equal(3); + + //One more cycle of crocs and possibly market using random moves. + g.move(g.randomMove()); + expect(g.board.get("h3")).to.equal("X0"); //Crocodiles haven't moved. + expect(g.market.length).to.equal(3); + + g.move(g.randomMove()); + expect(g.board.get("h2")).to.equal("X0"); //Crocodiles have moved. End of round 4. + expect(g.market.length).to.equal(3); + + }); + + it ("Implements basic suit movement rules", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["courts","#market"],"gameover":false,"winner":[],"stack":[{"_version":"20251220","_results":[],"_timestamp":"2025-12-29T03:38:17.329Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","7VY"],["c4","PVLY"],["d4","PMSL"],["e4","2MK"],["f4","3LY"],["g4","PMYK"],["h4","5ML"],["i4","8YK"],["j4","NY"],["k4","PSVK"],["l4","1L"],["m4","6MV"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["TSLK","NM","9LK","TMLY"],["9MS","7SK","1K","2VL"]],"hands":[[],[]],"market":["NS","3SK","TMVK","5YK","TSVY","NV"],"discards":[],"nummoves":3}]}`); + + //Can move to first occurrence of only suit. + expect(g.validateMove("NM:a3-d3")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of only suit. + expect(g.validateMove("NM:a3-e3")).to.have.deep.property("valid", false); + + //Can move to first occurrence of first suit. + expect(g.validateMove("9LK:a3-c2")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of first suit. + expect(g.validateMove("9LK:a3-d1")).to.have.deep.property("valid", false); + //Can move to first occurrence of second suit. + expect(g.validateMove("9LK:a3-e2")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of second suit. + expect(g.validateMove("9LK:a3-i2")).to.have.deep.property("valid", false); + + //Can move to first occurrence of first suit. + expect(g.validateMove("TSLK:a3-d2")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of first suit. + expect(g.validateMove("TSLK:a3-k3")).to.have.deep.property("valid", false); + //Can move to first occurrence of second suit. + expect(g.validateMove("TSLK:a3-c2")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of second suit. + expect(g.validateMove("TSLK:a3-d1")).to.have.deep.property("valid", false); + //Can move to first occurrence of third suit. + expect(g.validateMove("TSLK:a3-e2")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of third suit. + expect(g.validateMove("TSLK:a3-k1")).to.have.deep.property("valid", false); + + }); + + it ("Implements advanced suit movement rules", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["advanced","courts"],"gameover":false,"winner":[],"stack":[{"_version":"20251220","_results":[],"_timestamp":"2025-12-29T04:01:17.728Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","PSVK"],["c4","PMYK"],["d4","9LK"],["e4","7VY"],["f4","9VY"],["g4","NL"],["h4","PMSL"],["i4","9MS"],["j4","5ML"],["k4","PVLY"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["6LK","8YK","TMLY","1L"],["8MS","7SK","NM","5SV"]],"hands":[[],[]],"market":["NK","1Y","2VL","NV","NS","1K"],"discards":[],"nummoves":3}]}`); + + //Can move to first occurrence of only suit. (Ace/Crown rule unchanged.) + expect(g.validateMove("1L:a3-d3")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of only suit. + expect(g.validateMove("1L:a3-h1")).to.have.deep.property("valid", false); //TODO: better message? + + //Can move to first occurrence of the first occuring suit. + expect(g.validateMove("6LK:a3-b1")).to.have.deep.property("valid", true); + //Can't move to first occurrence of the other suit. + expect(g.validateMove("6LK:a3-d3")).to.have.deep.property("valid", false); + //Can't move to subsequent occurrences of first suit. + expect(g.validateMove("6LK:a3-g3")).to.have.deep.property("valid", false); + //Can't move to subsequent occurrences of second suit. + expect(g.validateMove("6LK:a3-d2")).to.have.deep.property("valid", false); + + //Can move to first occurrence of first suit. (Pawn/Court rule unchanged.) + expect(g.validateMove("TMLY:a3-c3")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of first suit. + expect(g.validateMove("TMLY:a3-h3")).to.have.deep.property("valid", false); + //Can move to first occurrence of second suit. + expect(g.validateMove("TMLY:a3-d3")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of second suit. + expect(g.validateMove("TMLY:a3-h1")).to.have.deep.property("valid", false); + //Can move to first occurrence of third suit. + expect(g.validateMove("TMLY:a3-c2")).to.have.deep.property("valid", true); + //Can't move to subsequent occurrences of third suit. + expect(g.validateMove("TMLY:a3-k1")).to.have.deep.property("valid", false); + + }); + + it ("Implements the no-refills market variant", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["courts","#market"],"gameover":false,"winner":[],"stack":[{"_version":"20251220","_results":[],"_timestamp":"2025-12-29T03:38:17.329Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","7VY"],["c4","PVLY"],["d4","PMSL"],["e4","2MK"],["f4","3LY"],["g4","PMYK"],["h4","5ML"],["i4","8YK"],["j4","NY"],["k4","PSVK"],["l4","1L"],["m4","6MV"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["TSLK","NM","9LK","TMLY"],["9MS","7SK","1K","2VL"]],"hands":[[],[]],"market":["NS","3SK","TMVK","5YK","TSVY","NV"],"discards":[],"nummoves":3}]}`); + //Setup. + g.move("NM:a3-d3/9LK:a3-c2/TSLK:a3-d2/"); + g.move("9MS:a2-k3/7SK:a2-n2/1K:k3-n2/"); + g.move("d3-c3,5YK/c2-b2,TMVK/d2-c2,TSVY/"); + g.move("2VL:a2-d1/d1-c1,3SK/c1-b3,NS/"); + //Player one attempts to refill the market. + expect(g.validateMove("5YK:b2-e2/e2-d2,NV!/")).to.have.deep.property("valid", false); + expect(g.validateMove("5YK:b2-e2/e2-d2,NV/")).to.have.deep.property("valid", true); + + }); +});