diff --git a/src/games/frogger.ts b/src/games/frogger.ts index f5955ea4..0ae785a1 100644 --- a/src/games/frogger.ts +++ b/src/games/frogger.ts @@ -413,6 +413,11 @@ export class FroggerGame extends GameBase { return cards; } + private getFullHand(player: playerid): string[] { + //Returns concatenated hidden and visible hands for player playerId. + return this.hands[player - 1].concat(this.closedhands[player - 1]); + } + private getNextBack(from: string): string[] { //Walk back through the board until we find a free column. //Return an array of all available cells in that column. @@ -853,7 +858,8 @@ export class FroggerGame extends GameBase { } public moves(player?: playerid): string[] { - //Used for the autopasser. Not a full move list. + //Used for the autopasser. + //Not a full move list, but better than just one random single move. if (this.gameover) { return []; } @@ -867,12 +873,65 @@ export class FroggerGame extends GameBase { return ["pass"]; } - return [this.randomMove()]; + //Make a list of all possible first moves (out of the three allowed). + let moves:string[] = []; + if (this.checkBlocked()) { + //Note the market must be populated at this point. + moves = this.market.map(card => card + "//"); + return moves; + } + + //List frogs. + let firstFrog = ""; + const freeFrogs: string[] = []; + for (let row = 1; row < this.rows; row++) { + for (let col = 0; col < this.columns - 1; col++) { + const cell = this.coords2algebraic(col, row); + if (this.board.has(cell)) { + const frog = this.board.get(cell)!; + if ( frog.charAt(1) === this.currplayer.toString() ) { + if (col === 0) + firstFrog = cell; + else + freeFrogs.push(cell); + } + } + } + } + + //Get backward moves. + freeFrogs.forEach( from => { + const toArray = this.getNextBack(from); + toArray.forEach( to => { + moves.push(`${from}-${to}`); //the non-profit move + this.getWhiteMarket(to).forEach( card => moves.push(`${from}-${to},${card}`) ); + }); + }); + + //Get forward moves. + //Now we can use a first frog. + if (firstFrog !== "") + freeFrogs.push(firstFrog); + + const amalgahand = this.getFullHand(this.currplayer); + amalgahand.forEach( card => { + freeFrogs.forEach( from => { + let targets:string[] = this.getNextForwardsForCard(from, card); + //Can sometimes get duplicate targets, so uniquify. + targets = [...new Set(targets)]; + targets.forEach( to => moves.push(`${card}:${from}-${to}`) ); + }); + }); + + return moves; } public randomMove(): string { //We return only one, legal move, for testing purposes. + //Now that there's a move list we could use it, + // but these results are weighted so keeping them. + if (this.gameover) { throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); } @@ -913,7 +972,7 @@ export class FroggerGame extends GameBase { if ( handcard ) { //hop forward - const card = this.randomElement( this.closedhands[this.currplayer - 1].concat(this.hands[this.currplayer - 1]) ); + const card = this.randomElement( this.getFullHand(this.currplayer) ); //Card shouldn't be invisible but if it is we need to give up gracefully. if (card === "") { return "hidden"; @@ -1113,10 +1172,12 @@ export class FroggerGame extends GameBase { //Internal autocompletion. let automove = result.autocomplete; result = this.validateMove(automove) as IClickResult; + //console.log("first autocomplete", automove); //A double auto-completion may be needed. if (result.autocomplete !== undefined) { automove = result.autocomplete; result = this.validateMove(automove) as IClickResult; + //console.log("double autocompleting", automove); } result.move = automove; } else { @@ -1266,14 +1327,14 @@ export class FroggerGame extends GameBase { //Check cards. //(The case remaining with no card is falling back at no profit.) if (subIFM.card) { - if (subIFM.forward && (cloned.closedhands[cloned.currplayer - 1].concat(cloned.hands[cloned.currplayer - 1])).indexOf(subIFM.card) < 0 ) { + if (subIFM.forward && cloned.getFullHand(cloned.currplayer).indexOf(subIFM.card) < 0 ) { //Bad hand card. result.valid = false; result.message = i18next.t("apgames:validation.frogger.NO_SUCH_HAND_CARD", {card: subIFM.card}); return result; } else if (!subIFM.forward && cloned.market.indexOf(subIFM.card) < 0 ) { //Bad card? Unless... - if ( (cloned.closedhands[cloned.currplayer - 1].concat(cloned.hands[cloned.currplayer - 1])).indexOf(subIFM.card) > -1 ) { + if ( cloned.getFullHand(cloned.currplayer).indexOf(subIFM.card) > -1 ) { //The player clicked on a hand card to start the next move. //We use autocompletion to patch up this case. result.valid = true; @@ -1349,7 +1410,9 @@ export class FroggerGame extends GameBase { result.message = i18next.t("apgames:validation.frogger.PLACE_NEXT"); //Internal autocompletion: - const targets:string[] = subIFM.forward ? cloned.getNextForwardsForCard(subIFM.from, subIFM.card!) : this.getNextBack(subIFM.from); + let targets:string[] = subIFM.forward ? cloned.getNextForwardsForCard(subIFM.from, subIFM.card!) : cloned.getNextBack(subIFM.from); + //Can sometimes get duplicate targets, so uniquify. + targets = [...new Set(targets)]; if (targets.length === 1) { result.autocomplete = m + targets[0] + (subIFM.forward ? "/" : ""); } @@ -1794,7 +1857,7 @@ export class FroggerGame extends GameBase { if (this._highlight.indexOf(card.uid) > -1) { glyph = card.toGlyph({border: true, fill: { func: "flatten", - fg: "_context_strokes", + fg: "_context_labels", bg: "_context_background", opacity: 0.2 }}); @@ -1843,7 +1906,7 @@ export class FroggerGame extends GameBase { }, { text: count.toString(), - colour: "_context_strokes", + colour: "_context_labels", scale: 0.66 } ] @@ -1886,7 +1949,7 @@ export class FroggerGame extends GameBase { // build pieces areas const areas: AreaPieces[] = []; for (let p = 1; p <= this.numplayers; p++) { - const hand = this.closedhands[p-1].concat(this.hands[p-1]); + const hand = this.getFullHand(p as playerid); if (hand.length > 0) { areas.push({ type: "pieces", @@ -1926,10 +1989,13 @@ export class FroggerGame extends GameBase { // create an area for all invisible cards (if there are any cards left) const hands = this.hands.map(h => [...h]); - const visibleCards = [...this.getBoardCards(), ...hands.flat(), ...this.market, ...this.discards].map(uid => Card.deserialize(uid)); + const closedhands = this.closedhands.map(h => [...h]); + const visibleCards = [...this.getBoardCards(), ...hands.flat(), ...this.market, ...this.discards, ...closedhands.flat()].map(uid => Card.deserialize(uid)); + if (visibleCards.includes(undefined)) { throw new Error("Could not deserialize one of the cards. This should never happen!"); } + const remaining = allcards.sort(cardSortAsc).filter(c => visibleCards.find(cd => cd!.uid === c.uid) === undefined).map(c => "c" + c.uid) as [string, ...string[]] if (remaining.length > 0) { areas.push({ diff --git a/test/games/frogger.test.ts b/test/games/frogger.test.ts index 5ef3fd5e..10384400 100644 --- a/test/games/frogger.test.ts +++ b/test/games/frogger.test.ts @@ -394,10 +394,35 @@ describe("Frogger", () => { //Special autocompletion case to reparse a "bad" handleClick result. expect(g.validateMove("TMLY:a3-d3/d3-c2,6LK")).to.have.deep.property("autocomplete", "TMLY:a3-d3/d3-c2/6LK:"); - expect(g.validateMove("TMLY:a3-d3/d3-c2/c2-b3,6LK")).to.have.deep.property("autocomplete", "TMLY:a3-d3/d3-c2/c2-b3/6LK:"); + //Same result when passing through handleClick. + expect(g.handleClick("TMLY:a3-d3/d3-c2", -1, -1, "c6LK")).to.have.deep.property("move", "TMLY:a3-d3/d3-c2/6LK:"); + //OK to autocorrect to a bad value b/c during the game it gets revalidated. + expect(g.validateMove("TMLY:a3-d3/d3-c2/c2-b3,6LK")).to.have.deep.property("autocomplete", "TMLY:a3-d3/d3-c2/c2-b3/6LK:"); expect(g.validateMove("TMLY:a3-d3/d3-c2/c2-b3/6LK:")).to.have.deep.property("valid", false); - + //Same result when passing through handleClick. + expect(g.handleClick("TMLY:a3-d3/d3-c2/c2-b3", -1, -1, "c6LK")).to.have.deep.property("move", "TMLY:a3-d3/d3-c2/c2-b3/6LK:"); + + }); + + it ("Double autocompletes", () => { + const g = new FroggerGame(`{"game":"frogger","numplayers":2,"variants":["continuous","advanced"],"gameover":false,"winner":[],"stack":[{"_version":"20251229","_results":[],"_timestamp":"2025-12-30T15:56:10.049Z","currplayer":1,"board":{"dataType":"Map","value":[["b4","PMYK"],["c4","PMSL"],["d4","1M"],["e4","PVLY"],["f4","NL"],["g4","PSVK"],["h4","NK"],["i4","1Y"],["j4","3SK"],["k4","NS"],["a3","X1-6"],["a2","X2-6"]]},"closedhands":[["","","",""],["8MS","7ML","9VY","8YK"]],"hands":[[],[]],"market":["6LK","6SY","9LK"],"discards":[],"nummoves":3},{"_version":"20251229","_results":[{"type":"move","from":"a3","to":"e3","what":"NV","how":"forward"},{"type":"move","from":"e3","to":"g3","what":"2SY","how":"forward"},{"type":"move","from":"g3","to":"l3","what":"1V","how":"forward"}],"_timestamp":"2025-12-31T20:57:34.367Z","currplayer":2,"lastmove":"NV:a3-e3/2SY:e3-g3/1V:g3-l3/","board":{"dataType":"Map","value":[["b4","PMYK"],["c4","PMSL"],["d4","1M"],["e4","PVLY"],["f4","NL"],["g4","PSVK"],["h4","NK"],["i4","1Y"],["j4","3SK"],["k4","NS"],["a3","X1-5"],["a2","X2-6"],["l3","X1-1"]]},"closedhands":[[""],["8MS","7ML","9VY","8YK"]],"hands":[[],[]],"market":["6LK","6SY","9LK"],"discards":["NV","2SY","1V"],"nummoves":3},{"_version":"20251229","_results":[{"type":"move","from":"a2","to":"b2","what":"8YK","how":"forward"},{"type":"move","from":"a2","to":"e3","what":"9VY","how":"forward"},{"type":"move","from":"e3","to":"d3","what":"6SY","how":"back"},{"type":"deckDraw"}],"_timestamp":"2026-01-01T03:11:03.580Z","currplayer":1,"lastmove":"8YK:a2-b2/9VY:a2-e3/e3-d3,6SY/","board":{"dataType":"Map","value":[["b4","PMYK"],["c4","PMSL"],["d4","1M"],["e4","PVLY"],["f4","NL"],["g4","PSVK"],["h4","NK"],["i4","1Y"],["j4","3SK"],["k4","NS"],["a3","X1-5"],["a2","X2-4"],["l3","X1-1"],["b2","X2"],["d3","X2"]]},"closedhands":[[""],["8MS","7ML"]],"hands":[[],["6SY"]],"market":["4YK","4VL","3LY"],"discards":["NV","2SY","1V","8YK","9VY"],"nummoves":3},{"_version":"20251229","_results":[{"type":"move","from":"a3","to":"b1","what":"1K","how":"forward"},{"type":"eject","from":"b2","to":"a2","what":"a Crown or Ace"},{"type":"move","from":"b1","to":"a3","what":"4VL","how":"back"},{"type":"deckDraw"}],"_timestamp":"2026-01-01T13:48:53.017Z","currplayer":2,"lastmove":"1K:a3-b1/b1-a3,4VL/","board":{"dataType":"Map","value":[["b4","PMYK"],["c4","PMSL"],["d4","1M"],["e4","PVLY"],["f4","NL"],["g4","PSVK"],["h4","NK"],["i4","1Y"],["j4","3SK"],["k4","NS"],["a3","X1-5"],["a2","X2-5"],["l3","X1-1"],["d3","X2"]]},"closedhands":[[],["8MS","7ML"]],"hands":[["4VL"],["6SY"]],"market":["NM","8VL","5YK"],"discards":["NV","2SY","1V","8YK","9VY","1K"],"nummoves":3}]}`); + + //Setup, with testing of single completes. + expect(g.handleClick("8MS:d3-g3/", -1, -1, "c7ML")).to.have.deep.property("move", "8MS:d3-g3/7ML:"); + + expect(g.validateMove("8MS:d3-g3/7ML:g3-")).to.have.deep.property("autocomplete", "8MS:d3-g3/7ML:g3-l2/"); + //Same result when passing through handleClick. + expect(g.handleClick("8MS:d3-g3/7ML:", 1, 6, "X2")).to.have.deep.property("move", "8MS:d3-g3/7ML:g3-l2/"); + + g.move("8MS:d3-g3/7ML:g3-l2/"); + + //The actual double autocomplete. + expect(g.validateMove("4VL:")).to.have.deep.property("autocomplete", "4VL:a3-"); + expect(g.validateMove("4VL:a3-")).to.have.deep.property("autocomplete", "4VL:a3-c1/"); + //The double validation happens in handleClick. + expect(g.handleClick("", -1, -1, "c4VL")).to.have.deep.property("move", "4VL:a3-c1/"); + });