diff --git a/locales/en/apgames.json b/locales/en/apgames.json index a8193a94..cdd7c7ab 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -311,7 +311,7 @@ "evenatodds": "To place on the table, place one end on empty ground adjacent to a matching active face; the other end may go on any adjacent empty ground. To stack, one end must match the face underneath; the other end may cover any active face *on a different tile* at the same height. In the notation, the first coordinate is the end that matches one of these rules. Blanks are blockers, belonging to no one.\n\nTiles in your hand are hidden from your opponent. Boneyard identities and the two removed overtime tiles are hidden until the game ends. The boneyard count is shown in the sidebar.", "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.", "forms": "Forms was designed by Steven Meyers in 2007. After each move, all pieces outside the group that contains the last-moved piece are captured. The last player to move loses (so Forms is a misère game). The first version allowed a piece to capture any opponent's piece. Later, in 2011, Meyers developed a second version, **Advanced Forms**, where you could only capture orthogonally adjacent pieces, but a piece could also move to an empty cell if both were connected by a sequence of orthogonal steps over empty cells. The setup also changed to a checkered pattern. This second version is the default variant here at AP. More information can be found at [WAG](https://jpneto.github.io/world_abstract_games/forms.htm).", - "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. Passes are handled automatically by the server, but a there's also a draw pool variant that avoids forced passing if desired.\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\nForced passes are no longer used when a player refills the draw pool (and submits) in the middle of his turn. That player's second partial turn is handled automatically by the server. There's also a draw pool variant that avoids the double-ply, if desired.\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.", "guerrilla": "Either player wins by removing all of the opponent's pieces. P2 also wins if they survive long enough that P1 runs out of insurgents to place.", "scribe": "The glyph reference chart is shown in the left margin. A dot marks each player's most recent placement. When a mini grid fills, glyph points are tallied and the higher score wins that grid (ties go to the player who completed it).", @@ -5659,7 +5659,7 @@ "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 refill of the draw pool after your third move. It will refill automatically after you submit.", - "VALID_REFILL": "Please submit your move to trigger the refill. The other player(s) will pass automatically, and you will have {{count}} moves remaining in your partial turn (as well as the option to pass)." + "VALID_REFILL": "Please submit your move to trigger the refill. You will get another turn automatically, and you will have {{count}} moves remaining in your partial turn (as well as the option to pass)." }, "furl": { "CAPTURE4UNFURL": "You signalled a capture unfurl move, but {{to}} not is occupied by the opponent. Use \">\" to denote non-capture unfurl.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 5c03cd86..49abf05b 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -24,7 +24,7 @@ "arimaa": "This position has occurred before. Further repeats of this position are forbidden.", "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 draw pool. Other players must pass so he can take his {{moves}} remaining move(s).", + "frogger": "{{player}} chose to refill the draw pool mid-move, so will take a partial turn to complete his {{moves}} remaining move(s).", "quincunx": "Player {{playerNum}} had the following cards left in their hand: {{cards}}.", "scribe_super": "{{player}}'s super formation: {{formation}}.", "stawvs": "{{player}} was unable to move and must pass from now on. Their pieces remain on the following pyramids: {{pyramids}}." diff --git a/src/games/_base.ts b/src/games/_base.ts index d424d3cb..9a519b68 100644 --- a/src/games/_base.ts +++ b/src/games/_base.ts @@ -687,10 +687,14 @@ export abstract class GameBase { public chatLog(players: string[]): string[][] { const result: string[][] = []; - for (const state of this.stack) { + // Index 0 has no associated ply) so it's skipped. + for (let i = 1; i < this.stack.length; i++) { + const state = this.stack[i]; if ( (state._results !== undefined) && (state._results.length > 0) ) { const node: string[] = [(state._timestamp && new Date(state._timestamp).toISOString()) || "unknown"]; - let otherPlayer = state.currplayer as number - 1; + // Resolved via plyActor(), not `state.currplayer - 1`, + // to fix turn model cases of skip-turn and sequenced. + let otherPlayer = this.plyActor(i); if (otherPlayer < 1) { otherPlayer = this.numplayers; } diff --git a/src/games/frogger.ts b/src/games/frogger.ts index 2c30e84d..71dcbb3a 100644 --- a/src/games/frogger.ts +++ b/src/games/frogger.ts @@ -2,6 +2,7 @@ import { GameBase, IAPGameState, IClickResult, IIndividualState, IRenderOpts, IV import type { IGamePly, IGameRound, TurnModel } from "./_turn-model"; import { defaultPlyActor, defaultShouldCloseRound } from "./_turn-plies"; import { sequencedSkiptoPlyActor, sequencedSkiptoShouldCloseRound } from "./_turn-sequenced-skipto"; +import { sequencedShouldCloseRound } from "./_turn-sequenced"; import { APGamesInformation } from "../schemas/gameinfo"; import { APRenderRep, AreaPieces, Glyph, MarkerFlood, MarkerGlyph, RowCol} from "@abstractplay/renderer/build/schemas/schema"; import { APMoveResult } from "../schemas/moveresults"; @@ -28,6 +29,7 @@ export type FrameState = { export interface IMoveState extends IIndividualState { currplayer: playerid; skipto?: playerid; + refillPending?: playerid; board: Map; closedhands: string[][]; hands: string[][]; @@ -62,7 +64,7 @@ export class FroggerGame extends GameBase { name: "Frogger", uid: "frogger", playercounts: [2,3,4,5], - version: "20251229", + version: "20260822", dateAdded: "2026-01-26", // i18next.t("apgames:descriptions.frogger") description: "apgames:descriptions.frogger", @@ -110,6 +112,7 @@ export class FroggerGame extends GameBase { public numplayers = 2; public currplayer: playerid = 1; public skipto?: playerid|undefined; + public refillPending?: playerid|undefined; public board!: Map; public closedhands: string[][] = []; public hands: string[][] = []; @@ -133,6 +136,16 @@ export class FroggerGame extends GameBase { private _points: string[] = []; private _button: boolean = true; //for refill button hiding + // Games created before this version used the legacy `skipto` pass-chain + // mechanism for refills (see _turn-sequenced-skipto.ts); games created + // at or after it use the same-seat `refillPending` mechanism instead. + // Checked against stack[0], not the current entry, so the answer is + // fixed for a game's whole life - never a per-entry/mid-game switch. + private static readonly SEQUENCED_REFILLS_SINCE = "20260822"; + private usesSequencedRefills(): boolean { + return parseInt(this.stack[0]._version, 10) >= parseInt(FroggerGame.SEQUENCED_REFILLS_SINCE, 10); + } + constructor(state: number | IFroggerState | string, variants?: string[]) { super(); if (typeof state === "number") { @@ -254,6 +267,7 @@ export class FroggerGame extends GameBase { this.results = [...state._results]; this.currplayer = state.currplayer; this.skipto = state.skipto; + this.refillPending = state.refillPending; this.board = new Map(state.board); this.closedhands = state.closedhands.map(h => [...h]); this.hands = state.hands.map(h => [...h]); @@ -880,18 +894,20 @@ export class FroggerGame extends GameBase { } private refillMarket(): number { - //Fills the market regardless of current size. + //Tops the market up to marketsize. //Shuffles the discards when necessary. - //Refill variant behavior is mostly handled by the caller, + //Refill variant behavior is mostly handled by the caller. + //Returns how many cards were actually drawn this call. //May be called when the market is already full (in the continuous variant). if (this.market.length === this.marketsize) return 0; //First, try to draw what we need from the deck. - let toDraw = Math.min(this.marketsize, this.deck.size); + const needed = this.marketsize - this.market.length; + let toDraw = Math.min(needed, this.deck.size); - this.market = [...this.deck.draw(toDraw).map(c => c.uid)]; + this.market.push(...this.deck.draw(toDraw).map(c => c.uid)); if (this.market.length === this.marketsize) { return toDraw; @@ -941,7 +957,7 @@ export class FroggerGame extends GameBase { player = this.currplayer; } - if (this.skipto !== undefined && this.skipto !== this.currplayer ) { + if (!this.usesSequencedRefills() && this.skipto !== undefined && this.skipto !== this.currplayer ) { //Passing for market hiding. return ["pass"]; } @@ -1013,7 +1029,7 @@ export class FroggerGame extends GameBase { } //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 ) + if ( this.variants.includes("refills") && !this.usesSequencedRefills() && this.skipto !== undefined && this.skipto !== this.currplayer ) return "pass"; if (this.checkBlocked()) { @@ -1106,7 +1122,7 @@ export class FroggerGame extends GameBase { try { - if ( this.variants.includes("refills") && this.skipto && this.skipto !== this.currplayer ) { + if ( this.variants.includes("refills") && !this.usesSequencedRefills() && 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 { @@ -1310,15 +1326,19 @@ export class FroggerGame extends GameBase { if (m === "pass") { //May only pass in rare/refill situations. - if ( this.variants.includes("refills") && this.skipto !== undefined ) { + if ( this.variants.includes("refills") && ( + (!this.usesSequencedRefills() && this.skipto !== undefined) || + (this.usesSequencedRefills() && this.refillPending !== undefined && this.refillPending === this.currplayer) + ) ) { - // && this.skipto !== this.currplayer - //You must pass if you're not the player being skipped to. + // Legacy (!usesSequencedRefills): any pass while skipto is + // pending is valid - either you're not the obligated seat + // (forced pass for market hiding) or you are and are + // declining your own supplemental turn. - // && 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. + // Sequenced (usesSequencedRefills): currplayer never becomes + // a non-obligated seat, so the only reachable case is the + // obligated seat declining its own supplemental turn. result.valid = true; result.message = i18next.t("apgames:validation._general.VALID_MOVE"); @@ -1647,6 +1667,11 @@ export class FroggerGame extends GameBase { } } + // Captured before anything mutates, so checkEOG() can always check + // the seat that just acted, regardless of whether currplayer + // advances this turn (it doesn't, on a fresh refill announce). + const actingPlayer = this.currplayer; + this.results = []; this.frames = []; @@ -1663,7 +1688,10 @@ export class FroggerGame extends GameBase { // once he sees the market and (perhaps) doesn't like it. //In that case, we need to clean up (below). - if ( this.variants.includes("refills") && this.skipto !== undefined && this.skipto === this.currplayer) { + if ( this.variants.includes("refills") && ( + (!this.usesSequencedRefills() && this.skipto !== undefined && this.skipto === this.currplayer) || + (this.usesSequencedRefills() && this.refillPending !== undefined && this.refillPending === this.currplayer) + ) ) { //Player passed during refill turn. this.results.push({type: "pass", why: "to complete their partial turn"}); } else if (this.checkBlocked() && this.market.length === 0) { @@ -1751,6 +1779,15 @@ export class FroggerGame extends GameBase { this.moveFrog(subIFM.from,subIFM.to); } + // Group results before a possible refill break to prevent data loss on exit. + this.results.push({type: "_group", who: this.currplayer, results: results as [APMoveResult, ...APMoveResult[]]}); + // store current board, market, and discards in frames + this.frames.push({ + board: new Map(this.board), + market: [...this.market], + discards: frameDiscards + }) + if (refill) { remaining = 2 - s; break; @@ -1761,21 +1798,12 @@ export class FroggerGame extends GameBase { //but we need an array to highlight legal market cards. this._highlight = [subIFM.card]; } - - // group the results for each step together - this.results.push({type: "_group", who: this.currplayer, results: results as [APMoveResult, ...APMoveResult[]]}); - // store current board, market, and discards in frames - this.frames.push({ - board: new Map(this.board), - market: [...this.market], - discards: frameDiscards - }) } //We may leave the last frame in case of crocodile action, so just check the results length for now. if (this.results.length + 1 !== this.frames.length) { throw new Error(`There's a mismatch in the length of the results array and the frames array. This should never happen.`); - } else if (!this.variants.includes("crocodiles") || this.currplayer as number !== this.numplayers || this.skipto) { + } else if (!this.variants.includes("crocodiles") || this.currplayer as number !== this.numplayers || this.skipto || this.refillPending) { //We don't need the last frame. this.frames.pop(); } @@ -1783,26 +1811,41 @@ export class FroggerGame extends GameBase { if (partial || emulation) { return this; } + // Only a fresh sequenced-model announce skips the currplayer + // advance below (it stays on this seat for the supplemental + // submit). Every other path - a legacy announce, a normal move, + // or either kind of pass - still advances as always. + let staysOnSeat = false; + if (refill) { - //Set skipto and nummoves. + //Set skipto/refillPending and nummoves. //Don't progress crocodiles. - //Skip to my lou. - //After the new turn, we'll need to update nummoves, skipto, and crocs. + //After the new turn, we'll need to update nummoves, skipto/refillPending, and crocs. this.results.push({type: "announce", payload: [remaining!]}); - this.skipto = this.currplayer; + if (this.usesSequencedRefills()) { + this.refillPending = this.currplayer; + staysOnSeat = true; + } else { + this.skipto = this.currplayer; + } this.nummoves = remaining!; } else { - //If this was the refill turn, unset skipto and nummoves, + //If this was the refill turn, unset skipto/refillPending and nummoves, // regardless of whether currplayer passed or moved. - if ( this.variants.includes("refills") && this.skipto !== undefined && this.skipto === this.currplayer) { + if (this.usesSequencedRefills()) { + if (this.refillPending !== undefined && this.refillPending === this.currplayer) { + this.refillPending = undefined; + this.nummoves = 3; + } + } else if ( this.variants.includes("refills") && this.skipto !== undefined && this.skipto === this.currplayer) { this.skipto = undefined; this.nummoves = 3; } //update crocodiles if croccy but don't add a frame. - if (this.variants.includes("crocodiles") && this.currplayer as number === this.numplayers && !this.skipto) { + if (this.variants.includes("crocodiles") && this.currplayer as number === this.numplayers && !this.skipto && !this.refillPending) { this.results.push({type: "declare"}); //Advance the crocodiles. const victims = this.popCrocs(); @@ -1825,27 +1868,24 @@ export class FroggerGame extends GameBase { // update currplayer this.lastmove = m; - let newplayer = (this.currplayer as number) + 1; - if (newplayer > this.numplayers) { - newplayer = 1; + if (!staysOnSeat) { + let newplayer = (this.currplayer as number) + 1; + if (newplayer > this.numplayers) { + newplayer = 1; + } + this.currplayer = newplayer as playerid; } - this.currplayer = newplayer as playerid; - this.checkEOG(); + this.checkEOG(actingPlayer); this.saveState(); return this; } - protected checkEOG(): FroggerGame { + protected checkEOG(actingPlayer: playerid): FroggerGame { //You can only win on your own turn. - let prevplayer = this.currplayer - 1; - if (prevplayer < 1) { - prevplayer = this.numplayers; - } - - if ( this.countColumnFrogs(true, prevplayer as playerid) === 6 ) { + if ( this.countColumnFrogs(true, actingPlayer) === 6 ) { this.gameover = true; - this.winner.push(prevplayer as playerid); + this.winner.push(actingPlayer); } if (this.gameover) { @@ -1886,6 +1926,7 @@ export class FroggerGame extends GameBase { _timestamp: new Date(), currplayer: this.currplayer, skipto: this.skipto, + refillPending: this.refillPending, lastmove: this.lastmove, board: new Map(this.board), closedhands: this.closedhands.map(h => [...h]), @@ -2137,7 +2178,8 @@ export class FroggerGame extends GameBase { const group = this.results[i-1]; if (group !== undefined && group.type === "_group") { //throw new Error(`The only results that should be present are _group results!`); - results = group.results; + // Changed alias to a copy, fixing weird chatlog issues. + results = [...group.results]; } else if (group !== undefined) { results = [group]; } @@ -2383,17 +2425,26 @@ export class FroggerGame extends GameBase { } protected plyActor(stackIndex: number): number { - if (!this.variants.includes("refills")) { + if (!this.variants.includes("refills") || this.usesSequencedRefills()) { + // New model: currplayer never leaves the obligated seat, so the + // default (stack[stackIndex-1].currplayer) is always right. return defaultPlyActor(this, stackIndex); } - return sequencedSkiptoPlyActor(this, stackIndex); + return sequencedSkiptoPlyActor(this, stackIndex); // legacy } protected shouldCloseRound(roundPlies: IGamePly[], stackIndex: number): boolean { if (!this.variants.includes("refills")) { return defaultShouldCloseRound(this, roundPlies); } - return sequencedSkiptoShouldCloseRound(this, roundPlies, stackIndex); + if (!this.usesSequencedRefills()) { + return sequencedSkiptoShouldCloseRound(this, roundPlies, stackIndex); // legacy + } + const after = this.stack[stackIndex]; + if (after.refillPending !== undefined) { + return false; // stay open until the supplemental resolves + } + return sequencedShouldCloseRound(this, roundPlies, stackIndex); } /** Refill follow-ups can place several plies on one seat in a cycle — one sparse row per ply. */ diff --git a/test/games/frogger.test.ts b/test/games/frogger.test.ts index 4f226310..94d9fecb 100644 --- a/test/games/frogger.test.ts +++ b/test/games/frogger.test.ts @@ -2,6 +2,7 @@ import "mocha"; import { expect } from "chai"; +import { addResource } from "../../src"; import { FroggerGame } from '../../src/games'; describe("Frogger", () => { @@ -274,6 +275,16 @@ describe("Frogger", () => { }); + it ("continuous market top up regression test", () => { + // Only top up the market in the continuous variant. + 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}]}`); + g.move("1M:a3-g3/g3-f3,6LK/6LK:f3-i3/"); // pops only 6LK; 6SY and 4VL are never touched + expect(g.market).to.include("6SY"); + expect(g.market).to.include("4VL"); + expect(g.market).to.not.include("6LK"); // popped, replaced by exactly one new card + 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}]}`); @@ -367,6 +378,33 @@ describe("Frogger", () => { }); + it ("Regression test of a submove with refill", () => { + // Regression test for a chatlogging issue. + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["courts","#market","refills"],"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}]}`); + 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/"); + g.move("5YK:b2-e2/e2-d2,NV!/"); + const group = g.results.find(r => r.type === "_group"); + expect(group).to.not.equal(undefined); + expect((group as {results: {type: string}[]}).results.some(r => r.type === "move")).to.equal(true); + expect(g.results.some(r => r.type === "announce")).to.equal(true); + }); + + it ("Render() does not corrupt the shared results array", () => { + // Regression test for an issue with the chatlog. + addResource("en"); + 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}]}`); + g.move("1M:a3-g3/g3-f3,6LK/6LK:f3-i3/"); + g.render(); // triggers the aliasing bug, if present + const log = g.chatLog(["Alice", "Bob"]); + const lastNode = log[log.length - 1]; + // Match only the refill message itself, not related draw messages. + const occurrences = lastNode.filter(line => line.startsWith("The draw pool was")).length; + expect(occurrences).to.equal(1); + }); + 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}]}`); diff --git a/test/games/froggerTurnModel.test.ts b/test/games/froggerTurnModel.test.ts index 3c4f330d..27c28d82 100644 --- a/test/games/froggerTurnModel.test.ts +++ b/test/games/froggerTurnModel.test.ts @@ -1,5 +1,6 @@ import "mocha"; import { expect } from "chai"; +import { addResource } from "../../src"; import { FroggerGame } from "../../src/games/frogger"; import { sequencedSkiptoPlyActor, @@ -60,3 +61,46 @@ describe("Frogger refills turn model (Phase 6)", () => { expect(sequencedSkiptoShouldCloseRound(g, plies, stackIndex)).to.equal(false); }); }); + +describe("Frogger refills use sequenced mechanism correctly", () => { + // Same board/hands as emptyDeckRefills above, but _version bumped. + const sequencedRefills = `{"game":"frogger","numplayers":2,"variants":["freeswim","refills"],"gameover":false,"winner":[],"stack":[{"_version":"20260822","_results":[{"type":"move","from":"g3","to":"f3","what":"2MK","how":"back"},{"type":"move","from":"f3","to":"e2","what":"6MV","how":"back"},{"type":"move","from":"e2","to":"d3","what":"NS","how":"back"}],"_timestamp":"2026-01-08T20:08:26.404Z","currplayer":2,"lastmove":"g3-f3,2MK/f3-e2,6MV/e2-d3,NS/","board":{"dataType":"Map","value":[["b4","3MV"],["c4","4YK"],["d4","NY"],["e4","PVLY"],["f4","5SV"],["g4","5YK"],["h4","PMSL"],["i4","PMYK"],["j4","NM"],["k4","4VL"],["l4","PSVK"],["m4","4MS"],["a3","X1-5"],["a2","X2-5"],["c3","X2"],["d3","X1"]]},"closedhands":[["5ML","8VL","3LY"],["2VL","9VY","7SK"]],"hands":[["1Y","6LK","9LK","NK","8YK","2SY","1K","2MK","6MV","NS"],["9MS","1L","7VY","1S","6SY","1M","NL","1V","3SK","7ML"]],"market":["NV"],"discards":[],"nummoves":3}]}`; + + it("a refill request sets refillPending, not skipto", () => { + const g = new FroggerGame(sequencedRefills); + g.move("c3-b3,NV!/"); + expect(g.currplayer).to.equal(2); + expect(g.refillPending).to.equal(2); + expect(g.skipto).to.equal(undefined); + }); + + it("the supplemental submit doesn't involve forced passes", () => { + const g = new FroggerGame(sequencedRefills); + g.move("c3-b3,NV!/"); + // No intervening g.move("pass") - the other seat is never prompted. + g.move("b3-a2,8MS/"); + expect(g.refillPending).to.equal(undefined); + expect(g.currplayer).to.equal(1); + }); + + it("getPlies groups both plies under the same actor and round, with no passes", () => { + const g = new FroggerGame(sequencedRefills); + g.move("c3-b3,NV!/"); + g.move("b3-a2,8MS/"); + + const plies = g.getPlies(); + expect(plies.map((p) => p.actor)).to.deep.equal([2, 2]); + expect(plies.map((p) => p.round)).to.deep.equal([0, 0]); + expect(g.getRounds()).to.have.length(2); + expect(plyOrderedMovesFromRounds(g.getRounds())).to.deep.equal(plies.map((p) => p.move)); + }); + + it("chatLog names the refilling player correctly", () => { + // Regression test for the shared GameBase.chatLog(). + addResource("en"); + const g = new FroggerGame(sequencedRefills); + g.move("c3-b3,NV!/"); + const [, message] = g.chatLog(["Alice", "Bob"])[0]; + expect(message).to.include("Bob"); // player 2, who announced - not "Alice" + }); +});