From 95e2a27b9dc354e1358898f749c7d51aba98d91b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:54:25 +0000 Subject: [PATCH] Ice Palace: dot cells, paint, and stashes for the new renderer Notation - Cells are written x.y rather than x,y: the front end reads commas in a move as separating simultaneous moves, so a build of numplayers - 1 pyramids was split into one move per seat. Games saved with comma cells keep working: moves typed or replayed in the old notation are read in the new one, and stack entries are rewritten on load. Rendering - Every legend glyph is coloured through `paint`, which the renderer now asks for in place of the deprecated `colour`. Translucency moves into the fill slot, so outlines stay solid as before. - The hand and Pool are laid out alike under either board, since both renderers now lay stashes out with the same step; the 3D-board-only layouts with placeholders and headroom go. - In 3D the Pool is shown as nests, each holding at most one pyramid of each size: the renderer rests a column's pyramids on one base line by size, so several of a size in one column were drawn as one, and a spacer among them was an error ("Could not resolve silhouette tier"). - The 3D hand glyph puts the pyramid before its invisible click square, which the renderer reads for the pyramid's size, so hand pyramids rest on one base line. - Black and White pyramids paint their outline and size pips in whichever of black and white contrasts with the body, so a Black pyramid shows its pips. The sidebar sends each pyramid as its legend glyph so the border reaches it too. Playground - Status glyphs are sized to the text; the renderer sizes an inline glyph's svg to 100%, which the wrapper's max-height did not constrain. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NSoBhzyAEBBaTsYtVpV8aT --- playground/playground.css | 7 + src/games/icepalace.ts | 256 +++++++++--------- test/games/icepalace.test.ts | 494 +++++++++++++++++++++++------------ 3 files changed, 453 insertions(+), 304 deletions(-) diff --git a/playground/playground.css b/playground/playground.css index 7c5fbf9a..6d7740d6 100644 --- a/playground/playground.css +++ b/playground/playground.css @@ -1204,6 +1204,13 @@ body[data-theme='dark'] #gameStatusPanel .stash-glyph-wrapper { background-color: rgba(255,255,255,0.03); } +/* Status glyphs: the renderer sizes an inline glyph's to 100%, which the wrapper's + max-height does not constrain, so size the svg itself (the front end uses an ). */ +.status-glyph > svg { + height: 1.25em; + width: auto; +} + #gameStatusPanel .stash-movepart { font-size: 0.75em; color: #555; diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 4104a9a8..8f1842a4 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -2,7 +2,7 @@ import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, ISt import type { IGamePly } from "./_turn-model.js"; import { GameBaseSequenced } from "./_turn-sequenced.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; -import { APRenderRep, AreaStackingExpanded, AreaVolcanoStash, Colourfuncs, Glyph } from "@abstractplay/renderer/build/schemas/schema"; +import { APRenderRep, AreaStackingExpanded, AreaVolcanoStash, Colourfuncs, Glyph, SheetGlyph } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; import { reviver, UserFacingError } from "../common/index.js"; import i18next from "i18next"; @@ -13,8 +13,10 @@ import i18next from "i18next"; * Shared vocabulary and legality checks for Ice Palace. * * Both structures (the Yard and the Ice Palace) are built on imaginary grids that - * stretch to infinity, so cells are stored as `"x,y"` keys rather than on a fixed - * board. `y` is positive upwards, matching `UnboundedSquareBoard.abs2notation`. + * stretch to infinity, so cells are stored as `"x.y"` keys rather than on a fixed + * board. `y` is positive upwards, matching `UnboundedSquareBoard.abs2notation`, but the + * coordinates are joined with a dot, as in Thricewise, rather than a comma: the front + * end reads commas in a move as separating simultaneous moves. */ /** 1 = small, 2 = medium, 3 = large. */ @@ -47,13 +49,19 @@ export const sizeOf = (piece: PieceId): Size => { export const makePiece = (colour: string, size: Size): PieceId => `${colour}${SIZE_CHARS[size - 1]}`; -export const cellOf = (x: number, y: number): Cell => `${x},${y}`; +export const cellOf = (x: number, y: number): Cell => `${x}.${y}`; export const coordsOf = (cell: Cell): [number, number] => { - const parts = cell.split(","); + const parts = cell.split("."); return [Number(parts[0]), Number(parts[1])]; }; +/** + * Games saved before cells used a dot wrote them `"x,y"`. Rewrites any such cells in a + * cell, move or other string; text without them is returned unchanged. + */ +export const upgradeLegacyCells = (text: string): string => text.replace(/(-?\d+),(-?\d+)/g, "$1.$2"); + /** Adjacency is side-by-side only; the four diagonals are not adjacent. */ export const neighbours = (cell: Cell): Cell[] => { const [x, y] = coordsOf(cell); @@ -561,18 +569,10 @@ const DOT_KEY = "dot"; */ const BLACK: Colourfuncs = { func: "custom", default: "#000000", palette: 7 }; const WHITE: Colourfuncs = { func: "custom", default: "#ffffff", palette: 8 }; -/** Empty steps added above each perspective stash column, to clear the area's label. */ -const STASH_HEADROOM = 2; -/** - * Empty steps under a nest in a perspective stash. The renderer leaves no room between - * one area and the next, and a nest fills its cell to the bottom edge, where a 3D pyramid - * stops short; without these the nests would sit on the Pool's label. - */ -const NEST_CLEARANCE = 2; /** Legend key of an invisible spacer, used to lay the Pool out in columns with gaps. */ const GAP_KEY = "gap"; /** An invisible cell-sized square: a spacer, and a larger click target behind a pyramid. */ -const BLANK: Glyph = { name: "piece-square-borderless", colour: "_context_background", opacity: 0 }; +const BLANK: Glyph = { name: "piece-square-borderless", paint: { fill: { colour: "_context_background", opacity: 0 } } }; /** Seen from above, a stack's pyramids overlap; this lets the lower ones show through. */ const TOP_OPACITY = 0.75; /** How a pyramid is drawn: in perspective, from above, from the side, or nested in a stash. */ @@ -783,7 +783,7 @@ export class IcePalaceGame extends GameBaseSequenced { this.gameover = state.gameover; this.winner = [...state.winner]; this.variants = state.variants; - this.stack = [...state.stack]; + this.stack = state.stack.map(IcePalaceGame.upgradeEntry); } this.load(); } @@ -892,6 +892,41 @@ export class IcePalaceGame extends GameBaseSequenced { return { palace: cloneStructure(frame.palace), stock: [...frame.stock] }; } + /** + * A stack entry with any cells saved in the old `x,y` form rewritten as `x.y`: the + * structures, the build frames, the move and the cells in its results. Saved games + * from before the change keep loading, and their moves read like new ones. + */ + private static upgradeEntry(entry: IMoveState): IMoveState { + const upgradeStructure = (struct: Structure): Structure => + new Map([...struct.entries()].map(([cell, stack]) => [upgradeLegacyCells(cell), stack])); + const upgraded: IMoveState = { + ...entry, + _results: entry._results.map(IcePalaceGame.upgradeResult), + yard: upgradeStructure(entry.yard), + palace: upgradeStructure(entry.palace), + }; + if (entry.lastmove !== undefined) { + upgraded.lastmove = upgradeLegacyCells(entry.lastmove); + } + if (entry.frames !== undefined) { + upgraded.frames = entry.frames.map(frame => ({ palace: upgradeStructure(frame.palace), stock: frame.stock })); + } + return upgraded; + } + + /** A result's cell, or the build suggested in an announcement, in the current notation. */ + private static upgradeResult(result: APMoveResult): APMoveResult { + const upgraded = { ...result } as Record; + if (typeof upgraded.where === "string") { + upgraded.where = upgradeLegacyCells(upgraded.where); + } + if (Array.isArray(upgraded.payload)) { + upgraded.payload = upgraded.payload.map(p => (typeof p === "string" ? upgradeLegacyCells(p) : p)); + } + return upgraded as unknown as APMoveResult; + } + public state(): IIcePalaceState { return { game: IcePalaceGame.gameinfo.uid, @@ -969,12 +1004,13 @@ export class IcePalaceGame extends GameBaseSequenced { return super.randomMove(); } + /** Moves from games saved before cells used a dot are read in the current notation. */ private static normalise(m: string): string { const cleaned = m.replace(/\s+/g, ""); if (cleaned.toLowerCase() === "pass") { return "pass"; } - return cleaned.toUpperCase(); + return upgradeLegacyCells(cleaned.toUpperCase()); } /** Splits a placement into its pyramid and its cell. */ @@ -985,7 +1021,7 @@ export class IcePalaceGame extends GameBaseSequenced { } const piece = token.substring(0, at); const cell = token.substring(at + 1); - if (!/^[1-6BW][SML]$/.test(piece) || !/^-?\d+,-?\d+$/.test(cell)) { + if (!/^[1-6BW][SML]$/.test(piece) || !/^-?\d+\.-?\d+$/.test(cell)) { return undefined; } return { piece, cell }; @@ -1381,11 +1417,7 @@ export class IcePalaceGame extends GameBaseSequenced { /** A pyramid as it appears in the status panel. */ private statusGlyph(piece: PieceId): StatusValue { - const glyph = this.glyphFor(piece, "nest"); - if (!("name" in glyph) || glyph.name === undefined) { - throw new Error(`Expected sheet glyph for pyramid status: ${piece}`); - } - return this.statusSheetGlyph(glyph.name, glyph.colour as number | string | Colourfuncs); + return this.statusLegendGlyph(this.glyphFor(piece, "nest")); } /** @@ -1407,61 +1439,62 @@ export class IcePalaceGame extends GameBaseSequenced { return column; } - private glyphFor(piece: PieceId, view: PyramidView = "3D"): Glyph { + /** A pyramid's colour: its player's, or the Pool's Black or White. */ + private static pyramidColour(piece: PieceId): number | Colourfuncs { + const colour = colourOf(piece); + return colour === NULL_COLOUR ? BLACK : colour === WILD_COLOUR ? WHITE : Number(colour); + } + + /** + * A pyramid's glyph. Seen from above, a pyramid is translucent so that the ones below + * it in a stack show through, unless it is `opaque`, as the Palace and the Pool are. + * The translucency is the fill's alone; the outline stays solid. + * + * The sheet draws the outline and the size pips black, which vanish on a Black pyramid. + * Black and White get whichever of black and white reads best against the body, so + * the pips show on Black and still do if a viewer recolours either; the players' + * pyramids keep the sheet's outline, as in the other pyramid games. + */ + private glyphFor(piece: PieceId, view: PyramidView = "3D", opaque = false): SheetGlyph { const size = SIZE_NAMES[sizeOf(piece) - 1]; const name = view === "3D" ? `pyramid-up-${size}-3D` : view === "top" ? `pyramid-up-${size}-upscaled` : view === "side" ? `pyramid-flat-${size}` : `pyramid-flattened-${size}`; - const colour = colourOf(piece); - const glyph: Glyph = { - name, - colour: colour === NULL_COLOUR ? BLACK : colour === WILD_COLOUR ? WHITE : Number(colour), - }; - if (view === "top") { - glyph.opacity = TOP_OPACITY; + const colour = IcePalaceGame.pyramidColour(piece); + const fill = view === "top" && !opaque ? { colour, opacity: TOP_OPACITY } : colour; + if (typeof colour === "number") { + return { name, paint: { fill } }; } - return glyph; + const border: Colourfuncs = { func: "bestContrast", bg: colour, fg: ["#000000", "#ffffff"] }; + return { name, paint: { fill, border } }; } /** * The offered pyramids as a stash, one per column, smallest first. Seen from above each - * is a nest, a filled shape that takes clicks by itself. In 3D each sits on an invisible - * square, so a click anywhere in its cell picks it rather than only a click on the thin - * outline; those get keys of their own, since on the board the square would reach into - * neighbouring cells. A 3D glyph's apex is fixed, so left alone the smaller pyramids - * would hang from the top: the perspective renderer lifts mediums and larges with - * placeholders, and the top-down renderer, which takes none, has the smaller ones - * nudged down instead. Either way every base rests on one line. Nests in the - * perspective renderer are lifted clear of the area below instead. + * is a nest, a filled shape that takes clicks by itself. In 3D each is drawn with an + * invisible square over it, so a click anywhere in its cell picks it rather than only a + * click on the thin outline; those get keys of their own, since on the board the square + * would reach into neighbouring cells. The pyramid comes first: the renderer reads a 3D + * stash piece's size from its first layer to rest every base on one line. */ - private handStash(offered: PieceId[], legend: Legend, expanding: boolean, areas3D: boolean): string[][] { + private handStash(offered: PieceId[], legend: Legend, areas3D: boolean): string[][] { return [...offered].sort(pieceSort).map(piece => { const key = IcePalaceGame.legendKey(piece, areas3D ? "hand" : "stash"); - if (areas3D) { - legend[key] = [BLANK, this.glyphFor(piece, "3D")]; - return [key]; - } if (!(key in legend)) { - legend[key] = this.glyphFor(piece, "nest"); - } - if (expanding) { - return [key]; + legend[key] = areas3D ? [this.glyphFor(piece, "3D"), BLANK] : this.glyphFor(piece, "nest"); } - const lift = NEST_CLEARANCE; - return IcePalaceGame.withHeadroom([...Array(lift).fill("-"), key]); + return [key]; }); } /** - * The Pool as a stash, one column per colour, largest at the bottom, each pyramid a - * fixed distance above the last with an extra gap between sizes. Seen from above the - * pyramids are turned to diamonds and drawn opaque; a diamond is wider than a stash - * column, so a spacer column keeps the colours apart. In 3D the glyphs are aligned as - * in the hand. + * The Pool as a stash, one column per colour. Seen from above the pyramids are turned + * to diamonds and drawn opaque; a diamond is wider than a stash column, so a spacer + * column keeps the colours apart. */ - private poolStash(legend: Legend, expanding: boolean, areas3D: boolean): string[][] { + private poolStash(legend: Legend, areas3D: boolean): string[][] { const sorted = [...this.pool].sort(pieceSort); for (const piece of sorted) { const key = IcePalaceGame.legendKey(piece, "pool"); @@ -1471,29 +1504,30 @@ export class IcePalaceGame extends GameBaseSequenced { if (areas3D) { legend[key] = this.glyphFor(piece, "3D"); } else { - const glyph = this.glyphFor(piece, "top"); - delete glyph.opacity; - glyph.rotate = 45; - legend[key] = glyph; + legend[key] = { ...this.glyphFor(piece, "top", true), rotate: 45 }; } } + const columns = IcePalaceGame.poolColumns(sorted, areas3D); + if (areas3D) { + return columns; + } legend[GAP_KEY] = BLANK; - const columns = expanding - ? IcePalaceGame.poolColumns(sorted) - : areas3D - ? IcePalaceGame.densePerspectivePool(sorted) - : IcePalaceGame.perspectivePool(sorted, areas3D); // The renderer sets stash columns a fixed slot apart, so an empty one is the only // way to widen the gap. - return areas3D ? columns : columns.flatMap((column, i) => i === 0 ? [column] : [[GAP_KEY], column]); + return columns.flatMap((column, i) => i === 0 ? [column] : [[GAP_KEY], column]); } /** - * The Pool for the top-down renderer, whose stash sets each index a fixed 0.35 of a - * cell above the last: one column of legend keys per colour, bottom first, larges, - * then mediums, then smalls, with a spacer between sizes. + * The Pool's columns, bottom first. Seen from above, one column per colour holds all + * its pyramids, larges, then mediums, then smalls, each a fixed step above the last and + * with a spacer between sizes. In 3D the renderer rests a column's pyramids on one base + * line by size, so two of a size in one column would be drawn as one: a colour is shown + * as nests instead, each holding at most one pyramid of each size. */ - private static poolColumns(sorted: PieceId[]): string[][] { + private static poolColumns(sorted: PieceId[], areas3D: boolean): string[][] { + if (areas3D) { + return IcePalaceGame.nests(sorted).flatMap(IcePalaceGame.nestColumns); + } return IcePalaceGame.nests(sorted).map(nest => { const column: string[] = []; let lastSize: number | undefined; @@ -1509,57 +1543,19 @@ export class IcePalaceGame extends GameBaseSequenced { } /** - * The Pool for the perspective renderer (dense columns): one column per colour, - * legend keys bottom → top; silhouette spacing comes from glyph names in the renderer. - */ - private static densePerspectivePool(sorted: PieceId[]): string[][] { - return IcePalaceGame.nests(sorted).map((nest) => - nest.map((piece) => IcePalaceGame.legendKey(piece, "pool")), - ); - } - - /** - * The Pool for the perspective renderer: a stash column per colour, bottom first, with - * each pyramid's base a fixed distance above the one below and an extra step between - * sizes, so the columns overlap evenly as in the top-down renderer. - * - * The stash raises each index by 0.15 of a cell, and a 3D glyph's base already sits one - * such step higher for each size down (large 0.80, medium 0.65, small 0.50), so heights - * are whole steps. `"-"` placeholders spend the steps. A diamond seen from above is - * centred whatever its size, so its whole height is spent with them. - */ - private static perspectivePool(sorted: PieceId[], pyramids3D: boolean): string[][] { - const between = 2; - const betweenSizes = 2; - return IcePalaceGame.nests(sorted).map(nest => { - const column: string[] = []; - let lastBase: number | undefined; - let lastSize: number | undefined; - for (const piece of nest) { - // Where this size's base sits unraised, in steps below a small's. - const own = pyramids3D ? sizeOf(piece) - 1 : 0; - // The first pyramid rests on the ground, a small's unraised base line. - const target = lastBase === undefined - ? 0 - : lastBase - between - (sizeOf(piece) !== lastSize ? betweenSizes : 0); - const index = own - target; - while (column.length < index) { - column.push("-"); - } - column.push(IcePalaceGame.legendKey(piece, "pool")); - lastBase = target; - lastSize = sizeOf(piece); - } - return IcePalaceGame.withHeadroom(column); - }); - } - - /** - * The renderer hangs a stash's tallest column just under its label, leaving little room - * for a label drawn in a large font. Empty steps on top push everything down a little. + * One colour's pyramids as nests, largest at the bottom: the first takes one of every + * size, the next one of every size still left, and so on until none is. */ - private static withHeadroom(column: string[]): string[] { - return [...column, ...Array(STASH_HEADROOM).fill("-")]; + private static nestColumns(pieces: PieceId[]): string[][] { + const colour = colourOf(pieces[0]); + const counts = SIZES.map(size => pieces.filter(piece => sizeOf(piece) === size).length); + const columns: string[][] = []; + for (let nest = 0; nest < Math.max(...counts); nest++) { + columns.push([...SIZES].reverse() + .filter(size => counts[size - 1] > nest) + .map(size => IcePalaceGame.legendKey(makePiece(colour, size), "pool"))); + } + return columns; } /** The offered pyramids gathered into nests, one per colour, largest at the bottom. */ @@ -1723,13 +1719,13 @@ export class IcePalaceGame extends GameBaseSequenced { /** One board: the stacks, the hand and Pool below it, and the overlays on top. */ private renderView(opts: IRenderOpts | undefined, layout: ILayout, view: IBoardView, entered: { where: Which; cells: Cell[] }): APRenderRep { // The top-down board is the default; the perspective board is the alternate. The - // board picks the renderer, which draws the stashes too, so a stash style is a - // matter of glyphs, laid out in columns the way that renderer allows. + // board picks the renderer, which draws the stashes too, but both renderers lay + // stashes out alike, so a stash style is a matter of glyphs and not of the board. const expanding = !this.hasDisplay(opts, "perspective"); const areas3D = this.hasDisplay(opts, "perspective-areas"); const legend: Legend = {}; const pieces = this.boardPieces(layout, view, legend, expanding); - const areas = this.areasBelow(view, legend, expanding, areas3D); + const areas = this.areasBelow(view, legend, areas3D); const blocked = IcePalaceGame.unreachable(layout, view.palace, view.yard); const rep: APRenderRep = { renderer: expanding ? "stacking-expanding" : "stacking-3D", @@ -1768,11 +1764,7 @@ export class IcePalaceGame extends GameBaseSequenced { for (const piece of stack) { const key = IcePalaceGame.legendKey(piece, where); if (!(key in legend)) { - const glyph = this.glyphFor(piece, expanding ? "top" : "3D"); - if (where === "palace") { - delete glyph.opacity; - } - legend[key] = glyph; + legend[key] = this.glyphFor(piece, expanding ? "top" : "3D", where === "palace"); } } pieces[row][col] = expanding @@ -1788,7 +1780,7 @@ export class IcePalaceGame extends GameBaseSequenced { * than pieces areas: the renderer puts both directly under the board, so the hand * must be a stash once the Pool is one. */ - private areasBelow(view: IBoardView, legend: Legend, expanding: boolean, areas3D: boolean): AreaVolcanoStash[] { + private areasBelow(view: IBoardView, legend: Legend, areas3D: boolean): AreaVolcanoStash[] { const areas: AreaVolcanoStash[] = []; const offered = view.phase === "build" ? view.stock : this.handOf(this.currplayer); if (offered.length > 0) { @@ -1797,14 +1789,14 @@ export class IcePalaceGame extends GameBaseSequenced { const label = view.phase === "build" ? this.neutralAreaLabel("apgames:icepalace.STOCK") : this.seatAreaLabel(this.currplayer, "apgames:icepalace.HAND"); - areas.push({ type: "localStash", label, stash: this.handStash(offered, legend, expanding, areas3D) }); + areas.push({ type: "localStash", label, stash: this.handStash(offered, legend, areas3D) }); } // Everything still in the Pool, for reference. Draws are made at random when hands // are refilled, so showing the contents gives nothing away. if (this.pool.length > 0) { // i18next.t("apgames:icepalace.POOL") const label = this.neutralAreaLabel("apgames:icepalace.POOL"); - areas.push({ type: "localStash", label, stash: this.poolStash(legend, expanding, areas3D) }); + areas.push({ type: "localStash", label, stash: this.poolStash(legend, areas3D) }); } return areas; } @@ -1833,7 +1825,7 @@ export class IcePalaceGame extends GameBaseSequenced { if (region === undefined) { return; } - legend[DOT_KEY] = { name: "piece", colour: "_context_annotations", scale: 0.27, opacity: 0.5 }; + legend[DOT_KEY] = { name: "piece", paint: { fill: { colour: "_context_annotations", opacity: 0.5 } }, scale: 0.27 }; for (const cell of legalCellsFor(this.structure(which, view), this.selected, legal)) { const [row, col] = IcePalaceGame.drawnAt(layout, region, cell); pieces[row][col].push(DOT_KEY); diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 74326f14..4744f3f1 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -2,8 +2,10 @@ import "mocha"; import { expect } from "chai"; import { addResource } from "../../src"; +import { replacer, reviver } from "../../src/common"; import { assertChatLogParity } from "../fixtures/chat/helpers"; import { + IIcePalaceState, IcePalaceGame, PieceId, Structure, @@ -81,10 +83,10 @@ describe("Ice Palace: playing a hand", () => { it("accepts typed moves, but founds an empty structure only at the origin", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); // Case and spaces do not matter. - expect(g.validateMove(" 1m @ 0,0 ").valid).to.be.true; + expect(g.validateMove(" 1m @ 0.0 ").valid).to.be.true; // The lead cannot found the Yard anywhere else. - expect(g.validateMove("1M@5,7").valid).to.be.false; - expect(g.validateMove("1M@1,0").valid).to.be.false; + expect(g.validateMove("1M@5.7").valid).to.be.false; + expect(g.validateMove("1M@1.0").valid).to.be.false; }); it("will not let the lead pass", () => { @@ -95,51 +97,51 @@ describe("Ice Palace: playing a hand", () => { it("lets the lead place anything anywhere", () => { const g = rig(new IcePalaceGame(3), [["BS"], ["2L"], ["3L"]]); - g.move("BS@0,0"); - expect(g.topOfCell("yard", "0,0")).to.equal("BS"); + g.move("BS@0.0"); + expect(g.topOfCell("yard", "0.0")).to.equal("BS"); expect(g.currplayer).to.equal(2); }); it("stacks bigger over smaller regardless of colour", () => { const g = rig(new IcePalaceGame(3), [["1S"], ["BM"], ["3S"]]); - g.move("1S@0,0"); + g.move("1S@0.0"); // Black stacks fine; only its colour matching is crippled. - g.move("BM@0,0"); - expect(g.topOfCell("yard", "0,0")).to.equal("BM"); + g.move("BM@0.0"); + expect(g.topOfCell("yard", "0.0")).to.equal("BM"); // Nothing may go under, and a small cannot cover a medium. - expect(g.validateMove("3S@0,0").valid).to.be.false; + expect(g.validateMove("3S@0.0").valid).to.be.false; }); it("refuses to stack anything over a large", () => { const g = rig(new IcePalaceGame(3), [["1L"], ["2L"], ["3S"]]); - g.move("1L@0,0"); - expect(g.validateMove("2L@0,0").valid).to.be.false; + g.move("1L@0.0"); + expect(g.validateMove("2L@0.0").valid).to.be.false; }); it("founds a new stack only next to a matching top", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S", "2S"], ["3S"]]); - g.move("1M@0,0"); - expect(g.validateMove("2S@1,0").valid).to.be.false; - expect(g.validateMove("1S@1,0").valid).to.be.true; + g.move("1M@0.0"); + expect(g.validateMove("2S@1.0").valid).to.be.false; + expect(g.validateMove("1S@1.0").valid).to.be.true; }); it("treats White as wild and Black as matching nothing", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["WS", "BS"], ["3S"]]); - g.move("1M@0,0"); - expect(g.validateMove("WS@1,0").valid).to.be.true; - expect(g.validateMove("BS@1,0").valid).to.be.false; + g.move("1M@0.0"); + expect(g.validateMove("WS@1.0").valid).to.be.true; + expect(g.validateMove("BS@1.0").valid).to.be.false; }); it("rejects diagonal placements", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]]); - g.move("1M@0,0"); - expect(g.validateMove("1S@1,1").valid).to.be.false; - expect(g.validateMove("1S@0,1").valid).to.be.true; + g.move("1M@0.0"); + expect(g.validateMove("1S@1.1").valid).to.be.false; + expect(g.validateMove("1S@0.1").valid).to.be.true; }); it("ends the hand only after every player has passed in a row", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); g.move("pass"); g.move("pass"); expect(g.phase).to.equal("hand"); @@ -152,18 +154,18 @@ describe("Ice Palace: playing a hand", () => { it("lets the last placer keep extending instead of closing the hand", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); g.move("pass"); g.move("pass"); - g.move("1L@0,0"); + g.move("1L@0.0"); expect(g.phase).to.equal("hand"); expect(g.passes).to.equal(0); }); it("hands the build to whoever placed last", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); - g.move("1M@0,0"); - g.move("1S@0,1"); + g.move("1M@0.0"); + g.move("1S@0.1"); g.move("pass"); g.move("pass"); g.move("pass"); @@ -177,13 +179,13 @@ describe("Ice Palace: move lists and auto-passing", () => { // A small Black cannot be played at all: nothing is smaller for it to cover, and // Black matches no colour, so it can never found a stack either. const g = rig(new IcePalaceGame(3), [["1M"], ["BS"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); expect(g.moves()).to.deep.equal(["pass"]); }); it("keeps pass on offer for a player who could place instead", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); const moves = g.moves(); expect(moves).to.include("pass"); expect(moves.length).to.be.greaterThan(1); @@ -196,10 +198,10 @@ describe("Ice Palace: move lists and auto-passing", () => { it("does not enumerate builds, but still supplies one on request", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2L"], ["3L"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); g.move("pass"); g.move("pass"); - g.move("1L@0,0"); + g.move("1L@0.0"); while (g.phase === "hand") { g.move("pass"); } @@ -233,54 +235,54 @@ describe("Ice Palace: building the Palace", () => { }; it("discards Black and White on the way out of the Yard", () => { - const g = toBuild([["BL"], ["2L"], ["3L"]], "BL@0,0"); + const g = toBuild([["BL"], ["2L"], ["3L"]], "BL@0.0"); expect(g.phase).to.equal("build"); expect(g.stock).to.be.empty; expect(g.buildMin).to.equal(0); }); it("auto-resolves a build with nothing placeable", () => { - const g = toBuild([["BL"], ["2L"], ["3L"]], "BL@0,0"); + const g = toBuild([["BL"], ["2L"], ["3L"]], "BL@0.0"); expect(g.moves()).to.deep.equal(["pass"]); g.move("pass"); expect(g.phase).to.equal("hand"); }); it("reports the maximum the builder must reach", () => { - const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0,0", ["pass", "pass", "1L@0,0"]); + const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0.0", ["pass", "pass", "1L@0.0"]); expect(g.stock.sort()).to.deep.equal(["1L", "1M"]); expect(g.buildMin).to.equal(2); }); it("refuses a build that stops short of the maximum", () => { - const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0,0", ["pass", "pass", "1L@0,0"]); - const short = g.validateMove("1L@0,0"); + const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0.0", ["pass", "pass", "1L@0.0"]); + const short = g.validateMove("1L@0.0"); expect(short.valid).to.be.true; expect(short.complete).to.equal(-1); }); it("never auto-commits a completed build", () => { - const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0,0", ["pass", "pass", "1L@0,0"]); - const full = g.validateMove("1L@0,0;1M@0,0"); + const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0.0", ["pass", "pass", "1L@0.0"]); + const full = g.validateMove("1L@0.0;1M@0.0"); expect(full.valid).to.be.true; expect(full.complete).to.equal(0); }); it("applies a build and starts the next hand with the token moved on", () => { - const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0,0", ["pass", "pass", "1L@0,0"]); - g.move("1L@0,0;1M@0,0"); + const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0.0", ["pass", "pass", "1L@0.0"]); + g.move("1L@0.0;1M@0.0"); expect(g.phase).to.equal("hand"); expect(g.lead).to.equal(2); expect(g.currplayer).to.equal(2); - expect(g.palace.get("0,0")).to.deep.equal(["1L", "1M"]); + expect(g.palace.get("0.0")).to.deep.equal(["1L", "1M"]); }); it("renders a build as frames, one per placement, on the final layout", () => { - const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0,0", ["pass", "pass", "1L@0,0"]); + const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0.0", ["pass", "pass", "1L@0.0"]); type Rep = { pieces: string[][][]; areas?: { stash?: string[][]; label?: { textKey?: string } }[] }; // Before the build there is nothing to step through. expect(Array.isArray(g.render())).to.be.false; - g.move("1L@0,0;1M@0,0"); + g.move("1L@0.0;1M@0.0"); const reps = g.render() as unknown as Rep[]; expect(reps).to.have.length(3); // Before, after the first placement, and the game as it stands, all the same size. @@ -296,7 +298,7 @@ describe("Ice Palace: building the Palace", () => { expect(reps[2].areas![0].label?.textKey).to.equal("apgames:icepalace.HAND"); // Rendering leaves the game as it was, and the frames survive a round trip. expect(g.phase).to.equal("hand"); - expect(g.palace.get("0,0")).to.deep.equal(["1L", "1M"]); + expect(g.palace.get("0.0")).to.deep.equal(["1L", "1M"]); expect((g.clone().render() as unknown as Rep[]).length).to.equal(3); // Frames belong to the build alone: the next move renders singly again. g.move(g.moves()[0]); @@ -305,15 +307,15 @@ describe("Ice Palace: building the Palace", () => { it("outlines the cells the last move placed into", () => { type Rep = { pieces: string[][][]; annotations?: { type: string; targets: { row: number; col: number }[] }[] }; - const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0,0", ["pass", "pass", "1L@0,0"]); + const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0.0", ["pass", "pass", "1L@0.0"]); // The pass that ended the hand placed nothing. expect((g.render() as unknown as Rep).annotations).to.be.undefined; // A build in progress outlines its pending placements in the Palace. - const pending = g.clone().move("1L@0,0", { partial: true }).render() as unknown as Rep; + const pending = g.clone().move("1L@0.0", { partial: true }).render() as unknown as Rep; expect(pending.annotations).to.have.length(1); expect(pending.annotations![0].targets.map(t => pending.pieces[t.row][t.col].join(","))).to.deep.equal(["a1L"]); // A finished build: each frame outlines its own placement, the last frame all of them. - g.move("1L@0,0;1M@0,0"); + g.move("1L@0.0;1M@0.0"); const reps = g.render() as unknown as Rep[]; // (Once the next hand opens the Yard shows too, so the Palace sits further right.) const outlined = (rep: Rep) => rep.annotations![0].targets.map(t => rep.pieces[t.row][t.col].join(",")); @@ -329,18 +331,18 @@ describe("Ice Palace: building the Palace", () => { }); it("keeps no frames for a partial build or a build of nothing", () => { - const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0,0", ["pass", "pass", "1L@0,0"]); - g.move("1L@0,0", { partial: true }); + const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0.0", ["pass", "pass", "1L@0.0"]); + g.move("1L@0.0", { partial: true }); expect(Array.isArray(g.render())).to.be.false; - const empty = toBuild([["BL"], ["2L"], ["3L"]], "BL@0,0"); + const empty = toBuild([["BL"], ["2L"], ["3L"]], "BL@0.0"); empty.move("pass"); expect(Array.isArray(empty.render())).to.be.false; }); it("refuses a build that breaks the Palace code", () => { - const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0,0", ["pass", "pass", "1L@0,0"]); + const g = toBuild([["1L", "1M"], ["2L"], ["3L"]], "1M@0.0", ["pass", "pass", "1L@0.0"]); // Small over large is fine, large over medium is not. - expect(g.validateMove("1M@0,0;1L@0,0").valid).to.be.false; + expect(g.validateMove("1M@0.0;1L@0.0").valid).to.be.false; }); }); @@ -348,9 +350,9 @@ describe("Ice Palace: scoring and ending", () => { it("scores each stack for whoever is on top", () => { const g = new IcePalaceGame(3); g.palace = new Map([ - ["0,0", ["1L", "2M", "3S"]], - ["1,0", ["2L", "1M"]], - ["2,0", ["3L"]], + ["0.0", ["1L", "2M", "3S"]], + ["1.0", ["2L", "1M"]], + ["2.0", ["3L"]], ]); expect(g.getPlayerScore(1)).to.equal(2); expect(g.getPlayerScore(2)).to.equal(0); @@ -359,8 +361,8 @@ describe("Ice Palace: scoring and ending", () => { it("ends the game when the Pool cannot replenish every hand", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2L"], ["3L"]], []); - g.palace = new Map([["5,5", ["1L", "2M"]]]); - g.move("1M@0,0"); + g.palace = new Map([["5.5", ["1L", "2M"]]]); + g.move("1M@0.0"); while (g.phase === "hand") { g.move("pass"); } @@ -373,10 +375,10 @@ describe("Ice Palace: scoring and ending", () => { const g = rig(new IcePalaceGame(3), [["BL"], ["2L"], ["3L"]], []); // Two stacks of equal height topped by different players. g.palace = new Map([ - ["5,5", ["2L", "1M"]], - ["6,5", ["2L", "3M"]], + ["5.5", ["2L", "1M"]], + ["6.5", ["2L", "3M"]], ]); - g.move("BL@0,0"); + g.move("BL@0.0"); while (g.phase === "hand") { g.move("pass"); } @@ -413,28 +415,28 @@ describe("Ice Palace: board interaction", () => { it("maps a click on a drawn stack back to its cell", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2L"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); const [row, col] = drawnAt(g.render() as Rep, "1M"); // stacking-3D reports a click on a stacked pyramid with its stack index. const click = g.handleClick("2L", row, col, "0"); expect(click.valid, click.message).to.be.true; - expect(click.move).to.equal("2L@0,0"); + expect(click.move).to.equal("2L@0.0"); }); it("offers frontier space to found new stacks into", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); const [row, col] = drawnAt(g.render() as Rep, "1M"); // The cell to the right is empty padding, and an empty-cell click carries "". const click = g.handleClick("1S", row, col + 1, ""); expect(click.valid, click.message).to.be.true; - expect(click.move).to.equal("1S@1,0"); + expect(click.move).to.equal("1S@1.0"); }); it("never places on the Palace during a hand; a click there drops the pick", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); - g.palace = new Map([["0,0", ["2L"]]]); - g.move("1M@0,0"); + g.palace = new Map([["0.0", ["2L"]]]); + g.move("1M@0.0"); const [row, col] = drawnAt(g.render() as Rep, "2L"); const click = g.handleClick("1S", row, col, "0"); expect(click.move).to.equal(""); @@ -444,67 +446,67 @@ describe("Ice Palace: board interaction", () => { it("switches the pick when another pyramid is clicked", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S", "2L"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); const click = g.handleClick("1S", -1, -1, "p2L"); expect(click.valid).to.be.true; expect(click.move).to.equal("2L"); // During a build, only the unplaced pick is switched. const b = rig(new IcePalaceGame(3), [["1M", "1S"], ["2S"], ["3S"]], fatPool()); - b.move("1M@0,0"); + b.move("1M@0.0"); b.move("pass"); b.move("pass"); - b.move("1S@1,0"); + b.move("1S@1.0"); b.move("pass"); b.move("pass"); b.move("pass"); expect(b.phase).to.equal("build"); - expect(b.handleClick("1M@0,0;1M", -1, -1, "p1S").move).to.equal("1M@0,0;1S"); + expect(b.handleClick("1M@0.0;1M", -1, -1, "p1S").move).to.equal("1M@0.0;1S"); }); it("maps build clicks against the Palace as drawn, pending placements included", () => { const b = rig(new IcePalaceGame(3), [["1M", "1S"], ["2S"], ["3S"]], fatPool()); - b.palace = new Map([["0,0", ["1L"]], ["1,0", ["1L"]], ["2,0", ["1L"]]]); - b.move("1M@0,0"); + b.palace = new Map([["0.0", ["1L"]], ["1.0", ["1L"]], ["2.0", ["1L"]]]); + b.move("1M@0.0"); b.move("pass"); b.move("pass"); - b.move("1S@1,0"); + b.move("1S@1.0"); b.move("pass"); b.move("pass"); b.move("pass"); expect(b.phase).to.equal("build"); - // Founding at 3,0 widens the Palace by a column, shifting the board as drawn. + // Founding at 3.0 widens the Palace by a column, shifting the board as drawn. const partial = b.clone(); - partial.move("1M@3,0;1S", { partial: true }); + partial.move("1M@3.0;1S", { partial: true }); const rep = partial.render() as Rep; const [row, col] = drawnAt(rep, "1M"); - const click = b.handleClick("1M@3,0;1S", row, col, ""); - expect(click.move).to.equal("1M@3,0;1S@3,0"); + const click = b.handleClick("1M@3.0;1S", row, col, ""); + expect(click.move).to.equal("1M@3.0;1S@3.0"); }); it("drops the pick when clicking a cell it cannot go to, keeping earlier placements", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); // A player-2 small cannot found beside a player-1 medium; the Yard origin is row 3, col 3. const click = g.handleClick("2S", 3, 4, ""); expect(click.valid).to.be.true; expect(click.move).to.equal(""); const b = rig(new IcePalaceGame(3), [["1M", "1S"], ["2S"], ["3S"]], fatPool()); - b.move("1M@0,0"); + b.move("1M@0.0"); b.move("pass"); b.move("pass"); - b.move("1S@1,0"); + b.move("1S@1.0"); b.move("pass"); b.move("pass"); b.move("pass"); // A far corner of the Palace touches nothing the small could found beside. - const cancel = b.handleClick("1M@0,0;1S", 0, 0, ""); - expect(cancel.move).to.equal("1M@0,0"); + const cancel = b.handleClick("1M@0.0;1S", 0, 0, ""); + expect(cancel.move).to.equal("1M@0.0"); expect(cancel.valid).to.be.true; }); it("selects a pyramid when its entry in the pieces area is clicked", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2L", "2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); // The pieces area passes the legend key, which carries a letter prefix. const click = g.handleClick("", -1, -1, "p2L"); expect(click.valid, click.message).to.be.true; @@ -513,7 +515,7 @@ describe("Ice Palace: board interaction", () => { it("offers the current hand while a hand is played, and the stock while building", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2L", "2S"], ["3L"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); // The hand is a stash of one-pyramid stacks, labelled with the player's name. const hand = (persp(g) as Rep).areas![0].stash!; expect(hand.flat().filter(k => k !== "-")).to.deep.equal(["h2S", "h2L"]); @@ -521,7 +523,7 @@ describe("Ice Palace: board interaction", () => { expect(hand).to.deep.equal([["h2S"], ["h2L"]]); g.move("pass"); g.move("pass"); - g.move("1L@0,0"); + g.move("1L@0.0"); while (g.phase === "hand") { g.move("pass"); } @@ -535,8 +537,8 @@ describe("Ice Palace: board interaction", () => { // and spends "-" placeholders only to keep a base from sinking below the ground. const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); // The lone medium is a colour no other stack holds, so drawnAt finds only it. - g.palace = new Map([["0,0", ["1L", "2M", "3S"]], ["1,0", ["2L"]], ["2,0", ["3M"]]]); - g.yard = new Map([["0,0", ["1S", "2M", "3L"]]]); + g.palace = new Map([["0.0", ["1L", "2M", "3S"]], ["1.0", ["2L"]], ["2.0", ["3M"]]]); + g.yard = new Map([["0.0", ["1S", "2M", "3L"]]]); const rep = persp(g) as unknown as Rep & { board: { stackOffset?: number }; legend: Record }; expect(rep.board.stackOffset).to.equal(0.15); expect(rep.legend.p1L.nudge).to.be.undefined; @@ -561,10 +563,10 @@ describe("Ice Palace: board interaction", () => { expect(rep.pieces[0].length).to.equal(7); // The lead goes in the middle, wherever in the empty region it is clicked. const click = g.handleClick("1M", 0, 0, ""); - expect(click.move).to.equal("1M@0,0"); - g.move("1M@0,0"); + expect(click.move).to.equal("1M@0.0"); + g.move("1M@0.0"); // With a Palace as well, both regions show, one empty column apart. - g.palace = new Map([["0,0", ["2L"]]]); + g.palace = new Map([["0.0", ["2L"]]]); rep = g.render() as Rep; expect(rep.pieces.length).to.equal(7); expect(rep.pieces[0].length).to.equal(7 + 1 + 7); @@ -572,7 +574,7 @@ describe("Ice Palace: board interaction", () => { expect(drawnAt(rep, "1M")[1]).to.equal(3); expect(drawnAt(rep, "2L")[1]).to.equal(7 + 1 + 3); // Growing past the minimum extends the board only in that direction. - g.yard.set("4,0", ["1S"]); + g.yard.set("4.0", ["1S"]); rep = g.render() as Rep; expect(rep.pieces[0].length).to.equal(7 + 1 + 9); }); @@ -590,10 +592,11 @@ describe("Ice Palace: board interaction", () => { ["b2S"], ["bWM"], ]); - // A hand pyramid sits on an invisible square that widens its click target. + // A hand pyramid comes first, under an invisible square that widens its click target. const legend = (persp(g) as unknown as { legend: Record }).legend; expect(legend.h1M).to.be.an("array").with.length(2); - expect((legend.h1M as { opacity?: number }[])[0].opacity).to.equal(0); + expect((legend.h1M as { name?: string }[])[0].name).to.equal("pyramid-up-medium-3D"); + expect((legend.h1M as { paint?: unknown }[])[1].paint).to.deep.equal({ fill: { colour: "_context_background", opacity: 0 } }); // Clicking a spacer is like clicking the Pool. expect(g.handleClick("", -1, -1, "gap").valid).to.be.false; // A click on the Pool changes nothing, whether or not a pyramid is picked. @@ -610,10 +613,10 @@ describe("Ice Palace: board interaction", () => { it("counts the pyramids placed so far against the number to build", () => { const g = rig(new IcePalaceGame(3), [["1M", "1S"], ["2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); g.move("pass"); g.move("pass"); - g.move("1S@1,0"); + g.move("1S@1.0"); g.move("pass"); g.move("pass"); g.move("pass"); @@ -622,7 +625,7 @@ describe("Ice Palace: board interaction", () => { typeof st.key === "object" && st.key !== null && "textKey" in st.key && st.key.textKey === "apgames:status.icepalace.PLACED")!.value; expect(placed()).to.deep.equal(["0 / 2"]); const tmp = g.clone(); - tmp.move("1M@0,0", { partial: true }); + tmp.move("1M@0.0", { partial: true }); expect(tmp.sidebarStatuses().find(st => typeof st.key === "object" && st.key !== null && "textKey" in st.key && st.key.textKey === "apgames:status.icepalace.PLACED")!.value) .to.deep.equal(["1 / 2"]); @@ -637,11 +640,10 @@ describe("Ice Palace: board interaction", () => { // The button is a meeple in the leader's colour. expect(statuses[0].value[0]).to.deep.equal({ kind: "sheet", name: "meeple", colour: 1 }); expect(statuses[1].value.length).to.equal(1); - // Sheet glyphs use tagged `kind: "sheet"` (legacy `{ glyph, colour }` still works on the front). + // A pyramid is sent as its legend glyph, so the sidebar paints it as the board does. expect(statuses[1].value[0]).to.deep.equal({ - kind: "sheet", - name: "pyramid-flattened-small", - colour: 2, + kind: "legend", + entry: { name: "pyramid-flattened-small", paint: { fill: 2 } }, }); expect(statuses[2].value.length).to.equal(3); g.lead = 2; @@ -664,7 +666,7 @@ describe("Ice Palace: board interaction", () => { expect(dotted(persp(g) as Rep)).to.deep.equal([]); g.move("1M", { partial: true }); expect(dotted(persp(g) as Rep)).to.deep.equal(["3,3"]); - g.move("1M@0,0"); + g.move("1M@0.0"); // A large of the wrong colour can only go on top of the medium at the origin, so // the dot rides on that stack rather than replacing it. g.move("2L", { partial: true }); @@ -675,7 +677,7 @@ describe("Ice Palace: board interaction", () => { g.move("1S", { partial: true }); expect(dotted(persp(g) as Rep)).to.deep.equal(["2,3", "3,2", "3,4", "4,3"]); // Completing the placement clears the dots. - g.move("1S@1,0"); + g.move("1S@1.0"); expect(dotted(persp(g) as Rep)).to.deep.equal([]); }); }); @@ -703,9 +705,9 @@ describe("Ice Palace: top-down display", () => { it("looks straight down at the same footprint, with translucent stacks and no placeholders", () => { const g = rig(new IcePalaceGame(3), [["1M", "1L"], ["1S"], ["3S"]], fatPool()); - g.move("1M@0,0"); - g.move("1S@1,0"); - g.palace = new Map([["0,0", ["2L", "1M"]]]); + g.move("1M@0.0"); + g.move("1S@1.0"); + g.palace = new Map([["0.0", ["2L", "1M"]]]); const flat = expanding(g); const deep = g.render({ altDisplays: ["perspective"] }) as unknown as Rep; expect(flat.renderer).to.equal("stacking-expanding"); @@ -719,26 +721,29 @@ describe("Ice Palace: top-down display", () => { // The Palace stack is listed bottom first, drawn from above. // The Palace stack is opaque, with keys of its own; the Yard stays translucent. expect(flat.pieces![3][7 + 1 + 3]).to.deep.equal(["a2L", "a1M"]); - expect(flat.legend!.a2L).to.deep.equal({ name: "pyramid-up-large-upscaled", colour: 2 }); - expect(flat.legend!.a1M).to.deep.equal({ name: "pyramid-up-medium-upscaled", colour: 1 }); + expect(flat.legend!.a2L).to.deep.equal({ name: "pyramid-up-large-upscaled", paint: { fill: 2 } }); + expect(flat.legend!.a1M).to.deep.equal({ name: "pyramid-up-medium-upscaled", paint: { fill: 1 } }); expect(flat.pieces![3][3]).to.deep.equal(["p1M"]); - expect(flat.legend!.p1M).to.deep.equal({ name: "pyramid-up-medium-upscaled", colour: 1, opacity: 0.75 }); + // Only the fill is translucent, so the outline stays solid. + expect(flat.legend!.p1M).to.deep.equal({ name: "pyramid-up-medium-upscaled", paint: { fill: { colour: 1, opacity: 0.75 } } }); }); it("offers the hand, then the stock, as nests of one colour each below the board", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2S", "1L", "2L", "WS", "2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); let rep = expanding(g); // The hand, then the Pool. expect(rep.areas).to.have.length(2); expect(rep.areas![0].type).to.equal("localStash"); // Player 2's hand, one pyramid per stack, left to right. expect(rep.areas![0].stash).to.deep.equal([["s1L"], ["s2S"], ["s2S"], ["s2L"], ["sWS"]]); - expect(rep.legend!.s2L).to.deep.equal({ name: "pyramid-flattened-large", colour: 2 }); - // White is white by default, or palette slot 8 for a viewer with a saved palette. + expect(rep.legend!.s2L).to.deep.equal({ name: "pyramid-flattened-large", paint: { fill: 2 } }); + // White is white by default, or palette slot 8 for a viewer with a saved palette, + // and its outline and pips contrast with whichever it is. + const white = { func: "custom", default: "#ffffff", palette: 8 }; expect(rep.legend!.sWS).to.deep.equal({ name: "pyramid-flattened-small", - colour: { func: "custom", default: "#ffffff", palette: 8 }, + paint: { fill: white, border: { func: "bestContrast", bg: white, fg: ["#000000", "#ffffff"] } }, }); // A click on a nested pyramid picks it, like a click in the pieces area. expect(g.handleClick("", -1, -1, "s2S").move).to.equal("2S"); @@ -758,7 +763,7 @@ describe("Ice Palace: top-down display", () => { let board = expanding(g).board as unknown as { width: number; height: number; blocked: { row: number; col: number }[] }; expect(board.blocked).to.have.length(board.width * board.height - 1); expect(board.blocked).to.not.deep.include({ row: 3, col: 3 }); - g.move("1M@0,0"); + g.move("1M@0.0"); board = expanding(g).board as unknown as typeof board; const open = []; for (let row = 0; row < board.height; row++) { @@ -796,12 +801,12 @@ describe("Ice Palace: top-down display", () => { // A spacer column sits between each pair of colours. expect(pool.stash).to.deep.equal([["b1L", "b1L", "gap", "b1S"], ["gap"], ["b2S"], ["gap"], ["bWM"]]); // Pool pyramids are seen from above, as opaque diamonds. - expect(rep.legend!.b1L).to.deep.equal({ name: "pyramid-up-large-upscaled", colour: 1, rotate: 45 }); + expect(rep.legend!.b1L).to.deep.equal({ name: "pyramid-up-large-upscaled", paint: { fill: 1 }, rotate: 45 }); }); it("still dots the legal cells once a pyramid is picked", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); g.move("1S", { partial: true }); const rep = expanding(g); const dotted: string[] = []; @@ -815,15 +820,15 @@ describe("Ice Palace: top-down display", () => { it("lays out the hovered stack beside the board, bottom first, from the side", () => { const g = rig(new IcePalaceGame(3), [["1M", "1L"], ["1S"], ["3S"]], fatPool()); - g.move("1M@0,0"); - g.move("1S@1,0"); - g.palace = new Map([["0,0", ["2L", "1M"]]]); + g.move("1M@0.0"); + g.move("1S@1.0"); + g.palace = new Map([["0.0", ["2L", "1M"]]]); // The Yard's origin sits at the centre of the left region. let rep = g.renderColumn(3, 3) as unknown as Rep; expect(rep.renderer).to.equal("stacking-expanding"); expect(rep.board).to.be.null; expect(rep.areas).to.deep.equal([{ type: "expandedColumn", stack: ["c1M"] }]); - expect(rep.legend!.c1M).to.deep.equal({ name: "pyramid-flat-medium", colour: 1 }); + expect(rep.legend!.c1M).to.deep.equal({ name: "pyramid-flat-medium", paint: { fill: 1 } }); // The Palace can be looked into during a hand too. rep = g.renderColumn(7 + 1 + 3, 3) as unknown as Rep; expect(rep.areas).to.deep.equal([{ type: "expandedColumn", stack: ["c2L", "c1M"] }]); @@ -846,20 +851,14 @@ describe("Ice Palace: mixing the board and stash displays", () => { it("draws the hand and Pool from above under the 3D board unless asked otherwise", () => { const rep = rigged().render({ altDisplays: ["perspective"] }) as unknown as Rep; expect(rep.renderer).to.equal("stacking-3D"); - // The hand: nests, one per column, lifted two steps clear of the Pool's label below - // and with two steps of headroom under the hand's own label. - expect(rep.areas![0].stash).to.deep.equal([["-", "-", "s1M", "-", "-"]]); + // Both renderers lay stashes out alike, so the hand and Pool are as under the + // top-down board: nests one per column, and opaque diamonds with a spacer between + // sizes and a spacer column between colours. + expect(rep.areas).to.deep.equal((rigged().render({ altDisplays: [] }) as unknown as Rep).areas); + expect(rep.areas![0].stash).to.deep.equal([["s1M"]]); expect(rep.legend.s1M).to.include({ name: "pyramid-flattened-medium" }); - // The Pool: opaque diamonds two steps apart and four between sizes, with a spacer - // column between colours, as the top-down renderer draws them. - expect(rep.areas![1].stash).to.deep.equal([ - ["b1L", "-", "-", "-", "b1S", "-", "-"], - ["gap"], - ["b2S", "-", "-"], - ["gap"], - ["bWM", "-", "-"], - ]); - expect(rep.legend.b1L).to.include({ name: "pyramid-up-large-upscaled", rotate: 45 }); + expect(rep.areas![1].stash).to.deep.equal([["b1L", "gap", "b1S"], ["gap"], ["b2S"], ["gap"], ["bWM"]]); + expect(rep.legend.b1L).to.deep.include({ name: "pyramid-up-large-upscaled", paint: { fill: 1 }, rotate: 45 }); expect(rep.legend.b1L).to.not.have.any.keys("opacity", "nudge"); }); @@ -867,18 +866,105 @@ describe("Ice Palace: mixing the board and stash displays", () => { const rep = rigged().render({ altDisplays: ["perspective-areas"] }) as unknown as Rep; expect(rep.renderer).to.equal("stacking-expanding"); // Dense stash columns; 3D pyramid silhouettes carry vertical spacing, not nudges or "-". + // The pyramid is the hand glyph's first layer, which the renderer reads for its size. expect(rep.areas![0].stash).to.deep.equal([["h1M"]]); const hand = rep.legend.h1M as Part[]; expect(hand).to.have.length(2); - expect(hand[1]).to.deep.include({ name: "pyramid-up-medium-3D" }); - expect(hand[1]).to.not.have.any.keys("nudge"); - // The Pool on the renderer's fixed steps: a spacer between sizes, no spacer columns. - expect(rep.areas![1].stash).to.deep.equal([["b1L", "gap", "b1S"], ["b2S"], ["bWM"]]); + expect(hand[0]).to.deep.include({ name: "pyramid-up-medium-3D" }); + expect(hand[0]).to.not.have.any.keys("nudge"); + // The Pool as in the 3D board: one dense column per colour, which the renderer nests + // on a shared base line. A spacer among 3D pyramids is an error to the renderer. + expect(rep.areas![1].stash).to.deep.equal([["b1L", "b1S"], ["b2S"], ["bWM"]]); expect(rep.legend.b1L).to.not.have.any.keys("nudge"); expect(rep.legend.b1S).to.deep.include({ name: "pyramid-up-small-3D" }); expect(rep.legend.b1S).to.not.have.any.keys("nudge"); expect(rep.areas!.flatMap(a => a.stash!.flat())).to.not.include("-"); }); + + it("shows the 3D Pool as nests, so no two pyramids are drawn as one", () => { + // The renderer rests a 3D column's pyramids on one base line by size, so a colour + // with several of a size is shown as several nests, each holding one of each size + // that is left: three larges, a medium and two smalls make three nests. + const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], ["1S", "1L", "WM", "1L", "1M", "WM", "1S", "1L"]); + for (const altDisplays of [["perspective-areas"], ["perspective", "perspective-areas"]]) { + const rep = g.render({ altDisplays }) as unknown as Rep; + expect(rep.areas![1].stash).to.deep.equal([ + ["b1L", "b1M", "b1S"], + ["b1L", "b1S"], + ["b1L"], + ["bWM"], + ["bWM"], + ]); + } + // Seen from above, a colour's pyramids stack in one column with a spacer between sizes. + const flat = g.render({ altDisplays: [] }) as unknown as Rep; + expect(flat.areas![1].stash).to.deep.equal([ + ["b1L", "b1L", "b1L", "gap", "b1M", "gap", "b1S", "b1S"], + ["gap"], + ["bWM", "bWM"], + ]); + }); + + it("outlines Black and White pyramids in whichever of black and white contrasts", () => { + // The sheet's black outline and pips would vanish on a Black pyramid, in every view + // and in the sidebar; the players' pyramids keep the sheet's outline. + const g = rig(new IcePalaceGame(3), [["1S"], ["BM", "BL", "2S"], ["3S"]], ["WM", "1L"]); + g.palace = new Map([["0.0", ["2L", "BM"]]]); + g.move("1S@0.0"); + // Seat 2's Black medium covers the small; its large stays in hand for the sidebar. + g.move("BM@0.0", { partial: true }); + const reps = [[], ["perspective"], ["perspective-areas"], ["perspective", "perspective-areas"]] + .map(altDisplays => g.render({ altDisplays }) as unknown as Rep); + reps.push(g.renderColumn(7 + 1 + 3, 3) as unknown as Rep); + type Painted = { paint?: { fill?: unknown; border?: unknown } }; + const contrast = (body: unknown) => ({ func: "bestContrast", bg: body, fg: ["#000000", "#ffffff"] }); + const black = { func: "custom", default: "#000000", palette: 7 }; + const white = { func: "custom", default: "#ffffff", palette: 8 }; + let seen = 0; + for (const rep of reps) { + for (const [key, entry] of Object.entries(rep.legend)) { + if (!/^[a-z][BW][SML]$/.test(key)) { + continue; + } + const glyph = ([] as Painted[]).concat(entry as Painted | Painted[]).find(part => part.paint?.border !== undefined); + expect(glyph, key).to.not.be.undefined; + expect(glyph!.paint!.border).to.deep.equal(contrast(key[1] === "B" ? black : white)); + seen++; + } + for (const [key, entry] of Object.entries(rep.legend)) { + if (/^[a-z][1-6][SML]$/.test(key)) { + for (const part of ([] as Painted[]).concat(entry as Painted | Painted[])) { + expect(part.paint, key).to.not.have.property("border"); + } + } + } + } + expect(seen).to.be.greaterThan(8); + // The sidebar carries the same glyph. + const hand = g.sidebarStatuses()[1].value as { kind: string; entry?: Painted & { name?: string } }[]; + const bl = hand.find(v => v.kind === "legend" && v.entry?.name === "pyramid-flattened-large")!; + expect(bl).to.not.be.undefined; + expect(bl.entry!.paint).to.deep.equal({ fill: black, border: contrast(black) }); + }); + + it("colours every glyph through paint, not the deprecated colour", () => { + const g = rigged(); + g.palace = new Map([["0.0", ["2L"]]]); + g.move("1M@0.0"); + // A picked pyramid adds the dots for its legal cells. + g.move("2S", { partial: true }); + const reps = [[], ["perspective"], ["perspective-areas"], ["perspective", "perspective-areas"]] + .map(altDisplays => g.render({ altDisplays }) as unknown as Rep); + reps.push(g.renderColumn(3, 3) as unknown as Rep); + expect(reps[0].legend).to.have.property("dot"); + const glyphs = reps.flatMap(rep => Object.values(rep.legend).flat()); + expect(glyphs.length).to.be.greaterThan(10); + for (const glyph of glyphs) { + expect(glyph).to.have.property("paint"); + // A layer opacity beside paint would fade the outline too; only fills are translucent. + expect(glyph).to.not.have.any.keys("colour", "colour2", "opacity"); + } + }); }); describe("Ice Palace: event log", () => { @@ -893,7 +979,7 @@ describe("Ice Palace: event log", () => { it("announces the build with the count and one legal way to do it", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); g.move("pass"); g.move("pass"); g.move("pass"); @@ -901,7 +987,7 @@ describe("Ice Palace: event log", () => { const lines = lastLines(g); const announce = lines.find(l => l.textKey === "apresults:ANNOUNCE.icepalace_build")!; expect(announce).to.not.be.undefined; - expect(announce.textParams).to.include({ count: 1, move: "1M@0,0" }); + expect(announce.textParams).to.include({ count: 1, move: "1M@0.0" }); // The suggested move is a complete, legal build as it stands. const check = g.validateMove(announce.textParams!.move as string); expect(check.valid).to.be.true; @@ -909,14 +995,14 @@ describe("Ice Palace: event log", () => { // It is quoted for copying, and the line is attributed to the builder. const text = g.chatLog(names).flat().join("\n"); expect(text).to.include("Alice won the hand"); - expect(text).to.include("`1M@0,0`"); + expect(text).to.include("`1M@0.0`"); assertChatLogParity(g, names); }); it("says so when nothing from the Yard can be built", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); - g.palace = new Map([["0,0", ["2S"]]]); - g.move("1M@0,0"); + g.palace = new Map([["0.0", ["2S"]]]); + g.move("1M@0.0"); g.move("pass"); g.move("pass"); g.move("pass"); @@ -931,9 +1017,9 @@ describe("Ice Palace: event log", () => { describe("Ice Palace: serialization", () => { it("survives a round trip through its own state", () => { const g = rig(new IcePalaceGame(3), [["1M", "1L"], ["WS"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); // White is wild, so it may found beside the player-1 medium. - g.move("WS@0,1"); + g.move("WS@0.1"); const clone = g.clone(); expect(clone.phase).to.equal(g.phase); expect(clone.currplayer).to.equal(g.currplayer); @@ -944,17 +1030,17 @@ describe("Ice Palace: serialization", () => { it("revives the whole stack, Maps included, from serialized JSON mid-build", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); - g.palace = new Map([["0,0", ["2L"]]]); + g.palace = new Map([["0.0", ["2L"]]]); g.lead = 2; g.currplayer = 2; - g.move("1S@0,0"); + g.move("1S@0.0"); g.move("pass"); g.move("pass"); g.move("pass"); expect(g.phase).to.equal("build"); const stockBefore = [...g.stock]; const palaceBefore = [...g.palace.entries()].map(([cell, stack]) => [cell, [...stack]]); - g.move("1S@0,0", { partial: true }); + g.move("1S@0.0", { partial: true }); const json = g.serialize(); expect(json).to.be.a("string"); const revived = new IcePalaceGame(json); @@ -969,7 +1055,71 @@ describe("Ice Palace: serialization", () => { expect(revived.stack.length).to.equal(g.stack.length); expect(revived.stack[revived.stack.length - 1]._results).to.deep.equal(g.stack[g.stack.length - 1]._results); // The partial placement was never committed, so it is not in the saved state. - expect(revived.palace.get("0,0")).to.deep.equal(["2L"]); + expect(revived.palace.get("0.0")).to.deep.equal(["2L"]); + }); + + /** A three-player hand won by player 1, who now builds two pyramids. */ + const readyToBuild = (): IcePalaceGame => { + const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2L"], ["3L"]], fatPool()); + for (const m of ["1M@0.0", "pass", "pass", "1L@0.0", "pass", "pass", "pass"]) { + g.move(m); + } + return g; + }; + + it("writes cells with a dot, so no move carries a comma", () => { + // The front end reads commas in a move as separating simultaneous moves, which + // made a two-pyramid build in a three-player game look like one move per seat. + const g = readyToBuild(); + expect(g.phase).to.equal("build"); + const announce = g.results.find(r => r.type === "announce") as { payload: unknown[] }; + expect(announce.payload[2]).to.be.a("string").and.not.include(","); + g.move("1L@0.0;1M@0.0"); + expect(g.moves().filter(m => m.includes(","))).to.be.empty; + g.move(g.moves()[0]); + expect(g.getPlies().filter(ply => ply.move.includes(","))).to.be.empty; + }); + + it("reads moves typed in the old comma notation", () => { + const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); + expect(g.validateMove("1M@0,0").valid).to.be.true; + g.move("1M@0,0"); + expect(g.lastmove).to.equal("1M@0.0"); + expect(g.topOfCell("yard", "0.0")).to.equal("1M"); + }); + + it("loads a game saved with comma cells as if it had been played with dots", () => { + const g = readyToBuild(); + g.move("1L@0.0;1M@0.0"); + // The same game as the engine saved it before cells used a dot. + const toComma = (text: string): string => text.replace(/(-?\d+)\.(-?\d+)/g, "$1,$2"); + const commaKeys = (struct: Structure): Structure => + new Map([...struct.entries()].map(([cell, stack]) => [toComma(cell), stack])); + const saved = JSON.parse(g.serialize(), reviver) as IIcePalaceState; + for (const entry of saved.stack) { + entry.yard = commaKeys(entry.yard); + entry.palace = commaKeys(entry.palace); + if (entry.frames !== undefined) { + entry.frames = entry.frames.map(frame => ({ ...frame, palace: commaKeys(frame.palace) })); + } + if (entry.lastmove !== undefined) { + entry.lastmove = toComma(entry.lastmove); + } + for (const result of entry._results as unknown as Record[]) { + if (typeof result.where === "string") { + result.where = toComma(result.where); + } + if (Array.isArray(result.payload)) { + result.payload = result.payload.map(p => (typeof p === "string" ? toComma(p) : p)); + } + } + } + const legacy = JSON.stringify(saved, replacer); + expect(legacy).to.include("\"1L@0,0;1M@0,0\""); + + const loaded = new IcePalaceGame(legacy); + expect(loaded.serialize()).to.equal(g.serialize()); + expect(loaded.validateMove(g.moves()[0]).valid).to.be.true; }); }); @@ -978,13 +1128,13 @@ describe("Ice Palace: sequenced turn model and record export", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2L"], ["3L"]], fatPool()); // Alice opens and Charlie is the last to place; then everyone passes, and Charlie // builds; then Bob leads the next hand. - for (const m of ["1M@0,0", "pass", "3L@0,0", "pass", "pass", "pass"]) { + for (const m of ["1M@0.0", "pass", "3L@0.0", "pass", "pass", "pass"]) { g.move(m); } expect(g.phase).to.equal("build"); expect(g.currplayer).to.equal(3); expect(g.stock).to.deep.equal(["1M", "3L"]); - g.move("3L@0,0;1M@0,0"); + g.move("3L@0.0;1M@0.0"); expect(g.phase).to.equal("hand"); g.move(g.moves()[0]); const plies = g.getPlies().map(p => [p.actor, p.round]); @@ -1001,29 +1151,29 @@ describe("Ice Palace: sequenced turn model and record export", () => { const played = (): IcePalaceGame => { const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); + g.move("1M@0.0"); g.move("pass"); g.move("pass"); g.move("pass"); - g.move("1M@0,0"); + g.move("1M@0.0"); return g; }; it("refuses to commit an incomplete move", () => { const g = rig(new IcePalaceGame(3), [["1M", "1S"], ["2S"], ["3S"]], fatPool()); expect(() => g.move("1M")).to.throw("FAILSAFE"); - g.move("1M@0,0"); + g.move("1M@0.0"); g.move("pass"); g.move("pass"); - g.move("1S@1,0"); + g.move("1S@1.0"); g.move("pass"); g.move("pass"); g.move("pass"); expect(g.phase).to.equal("build"); expect(g.buildMin).to.equal(2); - expect(() => g.move("1M@0,0;1S")).to.throw("FAILSAFE"); - expect(() => g.move("1M@0,0")).to.throw("FAILSAFE"); - g.move("1M@0,0;1S@0,0"); + expect(() => g.move("1M@0.0;1S")).to.throw("FAILSAFE"); + expect(() => g.move("1M@0.0")).to.throw("FAILSAFE"); + g.move("1M@0.0;1S@0.0"); expect(g.phase).to.equal("hand"); }); @@ -1091,7 +1241,7 @@ const check = (palace: Structure, pieces: PieceId[], expected: number): void => describe("Ice Palace: maximum build", () => { it("places nothing when the Yard held only Black and White", () => { - check(palaceOf({ "0,0": ["1L"] }), [], 0); + check(palaceOf({ "0.0": ["1L"] }), [], 0); }); it("stacks a whole large-medium-small tower into an empty Palace", () => { @@ -1107,17 +1257,17 @@ describe("Ice Palace: maximum build", () => { it("finds the ordering that beats a greedy build", () => { // Covering the large with the small first strands the medium. The medium has to // go down first so the small has a medium to sit on. - check(palaceOf({ "0,0": ["1L"] }), ["2S", "3M"], 2); + check(palaceOf({ "0.0": ["1L"] }), ["2S", "3M"], 2); }); it("spends the only large top on one colour and strands the other", () => { - check(palaceOf({ "0,0": ["1L"] }), ["2M", "3M"], 1); + check(palaceOf({ "0.0": ["1L"] }), ["2M", "3M"], 1); }); it("regrows a large top by founding, enabling a second colour", () => { // Colour 2 is enabled off the existing large, then its own large is founded as a // fresh large top, which colour 3's medium can then use. - check(palaceOf({ "0,0": ["1L"] }), ["2M", "2L", "3M"], 3); + check(palaceOf({ "0.0": ["1L"] }), ["2M", "2L", "3M"], 3); }); it("founds without limit once a colour is enabled", () => { @@ -1125,19 +1275,19 @@ describe("Ice Palace: maximum build", () => { for (let i = 0; i < 12; i++) { pieces.push("1S"); } - check(palaceOf({ "0,0": ["1L"] }), pieces, 12); + check(palaceOf({ "0.0": ["1L"] }), pieces, 12); }); it("strands colours that are absent when every open top is small", () => { - check(palaceOf({ "0,0": ["1L", "1M", "1S"] }), ["2S", "2M", "3L"], 0); + check(palaceOf({ "0.0": ["1L", "1M", "1S"] }), ["2S", "2M", "3L"], 0); }); it("places a large only when its own colour is already on an open top", () => { - check(palaceOf({ "0,0": ["1L", "1M", "1S"] }), ["1L", "1L"], 2); + check(palaceOf({ "0.0": ["1L", "1M", "1S"] }), ["1L", "1L"], 2); }); it("chains large to medium to small across three new colours", () => { - check(palaceOf({ "0,0": ["1L"] }), ["2M", "3S"], 2); + check(palaceOf({ "0.0": ["1L"] }), ["2M", "3S"], 2); }); it("opens an empty Palace with a medium when that beats leading with the large", () => { @@ -1147,11 +1297,11 @@ describe("Ice Palace: maximum build", () => { }); it("uses every pyramid when each colour has something small enough", () => { - check(palaceOf({ "0,0": ["1L"], "1,0": ["1L"] }), ["2M", "2S", "3M", "3S"], 4); + check(palaceOf({ "0.0": ["1L"], "1.0": ["1L"] }), ["2M", "2S", "3M", "3S"], 4); }); it("keeps the Palace connected and never buries a small", () => { - const palace = palaceOf({ "0,0": ["1L"] }); + const palace = palaceOf({ "0.0": ["1L"] }); const pieces: PieceId[] = ["1M", "1S", "1L", "2M"]; const plan = maximumBuild(palace, pieces); replay(palace, pieces, plan); @@ -1159,7 +1309,7 @@ describe("Ice Palace: maximum build", () => { }); it("does not mutate the Palace it was handed", () => { - const palace = palaceOf({ "0,0": ["1L"] }); + const palace = palaceOf({ "0.0": ["1L"] }); maximumBuild(palace, ["2M", "2S"]); expect(palace.size).to.equal(1); expect(palace.get(cellOf(0, 0))).to.deep.equal(["1L"]); @@ -1222,11 +1372,11 @@ const bruteForce = (palace: Structure, pieces: PieceId[]): number => { describe("Ice Palace: maximum build matches exhaustive search", () => { const palaces: Record = { - "a lone large": palaceOf({ "0,0": ["1L"] }), - "a lone small": palaceOf({ "0,0": ["1S"] }), - "a finished tower": palaceOf({ "0,0": ["1L", "2M", "3S"] }), - "two adjacent larges": palaceOf({ "0,0": ["1L"], "1,0": ["2L"] }), - "a large beside a covered medium": palaceOf({ "0,0": ["1L"], "0,1": ["2L", "3M"] }), + "a lone large": palaceOf({ "0.0": ["1L"] }), + "a lone small": palaceOf({ "0.0": ["1S"] }), + "a finished tower": palaceOf({ "0.0": ["1L", "2M", "3S"] }), + "two adjacent larges": palaceOf({ "0.0": ["1L"], "1.0": ["2L"] }), + "a large beside a covered medium": palaceOf({ "0.0": ["1L"], "0.1": ["2L", "3M"] }), }; const yards: PieceId[][] = [ @@ -1257,8 +1407,8 @@ describe("Ice Palace: maximum build matches exhaustive search", () => { describe("Ice Palace: move table presentation", () => { it("compact rows merge a full seat cycle; export stays sparse", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S", "2S"], ["3S"]], fatPool()); - g.move("1M@0,0"); - g.move("1S@1,0"); + g.move("1M@0.0"); + g.move("1S@1.0"); g.move("pass"); const pathLength = g.stack.length - 1; expect(pathLength).to.equal(3);