diff --git a/locales/en/apgames.json b/locales/en/apgames.json index c1459290..745166ee 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -4153,6 +4153,7 @@ "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_HOP_BACKWARD_EXCUSE": "Your frog cannot hop backward from the Excuse.", "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": "Draw pool", diff --git a/src/games/frogger.ts b/src/games/frogger.ts index 665f60d8..632c005e 100644 --- a/src/games/frogger.ts +++ b/src/games/frogger.ts @@ -63,7 +63,7 @@ export class FroggerGame extends GameBase { { type: "designer", name: "José Carlos de Diego Guerrero", - urls: ["http://www.labsk.net"], + urls: ["https://labsk.net/wkr/autor/"], }, { type: "coder", @@ -420,7 +420,8 @@ export class FroggerGame extends GameBase { const fromX = this.algebraic2coords(from)[0]; if ( fromX === 0 ) { - throw new Error("Could not back up from the Excuse. This should never happen."); + //This can happen now so don't throw it never happens. + return []; } for (let c = fromX - 1; c > 0; c--) { @@ -1044,6 +1045,9 @@ export class FroggerGame extends GameBase { } } } else { + //It would help to be able to check the market for the card + //(${piece!.substring(1)}), b/c it may start a new submove, + //but that would require cloning. newmove = `${move},${piece!.substring(1)}/`; } } @@ -1101,11 +1105,18 @@ export class FroggerGame extends GameBase { } } - const result = this.validateMove(newmove) as IClickResult; + let result = this.validateMove(newmove) as IClickResult; if (! result.valid) { result.move = move; } else { - result.move = newmove; + if (result.autocomplete !== undefined) { + //Internal autocompletion: + const automove = result.autocomplete; + result = this.validateMove(automove) as IClickResult; + result.move = automove; + } else { + result.move = newmove; + } } return result; } catch (e) { @@ -1250,16 +1261,29 @@ 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.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; + //Bad card? Unless... + if ( (cloned.closedhands[cloned.currplayer - 1].concat(cloned.hands[cloned.currplayer - 1])).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; + result.complete = -1; + result.canrender = true; + + //Trim the card off the end of the submone to start another. + result.autocomplete = m.substring(0,m.lastIndexOf(subIFM.card) - 1) + "/" + subIFM.card + ":"; + + return result; + } else { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.NO_SUCH_MARKET_CARD", {card: subIFM.card}); + return result; + } } } @@ -1271,6 +1295,13 @@ export class FroggerGame extends GameBase { result.complete = -1; result.canrender = true; result.message = i18next.t("apgames:validation.frogger.PIECE_NEXT"); + + //Internal autocompletion: + if (subIFM.card && (cloned.countColumnFrogs() + cloned.countColumnFrogs(true) === 6)) { + //If no frogs are on the road, the choice of frog is clear. + result.autocomplete = m + cloned.coords2algebraic(0, cloned.currplayer) + "-"; + } + return result; } else { //Reachable if an unblocked player submits the blocked move. @@ -1299,6 +1330,10 @@ export class FroggerGame extends GameBase { result.valid = false; result.message = i18next.t("apgames:validation.frogger.NO_RETURN"); return result; + } else if (fromX === 0 && !subIFM.forward) { + result.valid = false; + result.message = i18next.t("apgames:validation.frogger.INVALID_HOP_BACKWARD_EXCUSE"); + return result; } if ( ! subIFM.to ) { @@ -1307,6 +1342,13 @@ export class FroggerGame extends GameBase { result.complete = -1; result.canrender = true; 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); + if (targets.length === 1) { + result.autocomplete = m + targets[0] + (subIFM.forward ? "/" : ","); + } + return result; } else { //malformed, no longer reachable. @@ -1364,6 +1406,7 @@ export class FroggerGame extends GameBase { } } else if (!complete && cloned.market.length > 0) { // No card. May be a partial move, or can back up without a card. + // We don't autocomplete market picks because it's always optional. result.valid = true; result.complete = 0; result.canrender = true; @@ -1786,7 +1829,8 @@ export class FroggerGame extends GameBase { { name: "piece", colour: player, - scale: 0.75 + scale: 0.75, + opacity: 0.75 }, { text: count.toString(), diff --git a/test/games/frogger.test.ts b/test/games/frogger.test.ts index 05f677c6..5ef3fd5e 100644 --- a/test/games/frogger.test.ts +++ b/test/games/frogger.test.ts @@ -378,5 +378,27 @@ describe("Frogger", () => { 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); }); + + it ("Autocompletes a la Arimaa", () => { + 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:")).to.have.deep.property("autocomplete", "1L:a3-"); + expect(g.validateMove("1L:a3-")).to.have.deep.property("autocomplete", "1L:a3-d3/"); + + //Can move to first occurrence of the first occuring suit. + expect(g.validateMove("6LK:a3-")).to.have.deep.property("autocomplete", "6LK:a3-b1/"); + //Return valid false when user tries to move back from the Excuse. + expect(g.validateMove("a3-")).to.have.deep.property("valid", false); + + //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:"); + //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("valid", false); + + }); + + });