Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 76 additions & 10 deletions src/games/frogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 [];
}
Expand All @@ -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"));
}
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ? "/" : "");
}
Expand Down Expand Up @@ -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
}});
Expand Down Expand Up @@ -1843,7 +1906,7 @@ export class FroggerGame extends GameBase {
},
{
text: count.toString(),
colour: "_context_strokes",
colour: "_context_labels",
scale: 0.66
}
]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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({
Expand Down
29 changes: 27 additions & 2 deletions test/games/frogger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/");

});


Expand Down