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
39 changes: 35 additions & 4 deletions src/games/selfo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult, type ChatLogCollectContext, type ChatLogLine } from "./_base.js";
import type { APGamesInformation } from "../schemas/gameinfo.js";
import { APRenderRep } from "@abstractplay/renderer/build/schemas/schema";
import { APRenderRep, RowCol } from "@abstractplay/renderer/build/schemas/schema";
import type { APMoveResult } from "../schemas/moveresults.js";
import { HexTriGraph, reviver, UserFacingError, cloneState } from "../common/index.js";
import i18next from "i18next";
Expand Down Expand Up @@ -221,7 +221,7 @@ export class SelfoGame extends GameBase {
"A connection game on a hexagonal board. Each player starts with the same number of pieces scattered at random, and on your turn you slide one piece to an adjacent empty cell. The first player to bring all of their own pieces together into a single group wins.",
notes:
"The starting position is random: both players' pieces are scattered over the board, with no piece already enclosed, no player already connected, and no immediate win for the first player.\n\nOn your turn, move one of your pieces to an adjacent empty cell. You win by moving so that all of your pieces form one group of mutually adjacent pieces; only the player who has just moved can win this way. A player who has no legal move loses.\n\nA piece is _enclosed_ when your pieces cannot all reach each other through cells not occupied by your opponent, because opposing pieces and the board edge wall some of them off. If, after a move, both players have enclosed pieces, neither can ever connect and the game is a draw. With the **No enclosure** variant, a move that would enclose an opposing piece is not allowed, unless that move wins the game outright.\n\nThe second player may use the pie rule to swap sides after the first move.",
urls: [],
urls: ["https://en.wikipedia.org/wiki/Selfo"],
people: [
{
type: "designer",
Expand Down Expand Up @@ -252,6 +252,9 @@ export class SelfoGame extends GameBase {
private pieces = 9;
private noEnclosure = false;
private graph!: HexTriGraph;
/** Piece being moved and the cells it can reach: only set while a move is half entered (see move()). */
private selected: string | undefined;
private dots: string[] = [];

constructor(state?: ISelfoState | string, variants?: string[]) {
super();
Expand Down Expand Up @@ -411,6 +414,7 @@ export class SelfoGame extends GameBase {
if (m.length === 0) {
result.valid = true;
result.complete = -1;
result.canrender = true;
result.message = i18next.t(this.isPieTurn()
? "apgames:validation.selfo.INITIAL_INSTRUCTIONS_PIE"
: "apgames:validation.selfo.INITIAL_INSTRUCTIONS");
Expand Down Expand Up @@ -466,6 +470,7 @@ export class SelfoGame extends GameBase {
if (to === undefined || to === "") {
result.valid = true;
result.complete = -1;
result.canrender = true;
result.message = i18next.t("apgames:validation.selfo.PARTIAL");
return result;
}
Expand Down Expand Up @@ -503,7 +508,7 @@ export class SelfoGame extends GameBase {
return result;
}

public move(m: string, {trusted = false} = {}): SelfoGame {
public move(m: string, {trusted = false, partial = false} = {}): SelfoGame {
if (this.gameover) {
throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:validation._general.MOVES_GAMEOVER"));
}
Expand All @@ -516,11 +521,23 @@ export class SelfoGame extends GameBase {
if (! result.valid) {
throw new UserFacingError("VALIDATION_GENERAL", result.message)
}
if (! this.moves().includes(m)) {
if (!partial && ! this.moves().includes(m)) {
throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", {move: m}))
}
}

this.selected = undefined;
this.dots = [];
// A move that is only half entered: just mark the selected piece and where it
// can go, without playing anything (the front end previews it on a copy).
if (partial && (m === "" || this.graph.graph.hasNode(m))) {
if (m !== "") {
this.selected = m;
this.dots = this.moves().filter(mv => mv.startsWith(`${m}-`)).map(mv => mv.split("-")[1]);
}
return this;
}

this.results = [];
if (m === "pie") {
// The second player takes the first player's side: every piece changes
Expand Down Expand Up @@ -651,6 +668,20 @@ export class SelfoGame extends GameBase {
}
}

// A half-entered move: ring the selected piece and dot the cells it can reach
if (this.selected !== undefined) {
rep.annotations ??= [];
const [sx, sy] = this.graph.algebraic2coords(this.selected);
rep.annotations.push({type: "enter", targets: [{row: sy, col: sx}], shape: "circle"});
if (this.dots.length > 0) {
const targets: RowCol[] = this.dots.map(cell => {
const [x, y] = this.graph.algebraic2coords(cell);
return {row: y, col: x};
});
rep.annotations.push({type: "dots", targets: targets as [RowCol, ...RowCol[]]});
}
}

return rep;
}

Expand Down
72 changes: 72 additions & 0 deletions test/games/selfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,78 @@ describe("Selfo", () => {
});
});

describe("half-entered moves", () => {
type Mark = {type: string; targets: {row: number; col: number}[]};
const marks = (game: SelfoGame, type: string): Mark | undefined =>
game.render().annotations?.find(a => a.type === type) as unknown as Mark | undefined;
const rc = (cell: string): {row: number; col: number} => {
const [x, y] = graph.algebraic2coords(cell);
return {row: y, col: x};
};

for (const variants of [["r2f5"], ["r2f5", "no-enclosure"]]) {
it(`rings the selected piece and dots the cells it can reach (${variants.join(",")})`, () => {
const game = setup(variants, ["c3", "a1"], ["e1", "e3"]);
game.move("c3", {partial: true});
const reach = game.moves().filter(m => m.startsWith("c3-")).map(m => rc(m.split("-")[1]));
expect(reach.length).to.be.greaterThan(0);
expect(marks(game, "dots")!.targets).to.have.deep.members(reach);
expect(marks(game, "enter")!.targets).to.deep.equal([rc("c3")]);
});
}

it("tells the front end that a half-entered move can be rendered", () => {
const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]);
const [fx, fy] = graph.algebraic2coords("c3");
const click = game.handleClick("", fy, fx);
expect(click.valid).to.be.true;
expect(click.complete).to.equal(-1);
expect(click.canrender).to.be.true;
expect(game.validateMove("c3").canrender).to.be.true;
expect(game.validateMove("").canrender).to.be.true;
expect(game.validateMove("e1").canrender).to.be.undefined; // not your piece
});

it("does not play anything", () => {
const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]);
const board = [...game.board.entries()];
const stack = game.stack.length;
game.move("c3", {partial: true});
expect(game.currplayer).to.equal(1);
expect(game.stack.length).to.equal(stack);
expect([...game.board.entries()]).to.deep.equal(board);
expect(game.gameover).to.be.false;
});

it("marks nothing for an empty move, and the marks go away once a move is played", () => {
const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]);
game.move("", {partial: true});
expect(marks(game, "dots")).to.be.undefined;
expect(marks(game, "enter")).to.be.undefined;
game.move("c3", {partial: true});
expect(marks(game, "dots")).to.not.be.undefined;
const free = graph.neighbours("c3").find(c => !game.board.has(c))!;
game.move(`c3-${free}`);
expect(marks(game, "dots")).to.be.undefined;
expect(marks(game, "enter")).to.be.undefined;
expect(game.currplayer).to.equal(2);
});

it("still plays a complete move that is flagged as partial", () => {
const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]);
const free = graph.neighbours("c3").find(c => !game.board.has(c))!;
game.move(`c3-${free}`, {partial: true});
expect(game.board.get(free)).to.equal(1);
expect(game.board.has("c3")).to.be.false;
expect(game.currplayer).to.equal(2);
});

it("rejects an invalid selection even when partial", () => {
const game = setup(["r2f5"], ["c3", "a1"], ["e1", "e3"]);
expect(() => game.move("e1", {partial: true})).to.throw();
});
});

describe("pie rule", () => {
// a first move that does not end the game
const firstMove = (game: SelfoGame): string => game.moves().find(m => !game.clone().move(m).gameover)!;
Expand Down
Loading