diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 38eba58a..920db1d1 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -236,7 +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.", + "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 draw pool, so hands gradually become 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 draw pool in the middle of his turn. These are handled by the server, but a couple of variants for the draw pool 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.", @@ -1162,7 +1162,7 @@ }, "frogger": { "#market": { - "description": "The market does not refill until the next player's turn.", + "description": "The draw pool does not refill until the next player's turn.", "name": "No refills" }, "advanced": { @@ -1170,8 +1170,8 @@ "name": "Advanced" }, "continuous": { - "description": "The market is smaller but refills after each turn.", - "name": "Continuous market" + "description": "The draw pool is smaller but refills after each turn.", + "name": "Continuous refills" }, "courtpawns": { "description": "For variety, the roles of pawns and courts may be reversed.", @@ -1185,8 +1185,12 @@ "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" }, + "freeswim": { + "description": "There is no restriction on cards drawn from the draw pool.", + "name": "Free swim" + }, "refills": { - "description": "The market may be refilled during a player's turn, by splitting their actions over two turns.", + "description": "The draw pool may be refilled during a player's turn, by splitting their actions over two turns.", "name": "Refills" } }, @@ -4101,18 +4105,18 @@ "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.", + "CARD_NEXT": "You may click on a card in the draw pool to add it to your hand, or submit your move.", + "CARD_NEXT_OR": "You may click on a card in the draw pool 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.", + "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 card from the draw pool).", + "INVALID_MARKET_CARD": "Your chosen pool 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_MARKET": "Draw pool", "LABEL_REMAINING": "Cards in deck", "LABEL_DISCARDS": "Discard pile", "LABEL_STASH": "Player {{playerNum}}'s hand", @@ -4120,14 +4124,14 @@ "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_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 draw pool 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_PASSING": "There is no passing as such in Frogger. If you are blocked, you must draw a single card from the draw pool.", + "NO_REFILLS": "Refilling the draw pool 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.", + "NO_SUCH_MARKET_CARD": "The card \"{{card}}\" does not appear to be in the draw pool.", + "NOT_BLOCKED": "You're not blocked, so you must move back before claiming a pool 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.", @@ -4136,7 +4140,7 @@ "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." + "TOO_LATE_FOR_REFILL": "There is no need to force a refill of the draw pool after your third move. It will refill automatically after you submit." }, "furl": { diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 77318a9f..b08013eb 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -22,7 +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).", + "frogger": "{{player}} chose to refill the draw pool. 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}}." }, @@ -135,7 +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.", + "frogger": "{{player}} was blocked and drew {{card}} from the draw pool.", "jacynth": "{{player}} exerted influence at {{where}}.", "logger": "{{player}} claimed a protestor from the tree felled at {{where}}.", "majorities_line": "{{player}} claimed the line {{where}}.", @@ -169,7 +169,7 @@ "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.", + "frogger": "The draw pool was refilled.", "quincunx_one": "{{player}} drew {{count}} card.", "quincunx_other": "{{player}} drew {{count}} cards." }, @@ -301,7 +301,7 @@ "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_back": "{{player}} moved a frog back from {{from}} to {{to}}, and drew {{card}} from the draw pool.", "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}}.", @@ -345,7 +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.", + "frogger": "{{player}} passed to permit refilling the draw pool.", "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 index 1f5f44cf..665f60d8 100644 --- a/src/games/frogger.ts +++ b/src/games/frogger.ts @@ -77,7 +77,8 @@ export class FroggerGame extends GameBase { { 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: "freeswim" }, //no check on market card claims + { uid: "#market" }, //Now called the draw pool. The base setting is no refills. { uid: "refills", group: "market", default: true }, //the official rule { uid: "continuous", group: "market" }, //continuous small refills ], @@ -112,7 +113,8 @@ export class FroggerGame extends GameBase { private marketsize: number = 6; private deck!: Deck; private suitboard!: Map; - private selected: string[] = []; + private _highlight: string[] = []; + private _points: string[] = []; constructor(state: number | IFroggerState | string, variants?: string[]) { super(); @@ -371,6 +373,18 @@ export class FroggerGame extends GameBase { return (options.indexOf(to) > -1); } + private checkWhiteMarket(card: string, to: string): boolean { + const toX = this.algebraic2coords(to)[0]; + if ( toX === 0 || this.variants.includes("freeswim") ) { + // When backing up to start you can pick any market card. + return true; + } + + const suit = this.suitboard.get(to)!; + const suits = this.getSuits(card, "validateMove"); + return ( suits.indexOf(suit) === -1 ) + } + private countColumnFrogs(home?: boolean): number { //Returns number of currplayer's frogs in the start (false/undefined) or home (true) column. let col = 0; @@ -472,6 +486,45 @@ export class FroggerGame extends GameBase { return [to2]; } + private getNextForwardsForCard(from: string, cardId: string): string[] { + //Generates forward points for the renderer. + let points: string[] = []; + const card = Card.deserialize(cardId); + if (card === undefined) { + throw new Error(`Could not deserialize the card ${cardId} in getNextForwardsFromCard.`); + } + const suits = card.suits.map(s => s.uid); + + if (this.variants.includes("advanced") && card.rank.uid !== this.courtrank) { + points = this.getNextForwardAdvanced(from, suits); + } else { + for (let s = 0; s < suits.length; s++) { + points.push(this.getNextForward(from, suits[s])); + } + } + return points; + } + + private getWhiteMarket(to: string): string[] { + //Returns a list of available market cards given a frog destination. + const toX = this.algebraic2coords(to)[0]; + if ( toX === 0 || this.variants.includes("freeswim") ) { + //Unrestricted choice. + return this.market.slice(); + } + + const suit = this.suitboard.get(to); + const whiteMarket: string[] = []; + //Suit check. + this.market.forEach(card => { + const suits = this.getSuits(card, "randomMove (backward)"); + if (suits.indexOf(suit!) < 0) + whiteMarket.push(card); + }); + + return whiteMarket; + } + private getSuits(cardId: string, callerInfo: string): string[] { const card = Card.deserialize(cardId); if (card === undefined) { @@ -888,32 +941,15 @@ export class FroggerGame extends GameBase { 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[] = []; - //Suit check for random market pick. - this.market.forEach(card => { - const suits = this.getSuits(card, "randomMove (backward)"); - if (suits.indexOf(suit!) < 0) - whiteMarket.push(card); - }); - if (whiteMarket.length > 0) - card = this.randomElement(whiteMarket); - } - - if ( card ) + const whiteMarket = this.getWhiteMarket(to); + if (whiteMarket.length > 0) { + const card = this.randomElement(whiteMarket); return `${from}-${to},${card}`; - else + } else { return `${from}-${to}`; + } } - } public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { @@ -1269,7 +1305,7 @@ export class FroggerGame extends GameBase { if ( ! complete ) { result.valid = true; result.complete = -1; -// result.canrender = true; + result.canrender = true; result.message = i18next.t("apgames:validation.frogger.PLACE_NEXT"); return result; } else { @@ -1315,24 +1351,17 @@ export class FroggerGame extends GameBase { result.message = i18next.t("apgames:validation.frogger.INVALID_HOP_BACKWARD"); return result; } - if (toX > 0 && subIFM.card) { - const suit = cloned.suitboard.get(subIFM.to)!; - //Suit check on moving backward in validateMove. - const suits = cloned.getSuits(subIFM.card, "validateMove"); - if (suits.indexOf(suit) > -1) { + if (subIFM.card) { + // We already checked it was in the market. + // Suit check on moving backward. + if ( !cloned.checkWhiteMarket(subIFM.card, subIFM.to) ) { result.valid = false; result.message = i18next.t("apgames:validation.frogger.INVALID_MARKET_CARD"); return result; } else { - // If we have a card, a move back is complete. + // If we have a valid card, a move back is complete. complete = true; } - } else if (subIFM.card) { - // When backing up to start you can pick any market card. - // We already checked it was in the market. - - // If we have a card, a move back is complete. - complete = true; } else if (!complete && cloned.market.length > 0) { // No card. May be a partial move, or can back up without a card. result.valid = true; @@ -1404,7 +1433,8 @@ export class FroggerGame extends GameBase { } this.results = []; - this.selected = []; + this._highlight = []; + this._points = []; let marketEmpty = false; let refill = false; @@ -1442,8 +1472,14 @@ export class FroggerGame extends GameBase { bounced.forEach( ([from, to]) => { this.results.push({type: "eject", from: from, to: to, what: "a Crown or Ace"}); }); + } else if (subIFM.from) { + //Partial. Highlight the frog. + this._points.push(subIFM.from); + //Highlight possible moves. + const forwardPoints = this.getNextForwardsForCard(subIFM.from, subIFM.card); + forwardPoints.forEach(cell => this._points.push(cell)); } else { - //Partial. + //Partial no points. } } else if (subIFM.card) { marketEmpty = this.popMarket(subIFM.card); @@ -1451,8 +1487,21 @@ export class FroggerGame extends GameBase { if (subIFM.from) { this.results.push({type: "move", from: subIFM.from, to: subIFM.to!, what: subIFM.card!, how: "back"}); } + } else if (subIFM.to) { + if (partial) { + //Highlight available market cards. + this._highlight = this.getWhiteMarket(subIFM.to); + } else { + this.results.push({type: "move", from: subIFM.from!, to: subIFM.to, what: "no card", how: "back"}); + } + } else if (subIFM.from) { + //Partial. Highlight the frog. + this._points.push(subIFM.from); + //Highlight possible moves. + const backwardPoints = this.getNextBack(subIFM.from); + backwardPoints.forEach(cell => this._points.push(cell)); } else { - this.results.push({type: "move", from: subIFM.from!, to: subIFM.to!, what: "no card", how: "back"}); + //Would be the empty move but that's already covered. } if (subIFM.from && subIFM.to) { @@ -1465,8 +1514,9 @@ export class FroggerGame extends GameBase { } if (subIFM.card) { - this.selected.push(subIFM.card); - //console.log("selecting ", subIFM.card); + //In this situation we only highlight a single card, + //but we need an array to highlight legal market cards. + this._highlight = [subIFM.card]; } } } @@ -1689,10 +1739,10 @@ export class FroggerGame extends GameBase { const legend: ILegendObj = {}; for (const card of allcards) { let glyph = card.toGlyph(); - if (this.selected.indexOf(card.uid) > -1) { + if (this._highlight.indexOf(card.uid) > -1) { glyph = card.toGlyph({border: true, fill: { func: "flatten", - fg: "_context_fill", + fg: "_context_strokes", bg: "_context_background", opacity: 0.2 }}); @@ -1860,8 +1910,9 @@ export class FroggerGame extends GameBase { //console.log(rep); // Add annotations + rep.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!); @@ -1886,6 +1937,26 @@ export class FroggerGame extends GameBase { } } + if (this._points.length > 0) { + const pts = this._points.map(c => this.algebraic2coords(c)); + + //The first point is always the frog, so render it more visibly. + const points: {row: number, col: number}[] = []; + const point = pts.shift()!; + rep.annotations.push({type: "exit", targets: [{ row: point[1], col: point[0] }]}); + //The type requires contents so test. + if (pts.length > 0) { + for (const coords of pts) { + points.push({ row: coords[1], col: coords[0] }); + } + rep.annotations.push({type: "dots", targets: points as [{row: number; col: number;}, ...{row: number; col: number;}[]]}); + } + } + + if (rep.annotations.length === 0) { + delete rep.annotations; + } + return rep; } diff --git a/test/games/frogger.test.ts b/test/games/frogger.test.ts index ca389ce7..05f677c6 100644 --- a/test/games/frogger.test.ts +++ b/test/games/frogger.test.ts @@ -351,4 +351,32 @@ describe("Frogger", () => { }); + it ("Implements the original market rules", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["courtpawns"],"gameover":false,"winner":[],"stack":[{"_version":"20251229","_results":[],"_timestamp":"2025-12-31T23:44:13.590Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","3SK"],["c4","7VY"],["d4","TSVY"],["e4","5YK"],["f4","2SY"],["g4","8MS"],["h4","3LY"],["i4","TSLK"],["j4","1Y"],["k4","TMLY"],["l4","1S"],["m4","TMVK"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["1L","1K","6LK","6SY"],["NY","1V","NS","NM"]],"hands":[[],[]],"market":["9LK","NK","9VY","8YK","2MK","6MV"],"discards":[],"nummoves":3}]}`); + + expect(g.validateMove("1L:a3-h3/h3-g3/")).to.have.deep.property("valid", true); + expect(g.validateMove("1L:a3-h3/h3-g3,6MV")).to.have.deep.property("valid", false); + expect(g.validateMove("1L:a3-h3/h3-g3,2MK/")).to.have.deep.property("valid", false); + expect(g.validateMove("1L:a3-h3/h3-g3,8YK/")).to.have.deep.property("valid", true); + g.move("1L:a3-h3/h3-g3,8YK/"); + + //Second move is back to the Excuse. + expect(g.validateMove("NY:a2-c2/c2-b2,NK/b2-a2,9LK/")).to.have.deep.property("valid", false); + expect(g.validateMove("NY:a2-c2/c2-b2,9VY/b2-a2,NK/")).to.have.deep.property("valid", true); + }); + + it ("Implements the free swim variant", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["courtpawns","freeswim"],"gameover":false,"winner":[],"stack":[{"_version":"20251229","_results":[],"_timestamp":"2025-12-31T23:44:13.590Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","3SK"],["c4","7VY"],["d4","TSVY"],["e4","5YK"],["f4","2SY"],["g4","8MS"],["h4","3LY"],["i4","TSLK"],["j4","1Y"],["k4","TMLY"],["l4","1S"],["m4","TMVK"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["1L","1K","6LK","6SY"],["NY","1V","NS","NM"]],"hands":[[],[]],"market":["9LK","NK","9VY","8YK","2MK","6MV"],"discards":[],"nummoves":3}]}`); + + expect(g.validateMove("1L:a3-h3/h3-g3/")).to.have.deep.property("valid", true); + expect(g.validateMove("1L:a3-h3/h3-g3,6MV")).to.have.deep.property("valid", true); + expect(g.validateMove("1L:a3-h3/h3-g3,2MK/")).to.have.deep.property("valid", true); + expect(g.validateMove("1L:a3-h3/h3-g3,8YK/")).to.have.deep.property("valid", true); + g.move("1L:a3-h3/h3-g3,2MK/"); + + //Second move is back to the Excuse. + expect(g.validateMove("NY:a2-c2/c2-b2,NK/b2-a2,9LK/")).to.have.deep.property("valid", true); + expect(g.validateMove("NY:a2-c2/c2-b2,9LK/b2-a2,NK/")).to.have.deep.property("valid", true); + }); + });