From 1c4a0aac5ef2ecb7d0651062e0a2327e84496e86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 23:20:10 +0000 Subject: [PATCH 01/22] Add Ice Palace rules core and maximum-build solver The rules require the builder to use the maximum possible number of Yard pyramids, a number the tabletop rules leave to player agreement because searching the possibilities by hand is impractical. Computing it exactly is cheap, because founding is unbounded: a stack founded next to a stack of colour c is itself topped by c, so it chains outwards forever. The problem reduces to which colours can be got onto an outward-facing top, which is a small search over (enabled colours, open large tops, open medium tops). The plan is replayed against the real building code and the achieved length is what gets reported, so the number is always one the builder can reach. Over-estimating would wedge the build phase; under-estimating only relaxes it. Cross-checked against exhaustive search over every legal build order for 45 combinations of Palace shape and Yard contents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace/rules.ts | 202 +++++++++++++++++ src/games/icepalace/solver.ts | 329 ++++++++++++++++++++++++++++ test/games/icepalace-solver.test.ts | 211 ++++++++++++++++++ 3 files changed, 742 insertions(+) create mode 100644 src/games/icepalace/rules.ts create mode 100644 src/games/icepalace/solver.ts create mode 100644 test/games/icepalace-solver.test.ts diff --git a/src/games/icepalace/rules.ts b/src/games/icepalace/rules.ts new file mode 100644 index 00000000..c110bb44 --- /dev/null +++ b/src/games/icepalace/rules.ts @@ -0,0 +1,202 @@ +/** + * 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`. + */ + +/** 1 = small, 2 = medium, 3 = large. */ +export type Size = 1 | 2 | 3; + +/** Black is Null: it matches no colour, not even itself. */ +export const NULL_COLOUR = "B"; +/** White is Wild: it matches every colour except Black. */ +export const WILD_COLOUR = "W"; + +/** A colour char followed by a size char, e.g. `"3L"`, `"BS"`, `"WM"`. */ +export type PieceId = string; + +export const SIZE_CHARS = ["S", "M", "L"] as const; +export type SizeChar = (typeof SIZE_CHARS)[number]; + +export type Cell = string; +/** Cell key to the pyramids sitting there, ordered bottom to top. */ +export type Structure = Map; + +export const colourOf = (piece: PieceId): string => piece.substring(0, piece.length - 1); + +export const sizeOf = (piece: PieceId): Size => { + const idx = SIZE_CHARS.indexOf(piece[piece.length - 1] as SizeChar); + if (idx < 0) { + throw new Error(`Could not read a pyramid size from "${piece}".`); + } + return (idx + 1) as 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 coordsOf = (cell: Cell): [number, number] => { + const parts = cell.split(","); + return [Number(parts[0]), Number(parts[1])]; +}; + +/** Adjacency is side-by-side only; the four diagonals are not adjacent. */ +export const neighbours = (cell: Cell): Cell[] => { + const [x, y] = coordsOf(cell); + return [cellOf(x + 1, y), cellOf(x - 1, y), cellOf(x, y + 1), cellOf(x, y - 1)]; +}; + +export const topOf = (struct: Structure, cell: Cell): PieceId | undefined => { + const stack = struct.get(cell); + if (stack === undefined || stack.length === 0) { + return undefined; + } + return stack[stack.length - 1]; +}; + +/** Yard matching, where Black is Null and White is Wild. */ +export const coloursMatch = (a: string, b: string): boolean => { + if (a === NULL_COLOUR || b === NULL_COLOUR) { + return false; + } + if (a === WILD_COLOUR || b === WILD_COLOUR) { + return true; + } + return a === b; +}; + +/** Every empty cell touching the structure. */ +export const frontier = (struct: Structure): Cell[] => { + const cells = new Set(); + for (const cell of struct.keys()) { + for (const n of neighbours(cell)) { + if (!struct.has(n)) { + cells.add(n); + } + } + } + return [...cells]; +}; + +/** + * The empty cells connected to the infinite outside, computed over the bounding box + * grown by one ring. Enclosed pockets are excluded: a stack that touches only a pocket + * cannot be grown away from indefinitely, which the build solver relies on. + */ +export const exteriorCells = (struct: Structure): Set => { + const exterior = new Set(); + if (struct.size === 0) { + return exterior; + } + const coords = [...struct.keys()].map(coordsOf); + const minX = Math.min(...coords.map(c => c[0])) - 1; + const maxX = Math.max(...coords.map(c => c[0])) + 1; + const minY = Math.min(...coords.map(c => c[1])) - 1; + const maxY = Math.max(...coords.map(c => c[1])) + 1; + + const queue: Cell[] = [cellOf(minX, minY)]; + while (queue.length > 0) { + const cell = queue.pop()!; + if (exterior.has(cell) || struct.has(cell)) { + continue; + } + const [x, y] = coordsOf(cell); + if (x < minX || x > maxX || y < minY || y > maxY) { + continue; + } + exterior.add(cell); + queue.push(...neighbours(cell)); + } + return exterior; +}; + +const canFound = ( + struct: Structure, + piece: PieceId, + cell: Cell, + match: (a: string, b: string) => boolean, +): boolean => { + for (const n of neighbours(cell)) { + const top = topOf(struct, n); + if (top !== undefined && match(colourOf(piece), colourOf(top))) { + return true; + } + } + return false; +}; + +/** + * Yard building code: any colour may be added to a stack if it is bigger than the + * current top pyramid, and a new stack may be started next to any existing stack whose + * top pyramid matches its colour. The lead may go anywhere. + */ +export const legalYardPlacement = (struct: Structure, piece: PieceId, cell: Cell): boolean => { + if (struct.size === 0) { + return true; + } + const top = topOf(struct, cell); + if (top !== undefined) { + return sizeOf(piece) > sizeOf(top); + } + return canFound(struct, piece, cell, coloursMatch); +}; + +/** + * Ice Palace building code: the size rule is reversed, and a new stack must exactly match + * the colour of an adjacent top pyramid. Black and White never reach the Palace, so Wild + * and Null have no role here. The rules never say how the first pyramid is placed into an + * empty Palace, so it goes anywhere. + */ +export const legalPalacePlacement = (struct: Structure, piece: PieceId, cell: Cell): boolean => { + if (struct.size === 0) { + return true; + } + const top = topOf(struct, cell); + if (top !== undefined) { + return sizeOf(piece) < sizeOf(top); + } + return canFound(struct, piece, cell, (a, b) => a === b); +}; + +export const placeInto = (struct: Structure, piece: PieceId, cell: Cell): void => { + const stack = struct.get(cell); + if (stack === undefined) { + struct.set(cell, [piece]); + } else { + stack.push(piece); + } +}; + +export const cloneStructure = (struct: Structure): Structure => { + const copy: Structure = new Map(); + for (const [cell, stack] of struct.entries()) { + copy.set(cell, [...stack]); + } + return copy; +}; + +/** Every cell a piece could legally go, for either building code. */ +export const legalCellsFor = ( + struct: Structure, + piece: PieceId, + legal: (struct: Structure, piece: PieceId, cell: Cell) => boolean, +): Cell[] => { + if (struct.size === 0) { + return [cellOf(0, 0)]; + } + const cells: Cell[] = []; + for (const cell of struct.keys()) { + if (legal(struct, piece, cell)) { + cells.push(cell); + } + } + for (const cell of frontier(struct)) { + if (legal(struct, piece, cell)) { + cells.push(cell); + } + } + return cells; +}; diff --git a/src/games/icepalace/solver.ts b/src/games/icepalace/solver.ts new file mode 100644 index 00000000..d635037d --- /dev/null +++ b/src/games/icepalace/solver.ts @@ -0,0 +1,329 @@ +/** + * Works out the maximum number of Yard pyramids that can be built into the Ice Palace. + * + * Over the board this number is agreed by the players, because nobody wants to search the + * possibilities by hand. It is cheap to compute exactly, because of one observation: + * founding is unbounded. A new stack founded next to a stack of colour `c` is itself + * topped by `c` and sits on the frontier, so it can be chained outwards forever. Once a + * colour tops any outward-facing stack, every pyramid of that colour can be placed. + * + * So the only question is which colours can be got onto an outward-facing top, and that + * reduces to a small resource count. A colour is enabled if it already tops such a stack, + * or if one of its pyramids can be stacked onto an open top strictly larger than it, which + * spends that top. Every pyramid of an enabled colour, once founded, yields a fresh open + * top of its own size, so enabled colours holding larges regenerate the scarce resource. + * Larges can never be stacked onto anything, so a colour whose only Yard pyramids are + * large is placeable only if it already tops an open stack. + * + * The search over (enabled colours, open large tops, open medium tops) is tiny. The plan + * it produces is then played out against the real building code, and the length of the + * sequence actually achieved is what gets reported. That direction matters: the number is + * always one the builder can reach, never an over-estimate that would wedge the build. + */ + +import { + Cell, + PieceId, + Size, + Structure, + cellOf, + cloneStructure, + colourOf, + exteriorCells, + legalCellsFor, + legalPalacePlacement, + neighbours, + placeInto, + sizeOf, + topOf, +} from "./rules.js"; + +export interface Placement { + piece: PieceId; + cell: Cell; +} + +export interface BuildPlan { + /** How many Yard pyramids the builder must use. */ + max: number; + /** One legal way to reach that number, in order. */ + sequence: Placement[]; +} + +interface ColourCounts { + S: number; + M: number; + L: number; + total: number; +} + +/** Stack a `size` pyramid of `colour` onto an open top of size `consume`, enabling it. */ +interface EnableStep { + colour: string; + size: Size; + consume: Size; +} + +const tally = (pieces: PieceId[]): Map => { + const counts = new Map(); + for (const piece of pieces) { + const colour = colourOf(piece); + let entry = counts.get(colour); + if (entry === undefined) { + entry = { S: 0, M: 0, L: 0, total: 0 }; + counts.set(colour, entry); + } + const size = sizeOf(piece); + if (size === 1) { + entry.S++; + } else if (size === 2) { + entry.M++; + } else { + entry.L++; + } + entry.total++; + } + return counts; +}; + +/** Occupied cells that touch the infinite outside, with the colour and size on top. */ +const openTops = (struct: Structure): { cell: Cell; colour: string; size: Size }[] => { + const exterior = exteriorCells(struct); + const tops: { cell: Cell; colour: string; size: Size }[] = []; + for (const cell of struct.keys()) { + const top = topOf(struct, cell); + if (top === undefined) { + continue; + } + if (neighbours(cell).some(n => exterior.has(n))) { + tops.push({ cell, colour: colourOf(top), size: sizeOf(top) }); + } + } + return tops; +}; + +const planEnablements = ( + pending: string[], + counts: Map, + openL: number, + openM: number, +): { gain: number; steps: EnableStep[] } => { + const memo = new Map(); + + const search = (mask: number, nL: number, nM: number): { gain: number; steps: EnableStep[] } => { + const key = `${mask},${nL},${nM}`; + const cached = memo.get(key); + if (cached !== undefined) { + return cached; + } + let best: { gain: number; steps: EnableStep[] } = { gain: 0, steps: [] }; + for (let i = 0; i < pending.length; i++) { + if ((mask & (1 << i)) !== 0) { + continue; + } + const colour = pending[i]; + const cc = counts.get(colour)!; + const options: { size: Size; consume: Size; nL: number; nM: number }[] = []; + // A medium can only go under a large. The covered cell keeps its outward face, + // so it becomes an open medium top. + if (cc.M > 0 && nL >= 1) { + options.push({ size: 2, consume: 3, nL: nL - 1 + cc.L, nM: nM + cc.M }); + } + // A small can go under either, and leaves a small top behind, which is spent. + if (cc.S > 0 && nM >= 1) { + options.push({ size: 1, consume: 2, nL: nL + cc.L, nM: nM - 1 + cc.M }); + } + if (cc.S > 0 && nL >= 1) { + options.push({ size: 1, consume: 3, nL: nL - 1 + cc.L, nM: nM + cc.M }); + } + for (const option of options) { + const sub = search(mask | (1 << i), option.nL, option.nM); + const gain = cc.total + sub.gain; + if (gain > best.gain) { + best = { + gain, + steps: [{ colour, size: option.size, consume: option.consume }, ...sub.steps], + }; + } + } + } + memo.set(key, best); + return best; + }; + + return search(0, openL, openM); +}; + +/** + * An empty cell next to a stack of `colour` that will still touch the outside once filled, + * so the chain can keep growing from there. + */ +const pickFoundingCell = (struct: Structure, colour: string): Cell | undefined => { + const exterior = exteriorCells(struct); + let fallback: Cell | undefined; + for (const cell of struct.keys()) { + const top = topOf(struct, cell); + if (top === undefined || colourOf(top) !== colour) { + continue; + } + for (const n of neighbours(cell)) { + if (!exterior.has(n)) { + if (fallback === undefined && !struct.has(n)) { + fallback = n; + } + continue; + } + if (neighbours(n).some(nn => exterior.has(nn))) { + return n; + } + if (fallback === undefined) { + fallback = n; + } + } + } + return fallback; +}; + +const foundAll = ( + struct: Structure, + remaining: PieceId[], + sequence: Placement[], + colour: string, +): void => { + for (;;) { + const idx = remaining.findIndex(p => colourOf(p) === colour); + if (idx < 0) { + return; + } + const cell = pickFoundingCell(struct, colour); + if (cell === undefined) { + return; + } + const [piece] = remaining.splice(idx, 1); + placeInto(struct, piece, cell); + sequence.push({ piece, cell }); + } +}; + +const enableColour = ( + struct: Structure, + remaining: PieceId[], + sequence: Placement[], + step: EnableStep, +): boolean => { + const idx = remaining.findIndex(p => colourOf(p) === step.colour && sizeOf(p) === step.size); + if (idx < 0) { + return false; + } + const exterior = exteriorCells(struct); + let target: Cell | undefined; + for (const cell of struct.keys()) { + const top = topOf(struct, cell); + if (top === undefined || sizeOf(top) !== step.consume) { + continue; + } + if (neighbours(cell).some(n => exterior.has(n))) { + target = cell; + break; + } + } + if (target === undefined) { + return false; + } + const [piece] = remaining.splice(idx, 1); + placeInto(struct, piece, target); + sequence.push({ piece, cell: target }); + return true; +}; + +/** + * Mops up anything the plan left behind, which is how pyramids of unreachable colours find + * their way onto enclosed stacks that the resource count deliberately ignores. This can only + * add placements. + */ +const sweep = (struct: Structure, remaining: PieceId[], sequence: Placement[]): void => { + let progressed = true; + while (progressed && remaining.length > 0) { + progressed = false; + const order = remaining + .map((piece, idx) => ({ piece, idx })) + .sort((a, b) => sizeOf(b.piece) - sizeOf(a.piece)); + for (const { piece, idx } of order) { + const cells = legalCellsFor(struct, piece, legalPalacePlacement); + if (cells.length === 0) { + continue; + } + remaining.splice(idx, 1); + placeInto(struct, piece, cells[0]); + sequence.push({ piece, cell: cells[0] }); + progressed = true; + break; + } + } +}; + +const buildOnto = (palace: Structure, pieces: PieceId[]): BuildPlan => { + const struct = cloneStructure(palace); + const remaining = [...pieces]; + const sequence: Placement[] = []; + + const counts = tally(pieces); + const tops = openTops(struct); + const enabled = new Set(tops.map(t => t.colour)); + + let openL = tops.filter(t => t.size === 3).length; + let openM = tops.filter(t => t.size === 2).length; + for (const [colour, cc] of counts.entries()) { + if (enabled.has(colour)) { + openL += cc.L; + openM += cc.M; + } + } + + for (const colour of enabled) { + foundAll(struct, remaining, sequence, colour); + } + + const pending = [...counts.keys()].filter(c => !enabled.has(c)); + const plan = planEnablements(pending, counts, openL, openM); + for (const step of plan.steps) { + if (!enableColour(struct, remaining, sequence, step)) { + break; + } + foundAll(struct, remaining, sequence, step.colour); + } + + sweep(struct, remaining, sequence); + return { max: sequence.length, sequence }; +}; + +/** + * The most pyramids the builder can work into the Palace, with one sequence that gets there. + * `pieces` should already have had Black and White discarded. + */ +export const maximumBuild = (palace: Structure, pieces: PieceId[]): BuildPlan => { + if (pieces.length === 0) { + return { max: 0, sequence: [] }; + } + if (palace.size > 0) { + return buildOnto(palace, pieces); + } + + // An empty Palace takes its first pyramid anywhere, and which one it is matters a great + // deal, so try each distinct choice. + let best: BuildPlan = { max: 0, sequence: [] }; + const origin = cellOf(0, 0); + for (const seed of new Set(pieces)) { + const struct: Structure = new Map([[origin, [seed]]]); + const rest = [...pieces]; + rest.splice(rest.indexOf(seed), 1); + const sub = buildOnto(struct, rest); + if (sub.max + 1 > best.max) { + best = { + max: sub.max + 1, + sequence: [{ piece: seed, cell: origin }, ...sub.sequence], + }; + } + } + return best; +}; diff --git a/test/games/icepalace-solver.test.ts b/test/games/icepalace-solver.test.ts new file mode 100644 index 00000000..8bac3ac3 --- /dev/null +++ b/test/games/icepalace-solver.test.ts @@ -0,0 +1,211 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import "mocha"; +import { expect } from "chai"; +import { + PieceId, + Structure, + cellOf, + legalCellsFor, + legalPalacePlacement, + placeInto, +} from "../../src/games/icepalace/rules"; +import { maximumBuild } from "../../src/games/icepalace/solver"; + +const palaceOf = (stacks: Record): Structure => { + const struct: Structure = new Map(); + for (const [cell, stack] of Object.entries(stacks)) { + struct.set(cell, [...stack]); + } + return struct; +}; + +/** Replays a plan against the building code so a reported maximum is never taken on trust. */ +const replay = (palace: Structure, pieces: PieceId[], plan: ReturnType): void => { + const struct: Structure = new Map(); + for (const [cell, stack] of palace.entries()) { + struct.set(cell, [...stack]); + } + const pool = [...pieces]; + for (const { piece, cell } of plan.sequence) { + const idx = pool.indexOf(piece); + expect(idx, `plan used ${piece}, which was not in the Yard`).to.be.greaterThan(-1); + pool.splice(idx, 1); + expect( + legalPalacePlacement(struct, piece, cell), + `plan placed ${piece} illegally at ${cell}`, + ).to.be.true; + placeInto(struct, piece, cell); + } + expect(plan.sequence.length).to.equal(plan.max); +}; + +const check = (palace: Structure, pieces: PieceId[], expected: number): void => { + const plan = maximumBuild(palace, pieces); + replay(palace, pieces, plan); + expect(plan.max).to.equal(expected); +}; + +describe("Ice Palace: maximum build", () => { + it("places nothing when the Yard held only Black and White", () => { + check(palaceOf({ "0,0": ["1L"] }), [], 0); + }); + + it("stacks a whole large-medium-small tower into an empty Palace", () => { + check(new Map(), ["1L", "2M", "3S"], 3); + }); + + it("cannot place a second medium with no large left to cover", () => { + // Seed the large, cover it with one medium, and the other medium is stranded: + // nothing larger is left to stack onto and its colour tops nothing. + check(new Map(), ["1L", "2M", "3M"], 2); + }); + + 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); + }); + + it("spends the only large top on one colour and strands the other", () => { + 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); + }); + + it("founds without limit once a colour is enabled", () => { + const pieces: PieceId[] = []; + for (let i = 0; i < 12; i++) { + pieces.push("1S"); + } + 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); + }); + + 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); + }); + + it("chains large to medium to small across three new colours", () => { + check(palaceOf({ "0,0": ["1L"] }), ["2M", "3S"], 2); + }); + + it("opens an empty Palace with a medium when that beats leading with the large", () => { + // Seeding the large only reaches four. Seeding 2M, covering it with 1S to enable + // colour 1, then founding 1L as a fresh large top, carries 3M and 4S as well. + check(new Map(), ["1L", "1S", "2M", "3M", "4S"], 5); + }); + + it("uses every pyramid when each colour has something small enough", () => { + 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 pieces: PieceId[] = ["1M", "1S", "1L", "2M"]; + const plan = maximumBuild(palace, pieces); + replay(palace, pieces, plan); + expect(plan.max).to.equal(4); + }); + + it("does not mutate the Palace it was handed", () => { + 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"]); + }); +}); + +/** Exhaustive search over every legal build order, for cross-checking small positions. */ +const bruteForce = (palace: Structure, pieces: PieceId[]): number => { + const memo = new Map(); + + const key = (struct: Structure, remaining: PieceId[]): string => { + const cells = [...struct.keys()].map(c => { + const parts = c.split(","); + return [Number(parts[0]), Number(parts[1])] as [number, number]; + }); + const minX = Math.min(...cells.map(c => c[0])); + const minY = Math.min(...cells.map(c => c[1])); + const board = [...struct.entries()] + .map(([c, stack]) => { + const parts = c.split(","); + return `${Number(parts[0]) - minX},${Number(parts[1]) - minY}:${stack.join("")}`; + }) + .sort() + .join("|"); + return `${board}//${[...remaining].sort().join(",")}`; + }; + + const search = (struct: Structure, remaining: PieceId[]): number => { + if (remaining.length === 0) { + return 0; + } + const memoKey = key(struct, remaining); + const cached = memo.get(memoKey); + if (cached !== undefined) { + return cached; + } + let best = 0; + for (const piece of new Set(remaining)) { + for (const cell of legalCellsFor(struct, piece, legalPalacePlacement)) { + const next: Structure = new Map(); + for (const [c, stack] of struct.entries()) { + next.set(c, [...stack]); + } + placeInto(next, piece, cell); + const rest = [...remaining]; + rest.splice(rest.indexOf(piece), 1); + best = Math.max(best, 1 + search(next, rest)); + if (best === remaining.length) { + memo.set(memoKey, best); + return best; + } + } + } + memo.set(memoKey, best); + return best; + }; + + return search(palace, pieces); +}; + +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"] }), + }; + + const yards: PieceId[][] = [ + ["1S"], + ["4L"], + ["2M", "3M"], + ["2M", "3S"], + ["1S", "1M"], + ["2L", "2S"], + ["3M", "3S", "4M"], + ["1M", "2S", "3L"], + ["2S", "2S", "3M"], + ]; + + for (const [name, palace] of Object.entries(palaces)) { + for (const yard of yards) { + it(`${name} + [${yard.join(" ")}]`, () => { + const plan = maximumBuild(palace, yard); + replay(palace, yard, plan); + expect(plan.max, "solver must never claim more than is reachable").to.equal( + bruteForce(palace, yard), + ); + }); + } + } +}); From a0054f9fbc91bd324a7ba5f856d1097928a7d44b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 23:31:30 +0000 Subject: [PATCH 02/22] Add the Ice Palace game engine Three to six players compete to win hands into a temporary common Yard, and the winner of each hand builds what they won into the permanent Ice Palace, whose size rule runs opposite to the Yard's. Scores come from whoever owns the pyramid on top of each Palace stack. Extends GameBaseSequenced: the winner of a hand passes to close it and then acts again to build, so one seat acts twice before the seat cycle completes. Notable rules decisions, all recorded in comments: - Stacking ignores colour, per "Any color pyramid may be added to a stack if it is bigger than the current top pyramid". The French translation's "de la couleur d'un joueur" is a translation artifact. - A new stack must match one adjacent top, not all of them, per "adjacent to any existing stack ... the same color as the adjacent top pyramid". - Adjacency is orthogonal, per the rules' own grid illustration. - A hand ends on as many consecutive passes as there are players, so the last placer gets the option to extend rather than close. - The rules never say how the first pyramid enters an empty Palace, so it goes anywhere. - Replenishing is checked per size, since draws are made by size. - Building never auto-commits; the builder decides who scores what and wants to rearrange before it becomes permanent. Hands are public. The rules never address concealment either way, and it is a presentation decision that does not touch state or legality. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 867 +++++++++++++++++++++++++++++++++++ test/games/icepalace.test.ts | 272 +++++++++++ 2 files changed, 1139 insertions(+) create mode 100644 src/games/icepalace.ts create mode 100644 test/games/icepalace.test.ts diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts new file mode 100644 index 00000000..ffa4a540 --- /dev/null +++ b/src/games/icepalace.ts @@ -0,0 +1,867 @@ +import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IStashEntry, IStatus, IValidationResult } from "./_base.js"; +import { GameBaseSequenced } from "./_turn-sequenced.js"; +import type { APGamesInformation } from "../schemas/gameinfo.js"; +import { APRenderRep, Freepiece, Glyph, MarkerFreespaceLabel } from "@abstractplay/renderer/build/schemas/schema"; +import type { APMoveResult } from "../schemas/moveresults.js"; +import { reviver, UserFacingError } from "../common/index.js"; +import i18next from "i18next"; +import { + Cell, + NULL_COLOUR, + PieceId, + Size, + Structure, + WILD_COLOUR, + cellOf, + cloneStructure, + colourOf, + coordsOf, + legalCellsFor, + legalPalacePlacement, + legalYardPlacement, + makePiece, + placeInto, + sizeOf, + topOf, +} from "./icepalace/rules.js"; +import { maximumBuild } from "./icepalace/solver.js"; + +/** A hand is being played into the Yard, or its winner is building the Palace. */ +export type Phase = "hand" | "build"; + +const SIZES: Size[] = [1, 2, 3]; +const SIZE_NAMES = ["small", "medium", "large"]; +/** Every Icehouse stash holds five pyramids of each size. */ +const PER_SIZE_IN_STASH = 5; +/** Hands are replenished to two of each size. */ +const HAND_PER_SIZE = 2; + +export interface IMoveState extends IIndividualState { + currplayer: number; + yard: Structure; + palace: Structure; + hands: PieceId[][]; + pool: PieceId[]; + phase: Phase; + /** Consecutive passes; the hand ends when every player has passed in a row. */ + passes: number; + /** Seat holding the Turn Token, who leads the current hand. */ + lead: number; + /** Seat that played the most recent pyramid into the Yard, so wins the hand. */ + lastPlacer?: number; + /** Coloured Yard pyramids the hand winner is building with. */ + stock: PieceId[]; + /** How many of `stock` the builder must use, per the maximum-pyramids rule. */ + buildMin: number; + /** Set when the Pool could not replenish every hand, which ends the game. */ + exhausted: boolean; + lastmove?: string; +} + +export interface IIcePalaceState extends IAPGameState { + winner: number[]; + stack: Array; +} + +const pieceSort = (a: PieceId, b: PieceId): number => { + if (colourOf(a) !== colourOf(b)) { + return colourOf(a) < colourOf(b) ? -1 : 1; + } + return sizeOf(a) - sizeOf(b); +}; + +export class IcePalaceGame extends GameBaseSequenced { + public static readonly gameinfo: APGamesInformation = { + name: "Ice Palace", + uid: "icepalace", + playercounts: [3, 4, 5, 6], + version: "20260918", + dateAdded: "2026-09-18", + // i18next.t("apgames:descriptions.icepalace") + description: "apgames:descriptions.icepalace", + urls: ["https://icehousegames.org/wiki/index.php?title=Ice_Palace"], + people: [ + { + type: "designer", + name: "Geoff Hanna", + }, + ], + categories: [ + "goal>score>eog", + "mechanic>place", + "mechanic>stack", + "mechanic>share", + "mechanic>random>setup", + "mechanic>random>play", + "board>shape>rect", + "board>connect>rect", + "components>pyramids", + "other>2+players", + ], + flags: ["experimental", "scores", "player-stashes", "no-moves"], + }; + + public numplayers = 3; + public currplayer = 1; + public yard: Structure = new Map(); + public palace: Structure = new Map(); + public hands: PieceId[][] = []; + public pool: PieceId[] = []; + public phase: Phase = "hand"; + public passes = 0; + public lead = 1; + public lastPlacer?: number; + public stock: PieceId[] = []; + public buildMin = 0; + public exhausted = false; + public gameover = false; + public winner: number[] = []; + public variants: string[] = []; + public stack!: Array; + public results: Array = []; + + constructor(state: number | IIcePalaceState | string, variants?: string[]) { + super(); + if (typeof state === "number") { + if (!IcePalaceGame.gameinfo.playercounts.includes(state)) { + throw new Error(`Ice Palace does not support ${state} players.`); + } + this.numplayers = state; + if (variants !== undefined && variants.length > 0) { + this.variants = this.applyVariantConstraints(variants); + } + const { hands, pool } = IcePalaceGame.deal(this.numplayers); + const fresh: IMoveState = { + _version: IcePalaceGame.gameinfo.version, + _results: [], + _timestamp: new Date(), + currplayer: 1, + yard: new Map(), + palace: new Map(), + hands, + pool, + phase: "hand", + passes: 0, + lead: 1, + stock: [], + buildMin: 0, + exhausted: false, + }; + this.stack = [fresh]; + } else { + if (typeof state === "string") { + state = JSON.parse(state, reviver) as IIcePalaceState; + } + if (state.game !== IcePalaceGame.gameinfo.uid) { + throw new Error(`The Ice Palace engine cannot process a game of '${state.game}'.`); + } + this.numplayers = state.numplayers; + this.gameover = state.gameover; + this.winner = [...state.winner]; + this.variants = state.variants; + this.stack = [...state.stack]; + } + this.load(); + } + + /** + * Everyone keeps one pyramid of each size in their own colour; the rest of every stash, + * plus a full Black and White stash, go into the Pool. Each player then blindly draws + * one more of each size. + */ + private static deal(numplayers: number): { hands: PieceId[][]; pool: PieceId[] } { + const pool: PieceId[] = []; + const hands: PieceId[][] = []; + for (let p = 1; p <= numplayers; p++) { + const hand: PieceId[] = []; + for (const size of SIZES) { + hand.push(makePiece(p.toString(), size)); + for (let i = 0; i < PER_SIZE_IN_STASH - 1; i++) { + pool.push(makePiece(p.toString(), size)); + } + } + hands.push(hand); + } + for (const colour of [NULL_COLOUR, WILD_COLOUR]) { + for (const size of SIZES) { + for (let i = 0; i < PER_SIZE_IN_STASH; i++) { + pool.push(makePiece(colour, size)); + } + } + } + for (const hand of hands) { + for (const size of SIZES) { + const drawn = IcePalaceGame.drawSize(pool, size); + if (drawn !== undefined) { + hand.push(drawn); + } + } + hand.sort(pieceSort); + } + return { hands, pool }; + } + + /** Draws are made by size, so the Pool behaves as three separate bags. */ + private static drawSize(pool: PieceId[], size: Size): PieceId | undefined { + const candidates: number[] = []; + for (let i = 0; i < pool.length; i++) { + if (sizeOf(pool[i]) === size) { + candidates.push(i); + } + } + if (candidates.length === 0) { + return undefined; + } + const pick = candidates[Math.floor(Math.random() * candidates.length)]; + return pool.splice(pick, 1)[0]; + } + + public load(idx = -1): IcePalaceGame { + if (idx < 0) { + idx += this.stack.length; + } + if (idx < 0 || idx >= this.stack.length) { + throw new Error("Could not load the requested state from the stack."); + } + const state = this.stack[idx]; + this.currplayer = state.currplayer; + this.yard = cloneStructure(state.yard); + this.palace = cloneStructure(state.palace); + this.hands = state.hands.map(h => [...h]); + this.pool = [...state.pool]; + this.phase = state.phase; + this.passes = state.passes; + this.lead = state.lead; + this.lastPlacer = state.lastPlacer; + this.stock = [...state.stock]; + this.buildMin = state.buildMin; + this.exhausted = state.exhausted; + this.lastmove = state.lastmove; + this.results = [...state._results]; + return this; + } + + public moveState(): IMoveState { + return { + _version: IcePalaceGame.gameinfo.version, + _results: [...this.results], + _timestamp: new Date(), + currplayer: this.currplayer, + lastmove: this.lastmove, + yard: cloneStructure(this.yard), + palace: cloneStructure(this.palace), + hands: this.hands.map(h => [...h]), + pool: [...this.pool], + phase: this.phase, + passes: this.passes, + lead: this.lead, + lastPlacer: this.lastPlacer, + stock: [...this.stock], + buildMin: this.buildMin, + exhausted: this.exhausted, + }; + } + + public state(): IIcePalaceState { + return { + game: IcePalaceGame.gameinfo.uid, + numplayers: this.numplayers, + variants: this.variants, + gameover: this.gameover, + winner: [...this.winner], + stack: [...this.stack], + }; + } + + public clone(): IcePalaceGame { + return new IcePalaceGame(this.serialize()); + } + + /* ------------------------------------------------------------------ rules */ + + /** Whether this seat is leading the hand, which may not be passed. */ + private isLead(): boolean { + return this.yard.size === 0; + } + + private handOf(player: number): PieceId[] { + return this.hands[player - 1]; + } + + /** Every placement this seat could make into the Yard. */ + public yardPlacements(player: number): string[] { + const moves: string[] = []; + for (const piece of new Set(this.handOf(player))) { + for (const cell of legalCellsFor(this.yard, piece, legalYardPlacement)) { + moves.push(`${piece}@${cell}`); + } + } + return moves; + } + + /** Every placement the builder could make next, given what is already placed. */ + public buildPlacements(palace: Structure, stock: PieceId[]): string[] { + const moves: string[] = []; + for (const piece of new Set(stock)) { + for (const cell of legalCellsFor(palace, piece, legalPalacePlacement)) { + moves.push(`${piece}@${cell}`); + } + } + return moves; + } + + /** + * The move list is not exhaustive for the build phase, where the number of legal + * orderings and positions is astronomical; that is what the `no-moves` flag declares. + * The hand phase is enumerated in full, and the build phase offers one worked example. + */ + public moves(player?: number): string[] { + if (this.gameover) { + return []; + } + const seat = player ?? this.currplayer; + if (this.phase === "build") { + if (this.buildMin === 0) { + return ["pass"]; + } + const plan = maximumBuild(this.palace, this.stock); + return [plan.sequence.map(p => `${p.piece}@${p.cell}`).join(";")]; + } + const moves = this.yardPlacements(seat); + if (!this.isLead()) { + moves.push("pass"); + } + return moves; + } + + private static normalise(m: string): string { + const cleaned = m.replace(/\s+/g, ""); + if (cleaned.toLowerCase() === "pass") { + return "pass"; + } + return cleaned.toUpperCase(); + } + + /** Splits a placement into its pyramid and its cell. */ + private static parsePlacement(token: string): { piece: PieceId; cell: Cell } | undefined { + const at = token.indexOf("@"); + if (at < 1 || at === token.length - 1) { + return undefined; + } + const piece = token.substring(0, at); + const cell = token.substring(at + 1); + if (!/^[1-6BW][SML]$/.test(piece) || !/^-?\d+,-?\d+$/.test(cell)) { + return undefined; + } + return { piece, cell }; + } + + public validateMove(m: string): IValidationResult { + const result: IValidationResult = { valid: false, message: "" }; + if (this.gameover) { + result.message = i18next.t("apgames:MOVES_GAMEOVER"); + return result; + } + const move = IcePalaceGame.normalise(m); + if (move === "") { + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = + this.phase === "build" + ? i18next.t("apgames:validation.icepalace.INITIAL_BUILD", { count: this.buildMin }) + : i18next.t("apgames:validation.icepalace.INITIAL_HAND"); + return result; + } + return this.phase === "build" ? this.validateBuild(move) : this.validateHand(move); + } + + private validateHand(move: string): IValidationResult { + const result: IValidationResult = { valid: false, message: "" }; + if (move === "pass") { + if (this.isLead()) { + result.message = i18next.t("apgames:validation.icepalace.LEAD_CANNOT_PASS"); + return result; + } + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + const parsed = IcePalaceGame.parsePlacement(move); + if (parsed === undefined) { + result.message = i18next.t("apgames:validation.icepalace.BAD_PLACEMENT", { move }); + return result; + } + const { piece, cell } = parsed; + if (!this.handOf(this.currplayer).includes(piece)) { + result.message = i18next.t("apgames:validation.icepalace.NOT_IN_HAND", { piece }); + return result; + } + if (!legalYardPlacement(this.yard, piece, cell)) { + result.message = i18next.t("apgames:validation.icepalace.ILLEGAL_YARD", { piece, cell }); + return result; + } + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + private validateBuild(move: string): IValidationResult { + const result: IValidationResult = { valid: false, message: "" }; + if (move === "pass") { + if (this.buildMin > 0) { + result.message = i18next.t("apgames:validation.icepalace.MUST_BUILD", { + count: this.buildMin, + }); + return result; + } + result.valid = true; + result.complete = 1; + result.message = i18next.t("apgames:validation._general.VALID_MOVE"); + return result; + } + + const palace = cloneStructure(this.palace); + const stock = [...this.stock]; + for (const token of move.split(";")) { + const parsed = IcePalaceGame.parsePlacement(token); + if (parsed === undefined) { + result.message = i18next.t("apgames:validation.icepalace.BAD_PLACEMENT", { move: token }); + return result; + } + const { piece, cell } = parsed; + const idx = stock.indexOf(piece); + if (idx < 0) { + result.message = i18next.t("apgames:validation.icepalace.NOT_IN_STOCK", { piece }); + return result; + } + if (!legalPalacePlacement(palace, piece, cell)) { + result.message = i18next.t("apgames:validation.icepalace.ILLEGAL_PALACE", { piece, cell }); + return result; + } + stock.splice(idx, 1); + placeInto(palace, piece, cell); + } + + const placed = move.split(";").length; + result.valid = true; + result.canrender = true; + if (placed < this.buildMin) { + result.complete = -1; + result.message = i18next.t("apgames:validation.icepalace.BUILD_MORE", { + count: this.buildMin - placed, + }); + return result; + } + // Never auto-commit a build. The builder decides who scores what, and wants the + // chance to rearrange before the arrangement becomes permanent. + result.complete = 0; + result.message = + stock.length === 0 + ? i18next.t("apgames:validation.icepalace.BUILD_COMPLETE") + : i18next.t("apgames:validation.icepalace.BUILD_ENOUGH", { count: stock.length }); + return result; + } + + public move(m: string, { partial = false, trusted = false } = {}): IcePalaceGame { + if (this.gameover) { + throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER")); + } + const move = IcePalaceGame.normalise(m); + if (!trusted) { + const result = this.validateMove(move); + if (!result.valid) { + throw new UserFacingError("VALIDATION_GENERAL", result.message); + } + } + + this.results = []; + if (this.phase === "build") { + this.applyBuild(move, partial); + } else { + this.applyHand(move, partial); + } + if (partial) { + return this; + } + + this.lastmove = move; + this.checkEOG(); + this.saveState(); + return this; + } + + private applyHand(move: string, partial: boolean): void { + if (move === "pass") { + this.passes++; + this.results.push({ type: "pass" }); + } else { + const parsed = IcePalaceGame.parsePlacement(move)!; + const hand = this.handOf(this.currplayer); + hand.splice(hand.indexOf(parsed.piece), 1); + placeInto(this.yard, parsed.piece, parsed.cell); + this.passes = 0; + this.lastPlacer = this.currplayer; + this.results.push({ type: "place", what: parsed.piece, where: parsed.cell }); + } + if (partial) { + return; + } + if (this.passes >= this.numplayers && this.lastPlacer !== undefined) { + this.endHand(); + } else { + this.currplayer = (this.currplayer % this.numplayers) + 1; + } + } + + /** The hand winner takes the Yard; Black and White are discarded on the way. */ + private endHand(): void { + const stock: PieceId[] = []; + for (const [cell, stack] of this.yard.entries()) { + for (const piece of stack) { + const colour = colourOf(piece); + if (colour === NULL_COLOUR || colour === WILD_COLOUR) { + this.results.push({ type: "remove", where: cell, what: piece }); + } else { + stock.push(piece); + } + } + } + this.yard = new Map(); + this.stock = stock.sort(pieceSort); + this.buildMin = maximumBuild(this.palace, this.stock).max; + this.phase = "build"; + this.currplayer = this.lastPlacer!; + this.passes = 0; + } + + private applyBuild(move: string, partial: boolean): void { + if (move !== "pass") { + for (const token of move.split(";")) { + const parsed = IcePalaceGame.parsePlacement(token); + if (parsed === undefined) { + continue; + } + const idx = this.stock.indexOf(parsed.piece); + if (idx < 0) { + continue; + } + this.stock.splice(idx, 1); + placeInto(this.palace, parsed.piece, parsed.cell); + this.results.push({ type: "place", what: parsed.piece, where: parsed.cell }); + } + } + if (partial) { + return; + } + for (const piece of this.stock) { + this.results.push({ type: "remove", where: "stock", what: piece }); + } + this.stock = []; + this.buildMin = 0; + this.exhausted = !this.replenish(); + if (!this.exhausted) { + this.startHand(); + } + } + + private startHand(): void { + this.lead = (this.lead % this.numplayers) + 1; + this.currplayer = this.lead; + this.phase = "hand"; + this.passes = 0; + this.lastPlacer = undefined; + } + + /** + * Replenishes every hand to two pyramids of each size. Returns false when the Pool + * cannot do so, which ends the game. Because the Pool is shared and every player needs + * the same two of each size, "one player cannot draw back up" and "the Pool cannot + * refill everyone" are the same test. Hands never score, so a partial refill could not + * change the outcome and is not attempted. + */ + private replenish(): boolean { + for (const size of SIZES) { + let needed = 0; + for (const hand of this.hands) { + needed += HAND_PER_SIZE - hand.filter(p => sizeOf(p) === size).length; + } + const available = this.pool.filter(p => sizeOf(p) === size).length; + if (needed > available) { + return false; + } + } + for (let p = 0; p < this.numplayers; p++) { + for (const size of SIZES) { + while (this.hands[p].filter(x => sizeOf(x) === size).length < HAND_PER_SIZE) { + const drawn = IcePalaceGame.drawSize(this.pool, size); + if (drawn === undefined) { + return false; + } + this.hands[p].push(drawn); + } + } + this.hands[p].sort(pieceSort); + } + return true; + } + + protected checkEOG(): IcePalaceGame { + if (!this.exhausted) { + return this; + } + this.gameover = true; + const scores: number[] = []; + for (let p = 1; p <= this.numplayers; p++) { + scores.push(this.getPlayerScore(p)); + } + const best = Math.max(...scores); + this.winner = []; + for (let p = 1; p <= this.numplayers; p++) { + if (scores[p - 1] === best) { + this.winner.push(p); + } + } + this.results.push({ type: "eog" }, { type: "winners", players: [...this.winner] }); + return this; + } + + /** Each stack scores its height for whoever owns the pyramid on top. */ + public getPlayerScore(player: number): number { + let score = 0; + for (const stack of this.palace.values()) { + if (stack.length === 0) { + continue; + } + if (colourOf(stack[stack.length - 1]) === player.toString()) { + score += stack.length; + } + } + return score; + } + + public getPlayerStash(player: number): IStashEntry[] | undefined { + const hand = this.hands[player - 1]; + if (hand === undefined) { + return undefined; + } + const counts = new Map(); + for (const piece of hand) { + counts.set(piece, (counts.get(piece) ?? 0) + 1); + } + return [...counts.entries()] + .sort((a, b) => pieceSort(a[0], b[0])) + .map(([piece, count]) => ({ + count, + glyph: this.glyphFor(piece), + movePart: piece, + })); + } + + public sidebarScores(): IScores[] { + const scores: number[] = []; + for (let p = 1; p <= this.numplayers; p++) { + scores.push(this.getPlayerScore(p)); + } + return [{ name: this.neutralAreaLabel("apgames:status.SCORES"), scores }]; + } + + public sidebarStatuses(): IStatus[] { + const statuses: IStatus[] = [ + { + key: this.neutralAreaLabel("apgames:status.icepalace.POOL"), + value: [this.pool.length.toString()], + }, + ]; + if (this.phase === "build") { + statuses.push({ + key: this.neutralAreaLabel("apgames:status.icepalace.MUST_USE"), + value: [`${this.buildMin} / ${this.stock.length}`], + }); + } + return statuses; + } + + /* --------------------------------------------------------------- rendering */ + + private glyphFor(piece: PieceId): Glyph { + const name = `pyramid-up-${SIZE_NAMES[sizeOf(piece) - 1]}`; + const colour = colourOf(piece); + if (colour === NULL_COLOUR) { + return { name, colour: "#000000" }; + } + if (colour === WILD_COLOUR) { + return { name, colour: "#ffffff" }; + } + return { name, colour: Number(colour) }; + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + const result: IClickResult = { move, valid: false, message: "" }; + try { + const current = IcePalaceGame.normalise(move); + // Clicking a stash entry hands back the pyramid id; clicking the board hands + // back freespace coordinates, which map straight onto Yard/Palace cells. + let newmove: string; + if (piece !== undefined && /^[1-6BW][SML]$/.test(piece.toUpperCase())) { + newmove = this.appendToken(current, piece.toUpperCase()); + } else { + newmove = this.appendToken(current, `@${cellOf(col, row)}`); + } + const validated = this.validateMove(newmove); + if (!validated.valid) { + result.move = current === "" ? "" : current; + result.message = validated.message; + return result; + } + result.move = newmove; + result.valid = true; + result.complete = validated.complete; + result.canrender = validated.canrender; + result.message = validated.message; + return result; + } catch (e) { + result.message = i18next.t("apgames:validation._general.GENERIC", { move, row, col, piece, emessage: (e as Error).message }); + return result; + } + } + + /** Builds up a move string click by click, starting a new placement when one is full. */ + private appendToken(current: string, token: string): string { + if (token.startsWith("@")) { + if (current === "") { + return current; + } + const parts = current.split(";"); + const last = parts[parts.length - 1]; + if (last.includes("@")) { + return current; + } + parts[parts.length - 1] = last + token; + return parts.join(";"); + } + if (current === "") { + return token; + } + const parts = current.split(";"); + const last = parts[parts.length - 1]; + if (last.includes("@")) { + return this.phase === "build" ? `${current};${token}` : token; + } + parts[parts.length - 1] = token; + return parts.join(";"); + } + + /** + * Both structures grow on unbounded grids and have to be shown at once, so this uses the + * freespace renderer and lays them out side by side rather than trying to fit two boards + * into one bounded board. Stacks are drawn bottom to top with a rising offset, which reads + * correctly for the Palace and the Yard even though their size rules run opposite ways. + */ + public render(opts?: IRenderOpts): APRenderRep { + void opts; + const unit = 1; + const riser = 0.34; + const gap = 2; + + const legend: { [k: string]: Glyph } = {}; + const pieces: Freepiece[] = []; + const markers: MarkerFreespaceLabel[] = []; + + const extentOf = (struct: Structure): { width: number; height: number; minX: number; minY: number } => { + if (struct.size === 0) { + return { width: unit, height: unit, minX: 0, minY: 0 }; + } + const coords = [...struct.keys()].map(coordsOf); + const minX = Math.min(...coords.map(c => c[0])); + const maxX = Math.max(...coords.map(c => c[0])); + const minY = Math.min(...coords.map(c => c[1])); + const maxY = Math.max(...coords.map(c => c[1])); + return { + width: (maxX - minX + 1) * unit, + height: (maxY - minY + 1) * unit, + minX, + minY, + }; + }; + + const palaceExtent = extentOf(this.palace); + const yardExtent = extentOf(this.yard); + const height = Math.max(palaceExtent.height, yardExtent.height) + unit; + + const draw = (struct: Structure, extent: ReturnType, originX: number): void => { + for (const [cell, stack] of struct.entries()) { + const [x, y] = coordsOf(cell); + const baseX = originX + (x - extent.minX) * unit + unit / 2; + const baseY = height - (y - extent.minY) * unit - unit / 2; + for (let i = 0; i < stack.length; i++) { + const key = `p${stack[i]}`; + if (!(key in legend)) { + legend[key] = this.glyphFor(stack[i]); + } + pieces.push({ glyph: key, x: baseX, y: baseY - i * riser, id: `${cell}` }); + } + } + }; + + draw(this.palace, palaceExtent, 0); + const yardOrigin = palaceExtent.width + gap; + draw(this.yard, yardExtent, yardOrigin); + + markers.push({ + type: "label", + label: "Ice Palace", + points: [ + { x: 0, y: height + unit / 2 }, + { x: palaceExtent.width, y: height + unit / 2 }, + ], + }); + markers.push({ + type: "label", + label: this.phase === "build" ? "Building" : "Yard", + points: [ + { x: yardOrigin, y: height + unit / 2 }, + { x: yardOrigin + yardExtent.width, y: height + unit / 2 }, + ], + }); + + const rep: APRenderRep = { + renderer: "freespace", + board: { + width: palaceExtent.width + gap + yardExtent.width, + height: height + unit, + markers: markers.length > 0 ? markers : undefined, + }, + legend, + pieces, + }; + return rep; + } + + public getPlayerColour(player: number): number { + return player; + } + + public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { + let resolved = false; + switch (r.type) { + case "place": + node.push(i18next.t("apresults:PLACE.icepalace", { player, what: r.what, where: r.where })); + resolved = true; + break; + case "remove": + node.push(i18next.t("apresults:REMOVE.icepalace", { player, what: r.what })); + resolved = true; + break; + } + void results; + return resolved; + } + + /** Exposed for tests: the pyramid currently on top of a cell. */ + public topOfCell(struct: "yard" | "palace", cell: Cell): PieceId | undefined { + return topOf(struct === "yard" ? this.yard : this.palace, cell); + } +} diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts new file mode 100644 index 00000000..a6dd234f --- /dev/null +++ b/test/games/icepalace.test.ts @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import "mocha"; +import { expect } from "chai"; +import { IcePalaceGame } from "../../src/games/icepalace"; +import { PieceId, sizeOf } from "../../src/games/icepalace/rules"; + +const countOfSize = (hand: PieceId[], size: 1 | 2 | 3): number => + hand.filter(p => sizeOf(p) === size).length; + +/** Pins hands and Pool so a dealt game stops being random. */ +const rig = (g: IcePalaceGame, hands: PieceId[][], pool?: PieceId[]): IcePalaceGame => { + g.hands = hands.map(h => [...h]); + if (pool !== undefined) { + g.pool = [...pool]; + } + return g; +}; + +/** A Pool with plenty of everything, so replenishing never ends the game mid-test. */ +const fatPool = (): PieceId[] => { + const pool: PieceId[] = []; + for (let i = 0; i < 12; i++) { + pool.push("1S", "1M", "1L"); + } + return pool; +}; + +describe("Ice Palace: setup", () => { + it("deals every player two pyramids of each size", () => { + for (const n of [3, 4, 5, 6]) { + const g = new IcePalaceGame(n); + expect(g.hands.length).to.equal(n); + for (const hand of g.hands) { + expect(hand.length).to.equal(6); + expect(countOfSize(hand, 1)).to.equal(2); + expect(countOfSize(hand, 2)).to.equal(2); + expect(countOfSize(hand, 3)).to.equal(2); + } + } + }); + + it("guarantees each player one of each size in their own colour", () => { + const g = new IcePalaceGame(4); + for (let p = 1; p <= 4; p++) { + for (const size of ["S", "M", "L"]) { + expect(g.hands[p - 1]).to.include(`${p}${size}`); + } + } + }); + + it("puts the rest of every stash plus Black and White into the Pool", () => { + for (const n of [3, 4, 5, 6]) { + const g = new IcePalaceGame(n); + // Stashes total 15 each for n players plus Black and White, less six per hand. + expect(g.pool.length).to.equal(15 * (n + 2) - 6 * n); + for (const size of [1, 2, 3] as const) { + expect(countOfSize(g.pool, size)).to.equal(3 * n + 10); + } + } + }); + + it("refuses player counts the game does not support", () => { + expect(() => new IcePalaceGame(2)).to.throw(); + expect(() => new IcePalaceGame(7)).to.throw(); + }); +}); + +describe("Ice Palace: playing a hand", () => { + it("will not let the lead pass", () => { + const g = rig(new IcePalaceGame(3), [["1L"], ["2L"], ["3L"]]); + expect(g.validateMove("pass").valid).to.be.false; + expect(() => g.move("pass")).to.throw(); + }); + + 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"); + 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"); + // Black stacks fine; only its colour matching is crippled. + 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; + }); + + 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; + }); + + 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; + }); + + 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; + }); + + 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; + }); + + 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("pass"); + g.move("pass"); + expect(g.phase).to.equal("hand"); + // Back to the player who placed, who must also decline before the hand closes. + expect(g.currplayer).to.equal(1); + g.move("pass"); + expect(g.phase).to.equal("build"); + expect(g.currplayer).to.equal(1); + }); + + 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("pass"); + g.move("pass"); + 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("pass"); + g.move("pass"); + g.move("pass"); + expect(g.phase).to.equal("build"); + expect(g.currplayer).to.equal(2); + }); +}); + +describe("Ice Palace: building the Palace", () => { + const toBuild = (hands: PieceId[][], lead: string, rest: string[] = []): IcePalaceGame => { + const g = rig(new IcePalaceGame(3), hands, fatPool()); + g.move(lead); + for (const m of rest) { + g.move(m); + } + while (g.phase === "hand") { + g.move("pass"); + } + return g; + }; + + it("discards Black and White on the way out of the Yard", () => { + 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"); + 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"]); + 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"); + 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"); + 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"); + 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"]); + }); + + it("refuses a build that breaks the Palace code", () => { + 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; + }); +}); + +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"]], + ]); + expect(g.getPlayerScore(1)).to.equal(2); + expect(g.getPlayerScore(2)).to.equal(0); + expect(g.getPlayerScore(3)).to.equal(4); + }); + + 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"); + while (g.phase === "hand") { + g.move("pass"); + } + g.move(g.moves()[0]); + expect(g.gameover).to.be.true; + }); + + it("declares every top scorer a winner, which AP shows as a draw", () => { + // A Black lead is discarded rather than built, so the rigged Palace is the final one. + 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"]], + ]); + g.move("BL@0,0"); + while (g.phase === "hand") { + g.move("pass"); + } + g.move(g.moves()[0]); + expect(g.gameover).to.be.true; + expect(g.winner.length).to.be.greaterThan(1); + }); +}); + +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"); + // White is wild, so it may found beside the player-1 medium. + g.move("WS@0,1"); + const clone = g.clone(); + expect(clone.phase).to.equal(g.phase); + expect(clone.currplayer).to.equal(g.currplayer); + expect(clone.hands).to.deep.equal(g.hands); + expect([...clone.yard.entries()]).to.deep.equal([...g.yard.entries()]); + expect(clone.pool.length).to.equal(g.pool.length); + }); +}); From d749329e6d1f84a4b0100c54e5c47778da860a42 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 23:33:00 +0000 Subject: [PATCH 03/22] Add English strings for Ice Palace Covers the game description and name, the validation messages for both building codes and the build-minimum prompts, the sidebar statuses, and the place/remove move-log lines. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- locales/en/apgames.json | 20 ++++++++++++++++++++ locales/en/apresults.json | 2 ++ 2 files changed, 22 insertions(+) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 8a8786dc..e37749e0 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -129,6 +129,7 @@ "hexy": "An adaptation of Y on a regular hexagon where players vye to control most of the perimeter of the board.", "homeworlds": "An Icehouse game for 2 to 4 players. Players are interstellar civilizations vying for dominance. Each of the four colours of pyramid gives access to different actions. Amass a fleet, explore the galaxy, and ultimately destroy your opponent.", "hula": "Two players aim to form a loop around the center of a regular hexagonal board, using their own stones and, possibly, some number of neutral stones.", + "icepalace": "Three to six players compete to win hands by playing pyramids into a shared Yard, where each pyramid must be bigger than the one it covers. The winner of each hand builds everything they won into the Ice Palace, where the size rule runs the other way and only smaller pyramids may stack. Each Palace stack scores its height for whoever owns the pyramid on top, so small pyramids are the key to scoring. Almost everything is open to negotiation.", "intermedium": "Sowing stacks to enclose and capture. Win by capturing the opponent's city.", "invector": "Eliminate the adversary army while getting near the board center.", "iqishiqi": "Iqishiqi (pronounced EE-chee-shee-chee) is an abstract strategy game that is played on a hexagonal board composed of hexagonal cells. Players own designated edges of the board, and by clever placement of stones they push a neutral stone around the board. A player wins if he/she moves the neutral stone to one of his/her edges, or leaves his/her opponent unable to move.", @@ -417,6 +418,7 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", + "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", @@ -4870,6 +4872,10 @@ "GAME2": "Game 2", "INSURGENTS": "Insurgents remaining" }, + "icepalace": { + "MUST_USE": "Pyramids that must be used", + "POOL": "Pyramids left in the Pool" + }, "scribe": { "MINI_GRIDS": "Mini grids won" }, @@ -6328,6 +6334,20 @@ }, "VALID_W_ACTIONS": "Looks like a valid move, but you still have actions to spend." }, + "icepalace": { + "BAD_PLACEMENT": "Could not read '{{move}}' as a pyramid and a space.", + "BUILD_COMPLETE": "The whole Yard has been built into the Ice Palace. Submit when you are happy with it.", + "BUILD_ENOUGH": "You have used the maximum possible number of pyramids. The remaining {{count}} cannot be placed and will be discarded. Submit when you are happy with the arrangement.", + "BUILD_MORE": "You must place at least {{count}} more, because more of the Yard than that can be built.", + "ILLEGAL_PALACE": "The {{piece}} cannot go at {{cell}}. In the Ice Palace a pyramid may only cover a bigger one, or start a new stack beside a stack of its own colour.", + "ILLEGAL_YARD": "The {{piece}} cannot go at {{cell}}. In the Yard a pyramid may only cover a smaller one, or start a new stack beside a stack whose top it matches.", + "INITIAL_BUILD": "You won the hand. Build the Yard into the Ice Palace, using at least {{count}} pyramids.", + "INITIAL_HAND": "Place a pyramid from your hand into the Yard, or pass.", + "LEAD_CANNOT_PASS": "You are leading the hand and must place a pyramid to start the Yard.", + "MUST_BUILD": "You cannot decline to build: {{count}} of the Yard's pyramids can be placed.", + "NOT_IN_HAND": "You do not have a {{piece}} in hand.", + "NOT_IN_STOCK": "The {{piece}} is not among the pyramids you won." + }, "intermedium": { "INSTRUCTIONS": "Select a friendly stack; click on it as many times as pieces to move, then click on a diagonal path that starts adjacent to this sowing stack (one pieces per square), making 90º turns.", "NOT_FRIENDLY_STACK": "Select a friendly stack with two or more pieces to sow.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index 7ca9341b..e97695c4 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -483,6 +483,7 @@ "epam": "{{player}} placed a stone at {{where}}.", "gorogo_henge": "{{player}} placed a henge piece at {{where}}.", "gorogo_piece": "{{player}} placed a piece at {{where}}.", + "icepalace": "{{player}} placed a {{what}} at {{where}}.", "iqishiqi": "{{player}} placed a shared stone at {{where}}.", "knightline": "{{player}} placed their initial stack at {{where}}.", "logger_logger": "{{player}} placed their logger at {{where}}.", @@ -592,6 +593,7 @@ "fourinarow_sw": "{{player}} cleared two lines, shifting the pieces on the board downwards and leftwards.", "fourinarow_w": "{{player}} cleared a line, shifting the pieces on the board leftwards.", "gliss": "{{player}} moved their glider at {{where}} off the edge of the board.", + "icepalace": "{{what}} was discarded and removed from play.", "nonum": "{{player}} removed a piece from {{where}}.", "phutball_one": "{{count}} player was removed.", "phutball_other": "{{count}} players were removed.", From 8d40ab02c70bc67dcb4020b1aabffbb7ddcb389f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 23:35:16 +0000 Subject: [PATCH 04/22] Lay out both structures on one freespace canvas The Yard and the Ice Palace are both unbounded grids that have to be visible at once, which no single bounded board style supports. Freespace places them side by side, each padded by a ring of empty cells so there is somewhere to click when founding a stack on the frontier. Stacks are drawn bottom to top with each pyramid rising slightly above the one below, so the true order is readable. That matters here because the Yard stacks bigger over smaller and the Palace does the reverse, and a nesting convention would read one of them backwards. Freespace reports board clicks as continuous coordinates rather than grid indices, so handleClick inverts the same layout; clicks on a drawn pyramid carry their own cell id instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- locales/en/apgames.json | 3 +- src/games/icepalace.ts | 174 ++++++++++++++++++++++++----------- test/games/icepalace.test.ts | 40 ++++++++ 3 files changed, 162 insertions(+), 55 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index e37749e0..52e3cc9d 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -6346,7 +6346,8 @@ "LEAD_CANNOT_PASS": "You are leading the hand and must place a pyramid to start the Yard.", "MUST_BUILD": "You cannot decline to build: {{count}} of the Yard's pyramids can be placed.", "NOT_IN_HAND": "You do not have a {{piece}} in hand.", - "NOT_IN_STOCK": "The {{piece}} is not among the pyramids you won." + "NOT_IN_STOCK": "The {{piece}} is not among the pyramids you won.", + "OFF_STRUCTURE": "That space is not part of the structure you are building in." }, "intermedium": { "INSTRUCTIONS": "Select a friendly stack; click on it as many times as pieces to move, then click on a diagonal path that starts adjacent to this sowing stack (one pieces per square), making 90º turns.", diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index ffa4a540..b830c239 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -29,6 +29,32 @@ import { maximumBuild } from "./icepalace/solver.js"; /** A hand is being played into the Yard, or its winner is building the Palace. */ export type Phase = "hand" | "build"; +/** One cell of freespace canvas. */ +const UNIT = 1; +/** How far each pyramid in a stack rises above the one below it. */ +const RISER = 0.34; +/** Blank columns between the two structures. */ +const GAP = 2; +/** Rings of empty cells kept around each structure, to click into when founding. */ +const PADDING = 1; + +interface IStructureExtent { + originX: number; + minX: number; + minY: number; + cols: number; + rows: number; + width: number; + height: number; +} + +interface ILayout { + palace: IStructureExtent; + yard: IStructureExtent; + width: number; + height: number; +} + const SIZES: Size[] = [1, 2, 3]; const SIZE_NAMES = ["small", "medium", "large"]; /** Every Icehouse stash holds five pyramids of each size. */ @@ -702,13 +728,23 @@ export class IcePalaceGame extends GameBaseSequenced { const result: IClickResult = { move, valid: false, message: "" }; try { const current = IcePalaceGame.normalise(move); - // Clicking a stash entry hands back the pyramid id; clicking the board hands - // back freespace coordinates, which map straight onto Yard/Palace cells. let newmove: string; if (piece !== undefined && /^[1-6BW][SML]$/.test(piece.toUpperCase())) { + // A stash entry hands back the pyramid it represents. newmove = this.appendToken(current, piece.toUpperCase()); + } else if (piece !== undefined && /^[yp]:-?\d+,-?\d+$/.test(piece)) { + // A pyramid already in play hands back the cell it stands on. + newmove = this.appendToken(current, `@${piece.substring(2)}`); } else { - newmove = this.appendToken(current, `@${cellOf(col, row)}`); + // Empty freespace hands back continuous coordinates, which have to be + // mapped back through the layout this game renders with. + const cell = this.cellAt(col, row); + if (cell === undefined) { + result.move = current; + result.message = i18next.t("apgames:validation.icepalace.OFF_STRUCTURE"); + return result; + } + newmove = this.appendToken(current, `@${cell}`); } const validated = this.validateMove(newmove); if (!validated.valid) { @@ -762,76 +798,55 @@ export class IcePalaceGame extends GameBaseSequenced { */ public render(opts?: IRenderOpts): APRenderRep { void opts; - const unit = 1; - const riser = 0.34; - const gap = 2; - + const layout = this.layout(); const legend: { [k: string]: Glyph } = {}; const pieces: Freepiece[] = []; const markers: MarkerFreespaceLabel[] = []; - const extentOf = (struct: Structure): { width: number; height: number; minX: number; minY: number } => { - if (struct.size === 0) { - return { width: unit, height: unit, minX: 0, minY: 0 }; - } - const coords = [...struct.keys()].map(coordsOf); - const minX = Math.min(...coords.map(c => c[0])); - const maxX = Math.max(...coords.map(c => c[0])); - const minY = Math.min(...coords.map(c => c[1])); - const maxY = Math.max(...coords.map(c => c[1])); - return { - width: (maxX - minX + 1) * unit, - height: (maxY - minY + 1) * unit, - minX, - minY, - }; - }; - - const palaceExtent = extentOf(this.palace); - const yardExtent = extentOf(this.yard); - const height = Math.max(palaceExtent.height, yardExtent.height) + unit; - - const draw = (struct: Structure, extent: ReturnType, originX: number): void => { + const draw = (struct: Structure, extent: IStructureExtent, tag: string): void => { for (const [cell, stack] of struct.entries()) { const [x, y] = coordsOf(cell); - const baseX = originX + (x - extent.minX) * unit + unit / 2; - const baseY = height - (y - extent.minY) * unit - unit / 2; + const baseX = extent.originX + (x - extent.minX + 0.5) * UNIT; + const baseY = layout.height - (y - extent.minY + 0.5) * UNIT; for (let i = 0; i < stack.length; i++) { const key = `p${stack[i]}`; if (!(key in legend)) { legend[key] = this.glyphFor(stack[i]); } - pieces.push({ glyph: key, x: baseX, y: baseY - i * riser, id: `${cell}` }); + // Each pyramid in a stack rises a little above the one below, so the + // whole stack stays readable and its true order is visible. That matters + // because the Yard and the Palace stack in opposite size orders. + pieces.push({ + glyph: key, + x: baseX, + y: baseY - i * RISER, + id: `${tag}:${cell}`, + }); } } }; - draw(this.palace, palaceExtent, 0); - const yardOrigin = palaceExtent.width + gap; - draw(this.yard, yardExtent, yardOrigin); - - markers.push({ - type: "label", - label: "Ice Palace", - points: [ - { x: 0, y: height + unit / 2 }, - { x: palaceExtent.width, y: height + unit / 2 }, - ], - }); - markers.push({ - type: "label", - label: this.phase === "build" ? "Building" : "Yard", - points: [ - { x: yardOrigin, y: height + unit / 2 }, - { x: yardOrigin + yardExtent.width, y: height + unit / 2 }, - ], - }); + draw(this.palace, layout.palace, "p"); + draw(this.yard, layout.yard, "y"); + + const label = (text: string, extent: IStructureExtent): void => { + markers.push({ + type: "label", + label: text, + points: [ + { x: extent.originX, y: layout.height + UNIT / 2 }, + { x: extent.originX + extent.width, y: layout.height + UNIT / 2 }, + ], + }); + }; + label("Ice Palace", layout.palace); + label(this.phase === "build" ? "Yard (being built)" : "Yard", layout.yard); const rep: APRenderRep = { renderer: "freespace", board: { - width: palaceExtent.width + gap + yardExtent.width, - height: height + unit, + width: layout.width, + height: layout.height + UNIT, markers: markers.length > 0 ? markers : undefined, }, legend, @@ -840,6 +855,57 @@ export class IcePalaceGame extends GameBaseSequenced { return rep; } + /** + * Where each structure sits on the freespace canvas. Both grids are unbounded, so each + * is padded by a ring of empty cells; without it there would be nowhere to click to + * found a stack on the frontier. + */ + private layout(): ILayout { + const extentOf = (struct: Structure, originX: number): IStructureExtent => { + if (struct.size === 0) { + return { originX, minX: 0, minY: 0, cols: 1, rows: 1, width: UNIT, height: UNIT }; + } + const coords = [...struct.keys()].map(coordsOf); + const minX = Math.min(...coords.map(c => c[0])) - PADDING; + const maxX = Math.max(...coords.map(c => c[0])) + PADDING; + const minY = Math.min(...coords.map(c => c[1])) - PADDING; + const maxY = Math.max(...coords.map(c => c[1])) + PADDING; + const cols = maxX - minX + 1; + const rows = maxY - minY + 1; + return { originX, minX, minY, cols, rows, width: cols * UNIT, height: rows * UNIT }; + }; + + const palace = extentOf(this.palace, 0); + const yard = extentOf(this.yard, palace.width + GAP); + return { + palace, + yard, + width: palace.width + GAP + yard.width, + height: Math.max(palace.height, yard.height), + }; + } + + /** + * Turns a click on empty freespace back into a cell of whichever structure is in play + * this phase. Returns undefined when the click landed in the gutter or the wrong half. + */ + private cellAt(x: number, y: number): Cell | undefined { + const layout = this.layout(); + const extent = this.phase === "build" ? layout.palace : layout.yard; + const localX = x - extent.originX; + if (localX < 0 || localX >= extent.width) { + return undefined; + } + const localY = layout.height - y; + if (localY < 0 || localY >= extent.height) { + return undefined; + } + return cellOf( + extent.minX + Math.floor(localX / UNIT), + extent.minY + Math.floor(localY / UNIT), + ); + } + public getPlayerColour(player: number): number { return player; } diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index a6dd234f..de14def2 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -256,6 +256,46 @@ describe("Ice Palace: scoring and ending", () => { }); }); +describe("Ice Palace: board interaction", () => { + /** + * The freespace renderer reports clicks as continuous coordinates, so the layout maths + * has to invert cleanly. This checks the arithmetic only; the renderer JSON itself is + * not verified here. + */ + it("maps a click at a cell's drawn position back to that cell", () => { + const g = rig(new IcePalaceGame(3), [["1M"], ["2L"], ["3S"]], fatPool()); + g.move("1M@0,0"); + const rep = g.render() as { pieces: { x: number; y: number; id: string }[] }; + const drawn = rep.pieces.find(p => p.id === "y:0,0"); + expect(drawn, "the placed pyramid should be drawn").to.not.be.undefined; + // A large covers a medium, and colour is irrelevant when stacking. + const click = g.handleClick("2L", drawn!.y, drawn!.x, "_field"); + expect(click.valid, click.message).to.be.true; + 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"], ["1S"], ["3S"]], fatPool()); + g.move("1M@0,0"); + const rep = g.render() as { pieces: { x: number; y: number; id: string }[]; board: { width: number; height: number } }; + const drawn = rep.pieces.find(p => p.id === "y:0,0")!; + // One cell to the right of the only stack is inside the canvas and is a real cell. + const click = g.handleClick("1S", drawn.y, drawn.x + 1, "_field"); + expect(click.valid, click.message).to.be.true; + expect(click.move).to.equal("1S@1,0"); + }); + + it("selects a cell when an existing pyramid is clicked", () => { + const g = rig(new IcePalaceGame(3), [["1S", "1L"], ["1S"], ["3S"]], fatPool()); + g.move("1S@0,0"); + g.move("pass"); + g.move("pass"); + const click = g.handleClick("1L", 0, 0, "y:0,0"); + expect(click.valid, click.message).to.be.true; + expect(click.move).to.equal("1L@0,0"); + }); +}); + describe("Ice Palace: serialization", () => { it("survives a round trip through its own state", () => { const g = rig(new IcePalaceGame(3), [["1M", "1L"], ["WS"], ["3S"]], fatPool()); From 1d0b072b21723b17f9edc93242b1d984ff9ce324 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 23:46:51 +0000 Subject: [PATCH 05/22] Fix Ice Palace render scale and localize board labels Verified against the real renderer for the first time, which turned up two defects that typechecking alone could not: - Freespace coordinates are in the renderer's own cellsize units, and pieces are scaled to it. The layout used 1 unit per cell instead of 50, so every glyph drew at roughly its native size and the whole canvas came out as one black rectangle. - Rows are now pitched further apart than columns. A full three-pyramid stack rises two risers above its cell, which at the old pitch overlapped whatever stood in the row above. Board labels are structured render labels resolved by the front end rather than English baked into the engine. Confirmed by rendering a position through the real FreespaceRenderer: the Palace stacks read large to small upward and the Yard reads small to large, so the two opposite size rules are visually distinguishable, and Black and White are legible against the board. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- locales/en/apgames.json | 5 +++++ src/games/icepalace.ts | 42 +++++++++++++++++++++++++----------- test/games/icepalace.test.ts | 6 ++++-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 52e3cc9d..9d6b5071 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -288,6 +288,11 @@ "yonmoque": "Try to form four in a row in a game where not all spaces are equal and opposing pieces can be converted.", "zola": "A game where your movement is constrained by your distance from the centre of the board. Capturing moves must not increase that distance. Non-capturing moves must increase that distance. First person to capture all opposing pieces wins." }, + "icepalace": { + "PALACE": "Ice Palace", + "YARD": "Yard", + "YARD_BUILDING": "Yard (being built)" + }, "names": { "abande": "Abande", "accasta": "Accasta", diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index b830c239..fd3c0a66 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -29,12 +29,17 @@ import { maximumBuild } from "./icepalace/solver.js"; /** A hand is being played into the Yard, or its winner is building the Palace. */ export type Phase = "hand" | "build"; -/** One cell of freespace canvas. */ -const UNIT = 1; +/** One cell of freespace canvas, in renderer units; freespace scales pieces to `cellsize`. */ +const UNIT = 50; /** How far each pyramid in a stack rises above the one below it. */ -const RISER = 0.34; -/** Blank columns between the two structures. */ -const GAP = 2; +const RISER = UNIT * 0.34; +/** + * Rows are pitched further apart than columns so that a full three-pyramid stack, which + * rises two risers above its cell, cannot collide with whatever sits in the row above. + */ +const ROW_PITCH = UNIT + 2 * RISER; +/** Blank space between the two structures. */ +const GAP = UNIT * 2; /** Rings of empty cells kept around each structure, to click into when founding. */ const PADDING = 1; @@ -807,7 +812,7 @@ export class IcePalaceGame extends GameBaseSequenced { for (const [cell, stack] of struct.entries()) { const [x, y] = coordsOf(cell); const baseX = extent.originX + (x - extent.minX + 0.5) * UNIT; - const baseY = layout.height - (y - extent.minY + 0.5) * UNIT; + const baseY = layout.height - (y - extent.minY + 0.5) * ROW_PITCH; for (let i = 0; i < stack.length; i++) { const key = `p${stack[i]}`; if (!(key in legend)) { @@ -829,7 +834,7 @@ export class IcePalaceGame extends GameBaseSequenced { draw(this.palace, layout.palace, "p"); draw(this.yard, layout.yard, "y"); - const label = (text: string, extent: IStructureExtent): void => { + const label = (text: MarkerFreespaceLabel["label"], extent: IStructureExtent): void => { markers.push({ type: "label", label: text, @@ -839,8 +844,17 @@ export class IcePalaceGame extends GameBaseSequenced { ], }); }; - label("Ice Palace", layout.palace); - label(this.phase === "build" ? "Yard (being built)" : "Yard", layout.yard); + // Structured labels, resolved by the front end, rather than English baked in here. + // i18next.t("apgames:icepalace.PALACE") + label(this.neutralAreaLabel("apgames:icepalace.PALACE"), layout.palace); + // i18next.t("apgames:icepalace.YARD") + // i18next.t("apgames:icepalace.YARD_BUILDING") + label( + this.neutralAreaLabel( + this.phase === "build" ? "apgames:icepalace.YARD_BUILDING" : "apgames:icepalace.YARD", + ), + layout.yard, + ); const rep: APRenderRep = { renderer: "freespace", @@ -863,7 +877,7 @@ export class IcePalaceGame extends GameBaseSequenced { private layout(): ILayout { const extentOf = (struct: Structure, originX: number): IStructureExtent => { if (struct.size === 0) { - return { originX, minX: 0, minY: 0, cols: 1, rows: 1, width: UNIT, height: UNIT }; + return { originX, minX: 0, minY: 0, cols: 1, rows: 1, width: UNIT, height: ROW_PITCH }; } const coords = [...struct.keys()].map(coordsOf); const minX = Math.min(...coords.map(c => c[0])) - PADDING; @@ -872,7 +886,11 @@ export class IcePalaceGame extends GameBaseSequenced { const maxY = Math.max(...coords.map(c => c[1])) + PADDING; const cols = maxX - minX + 1; const rows = maxY - minY + 1; - return { originX, minX, minY, cols, rows, width: cols * UNIT, height: rows * UNIT }; + return { + originX, minX, minY, cols, rows, + width: cols * UNIT, + height: rows * ROW_PITCH, + }; }; const palace = extentOf(this.palace, 0); @@ -902,7 +920,7 @@ export class IcePalaceGame extends GameBaseSequenced { } return cellOf( extent.minX + Math.floor(localX / UNIT), - extent.minY + Math.floor(localY / UNIT), + extent.minY + Math.floor(localY / ROW_PITCH), ); } diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index de14def2..08739b2b 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -279,8 +279,10 @@ describe("Ice Palace: board interaction", () => { g.move("1M@0,0"); const rep = g.render() as { pieces: { x: number; y: number; id: string }[]; board: { width: number; height: number } }; const drawn = rep.pieces.find(p => p.id === "y:0,0")!; - // One cell to the right of the only stack is inside the canvas and is a real cell. - const click = g.handleClick("1S", drawn.y, drawn.x + 1, "_field"); + // One cell to the right of the only stack. Columns are pitched at the renderer's + // own cellsize, which is what freespace scales pieces to. + const CELL = 50; + const click = g.handleClick("1S", drawn.y, drawn.x + CELL, "_field"); expect(click.valid, click.message).to.be.true; expect(click.move).to.equal("1S@1,0"); }); From 2aa40d4a54d56e40dd563499daa31acb295a5b53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 00:09:15 +0000 Subject: [PATCH 06/22] Draw pyramids side-on, and collapse Ice Palace into one module Two changes, both about matching house style. Volcano's look comes from the `pyramid-flat-*` glyphs, which are the side-on triangle with size pips along the base, not `pyramid-up-*`, which is the top-down square with an X through it. Switching over reads far better here, and it makes the two building codes obvious at a glance: a Palace stack tapers upward while a Yard stack is top-heavy. Those glyphs carry no full-cell sizing box, so the renderer normalises all three sizes to the same footprint and small, medium and large come out identical. Scaling each legend entry back to the true Icehouse height ratios restores the distinction. The rules helpers and build solver move into icepalace.ts, and the two test files become one, since splitting a game across modules is not the norm here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 571 ++++++++++++++++++++++++++-- src/games/icepalace/rules.ts | 202 ---------- src/games/icepalace/solver.ts | 329 ---------------- test/games/icepalace-solver.test.ts | 211 ---------- test/games/icepalace.test.ts | 212 ++++++++++- 5 files changed, 755 insertions(+), 770 deletions(-) delete mode 100644 src/games/icepalace/rules.ts delete mode 100644 src/games/icepalace/solver.ts delete mode 100644 test/games/icepalace-solver.test.ts diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index fd3c0a66..b6c42c80 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -5,34 +5,543 @@ import { APRenderRep, Freepiece, Glyph, MarkerFreespaceLabel } from "@abstractpl import type { APMoveResult } from "../schemas/moveresults.js"; import { reviver, UserFacingError } from "../common/index.js"; import i18next from "i18next"; -import { - Cell, - NULL_COLOUR, - PieceId, - Size, - Structure, - WILD_COLOUR, - cellOf, - cloneStructure, - colourOf, - coordsOf, - legalCellsFor, - legalPalacePlacement, - legalYardPlacement, - makePiece, - placeInto, - sizeOf, - topOf, -} from "./icepalace/rules.js"; -import { maximumBuild } from "./icepalace/solver.js"; + +/* ------------------------------------------------- rules and legality */ + +/** + * 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`. + */ + +/** 1 = small, 2 = medium, 3 = large. */ +export type Size = 1 | 2 | 3; + +/** Black is Null: it matches no colour, not even itself. */ +export const NULL_COLOUR = "B"; +/** White is Wild: it matches every colour except Black. */ +export const WILD_COLOUR = "W"; + +/** A colour char followed by a size char, e.g. `"3L"`, `"BS"`, `"WM"`. */ +export type PieceId = string; + +export const SIZE_CHARS = ["S", "M", "L"] as const; +export type SizeChar = (typeof SIZE_CHARS)[number]; + +export type Cell = string; +/** Cell key to the pyramids sitting there, ordered bottom to top. */ +export type Structure = Map; + +export const colourOf = (piece: PieceId): string => piece.substring(0, piece.length - 1); + +export const sizeOf = (piece: PieceId): Size => { + const idx = SIZE_CHARS.indexOf(piece[piece.length - 1] as SizeChar); + if (idx < 0) { + throw new Error(`Could not read a pyramid size from "${piece}".`); + } + return (idx + 1) as 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 coordsOf = (cell: Cell): [number, number] => { + const parts = cell.split(","); + return [Number(parts[0]), Number(parts[1])]; +}; + +/** Adjacency is side-by-side only; the four diagonals are not adjacent. */ +export const neighbours = (cell: Cell): Cell[] => { + const [x, y] = coordsOf(cell); + return [cellOf(x + 1, y), cellOf(x - 1, y), cellOf(x, y + 1), cellOf(x, y - 1)]; +}; + +export const topOf = (struct: Structure, cell: Cell): PieceId | undefined => { + const stack = struct.get(cell); + if (stack === undefined || stack.length === 0) { + return undefined; + } + return stack[stack.length - 1]; +}; + +/** Yard matching, where Black is Null and White is Wild. */ +export const coloursMatch = (a: string, b: string): boolean => { + if (a === NULL_COLOUR || b === NULL_COLOUR) { + return false; + } + if (a === WILD_COLOUR || b === WILD_COLOUR) { + return true; + } + return a === b; +}; + +/** Every empty cell touching the structure. */ +export const frontier = (struct: Structure): Cell[] => { + const cells = new Set(); + for (const cell of struct.keys()) { + for (const n of neighbours(cell)) { + if (!struct.has(n)) { + cells.add(n); + } + } + } + return [...cells]; +}; + +/** + * The empty cells connected to the infinite outside, computed over the bounding box + * grown by one ring. Enclosed pockets are excluded: a stack that touches only a pocket + * cannot be grown away from indefinitely, which the build solver relies on. + */ +export const exteriorCells = (struct: Structure): Set => { + const exterior = new Set(); + if (struct.size === 0) { + return exterior; + } + const coords = [...struct.keys()].map(coordsOf); + const minX = Math.min(...coords.map(c => c[0])) - 1; + const maxX = Math.max(...coords.map(c => c[0])) + 1; + const minY = Math.min(...coords.map(c => c[1])) - 1; + const maxY = Math.max(...coords.map(c => c[1])) + 1; + + const queue: Cell[] = [cellOf(minX, minY)]; + while (queue.length > 0) { + const cell = queue.pop()!; + if (exterior.has(cell) || struct.has(cell)) { + continue; + } + const [x, y] = coordsOf(cell); + if (x < minX || x > maxX || y < minY || y > maxY) { + continue; + } + exterior.add(cell); + queue.push(...neighbours(cell)); + } + return exterior; +}; + +const canFound = ( + struct: Structure, + piece: PieceId, + cell: Cell, + match: (a: string, b: string) => boolean, +): boolean => { + for (const n of neighbours(cell)) { + const top = topOf(struct, n); + if (top !== undefined && match(colourOf(piece), colourOf(top))) { + return true; + } + } + return false; +}; + +/** + * Yard building code: any colour may be added to a stack if it is bigger than the + * current top pyramid, and a new stack may be started next to any existing stack whose + * top pyramid matches its colour. The lead may go anywhere. + */ +export const legalYardPlacement = (struct: Structure, piece: PieceId, cell: Cell): boolean => { + if (struct.size === 0) { + return true; + } + const top = topOf(struct, cell); + if (top !== undefined) { + return sizeOf(piece) > sizeOf(top); + } + return canFound(struct, piece, cell, coloursMatch); +}; + +/** + * Ice Palace building code: the size rule is reversed, and a new stack must exactly match + * the colour of an adjacent top pyramid. Black and White never reach the Palace, so Wild + * and Null have no role here. The rules never say how the first pyramid is placed into an + * empty Palace, so it goes anywhere. + */ +export const legalPalacePlacement = (struct: Structure, piece: PieceId, cell: Cell): boolean => { + if (struct.size === 0) { + return true; + } + const top = topOf(struct, cell); + if (top !== undefined) { + return sizeOf(piece) < sizeOf(top); + } + return canFound(struct, piece, cell, (a, b) => a === b); +}; + +export const placeInto = (struct: Structure, piece: PieceId, cell: Cell): void => { + const stack = struct.get(cell); + if (stack === undefined) { + struct.set(cell, [piece]); + } else { + stack.push(piece); + } +}; + +export const cloneStructure = (struct: Structure): Structure => { + const copy: Structure = new Map(); + for (const [cell, stack] of struct.entries()) { + copy.set(cell, [...stack]); + } + return copy; +}; + +/** Every cell a piece could legally go, for either building code. */ +export const legalCellsFor = ( + struct: Structure, + piece: PieceId, + legal: (struct: Structure, piece: PieceId, cell: Cell) => boolean, +): Cell[] => { + if (struct.size === 0) { + return [cellOf(0, 0)]; + } + const cells: Cell[] = []; + for (const cell of struct.keys()) { + if (legal(struct, piece, cell)) { + cells.push(cell); + } + } + for (const cell of frontier(struct)) { + if (legal(struct, piece, cell)) { + cells.push(cell); + } + } + return cells; +}; + +/* ------------------------------------------------------ build solver */ + +/** + * Works out the maximum number of Yard pyramids that can be built into the Ice Palace. + * + * Over the board this number is agreed by the players, because nobody wants to search the + * possibilities by hand. It is cheap to compute exactly, because of one observation: + * founding is unbounded. A new stack founded next to a stack of colour `c` is itself + * topped by `c` and sits on the frontier, so it can be chained outwards forever. Once a + * colour tops any outward-facing stack, every pyramid of that colour can be placed. + * + * So the only question is which colours can be got onto an outward-facing top, and that + * reduces to a small resource count. A colour is enabled if it already tops such a stack, + * or if one of its pyramids can be stacked onto an open top strictly larger than it, which + * spends that top. Every pyramid of an enabled colour, once founded, yields a fresh open + * top of its own size, so enabled colours holding larges regenerate the scarce resource. + * Larges can never be stacked onto anything, so a colour whose only Yard pyramids are + * large is placeable only if it already tops an open stack. + * + * The search over (enabled colours, open large tops, open medium tops) is tiny. The plan + * it produces is then played out against the real building code, and the length of the + * sequence actually achieved is what gets reported. That direction matters: the number is + * always one the builder can reach, never an over-estimate that would wedge the build. + */ + + +export interface Placement { + piece: PieceId; + cell: Cell; +} + +export interface BuildPlan { + /** How many Yard pyramids the builder must use. */ + max: number; + /** One legal way to reach that number, in order. */ + sequence: Placement[]; +} + +interface ColourCounts { + S: number; + M: number; + L: number; + total: number; +} + +/** Stack a `size` pyramid of `colour` onto an open top of size `consume`, enabling it. */ +interface EnableStep { + colour: string; + size: Size; + consume: Size; +} + +const tally = (pieces: PieceId[]): Map => { + const counts = new Map(); + for (const piece of pieces) { + const colour = colourOf(piece); + let entry = counts.get(colour); + if (entry === undefined) { + entry = { S: 0, M: 0, L: 0, total: 0 }; + counts.set(colour, entry); + } + const size = sizeOf(piece); + if (size === 1) { + entry.S++; + } else if (size === 2) { + entry.M++; + } else { + entry.L++; + } + entry.total++; + } + return counts; +}; + +/** Occupied cells that touch the infinite outside, with the colour and size on top. */ +const openTops = (struct: Structure): { cell: Cell; colour: string; size: Size }[] => { + const exterior = exteriorCells(struct); + const tops: { cell: Cell; colour: string; size: Size }[] = []; + for (const cell of struct.keys()) { + const top = topOf(struct, cell); + if (top === undefined) { + continue; + } + if (neighbours(cell).some(n => exterior.has(n))) { + tops.push({ cell, colour: colourOf(top), size: sizeOf(top) }); + } + } + return tops; +}; + +const planEnablements = ( + pending: string[], + counts: Map, + openL: number, + openM: number, +): { gain: number; steps: EnableStep[] } => { + const memo = new Map(); + + const search = (mask: number, nL: number, nM: number): { gain: number; steps: EnableStep[] } => { + const key = `${mask},${nL},${nM}`; + const cached = memo.get(key); + if (cached !== undefined) { + return cached; + } + let best: { gain: number; steps: EnableStep[] } = { gain: 0, steps: [] }; + for (let i = 0; i < pending.length; i++) { + if ((mask & (1 << i)) !== 0) { + continue; + } + const colour = pending[i]; + const cc = counts.get(colour)!; + const options: { size: Size; consume: Size; nL: number; nM: number }[] = []; + // A medium can only go under a large. The covered cell keeps its outward face, + // so it becomes an open medium top. + if (cc.M > 0 && nL >= 1) { + options.push({ size: 2, consume: 3, nL: nL - 1 + cc.L, nM: nM + cc.M }); + } + // A small can go under either, and leaves a small top behind, which is spent. + if (cc.S > 0 && nM >= 1) { + options.push({ size: 1, consume: 2, nL: nL + cc.L, nM: nM - 1 + cc.M }); + } + if (cc.S > 0 && nL >= 1) { + options.push({ size: 1, consume: 3, nL: nL - 1 + cc.L, nM: nM + cc.M }); + } + for (const option of options) { + const sub = search(mask | (1 << i), option.nL, option.nM); + const gain = cc.total + sub.gain; + if (gain > best.gain) { + best = { + gain, + steps: [{ colour, size: option.size, consume: option.consume }, ...sub.steps], + }; + } + } + } + memo.set(key, best); + return best; + }; + + return search(0, openL, openM); +}; + +/** + * An empty cell next to a stack of `colour` that will still touch the outside once filled, + * so the chain can keep growing from there. + */ +const pickFoundingCell = (struct: Structure, colour: string): Cell | undefined => { + const exterior = exteriorCells(struct); + let fallback: Cell | undefined; + for (const cell of struct.keys()) { + const top = topOf(struct, cell); + if (top === undefined || colourOf(top) !== colour) { + continue; + } + for (const n of neighbours(cell)) { + if (!exterior.has(n)) { + if (fallback === undefined && !struct.has(n)) { + fallback = n; + } + continue; + } + if (neighbours(n).some(nn => exterior.has(nn))) { + return n; + } + if (fallback === undefined) { + fallback = n; + } + } + } + return fallback; +}; + +const foundAll = ( + struct: Structure, + remaining: PieceId[], + sequence: Placement[], + colour: string, +): void => { + for (;;) { + const idx = remaining.findIndex(p => colourOf(p) === colour); + if (idx < 0) { + return; + } + const cell = pickFoundingCell(struct, colour); + if (cell === undefined) { + return; + } + const [piece] = remaining.splice(idx, 1); + placeInto(struct, piece, cell); + sequence.push({ piece, cell }); + } +}; + +const enableColour = ( + struct: Structure, + remaining: PieceId[], + sequence: Placement[], + step: EnableStep, +): boolean => { + const idx = remaining.findIndex(p => colourOf(p) === step.colour && sizeOf(p) === step.size); + if (idx < 0) { + return false; + } + const exterior = exteriorCells(struct); + let target: Cell | undefined; + for (const cell of struct.keys()) { + const top = topOf(struct, cell); + if (top === undefined || sizeOf(top) !== step.consume) { + continue; + } + if (neighbours(cell).some(n => exterior.has(n))) { + target = cell; + break; + } + } + if (target === undefined) { + return false; + } + const [piece] = remaining.splice(idx, 1); + placeInto(struct, piece, target); + sequence.push({ piece, cell: target }); + return true; +}; + +/** + * Mops up anything the plan left behind, which is how pyramids of unreachable colours find + * their way onto enclosed stacks that the resource count deliberately ignores. This can only + * add placements. + */ +const sweep = (struct: Structure, remaining: PieceId[], sequence: Placement[]): void => { + let progressed = true; + while (progressed && remaining.length > 0) { + progressed = false; + const order = remaining + .map((piece, idx) => ({ piece, idx })) + .sort((a, b) => sizeOf(b.piece) - sizeOf(a.piece)); + for (const { piece, idx } of order) { + const cells = legalCellsFor(struct, piece, legalPalacePlacement); + if (cells.length === 0) { + continue; + } + remaining.splice(idx, 1); + placeInto(struct, piece, cells[0]); + sequence.push({ piece, cell: cells[0] }); + progressed = true; + break; + } + } +}; + +const buildOnto = (palace: Structure, pieces: PieceId[]): BuildPlan => { + const struct = cloneStructure(palace); + const remaining = [...pieces]; + const sequence: Placement[] = []; + + const counts = tally(pieces); + const tops = openTops(struct); + const enabled = new Set(tops.map(t => t.colour)); + + let openL = tops.filter(t => t.size === 3).length; + let openM = tops.filter(t => t.size === 2).length; + for (const [colour, cc] of counts.entries()) { + if (enabled.has(colour)) { + openL += cc.L; + openM += cc.M; + } + } + + for (const colour of enabled) { + foundAll(struct, remaining, sequence, colour); + } + + const pending = [...counts.keys()].filter(c => !enabled.has(c)); + const plan = planEnablements(pending, counts, openL, openM); + for (const step of plan.steps) { + if (!enableColour(struct, remaining, sequence, step)) { + break; + } + foundAll(struct, remaining, sequence, step.colour); + } + + sweep(struct, remaining, sequence); + return { max: sequence.length, sequence }; +}; + +/** + * The most pyramids the builder can work into the Palace, with one sequence that gets there. + * `pieces` should already have had Black and White discarded. + */ +export const maximumBuild = (palace: Structure, pieces: PieceId[]): BuildPlan => { + if (pieces.length === 0) { + return { max: 0, sequence: [] }; + } + if (palace.size > 0) { + return buildOnto(palace, pieces); + } + + // An empty Palace takes its first pyramid anywhere, and which one it is matters a great + // deal, so try each distinct choice. + let best: BuildPlan = { max: 0, sequence: [] }; + const origin = cellOf(0, 0); + for (const seed of new Set(pieces)) { + const struct: Structure = new Map([[origin, [seed]]]); + const rest = [...pieces]; + rest.splice(rest.indexOf(seed), 1); + const sub = buildOnto(struct, rest); + if (sub.max + 1 > best.max) { + best = { + max: sub.max + 1, + sequence: [{ piece: seed, cell: origin }, ...sub.sequence], + }; + } + } + return best; +}; /** A hand is being played into the Yard, or its winner is building the Palace. */ export type Phase = "hand" | "build"; /** One cell of freespace canvas, in renderer units; freespace scales pieces to `cellsize`. */ const UNIT = 50; -/** How far each pyramid in a stack rises above the one below it. */ -const RISER = UNIT * 0.34; +/** + * True height ratios of the three pyramids, from the Icehouse glyph geometry + * (100 / 137.5 / 175), normalised against the large. + */ +const PYRAMID_SCALES = [100 / 175, 137.5 / 175, 1]; +/** + * How far each pyramid in a stack rises above the one below it. Small enough that the + * pyramids overlap and read as one stack, large enough that every apex stays visible. + */ +const RISER = UNIT * 0.38; /** * Rows are pitched further apart than columns so that a full three-pyramid stack, which * rises two risers above its cell, cannot collide with whatever sits in the row above. @@ -717,16 +1226,26 @@ export class IcePalaceGame extends GameBaseSequenced { /* --------------------------------------------------------------- rendering */ + /** + * Side-view pyramids, as Volcano draws them, rather than the top-down square. The + * `pyramid-flat-*` glyphs carry no full-cell sizing box, so the renderer normalises all + * three to the same footprint; scaling them back to their true height ratios is what + * keeps small, medium and large tellable apart. + */ private glyphFor(piece: PieceId): Glyph { - const name = `pyramid-up-${SIZE_NAMES[sizeOf(piece) - 1]}`; + const size = sizeOf(piece); + const glyph: Glyph = { + name: `pyramid-flat-${SIZE_NAMES[size - 1]}`, + scale: PYRAMID_SCALES[size - 1], + }; const colour = colourOf(piece); if (colour === NULL_COLOUR) { - return { name, colour: "#000000" }; + return { ...glyph, colour: "#000000" }; } if (colour === WILD_COLOUR) { - return { name, colour: "#ffffff" }; + return { ...glyph, colour: "#ffffff" }; } - return { name, colour: Number(colour) }; + return { ...glyph, colour: Number(colour) }; } public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { diff --git a/src/games/icepalace/rules.ts b/src/games/icepalace/rules.ts deleted file mode 100644 index c110bb44..00000000 --- a/src/games/icepalace/rules.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * 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`. - */ - -/** 1 = small, 2 = medium, 3 = large. */ -export type Size = 1 | 2 | 3; - -/** Black is Null: it matches no colour, not even itself. */ -export const NULL_COLOUR = "B"; -/** White is Wild: it matches every colour except Black. */ -export const WILD_COLOUR = "W"; - -/** A colour char followed by a size char, e.g. `"3L"`, `"BS"`, `"WM"`. */ -export type PieceId = string; - -export const SIZE_CHARS = ["S", "M", "L"] as const; -export type SizeChar = (typeof SIZE_CHARS)[number]; - -export type Cell = string; -/** Cell key to the pyramids sitting there, ordered bottom to top. */ -export type Structure = Map; - -export const colourOf = (piece: PieceId): string => piece.substring(0, piece.length - 1); - -export const sizeOf = (piece: PieceId): Size => { - const idx = SIZE_CHARS.indexOf(piece[piece.length - 1] as SizeChar); - if (idx < 0) { - throw new Error(`Could not read a pyramid size from "${piece}".`); - } - return (idx + 1) as 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 coordsOf = (cell: Cell): [number, number] => { - const parts = cell.split(","); - return [Number(parts[0]), Number(parts[1])]; -}; - -/** Adjacency is side-by-side only; the four diagonals are not adjacent. */ -export const neighbours = (cell: Cell): Cell[] => { - const [x, y] = coordsOf(cell); - return [cellOf(x + 1, y), cellOf(x - 1, y), cellOf(x, y + 1), cellOf(x, y - 1)]; -}; - -export const topOf = (struct: Structure, cell: Cell): PieceId | undefined => { - const stack = struct.get(cell); - if (stack === undefined || stack.length === 0) { - return undefined; - } - return stack[stack.length - 1]; -}; - -/** Yard matching, where Black is Null and White is Wild. */ -export const coloursMatch = (a: string, b: string): boolean => { - if (a === NULL_COLOUR || b === NULL_COLOUR) { - return false; - } - if (a === WILD_COLOUR || b === WILD_COLOUR) { - return true; - } - return a === b; -}; - -/** Every empty cell touching the structure. */ -export const frontier = (struct: Structure): Cell[] => { - const cells = new Set(); - for (const cell of struct.keys()) { - for (const n of neighbours(cell)) { - if (!struct.has(n)) { - cells.add(n); - } - } - } - return [...cells]; -}; - -/** - * The empty cells connected to the infinite outside, computed over the bounding box - * grown by one ring. Enclosed pockets are excluded: a stack that touches only a pocket - * cannot be grown away from indefinitely, which the build solver relies on. - */ -export const exteriorCells = (struct: Structure): Set => { - const exterior = new Set(); - if (struct.size === 0) { - return exterior; - } - const coords = [...struct.keys()].map(coordsOf); - const minX = Math.min(...coords.map(c => c[0])) - 1; - const maxX = Math.max(...coords.map(c => c[0])) + 1; - const minY = Math.min(...coords.map(c => c[1])) - 1; - const maxY = Math.max(...coords.map(c => c[1])) + 1; - - const queue: Cell[] = [cellOf(minX, minY)]; - while (queue.length > 0) { - const cell = queue.pop()!; - if (exterior.has(cell) || struct.has(cell)) { - continue; - } - const [x, y] = coordsOf(cell); - if (x < minX || x > maxX || y < minY || y > maxY) { - continue; - } - exterior.add(cell); - queue.push(...neighbours(cell)); - } - return exterior; -}; - -const canFound = ( - struct: Structure, - piece: PieceId, - cell: Cell, - match: (a: string, b: string) => boolean, -): boolean => { - for (const n of neighbours(cell)) { - const top = topOf(struct, n); - if (top !== undefined && match(colourOf(piece), colourOf(top))) { - return true; - } - } - return false; -}; - -/** - * Yard building code: any colour may be added to a stack if it is bigger than the - * current top pyramid, and a new stack may be started next to any existing stack whose - * top pyramid matches its colour. The lead may go anywhere. - */ -export const legalYardPlacement = (struct: Structure, piece: PieceId, cell: Cell): boolean => { - if (struct.size === 0) { - return true; - } - const top = topOf(struct, cell); - if (top !== undefined) { - return sizeOf(piece) > sizeOf(top); - } - return canFound(struct, piece, cell, coloursMatch); -}; - -/** - * Ice Palace building code: the size rule is reversed, and a new stack must exactly match - * the colour of an adjacent top pyramid. Black and White never reach the Palace, so Wild - * and Null have no role here. The rules never say how the first pyramid is placed into an - * empty Palace, so it goes anywhere. - */ -export const legalPalacePlacement = (struct: Structure, piece: PieceId, cell: Cell): boolean => { - if (struct.size === 0) { - return true; - } - const top = topOf(struct, cell); - if (top !== undefined) { - return sizeOf(piece) < sizeOf(top); - } - return canFound(struct, piece, cell, (a, b) => a === b); -}; - -export const placeInto = (struct: Structure, piece: PieceId, cell: Cell): void => { - const stack = struct.get(cell); - if (stack === undefined) { - struct.set(cell, [piece]); - } else { - stack.push(piece); - } -}; - -export const cloneStructure = (struct: Structure): Structure => { - const copy: Structure = new Map(); - for (const [cell, stack] of struct.entries()) { - copy.set(cell, [...stack]); - } - return copy; -}; - -/** Every cell a piece could legally go, for either building code. */ -export const legalCellsFor = ( - struct: Structure, - piece: PieceId, - legal: (struct: Structure, piece: PieceId, cell: Cell) => boolean, -): Cell[] => { - if (struct.size === 0) { - return [cellOf(0, 0)]; - } - const cells: Cell[] = []; - for (const cell of struct.keys()) { - if (legal(struct, piece, cell)) { - cells.push(cell); - } - } - for (const cell of frontier(struct)) { - if (legal(struct, piece, cell)) { - cells.push(cell); - } - } - return cells; -}; diff --git a/src/games/icepalace/solver.ts b/src/games/icepalace/solver.ts deleted file mode 100644 index d635037d..00000000 --- a/src/games/icepalace/solver.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Works out the maximum number of Yard pyramids that can be built into the Ice Palace. - * - * Over the board this number is agreed by the players, because nobody wants to search the - * possibilities by hand. It is cheap to compute exactly, because of one observation: - * founding is unbounded. A new stack founded next to a stack of colour `c` is itself - * topped by `c` and sits on the frontier, so it can be chained outwards forever. Once a - * colour tops any outward-facing stack, every pyramid of that colour can be placed. - * - * So the only question is which colours can be got onto an outward-facing top, and that - * reduces to a small resource count. A colour is enabled if it already tops such a stack, - * or if one of its pyramids can be stacked onto an open top strictly larger than it, which - * spends that top. Every pyramid of an enabled colour, once founded, yields a fresh open - * top of its own size, so enabled colours holding larges regenerate the scarce resource. - * Larges can never be stacked onto anything, so a colour whose only Yard pyramids are - * large is placeable only if it already tops an open stack. - * - * The search over (enabled colours, open large tops, open medium tops) is tiny. The plan - * it produces is then played out against the real building code, and the length of the - * sequence actually achieved is what gets reported. That direction matters: the number is - * always one the builder can reach, never an over-estimate that would wedge the build. - */ - -import { - Cell, - PieceId, - Size, - Structure, - cellOf, - cloneStructure, - colourOf, - exteriorCells, - legalCellsFor, - legalPalacePlacement, - neighbours, - placeInto, - sizeOf, - topOf, -} from "./rules.js"; - -export interface Placement { - piece: PieceId; - cell: Cell; -} - -export interface BuildPlan { - /** How many Yard pyramids the builder must use. */ - max: number; - /** One legal way to reach that number, in order. */ - sequence: Placement[]; -} - -interface ColourCounts { - S: number; - M: number; - L: number; - total: number; -} - -/** Stack a `size` pyramid of `colour` onto an open top of size `consume`, enabling it. */ -interface EnableStep { - colour: string; - size: Size; - consume: Size; -} - -const tally = (pieces: PieceId[]): Map => { - const counts = new Map(); - for (const piece of pieces) { - const colour = colourOf(piece); - let entry = counts.get(colour); - if (entry === undefined) { - entry = { S: 0, M: 0, L: 0, total: 0 }; - counts.set(colour, entry); - } - const size = sizeOf(piece); - if (size === 1) { - entry.S++; - } else if (size === 2) { - entry.M++; - } else { - entry.L++; - } - entry.total++; - } - return counts; -}; - -/** Occupied cells that touch the infinite outside, with the colour and size on top. */ -const openTops = (struct: Structure): { cell: Cell; colour: string; size: Size }[] => { - const exterior = exteriorCells(struct); - const tops: { cell: Cell; colour: string; size: Size }[] = []; - for (const cell of struct.keys()) { - const top = topOf(struct, cell); - if (top === undefined) { - continue; - } - if (neighbours(cell).some(n => exterior.has(n))) { - tops.push({ cell, colour: colourOf(top), size: sizeOf(top) }); - } - } - return tops; -}; - -const planEnablements = ( - pending: string[], - counts: Map, - openL: number, - openM: number, -): { gain: number; steps: EnableStep[] } => { - const memo = new Map(); - - const search = (mask: number, nL: number, nM: number): { gain: number; steps: EnableStep[] } => { - const key = `${mask},${nL},${nM}`; - const cached = memo.get(key); - if (cached !== undefined) { - return cached; - } - let best: { gain: number; steps: EnableStep[] } = { gain: 0, steps: [] }; - for (let i = 0; i < pending.length; i++) { - if ((mask & (1 << i)) !== 0) { - continue; - } - const colour = pending[i]; - const cc = counts.get(colour)!; - const options: { size: Size; consume: Size; nL: number; nM: number }[] = []; - // A medium can only go under a large. The covered cell keeps its outward face, - // so it becomes an open medium top. - if (cc.M > 0 && nL >= 1) { - options.push({ size: 2, consume: 3, nL: nL - 1 + cc.L, nM: nM + cc.M }); - } - // A small can go under either, and leaves a small top behind, which is spent. - if (cc.S > 0 && nM >= 1) { - options.push({ size: 1, consume: 2, nL: nL + cc.L, nM: nM - 1 + cc.M }); - } - if (cc.S > 0 && nL >= 1) { - options.push({ size: 1, consume: 3, nL: nL - 1 + cc.L, nM: nM + cc.M }); - } - for (const option of options) { - const sub = search(mask | (1 << i), option.nL, option.nM); - const gain = cc.total + sub.gain; - if (gain > best.gain) { - best = { - gain, - steps: [{ colour, size: option.size, consume: option.consume }, ...sub.steps], - }; - } - } - } - memo.set(key, best); - return best; - }; - - return search(0, openL, openM); -}; - -/** - * An empty cell next to a stack of `colour` that will still touch the outside once filled, - * so the chain can keep growing from there. - */ -const pickFoundingCell = (struct: Structure, colour: string): Cell | undefined => { - const exterior = exteriorCells(struct); - let fallback: Cell | undefined; - for (const cell of struct.keys()) { - const top = topOf(struct, cell); - if (top === undefined || colourOf(top) !== colour) { - continue; - } - for (const n of neighbours(cell)) { - if (!exterior.has(n)) { - if (fallback === undefined && !struct.has(n)) { - fallback = n; - } - continue; - } - if (neighbours(n).some(nn => exterior.has(nn))) { - return n; - } - if (fallback === undefined) { - fallback = n; - } - } - } - return fallback; -}; - -const foundAll = ( - struct: Structure, - remaining: PieceId[], - sequence: Placement[], - colour: string, -): void => { - for (;;) { - const idx = remaining.findIndex(p => colourOf(p) === colour); - if (idx < 0) { - return; - } - const cell = pickFoundingCell(struct, colour); - if (cell === undefined) { - return; - } - const [piece] = remaining.splice(idx, 1); - placeInto(struct, piece, cell); - sequence.push({ piece, cell }); - } -}; - -const enableColour = ( - struct: Structure, - remaining: PieceId[], - sequence: Placement[], - step: EnableStep, -): boolean => { - const idx = remaining.findIndex(p => colourOf(p) === step.colour && sizeOf(p) === step.size); - if (idx < 0) { - return false; - } - const exterior = exteriorCells(struct); - let target: Cell | undefined; - for (const cell of struct.keys()) { - const top = topOf(struct, cell); - if (top === undefined || sizeOf(top) !== step.consume) { - continue; - } - if (neighbours(cell).some(n => exterior.has(n))) { - target = cell; - break; - } - } - if (target === undefined) { - return false; - } - const [piece] = remaining.splice(idx, 1); - placeInto(struct, piece, target); - sequence.push({ piece, cell: target }); - return true; -}; - -/** - * Mops up anything the plan left behind, which is how pyramids of unreachable colours find - * their way onto enclosed stacks that the resource count deliberately ignores. This can only - * add placements. - */ -const sweep = (struct: Structure, remaining: PieceId[], sequence: Placement[]): void => { - let progressed = true; - while (progressed && remaining.length > 0) { - progressed = false; - const order = remaining - .map((piece, idx) => ({ piece, idx })) - .sort((a, b) => sizeOf(b.piece) - sizeOf(a.piece)); - for (const { piece, idx } of order) { - const cells = legalCellsFor(struct, piece, legalPalacePlacement); - if (cells.length === 0) { - continue; - } - remaining.splice(idx, 1); - placeInto(struct, piece, cells[0]); - sequence.push({ piece, cell: cells[0] }); - progressed = true; - break; - } - } -}; - -const buildOnto = (palace: Structure, pieces: PieceId[]): BuildPlan => { - const struct = cloneStructure(palace); - const remaining = [...pieces]; - const sequence: Placement[] = []; - - const counts = tally(pieces); - const tops = openTops(struct); - const enabled = new Set(tops.map(t => t.colour)); - - let openL = tops.filter(t => t.size === 3).length; - let openM = tops.filter(t => t.size === 2).length; - for (const [colour, cc] of counts.entries()) { - if (enabled.has(colour)) { - openL += cc.L; - openM += cc.M; - } - } - - for (const colour of enabled) { - foundAll(struct, remaining, sequence, colour); - } - - const pending = [...counts.keys()].filter(c => !enabled.has(c)); - const plan = planEnablements(pending, counts, openL, openM); - for (const step of plan.steps) { - if (!enableColour(struct, remaining, sequence, step)) { - break; - } - foundAll(struct, remaining, sequence, step.colour); - } - - sweep(struct, remaining, sequence); - return { max: sequence.length, sequence }; -}; - -/** - * The most pyramids the builder can work into the Palace, with one sequence that gets there. - * `pieces` should already have had Black and White discarded. - */ -export const maximumBuild = (palace: Structure, pieces: PieceId[]): BuildPlan => { - if (pieces.length === 0) { - return { max: 0, sequence: [] }; - } - if (palace.size > 0) { - return buildOnto(palace, pieces); - } - - // An empty Palace takes its first pyramid anywhere, and which one it is matters a great - // deal, so try each distinct choice. - let best: BuildPlan = { max: 0, sequence: [] }; - const origin = cellOf(0, 0); - for (const seed of new Set(pieces)) { - const struct: Structure = new Map([[origin, [seed]]]); - const rest = [...pieces]; - rest.splice(rest.indexOf(seed), 1); - const sub = buildOnto(struct, rest); - if (sub.max + 1 > best.max) { - best = { - max: sub.max + 1, - sequence: [{ piece: seed, cell: origin }, ...sub.sequence], - }; - } - } - return best; -}; diff --git a/test/games/icepalace-solver.test.ts b/test/games/icepalace-solver.test.ts deleted file mode 100644 index 8bac3ac3..00000000 --- a/test/games/icepalace-solver.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions */ -import "mocha"; -import { expect } from "chai"; -import { - PieceId, - Structure, - cellOf, - legalCellsFor, - legalPalacePlacement, - placeInto, -} from "../../src/games/icepalace/rules"; -import { maximumBuild } from "../../src/games/icepalace/solver"; - -const palaceOf = (stacks: Record): Structure => { - const struct: Structure = new Map(); - for (const [cell, stack] of Object.entries(stacks)) { - struct.set(cell, [...stack]); - } - return struct; -}; - -/** Replays a plan against the building code so a reported maximum is never taken on trust. */ -const replay = (palace: Structure, pieces: PieceId[], plan: ReturnType): void => { - const struct: Structure = new Map(); - for (const [cell, stack] of palace.entries()) { - struct.set(cell, [...stack]); - } - const pool = [...pieces]; - for (const { piece, cell } of plan.sequence) { - const idx = pool.indexOf(piece); - expect(idx, `plan used ${piece}, which was not in the Yard`).to.be.greaterThan(-1); - pool.splice(idx, 1); - expect( - legalPalacePlacement(struct, piece, cell), - `plan placed ${piece} illegally at ${cell}`, - ).to.be.true; - placeInto(struct, piece, cell); - } - expect(plan.sequence.length).to.equal(plan.max); -}; - -const check = (palace: Structure, pieces: PieceId[], expected: number): void => { - const plan = maximumBuild(palace, pieces); - replay(palace, pieces, plan); - expect(plan.max).to.equal(expected); -}; - -describe("Ice Palace: maximum build", () => { - it("places nothing when the Yard held only Black and White", () => { - check(palaceOf({ "0,0": ["1L"] }), [], 0); - }); - - it("stacks a whole large-medium-small tower into an empty Palace", () => { - check(new Map(), ["1L", "2M", "3S"], 3); - }); - - it("cannot place a second medium with no large left to cover", () => { - // Seed the large, cover it with one medium, and the other medium is stranded: - // nothing larger is left to stack onto and its colour tops nothing. - check(new Map(), ["1L", "2M", "3M"], 2); - }); - - 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); - }); - - it("spends the only large top on one colour and strands the other", () => { - 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); - }); - - it("founds without limit once a colour is enabled", () => { - const pieces: PieceId[] = []; - for (let i = 0; i < 12; i++) { - pieces.push("1S"); - } - 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); - }); - - 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); - }); - - it("chains large to medium to small across three new colours", () => { - check(palaceOf({ "0,0": ["1L"] }), ["2M", "3S"], 2); - }); - - it("opens an empty Palace with a medium when that beats leading with the large", () => { - // Seeding the large only reaches four. Seeding 2M, covering it with 1S to enable - // colour 1, then founding 1L as a fresh large top, carries 3M and 4S as well. - check(new Map(), ["1L", "1S", "2M", "3M", "4S"], 5); - }); - - it("uses every pyramid when each colour has something small enough", () => { - 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 pieces: PieceId[] = ["1M", "1S", "1L", "2M"]; - const plan = maximumBuild(palace, pieces); - replay(palace, pieces, plan); - expect(plan.max).to.equal(4); - }); - - it("does not mutate the Palace it was handed", () => { - 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"]); - }); -}); - -/** Exhaustive search over every legal build order, for cross-checking small positions. */ -const bruteForce = (palace: Structure, pieces: PieceId[]): number => { - const memo = new Map(); - - const key = (struct: Structure, remaining: PieceId[]): string => { - const cells = [...struct.keys()].map(c => { - const parts = c.split(","); - return [Number(parts[0]), Number(parts[1])] as [number, number]; - }); - const minX = Math.min(...cells.map(c => c[0])); - const minY = Math.min(...cells.map(c => c[1])); - const board = [...struct.entries()] - .map(([c, stack]) => { - const parts = c.split(","); - return `${Number(parts[0]) - minX},${Number(parts[1]) - minY}:${stack.join("")}`; - }) - .sort() - .join("|"); - return `${board}//${[...remaining].sort().join(",")}`; - }; - - const search = (struct: Structure, remaining: PieceId[]): number => { - if (remaining.length === 0) { - return 0; - } - const memoKey = key(struct, remaining); - const cached = memo.get(memoKey); - if (cached !== undefined) { - return cached; - } - let best = 0; - for (const piece of new Set(remaining)) { - for (const cell of legalCellsFor(struct, piece, legalPalacePlacement)) { - const next: Structure = new Map(); - for (const [c, stack] of struct.entries()) { - next.set(c, [...stack]); - } - placeInto(next, piece, cell); - const rest = [...remaining]; - rest.splice(rest.indexOf(piece), 1); - best = Math.max(best, 1 + search(next, rest)); - if (best === remaining.length) { - memo.set(memoKey, best); - return best; - } - } - } - memo.set(memoKey, best); - return best; - }; - - return search(palace, pieces); -}; - -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"] }), - }; - - const yards: PieceId[][] = [ - ["1S"], - ["4L"], - ["2M", "3M"], - ["2M", "3S"], - ["1S", "1M"], - ["2L", "2S"], - ["3M", "3S", "4M"], - ["1M", "2S", "3L"], - ["2S", "2S", "3M"], - ]; - - for (const [name, palace] of Object.entries(palaces)) { - for (const yard of yards) { - it(`${name} + [${yard.join(" ")}]`, () => { - const plan = maximumBuild(palace, yard); - replay(palace, yard, plan); - expect(plan.max, "solver must never claim more than is reachable").to.equal( - bruteForce(palace, yard), - ); - }); - } - } -}); diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 08739b2b..e490b6e0 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -1,8 +1,17 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import "mocha"; import { expect } from "chai"; -import { IcePalaceGame } from "../../src/games/icepalace"; -import { PieceId, sizeOf } from "../../src/games/icepalace/rules"; +import { + IcePalaceGame, + PieceId, + Structure, + cellOf, + legalCellsFor, + legalPalacePlacement, + maximumBuild, + placeInto, + sizeOf, +} from "../../src/games/icepalace"; const countOfSize = (hand: PieceId[], size: 1 | 2 | 3): number => hand.filter(p => sizeOf(p) === size).length; @@ -312,3 +321,202 @@ describe("Ice Palace: serialization", () => { expect(clone.pool.length).to.equal(g.pool.length); }); }); + +const palaceOf = (stacks: Record): Structure => { + const struct: Structure = new Map(); + for (const [cell, stack] of Object.entries(stacks)) { + struct.set(cell, [...stack]); + } + return struct; +}; + +/** Replays a plan against the building code so a reported maximum is never taken on trust. */ +const replay = (palace: Structure, pieces: PieceId[], plan: ReturnType): void => { + const struct: Structure = new Map(); + for (const [cell, stack] of palace.entries()) { + struct.set(cell, [...stack]); + } + const pool = [...pieces]; + for (const { piece, cell } of plan.sequence) { + const idx = pool.indexOf(piece); + expect(idx, `plan used ${piece}, which was not in the Yard`).to.be.greaterThan(-1); + pool.splice(idx, 1); + expect( + legalPalacePlacement(struct, piece, cell), + `plan placed ${piece} illegally at ${cell}`, + ).to.be.true; + placeInto(struct, piece, cell); + } + expect(plan.sequence.length).to.equal(plan.max); +}; + +const check = (palace: Structure, pieces: PieceId[], expected: number): void => { + const plan = maximumBuild(palace, pieces); + replay(palace, pieces, plan); + expect(plan.max).to.equal(expected); +}; + +describe("Ice Palace: maximum build", () => { + it("places nothing when the Yard held only Black and White", () => { + check(palaceOf({ "0,0": ["1L"] }), [], 0); + }); + + it("stacks a whole large-medium-small tower into an empty Palace", () => { + check(new Map(), ["1L", "2M", "3S"], 3); + }); + + it("cannot place a second medium with no large left to cover", () => { + // Seed the large, cover it with one medium, and the other medium is stranded: + // nothing larger is left to stack onto and its colour tops nothing. + check(new Map(), ["1L", "2M", "3M"], 2); + }); + + 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); + }); + + it("spends the only large top on one colour and strands the other", () => { + 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); + }); + + it("founds without limit once a colour is enabled", () => { + const pieces: PieceId[] = []; + for (let i = 0; i < 12; i++) { + pieces.push("1S"); + } + 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); + }); + + 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); + }); + + it("chains large to medium to small across three new colours", () => { + check(palaceOf({ "0,0": ["1L"] }), ["2M", "3S"], 2); + }); + + it("opens an empty Palace with a medium when that beats leading with the large", () => { + // Seeding the large only reaches four. Seeding 2M, covering it with 1S to enable + // colour 1, then founding 1L as a fresh large top, carries 3M and 4S as well. + check(new Map(), ["1L", "1S", "2M", "3M", "4S"], 5); + }); + + it("uses every pyramid when each colour has something small enough", () => { + 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 pieces: PieceId[] = ["1M", "1S", "1L", "2M"]; + const plan = maximumBuild(palace, pieces); + replay(palace, pieces, plan); + expect(plan.max).to.equal(4); + }); + + it("does not mutate the Palace it was handed", () => { + 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"]); + }); +}); + +/** Exhaustive search over every legal build order, for cross-checking small positions. */ +const bruteForce = (palace: Structure, pieces: PieceId[]): number => { + const memo = new Map(); + + const key = (struct: Structure, remaining: PieceId[]): string => { + const cells = [...struct.keys()].map(c => { + const parts = c.split(","); + return [Number(parts[0]), Number(parts[1])] as [number, number]; + }); + const minX = Math.min(...cells.map(c => c[0])); + const minY = Math.min(...cells.map(c => c[1])); + const board = [...struct.entries()] + .map(([c, stack]) => { + const parts = c.split(","); + return `${Number(parts[0]) - minX},${Number(parts[1]) - minY}:${stack.join("")}`; + }) + .sort() + .join("|"); + return `${board}//${[...remaining].sort().join(",")}`; + }; + + const search = (struct: Structure, remaining: PieceId[]): number => { + if (remaining.length === 0) { + return 0; + } + const memoKey = key(struct, remaining); + const cached = memo.get(memoKey); + if (cached !== undefined) { + return cached; + } + let best = 0; + for (const piece of new Set(remaining)) { + for (const cell of legalCellsFor(struct, piece, legalPalacePlacement)) { + const next: Structure = new Map(); + for (const [c, stack] of struct.entries()) { + next.set(c, [...stack]); + } + placeInto(next, piece, cell); + const rest = [...remaining]; + rest.splice(rest.indexOf(piece), 1); + best = Math.max(best, 1 + search(next, rest)); + if (best === remaining.length) { + memo.set(memoKey, best); + return best; + } + } + } + memo.set(memoKey, best); + return best; + }; + + return search(palace, pieces); +}; + +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"] }), + }; + + const yards: PieceId[][] = [ + ["1S"], + ["4L"], + ["2M", "3M"], + ["2M", "3S"], + ["1S", "1M"], + ["2L", "2S"], + ["3M", "3S", "4M"], + ["1M", "2S", "3L"], + ["2S", "2S", "3M"], + ]; + + for (const [name, palace] of Object.entries(palaces)) { + for (const yard of yards) { + it(`${name} + [${yard.join(" ")}]`, () => { + const plan = maximumBuild(palace, yard); + replay(palace, yard, plan); + expect(plan.max, "solver must never claim more than is reachable").to.equal( + bruteForce(palace, yard), + ); + }); + } + } +}); From 5e4a6ca23eeaff2f21e557545c317fd1718e5bb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 00:24:23 +0000 Subject: [PATCH 07/22] Drop no-moves so forced passes auto-pass A player holding only a small Black has no legal placement and must pass, and that happens often. The front drives both auto-passing and the Pass button off the enumerated move list, so declaring no-moves suppressed the list and left those players typing "pass" by hand every time. Hands enumerate cheaply, so the flag was only ever needed for the build phase. Builds are still not enumerated, but rather than offer a single worked build in the move dropdown, where it would read as the only legal arrangement, the build phase offers nothing to pick from and is entered by clicking. Choosing the arrangement is the point of the phase. randomMove supplies a real build so bots and random play keep working. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 45 +++++++++++++++++++--------------- test/games/icepalace.test.ts | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index b6c42c80..fcc3305e 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -638,7 +638,7 @@ export class IcePalaceGame extends GameBaseSequenced { "components>pyramids", "other>2+players", ], - flags: ["experimental", "scores", "player-stashes", "no-moves"], + flags: ["experimental", "scores", "player-stashes", "autopass"], }; public numplayers = 3; @@ -839,41 +839,46 @@ export class IcePalaceGame extends GameBaseSequenced { return moves; } - /** Every placement the builder could make next, given what is already placed. */ - public buildPlacements(palace: Structure, stock: PieceId[]): string[] { - const moves: string[] = []; - for (const piece of new Set(stock)) { - for (const cell of legalCellsFor(palace, piece, legalPalacePlacement)) { - moves.push(`${piece}@${cell}`); - } - } - return moves; + /** One legal way to build the whole Yard in, as a single compound move. */ + private suggestedBuild(): string { + return maximumBuild(this.palace, this.stock) + .sequence.map(p => `${p.piece}@${p.cell}`) + .join(";"); } /** - * The move list is not exhaustive for the build phase, where the number of legal - * orderings and positions is astronomical; that is what the `no-moves` flag declares. - * The hand phase is enumerated in full, and the build phase offers one worked example. + * Hands are enumerated in full. That is what lets the front auto-pass a player with no + * legal placement, and what puts a Pass button in front of everyone else, since both are + * driven off this list. + * + * Builds are deliberately not enumerated. The number of legal orderings and positions is + * astronomical, and putting a single worked build in the move dropdown would read as + * though it were the only legal arrangement, when choosing the arrangement is the entire + * point of the phase. So a build offers nothing to pick from unless nothing can be placed + * at all, and is entered by clicking instead. `randomMove` still returns a real build. */ public moves(player?: number): string[] { if (this.gameover) { return []; } - const seat = player ?? this.currplayer; if (this.phase === "build") { - if (this.buildMin === 0) { - return ["pass"]; - } - const plan = maximumBuild(this.palace, this.stock); - return [plan.sequence.map(p => `${p.piece}@${p.cell}`).join(";")]; + return this.buildMin === 0 ? ["pass"] : []; } - const moves = this.yardPlacements(seat); + const moves = this.yardPlacements(player ?? this.currplayer); if (!this.isLead()) { moves.push("pass"); } return moves; } + /** Builds are not enumerated, so hand one over rather than sampling an empty list. */ + public randomMove(): string { + if (!this.gameover && this.phase === "build") { + return this.buildMin === 0 ? "pass" : this.suggestedBuild(); + } + return super.randomMove(); + } + private static normalise(m: string): string { const cleaned = m.replace(/\s+/g, ""); if (cleaned.toLowerCase() === "pass") { diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index e490b6e0..9fd68bef 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -160,6 +160,53 @@ describe("Ice Palace: playing a hand", () => { }); }); +describe("Ice Palace: move lists and auto-passing", () => { + it("offers a stuck player nothing but a pass, which is what triggers auto-pass", () => { + // 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"); + 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"); + const moves = g.moves(); + expect(moves).to.include("pass"); + expect(moves.length).to.be.greaterThan(1); + }); + + it("never offers the lead a pass", () => { + const g = rig(new IcePalaceGame(3), [["1M", "1S"], ["1S"], ["3S"]], fatPool()); + expect(g.moves()).to.not.include("pass"); + }); + + 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("pass"); + g.move("pass"); + g.move("1L@0,0"); + while (g.phase === "hand") { + g.move("pass"); + } + expect(g.buildMin).to.be.greaterThan(0); + // Offering a single worked build in the dropdown would imply it were the only one. + expect(g.moves()).to.be.empty; + const suggested = g.randomMove(); + const check = g.validateMove(suggested); + expect(check.valid, check.message).to.be.true; + expect(check.complete).to.equal(0); + }); + + it("declares autopass and does not declare no-moves", () => { + const flags = IcePalaceGame.gameinfo.flags ?? []; + expect(flags).to.include("autopass"); + expect(flags).to.not.include("no-moves"); + }); +}); + describe("Ice Palace: building the Palace", () => { const toBuild = (hands: PieceId[][], lead: string, rest: string[] = []): IcePalaceGame => { const g = rig(new IcePalaceGame(3), hands, fatPool()); From 7a3d9b74044a501a9085654f93e97cca05e7514d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 00:56:51 +0000 Subject: [PATCH 08/22] Render Ice Palace with stacking-3D, a pieces area, and hands in the sidebar Freespace is not needed here. One stacking-3D squares board carries both structures: the Palace on the left and, while a hand is being played, the Yard to its right, each padded by a ring of empty cells so founding has somewhere to click. During the build the Yard has already been taken up into the stock, so only the Palace is on the board. Coordinate labels are hidden, since the two regions do not share a coordinate space. The current player's hand, or the builder's stock, is a pieces area, which is where a pyramid is picked from; every hand is also listed in the status panel as pyramids. That replaces the player-stashes mechanism. Board clicks now arrive as grid row and column, which map back through the same layout the render uses. Clicking a pyramid in the pieces area yields a bare pyramid id; validation previously rejected anything without a target cell, so a selection could never survive as a partial move. Both phases now accept a trailing bare pyramid as a partial move and prompt for a space. Verified by rendering through the renderer's public entry point, which also validates the JSON against its schema. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- locales/en/apgames.json | 8 +- src/games/icepalace.ts | 329 +++++++++++++++++------------------ test/games/icepalace.test.ts | 82 ++++++--- 3 files changed, 220 insertions(+), 199 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 9d6b5071..9183a8d6 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -289,9 +289,8 @@ "zola": "A game where your movement is constrained by your distance from the centre of the board. Capturing moves must not increase that distance. Non-capturing moves must increase that distance. First person to capture all opposing pieces wins." }, "icepalace": { - "PALACE": "Ice Palace", - "YARD": "Yard", - "YARD_BUILDING": "Yard (being built)" + "HAND": "{{player}}'s hand", + "STOCK": "Pyramids from the Yard, to be built into the Palace" }, "names": { "abande": "Abande", @@ -6352,7 +6351,8 @@ "MUST_BUILD": "You cannot decline to build: {{count}} of the Yard's pyramids can be placed.", "NOT_IN_HAND": "You do not have a {{piece}} in hand.", "NOT_IN_STOCK": "The {{piece}} is not among the pyramids you won.", - "OFF_STRUCTURE": "That space is not part of the structure you are building in." + "OFF_STRUCTURE": "That space is not part of the structure you are building in.", + "PARTIAL_PIECE": "Now choose where to place the {{piece}}." }, "intermedium": { "INSTRUCTIONS": "Select a friendly stack; click on it as many times as pieces to move, then click on a diagonal path that starts adjacent to this sowing stack (one pieces per square), making 90º turns.", diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index fcc3305e..c96cd7dd 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -1,7 +1,7 @@ -import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IStashEntry, IStatus, IValidationResult } from "./_base.js"; +import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IStatus, IValidationResult } from "./_base.js"; import { GameBaseSequenced } from "./_turn-sequenced.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; -import { APRenderRep, Freepiece, Glyph, MarkerFreespaceLabel } from "@abstractplay/renderer/build/schemas/schema"; +import { APRenderRep, AreaPieces, Glyph } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; import { reviver, UserFacingError } from "../common/index.js"; import i18next from "i18next"; @@ -530,41 +530,23 @@ export const maximumBuild = (palace: Structure, pieces: PieceId[]): BuildPlan => /** A hand is being played into the Yard, or its winner is building the Palace. */ export type Phase = "hand" | "build"; -/** One cell of freespace canvas, in renderer units; freespace scales pieces to `cellsize`. */ -const UNIT = 50; -/** - * True height ratios of the three pyramids, from the Icehouse glyph geometry - * (100 / 137.5 / 175), normalised against the large. - */ -const PYRAMID_SCALES = [100 / 175, 137.5 / 175, 1]; -/** - * How far each pyramid in a stack rises above the one below it. Small enough that the - * pyramids overlap and read as one stack, large enough that every apex stays visible. - */ -const RISER = UNIT * 0.38; -/** - * Rows are pitched further apart than columns so that a full three-pyramid stack, which - * rises two risers above its cell, cannot collide with whatever sits in the row above. - */ -const ROW_PITCH = UNIT + 2 * RISER; -/** Blank space between the two structures. */ -const GAP = UNIT * 2; -/** Rings of empty cells kept around each structure, to click into when founding. */ +/** Empty cells kept around each structure, so there is somewhere to click when founding. */ const PADDING = 1; +/** Empty columns separating the Palace from the Yard when both are on the board. */ +const GAP = 1; -interface IStructureExtent { - originX: number; +/** Where one structure sits on the shared board. */ +interface IRegion { + which: "palace" | "yard"; + col0: number; minX: number; minY: number; cols: number; rows: number; - width: number; - height: number; } interface ILayout { - palace: IStructureExtent; - yard: IStructureExtent; + regions: IRegion[]; width: number; height: number; } @@ -638,7 +620,7 @@ export class IcePalaceGame extends GameBaseSequenced { "components>pyramids", "other>2+players", ], - flags: ["experimental", "scores", "player-stashes", "autopass"], + flags: ["experimental", "scores", "autopass"], }; public numplayers = 3; @@ -933,6 +915,18 @@ export class IcePalaceGame extends GameBaseSequenced { result.message = i18next.t("apgames:validation._general.VALID_MOVE"); return result; } + if (!move.includes("@")) { + // A bare pyramid: picked from the hand, not yet placed. + if (!this.handOf(this.currplayer).includes(move)) { + result.message = i18next.t("apgames:validation.icepalace.NOT_IN_HAND", { piece: move }); + return result; + } + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation.icepalace.PARTIAL_PIECE", { piece: move }); + return result; + } const parsed = IcePalaceGame.parsePlacement(move); if (parsed === undefined) { result.message = i18next.t("apgames:validation.icepalace.BAD_PLACEMENT", { move }); @@ -970,7 +964,10 @@ export class IcePalaceGame extends GameBaseSequenced { const palace = cloneStructure(this.palace); const stock = [...this.stock]; - for (const token of move.split(";")) { + const tokens = move.split(";"); + // A trailing bare pyramid is one picked from the stock but not yet placed. + const pending = tokens[tokens.length - 1].includes("@") ? undefined : tokens.pop(); + for (const token of tokens) { const parsed = IcePalaceGame.parsePlacement(token); if (parsed === undefined) { result.message = i18next.t("apgames:validation.icepalace.BAD_PLACEMENT", { move: token }); @@ -989,8 +986,19 @@ export class IcePalaceGame extends GameBaseSequenced { stock.splice(idx, 1); placeInto(palace, piece, cell); } + if (pending !== undefined) { + if (!stock.includes(pending)) { + result.message = i18next.t("apgames:validation.icepalace.NOT_IN_STOCK", { piece: pending }); + return result; + } + result.valid = true; + result.complete = -1; + result.canrender = true; + result.message = i18next.t("apgames:validation.icepalace.PARTIAL_PIECE", { piece: pending }); + return result; + } - const placed = move.split(";").length; + const placed = tokens.length; result.valid = true; result.canrender = true; if (placed < this.buildMin) { @@ -1043,7 +1051,11 @@ export class IcePalaceGame extends GameBaseSequenced { this.passes++; this.results.push({ type: "pass" }); } else { - const parsed = IcePalaceGame.parsePlacement(move)!; + const parsed = IcePalaceGame.parsePlacement(move); + if (parsed === undefined) { + // A bare pyramid, picked but not yet placed: nothing to apply. + return; + } const hand = this.handOf(this.currplayer); hand.splice(hand.indexOf(parsed.piece), 1); placeInto(this.yard, parsed.piece, parsed.cell); @@ -1187,24 +1199,6 @@ export class IcePalaceGame extends GameBaseSequenced { return score; } - public getPlayerStash(player: number): IStashEntry[] | undefined { - const hand = this.hands[player - 1]; - if (hand === undefined) { - return undefined; - } - const counts = new Map(); - for (const piece of hand) { - counts.set(piece, (counts.get(piece) ?? 0) + 1); - } - return [...counts.entries()] - .sort((a, b) => pieceSort(a[0], b[0])) - .map(([piece, count]) => ({ - count, - glyph: this.glyphFor(piece), - movePart: piece, - })); - } - public sidebarScores(): IScores[] { const scores: number[] = []; for (let p = 1; p <= this.numplayers; p++) { @@ -1213,13 +1207,19 @@ export class IcePalaceGame extends GameBaseSequenced { return [{ name: this.neutralAreaLabel("apgames:status.SCORES"), scores }]; } + /** Every hand, drawn as pyramids, plus the Pool and what the builder still has to use. */ public sidebarStatuses(): IStatus[] { - const statuses: IStatus[] = [ - { - key: this.neutralAreaLabel("apgames:status.icepalace.POOL"), - value: [this.pool.length.toString()], - }, - ]; + const statuses: IStatus[] = []; + for (let p = 1; p <= this.numplayers; p++) { + statuses.push({ + key: this.seatStatusValue(p), + value: this.hands[p - 1].map(piece => this.glyphFor(piece)), + }); + } + statuses.push({ + key: this.neutralAreaLabel("apgames:status.icepalace.POOL"), + value: [this.pool.length.toString()], + }); if (this.phase === "build") { statuses.push({ key: this.neutralAreaLabel("apgames:status.icepalace.MUST_USE"), @@ -1231,26 +1231,16 @@ export class IcePalaceGame extends GameBaseSequenced { /* --------------------------------------------------------------- rendering */ - /** - * Side-view pyramids, as Volcano draws them, rather than the top-down square. The - * `pyramid-flat-*` glyphs carry no full-cell sizing box, so the renderer normalises all - * three to the same footprint; scaling them back to their true height ratios is what - * keeps small, medium and large tellable apart. - */ private glyphFor(piece: PieceId): Glyph { - const size = sizeOf(piece); - const glyph: Glyph = { - name: `pyramid-flat-${SIZE_NAMES[size - 1]}`, - scale: PYRAMID_SCALES[size - 1], - }; + const name = `pyramid-up-${SIZE_NAMES[sizeOf(piece) - 1]}-3D`; const colour = colourOf(piece); if (colour === NULL_COLOUR) { - return { ...glyph, colour: "#000000" }; + return { name, colour: "#000000" }; } if (colour === WILD_COLOUR) { - return { ...glyph, colour: "#ffffff" }; + return { name, colour: "#ffffff" }; } - return { ...glyph, colour: Number(colour) }; + return { name, colour: Number(colour) }; } public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { @@ -1259,15 +1249,12 @@ export class IcePalaceGame extends GameBaseSequenced { const current = IcePalaceGame.normalise(move); let newmove: string; if (piece !== undefined && /^[1-6BW][SML]$/.test(piece.toUpperCase())) { - // A stash entry hands back the pyramid it represents. + // The pieces area hands back the legend key, which is the pyramid itself. newmove = this.appendToken(current, piece.toUpperCase()); - } else if (piece !== undefined && /^[yp]:-?\d+,-?\d+$/.test(piece)) { - // A pyramid already in play hands back the cell it stands on. - newmove = this.appendToken(current, `@${piece.substring(2)}`); } else { - // Empty freespace hands back continuous coordinates, which have to be - // mapped back through the layout this game renders with. - const cell = this.cellAt(col, row); + // Anything else is a board click: an empty cell, or a pyramid already in a + // stack there, which arrives with its stack index in `piece`. + const cell = this.cellAt(row, col); if (cell === undefined) { result.move = current; result.message = i18next.t("apgames:validation.icepalace.OFF_STRUCTURE"); @@ -1277,7 +1264,7 @@ export class IcePalaceGame extends GameBaseSequenced { } const validated = this.validateMove(newmove); if (!validated.valid) { - result.move = current === "" ? "" : current; + result.move = current; result.message = validated.message; return result; } @@ -1320,132 +1307,132 @@ export class IcePalaceGame extends GameBaseSequenced { } /** - * Both structures grow on unbounded grids and have to be shown at once, so this uses the - * freespace renderer and lays them out side by side rather than trying to fit two boards - * into one bounded board. Stacks are drawn bottom to top with a rising offset, which reads - * correctly for the Palace and the Yard even though their size rules run opposite ways. + * One perspective board holds both structures: the Palace on the left and, while a hand + * is being played, the Yard to its right. During the build the Yard has already been + * taken up into the stock, which is offered in the pieces area instead. The pieces area + * is where the current player picks a pyramid from; every hand is also listed in the + * status panel. */ public render(opts?: IRenderOpts): APRenderRep { void opts; const layout = this.layout(); const legend: { [k: string]: Glyph } = {}; - const pieces: Freepiece[] = []; - const markers: MarkerFreespaceLabel[] = []; - - const draw = (struct: Structure, extent: IStructureExtent, tag: string): void => { + const pieces: string[][][] = []; + for (let row = 0; row < layout.height; row++) { + const line: string[][] = []; + for (let col = 0; col < layout.width; col++) { + line.push([]); + } + pieces.push(line); + } + for (const region of layout.regions) { + const struct = region.which === "palace" ? this.palace : this.yard; for (const [cell, stack] of struct.entries()) { const [x, y] = coordsOf(cell); - const baseX = extent.originX + (x - extent.minX + 0.5) * UNIT; - const baseY = layout.height - (y - extent.minY + 0.5) * ROW_PITCH; - for (let i = 0; i < stack.length; i++) { - const key = `p${stack[i]}`; - if (!(key in legend)) { - legend[key] = this.glyphFor(stack[i]); + const col = region.col0 + (x - region.minX); + const row = layout.height - 1 - (y - region.minY); + for (const piece of stack) { + if (!(piece in legend)) { + legend[piece] = this.glyphFor(piece); } - // Each pyramid in a stack rises a little above the one below, so the - // whole stack stays readable and its true order is visible. That matters - // because the Yard and the Palace stack in opposite size orders. - pieces.push({ - glyph: key, - x: baseX, - y: baseY - i * RISER, - id: `${tag}:${cell}`, - }); + pieces[row][col].push(piece); } } - }; + } - draw(this.palace, layout.palace, "p"); - draw(this.yard, layout.yard, "y"); - - const label = (text: MarkerFreespaceLabel["label"], extent: IStructureExtent): void => { - markers.push({ - type: "label", - label: text, - points: [ - { x: extent.originX, y: layout.height + UNIT / 2 }, - { x: extent.originX + extent.width, y: layout.height + UNIT / 2 }, - ], + const offered = this.phase === "build" ? this.stock : this.handOf(this.currplayer); + for (const piece of offered) { + if (!(piece in legend)) { + legend[piece] = this.glyphFor(piece); + } + } + const areas: AreaPieces[] = []; + if (offered.length > 0) { + areas.push({ + type: "pieces", + pieces: [...offered] as [string, ...string[]], + // i18next.t("apgames:icepalace.STOCK") + // i18next.t("apgames:icepalace.HAND") + label: this.phase === "build" + ? this.neutralAreaLabel("apgames:icepalace.STOCK") + : this.seatAreaLabel(this.currplayer, "apgames:icepalace.HAND"), + ownerMark: this.currplayer, }); - }; - // Structured labels, resolved by the front end, rather than English baked in here. - // i18next.t("apgames:icepalace.PALACE") - label(this.neutralAreaLabel("apgames:icepalace.PALACE"), layout.palace); - // i18next.t("apgames:icepalace.YARD") - // i18next.t("apgames:icepalace.YARD_BUILDING") - label( - this.neutralAreaLabel( - this.phase === "build" ? "apgames:icepalace.YARD_BUILDING" : "apgames:icepalace.YARD", - ), - layout.yard, - ); + } const rep: APRenderRep = { - renderer: "freespace", + renderer: "stacking-3D", + options: ["hide-labels"], board: { + style: "squares", width: layout.width, - height: layout.height + UNIT, - markers: markers.length > 0 ? markers : undefined, + height: layout.height, }, legend, - pieces, + pieces: pieces as [string[][], ...string[][][]], + areas: areas.length > 0 ? areas : undefined, }; return rep; } /** - * Where each structure sits on the freespace canvas. Both grids are unbounded, so each - * is padded by a ring of empty cells; without it there would be nowhere to click to - * found a stack on the frontier. + * The Yard is on the board while a hand is being played; the Palace whenever it holds + * anything, and always during the build. Each is padded by a ring of empty cells. An + * empty structure that must still take a placement collapses to a single cell, which + * is where the lead goes. */ private layout(): ILayout { - const extentOf = (struct: Structure, originX: number): IStructureExtent => { + const shown: ("palace" | "yard")[] = []; + if (this.phase === "build" || this.palace.size > 0) { + shown.push("palace"); + } + if (this.phase === "hand") { + shown.push("yard"); + } + + const regions: IRegion[] = []; + let col0 = 0; + let height = 0; + for (const which of shown) { + const struct = which === "palace" ? this.palace : this.yard; + let region: IRegion; if (struct.size === 0) { - return { originX, minX: 0, minY: 0, cols: 1, rows: 1, width: UNIT, height: ROW_PITCH }; + region = { which, col0, minX: 0, minY: 0, cols: 1, rows: 1 }; + } else { + const coords = [...struct.keys()].map(coordsOf); + const minX = Math.min(...coords.map(c => c[0])) - PADDING; + const maxX = Math.max(...coords.map(c => c[0])) + PADDING; + const minY = Math.min(...coords.map(c => c[1])) - PADDING; + const maxY = Math.max(...coords.map(c => c[1])) + PADDING; + region = { which, col0, minX, minY, cols: maxX - minX + 1, rows: maxY - minY + 1 }; } - const coords = [...struct.keys()].map(coordsOf); - const minX = Math.min(...coords.map(c => c[0])) - PADDING; - const maxX = Math.max(...coords.map(c => c[0])) + PADDING; - const minY = Math.min(...coords.map(c => c[1])) - PADDING; - const maxY = Math.max(...coords.map(c => c[1])) + PADDING; - const cols = maxX - minX + 1; - const rows = maxY - minY + 1; - return { - originX, minX, minY, cols, rows, - width: cols * UNIT, - height: rows * ROW_PITCH, - }; - }; - - const palace = extentOf(this.palace, 0); - const yard = extentOf(this.yard, palace.width + GAP); - return { - palace, - yard, - width: palace.width + GAP + yard.width, - height: Math.max(palace.height, yard.height), - }; + regions.push(region); + col0 += region.cols + GAP; + height = Math.max(height, region.rows); + } + return { regions, width: col0 - GAP, height }; } /** - * Turns a click on empty freespace back into a cell of whichever structure is in play - * this phase. Returns undefined when the click landed in the gutter or the wrong half. + * Which cell a board click landed on. Only the structure being built into this phase + * takes placements, so a click on the other one, or in the gap, is undefined. */ - private cellAt(x: number, y: number): Cell | undefined { + private cellAt(row: number, col: number): Cell | undefined { const layout = this.layout(); - const extent = this.phase === "build" ? layout.palace : layout.yard; - const localX = x - extent.originX; - if (localX < 0 || localX >= extent.width) { - return undefined; - } - const localY = layout.height - y; - if (localY < 0 || localY >= extent.height) { - return undefined; + const active = this.phase === "build" ? "palace" : "yard"; + for (const region of layout.regions) { + if (col < region.col0 || col >= region.col0 + region.cols) { + continue; + } + if (region.which !== active) { + return undefined; + } + return cellOf( + region.minX + (col - region.col0), + region.minY + (layout.height - 1 - row), + ); } - return cellOf( - extent.minX + Math.floor(localX / UNIT), - extent.minY + Math.floor(localY / ROW_PITCH), - ); + return undefined; } public getPlayerColour(player: number): number { diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 9fd68bef..dcbbf4d7 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -313,44 +313,78 @@ describe("Ice Palace: scoring and ending", () => { }); describe("Ice Palace: board interaction", () => { - /** - * The freespace renderer reports clicks as continuous coordinates, so the layout maths - * has to invert cleanly. This checks the arithmetic only; the renderer JSON itself is - * not verified here. - */ - it("maps a click at a cell's drawn position back to that cell", () => { + type Rep = { pieces: string[][][]; areas?: { pieces: string[] }[] }; + + /** Board row and column at which a cell's stack is drawn. */ + const drawnAt = (rep: Rep, piece: string): [number, number] => { + for (let row = 0; row < rep.pieces.length; row++) { + for (let col = 0; col < rep.pieces[row].length; col++) { + if (rep.pieces[row][col].includes(piece)) { + return [row, col]; + } + } + } + throw new Error(`${piece} is not drawn anywhere`); + }; + + 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"); - const rep = g.render() as { pieces: { x: number; y: number; id: string }[] }; - const drawn = rep.pieces.find(p => p.id === "y:0,0"); - expect(drawn, "the placed pyramid should be drawn").to.not.be.undefined; - // A large covers a medium, and colour is irrelevant when stacking. - const click = g.handleClick("2L", drawn!.y, drawn!.x, "_field"); + 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"); }); it("offers frontier space to found new stacks into", () => { - const g = rig(new IcePalaceGame(3), [["1M", "1S"], ["1S"], ["3S"]], fatPool()); + const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); g.move("1M@0,0"); - const rep = g.render() as { pieces: { x: number; y: number; id: string }[]; board: { width: number; height: number } }; - const drawn = rep.pieces.find(p => p.id === "y:0,0")!; - // One cell to the right of the only stack. Columns are pitched at the renderer's - // own cellsize, which is what freespace scales pieces to. - const CELL = 50; - const click = g.handleClick("1S", drawn.y, drawn.x + CELL, "_field"); + 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"); }); - it("selects a cell when an existing pyramid is clicked", () => { - const g = rig(new IcePalaceGame(3), [["1S", "1L"], ["1S"], ["3S"]], fatPool()); - g.move("1S@0,0"); + it("refuses clicks on the Palace while a hand is being played", () => { + const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); + 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.valid).to.be.false; + }); + + 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"); + const click = g.handleClick("", -1, -1, "2L"); + expect(click.valid, click.message).to.be.true; + expect(click.move).to.equal("2L"); + }); + + 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"); + expect((g.render() as Rep).areas?.[0].pieces).to.deep.equal(["2L", "2S"]); g.move("pass"); g.move("pass"); - const click = g.handleClick("1L", 0, 0, "y:0,0"); - expect(click.valid, click.message).to.be.true; - expect(click.move).to.equal("1L@0,0"); + g.move("1L@0,0"); + while (g.phase === "hand") { + g.move("pass"); + } + expect(g.phase).to.equal("build"); + expect((g.render() as Rep).areas?.[0].pieces.sort()).to.deep.equal(["1L", "1M"]); + }); + + it("lists every hand in the status panel", () => { + const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2S"], ["3L", "3M", "3S"]], fatPool()); + const statuses = g.sidebarStatuses(); + expect(statuses.length).to.be.greaterThan(3); + expect(statuses[0].value.length).to.equal(2); + expect(statuses[1].value.length).to.equal(1); + expect(statuses[2].value.length).to.equal(3); }); }); From f745281ca9d48cc5d04f6912d7d32557364aa4d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 00:58:58 +0000 Subject: [PATCH 09/22] Prefix Ice Palace legend keys so they are valid element ids Legend keys double as SVG element ids, and the renderer later looks them up with querySelector("#" + key). A pyramid id such as "1M" starts with a digit, which is not a valid selector in a real browser; svgdom accepted it, so the headless render passed and only a Chromium render exposed it. Keys now carry a letter prefix, and clicks from the pieces area, which hand back the key, accept it with or without the prefix. Verified by rendering the position in Chromium through the renderer's own playground harness, which also confirmed the pieces area draws below the board with the current player's owner mark and hand. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 29 ++++++++++++++++++++--------- test/games/icepalace.test.ts | 9 +++++---- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index c96cd7dd..facd5a10 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -1231,6 +1231,14 @@ export class IcePalaceGame extends GameBaseSequenced { /* --------------------------------------------------------------- rendering */ + /** + * Legend keys double as SVG element ids, and an id that starts with a digit is not a + * valid selector in a real browser, so the pyramid id gets a letter in front. + */ + private static legendKey(piece: PieceId): string { + return `p${piece}`; + } + private glyphFor(piece: PieceId): Glyph { const name = `pyramid-up-${SIZE_NAMES[sizeOf(piece) - 1]}-3D`; const colour = colourOf(piece); @@ -1248,9 +1256,10 @@ export class IcePalaceGame extends GameBaseSequenced { try { const current = IcePalaceGame.normalise(move); let newmove: string; - if (piece !== undefined && /^[1-6BW][SML]$/.test(piece.toUpperCase())) { - // The pieces area hands back the legend key, which is the pyramid itself. - newmove = this.appendToken(current, piece.toUpperCase()); + const picked = piece === undefined ? undefined : /^P?([1-6BW][SML])$/.exec(piece.toUpperCase()); + if (picked !== null && picked !== undefined) { + // The pieces area hands back the legend key, which names the pyramid. + newmove = this.appendToken(current, picked[1]); } else { // Anything else is a board click: an empty cell, or a pyramid already in a // stack there, which arrives with its stack index in `piece`. @@ -1332,25 +1341,27 @@ export class IcePalaceGame extends GameBaseSequenced { const col = region.col0 + (x - region.minX); const row = layout.height - 1 - (y - region.minY); for (const piece of stack) { - if (!(piece in legend)) { - legend[piece] = this.glyphFor(piece); + const key = IcePalaceGame.legendKey(piece); + if (!(key in legend)) { + legend[key] = this.glyphFor(piece); } - pieces[row][col].push(piece); + pieces[row][col].push(key); } } } const offered = this.phase === "build" ? this.stock : this.handOf(this.currplayer); for (const piece of offered) { - if (!(piece in legend)) { - legend[piece] = this.glyphFor(piece); + const key = IcePalaceGame.legendKey(piece); + if (!(key in legend)) { + legend[key] = this.glyphFor(piece); } } const areas: AreaPieces[] = []; if (offered.length > 0) { areas.push({ type: "pieces", - pieces: [...offered] as [string, ...string[]], + pieces: offered.map(piece => IcePalaceGame.legendKey(piece)) as [string, ...string[]], // i18next.t("apgames:icepalace.STOCK") // i18next.t("apgames:icepalace.HAND") label: this.phase === "build" diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index dcbbf4d7..13c9fcdf 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -319,7 +319,7 @@ describe("Ice Palace: board interaction", () => { const drawnAt = (rep: Rep, piece: string): [number, number] => { for (let row = 0; row < rep.pieces.length; row++) { for (let col = 0; col < rep.pieces[row].length; col++) { - if (rep.pieces[row][col].includes(piece)) { + if (rep.pieces[row][col].includes("p" + piece)) { return [row, col]; } } @@ -359,7 +359,8 @@ describe("Ice Palace: board interaction", () => { 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"); - const click = g.handleClick("", -1, -1, "2L"); + // 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; expect(click.move).to.equal("2L"); }); @@ -367,7 +368,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"); - expect((g.render() as Rep).areas?.[0].pieces).to.deep.equal(["2L", "2S"]); + expect((g.render() as Rep).areas?.[0].pieces).to.deep.equal(["p2L", "p2S"]); g.move("pass"); g.move("pass"); g.move("1L@0,0"); @@ -375,7 +376,7 @@ describe("Ice Palace: board interaction", () => { g.move("pass"); } expect(g.phase).to.equal("build"); - expect((g.render() as Rep).areas?.[0].pieces.sort()).to.deep.equal(["1L", "1M"]); + expect((g.render() as Rep).areas?.[0].pieces.sort()).to.deep.equal(["p1L", "p1M"]); }); it("lists every hand in the status panel", () => { From 3a6d490ca987fdcaadc5313723379adb67828bd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 01:19:53 +0000 Subject: [PATCH 10/22] Stand every pyramid on its cell in the stacking-3D view The -3D pyramid glyphs are drawn for Volcano's nests: all three sizes share an apex and grow downward from it, so a medium's base sits 15 units lower than a small's and a large's 30 lower. Volcano lists nests small-first and its default rise of 0.15 exactly cancels those depths, which is why its stacks look planted. Ice Palace stacks pyramids on one another, with a large at the bottom of every Palace tower, so the bottom piece sat up to 30 units low in its cell. Each size now carries a legend nudge that lifts it by its extra depth, so the bottom of any stack sits on the cell centre whatever its size. With bases aligned, the default rise would leave every apex in a stack coincident and the piece drawn last would paint over the tips below it; a rise of 0.25 keeps each tip visible without pulling a stack apart into separate pieces. Checked in Chromium against lone pyramids of each size and both stacking orders, and against Volcano's own playground example. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 28 ++++++++++++++++++++++++---- test/games/icepalace.test.ts | 12 ++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index facd5a10..8c55cab6 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -534,6 +534,21 @@ export type Phase = "hand" | "build"; const PADDING = 1; /** Empty columns separating the Palace from the Yard when both are on the board. */ const GAP = 1; +/** + * The `-3D` pyramid glyphs are drawn for Volcano's nests, so all three sizes share an apex + * and grow downward from it: a medium's base sits 15 units below a small's, a large's 30 + * below, in the sheet's 100-unit box. Here pyramids stand on one another rather than nest, + * and the bottom of a stack should sit on the cell whatever its size, so each size is + * nudged back up by its extra depth. The legend composes glyphs in a 500-unit box, hence ×5. + */ +const PYRAMID_BASE_NUDGE = [0, -75, -150]; +/** + * How far each pyramid rises above the one beneath, as a fraction of the cell. Volcano's + * default of 0.15 exactly cancels the base depths above for a nest, which with base-aligned + * glyphs would leave every apex coincident; 0.25 keeps each tip visible without pulling a + * stack apart into separate pieces. + */ +const STACK_OFFSET = 0.25; /** Where one structure sits on the shared board. */ interface IRegion { @@ -1240,15 +1255,19 @@ export class IcePalaceGame extends GameBaseSequenced { } private glyphFor(piece: PieceId): Glyph { - const name = `pyramid-up-${SIZE_NAMES[sizeOf(piece) - 1]}-3D`; + const size = sizeOf(piece); + const glyph: Glyph = { name: `pyramid-up-${SIZE_NAMES[size - 1]}-3D` }; + if (PYRAMID_BASE_NUDGE[size - 1] !== 0) { + glyph.nudge = { dx: 0, dy: PYRAMID_BASE_NUDGE[size - 1] }; + } const colour = colourOf(piece); if (colour === NULL_COLOUR) { - return { name, colour: "#000000" }; + return { ...glyph, colour: "#000000" }; } if (colour === WILD_COLOUR) { - return { name, colour: "#ffffff" }; + return { ...glyph, colour: "#ffffff" }; } - return { name, colour: Number(colour) }; + return { ...glyph, colour: Number(colour) }; } public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { @@ -1378,6 +1397,7 @@ export class IcePalaceGame extends GameBaseSequenced { style: "squares", width: layout.width, height: layout.height, + stackOffset: STACK_OFFSET, }, legend, pieces: pieces as [string[][], ...string[][][]], diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 13c9fcdf..1f043af2 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -379,6 +379,18 @@ describe("Ice Palace: board interaction", () => { expect((g.render() as Rep).areas?.[0].pieces.sort()).to.deep.equal(["p1L", "p1M"]); }); + it("stands every size on the cell and spaces stacked tips apart", () => { + // The -3D glyphs share an apex, so without a per-size nudge a large would sit 30 + // units lower in its cell than a small; and with bases aligned, Volcano's default + // rise would leave every apex in a stack coincident. + const g = rig(new IcePalaceGame(3), [["1S", "1M", "1L"], ["2S"], ["3S"]], fatPool()); + const rep = g.render() as { board: { stackOffset?: number }; legend: Record }; + expect(rep.board.stackOffset).to.equal(0.25); + expect(rep.legend.p1S.nudge).to.be.undefined; + expect(rep.legend.p1M.nudge?.dy).to.equal(-75); + expect(rep.legend.p1L.nudge?.dy).to.equal(-150); + }); + it("lists every hand in the status panel", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2S"], ["3L", "3M", "3S"]], fatPool()); const statuses = g.sidebarStatuses(); From 2fade119221fdd7d3c79342c2933607f7e4b28fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 01:36:27 +0000 Subject: [PATCH 11/22] Stack pyramids exactly on one another the way Volcano does The previous fix nudged each size up to stand on the cell and widened the rise to 0.25 so tips would not coincide. That was a compromise: a single rise cannot suit both a medium on a small and a small on a large, so pieces either floated or overlapped a little. Volcano does not tune the rise at all. It spends "-" placeholders, which the renderer skips but still counts, so that with the default rise of 0.15 one array index is exactly one glyph step: a small's base sits on the cell at index 0, a medium's at index 1, a large's at index 2, and their heights are one, two and three steps. A piece whose base should sit `top` steps up goes at index `top + height - 1`. stackColumn lays every stack out that way, so a Palace tower is ["-", "-", L, "-", M, S] and a Yard stack [S, "-", M, "-", "-", L], each pyramid standing on the apex of the one below it in either order, and a lone large is ["-", "-", L]. The nudges are gone and the rise is pinned to 0.15, which the layout depends on. Verified in Chromium against both stacking orders and lone pyramids of every size, and pinned by a test on the emitted columns. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 53 +++++++++++++++++++++--------------- test/games/icepalace.test.ts | 30 +++++++++++++------- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 8c55cab6..da66070a 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -535,20 +535,12 @@ const PADDING = 1; /** Empty columns separating the Palace from the Yard when both are on the board. */ const GAP = 1; /** - * The `-3D` pyramid glyphs are drawn for Volcano's nests, so all three sizes share an apex - * and grow downward from it: a medium's base sits 15 units below a small's, a large's 30 - * below, in the sheet's 100-unit box. Here pyramids stand on one another rather than nest, - * and the bottom of a stack should sit on the cell whatever its size, so each size is - * nudged back up by its extra depth. The legend composes glyphs in a 500-unit box, hence ×5. + * How far each array index rises above the last, as a fraction of the cell. It must stay at + * the renderer's default, because `stackColumn` relies on one index being exactly one glyph + * step: the `-3D` pyramids are drawn with a small's base at the cell centre, a medium's one + * step lower and a large's two lower, with heights of one, two and three steps. */ -const PYRAMID_BASE_NUDGE = [0, -75, -150]; -/** - * How far each pyramid rises above the one beneath, as a fraction of the cell. Volcano's - * default of 0.15 exactly cancels the base depths above for a nest, which with base-aligned - * glyphs would leave every apex coincident; 0.25 keeps each tip visible without pulling a - * stack apart into separate pieces. - */ -const STACK_OFFSET = 0.25; +const STACK_OFFSET = 0.15; /** Where one structure sits on the shared board. */ interface IRegion { @@ -1254,20 +1246,37 @@ export class IcePalaceGame extends GameBaseSequenced { return `p${piece}`; } - private glyphFor(piece: PieceId): Glyph { - const size = sizeOf(piece); - const glyph: Glyph = { name: `pyramid-up-${SIZE_NAMES[size - 1]}-3D` }; - if (PYRAMID_BASE_NUDGE[size - 1] !== 0) { - glyph.nudge = { dx: 0, dy: PYRAMID_BASE_NUDGE[size - 1] }; + /** + * Lays a stack out so each pyramid stands exactly on the one below it, the way Volcano + * does. A piece whose base should sit `top` steps above the ground belongs at index + * `top + height - 1`, and "-" placeholders, which the renderer skips but still counts, + * fill the indices in between. A lone large is therefore `["-", "-", large]`. + */ + private static stackColumn(stack: PieceId[]): string[] { + const column: string[] = []; + let top = 0; + for (const piece of stack) { + const height = sizeOf(piece); + const index = top + height - 1; + while (column.length < index) { + column.push("-"); + } + column.push(IcePalaceGame.legendKey(piece)); + top += height; } + return column; + } + + private glyphFor(piece: PieceId): Glyph { + const name = `pyramid-up-${SIZE_NAMES[sizeOf(piece) - 1]}-3D`; const colour = colourOf(piece); if (colour === NULL_COLOUR) { - return { ...glyph, colour: "#000000" }; + return { name, colour: "#000000" }; } if (colour === WILD_COLOUR) { - return { ...glyph, colour: "#ffffff" }; + return { name, colour: "#ffffff" }; } - return { ...glyph, colour: Number(colour) }; + return { name, colour: Number(colour) }; } public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { @@ -1364,8 +1373,8 @@ export class IcePalaceGame extends GameBaseSequenced { if (!(key in legend)) { legend[key] = this.glyphFor(piece); } - pieces[row][col].push(key); } + pieces[row][col] = IcePalaceGame.stackColumn(stack); } } diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 1f043af2..b64148e4 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -379,16 +379,26 @@ describe("Ice Palace: board interaction", () => { expect((g.render() as Rep).areas?.[0].pieces.sort()).to.deep.equal(["p1L", "p1M"]); }); - it("stands every size on the cell and spaces stacked tips apart", () => { - // The -3D glyphs share an apex, so without a per-size nudge a large would sit 30 - // units lower in its cell than a small; and with bases aligned, Volcano's default - // rise would leave every apex in a stack coincident. - const g = rig(new IcePalaceGame(3), [["1S", "1M", "1L"], ["2S"], ["3S"]], fatPool()); - const rep = g.render() as { board: { stackOffset?: number }; legend: Record }; - expect(rep.board.stackOffset).to.equal(0.25); - expect(rep.legend.p1S.nudge).to.be.undefined; - expect(rep.legend.p1M.nudge?.dy).to.equal(-75); - expect(rep.legend.p1L.nudge?.dy).to.equal(-150); + it("stands every pyramid exactly on the one below it", () => { + // The -3D glyphs are drawn for nests: at index 0 a small's base is on the cell, a + // medium's one rise-step lower, a large's two lower, with heights of one, two and + // three steps. Volcano gets exact stacking by spending "-" placeholders to lift each + // piece to the previous one's apex, and so does this. + const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); + g.palace = new Map([["0,0", ["1L", "2M", "3S"]], ["1,0", ["2L"]]]); + g.yard = new Map([["0,0", ["1S", "2M", "3L"]]]); + const rep = g.render() as Rep & { board: { stackOffset?: number }; legend: Record }; + expect(rep.board.stackOffset).to.equal(0.15); + expect(rep.legend.p1L.nudge).to.be.undefined; + // A Palace tower: large on the ground, medium on its apex, small on the medium's. + const [tr, tc] = drawnAt(rep, "3S"); + expect(rep.pieces[tr][tc]).to.deep.equal(["-", "-", "p1L", "-", "p2M", "p3S"]); + // A lone large has to be lifted two steps to stand on the ground. + const [lr, lc] = drawnAt(rep, "2L"); + expect(rep.pieces[lr][lc]).to.deep.equal(["-", "-", "p2L"]); + // A Yard stack: small on the ground, then medium, then large, each on the last apex. + const [yr, yc] = drawnAt(rep, "3L"); + expect(rep.pieces[yr][yc]).to.deep.equal(["p1S", "-", "p2M", "-", "-", "p3L"]); }); it("lists every hand in the status panel", () => { From 2e75461cf4f9313ce6335a2c1dff324a703571eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 01:40:44 +0000 Subject: [PATCH 12/22] Place stacked pyramids one index apart, as Volcano does The previous layout lifted each pyramid by the full height of the one beneath it, so a medium over a large sat three rise-steps up and tips ended up thirty units apart, which read as gaps. Volcano's rule is simpler: each piece sits one index above the last, and "-" placeholders are spent only to keep a base from sinking below the ground. A small stands on the cell at index 0, a medium at index 1 and a large at index 2, so a tower of large, medium, small is ["-", "-", L, M, S] and a Yard stack of small, medium, large needs no placeholders at all. Tips nest fifteen units apart, with no gaps, matching Volcano's own positions. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 18 ++++++++---------- test/games/icepalace.test.ts | 24 +++++++++++++----------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index da66070a..541cfe75 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -538,7 +538,7 @@ const GAP = 1; * How far each array index rises above the last, as a fraction of the cell. It must stay at * the renderer's default, because `stackColumn` relies on one index being exactly one glyph * step: the `-3D` pyramids are drawn with a small's base at the cell centre, a medium's one - * step lower and a large's two lower, with heights of one, two and three steps. + * step lower and a large's two lower. */ const STACK_OFFSET = 0.15; @@ -1247,22 +1247,20 @@ export class IcePalaceGame extends GameBaseSequenced { } /** - * Lays a stack out so each pyramid stands exactly on the one below it, the way Volcano - * does. A piece whose base should sit `top` steps above the ground belongs at index - * `top + height - 1`, and "-" placeholders, which the renderer skips but still counts, - * fill the indices in between. A lone large is therefore `["-", "-", large]`. + * Lays a stack out the way Volcano does: each pyramid sits one index above the last, and + * "-" placeholders, which the renderer skips but still counts, are spent only to stop a + * piece's base sinking below the ground. A small's base is on the ground at index 0, a + * medium's at index 1 and a large's at index 2, so a lone large is `["-", "-", large]` + * and a tower of large, medium, small is `["-", "-", large, medium, small]`. */ private static stackColumn(stack: PieceId[]): string[] { const column: string[] = []; - let top = 0; for (const piece of stack) { - const height = sizeOf(piece); - const index = top + height - 1; - while (column.length < index) { + const ground = sizeOf(piece) - 1; + while (column.length < ground) { column.push("-"); } column.push(IcePalaceGame.legendKey(piece)); - top += height; } return column; } diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index b64148e4..154169a9 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -379,26 +379,28 @@ describe("Ice Palace: board interaction", () => { expect((g.render() as Rep).areas?.[0].pieces.sort()).to.deep.equal(["p1L", "p1M"]); }); - it("stands every pyramid exactly on the one below it", () => { - // The -3D glyphs are drawn for nests: at index 0 a small's base is on the cell, a - // medium's one rise-step lower, a large's two lower, with heights of one, two and - // three steps. Volcano gets exact stacking by spending "-" placeholders to lift each - // piece to the previous one's apex, and so does this. + it("stacks pyramids the way Volcano does, one index above the last", () => { + // The -3D glyphs put a small's base on the cell at index 0, a medium's one rise-step + // lower and a large's two lower. Volcano sits each piece one index above the last + // and spends "-" placeholders only to keep a base from sinking below the ground. const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); - g.palace = new Map([["0,0", ["1L", "2M", "3S"]], ["1,0", ["2L"]]]); + // 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"]]]); const rep = g.render() as Rep & { board: { stackOffset?: number }; legend: Record }; expect(rep.board.stackOffset).to.equal(0.15); expect(rep.legend.p1L.nudge).to.be.undefined; - // A Palace tower: large on the ground, medium on its apex, small on the medium's. + // A Palace tower: the large is lifted onto the ground, then each piece sits one up. const [tr, tc] = drawnAt(rep, "3S"); - expect(rep.pieces[tr][tc]).to.deep.equal(["-", "-", "p1L", "-", "p2M", "p3S"]); - // A lone large has to be lifted two steps to stand on the ground. + expect(rep.pieces[tr][tc]).to.deep.equal(["-", "-", "p1L", "p2M", "p3S"]); + // Lone pieces need only enough lift to reach the ground. const [lr, lc] = drawnAt(rep, "2L"); expect(rep.pieces[lr][lc]).to.deep.equal(["-", "-", "p2L"]); - // A Yard stack: small on the ground, then medium, then large, each on the last apex. + const [mr, mc] = drawnAt(rep, "3M"); + expect(rep.pieces[mr][mc]).to.deep.equal(["-", "p3M"]); + // A Yard stack grows upward in size, so every base already clears the ground. const [yr, yc] = drawnAt(rep, "3L"); - expect(rep.pieces[yr][yc]).to.deep.equal(["p1S", "-", "p2M", "-", "-", "p3L"]); + expect(rep.pieces[yr][yc]).to.deep.equal(["p1S", "p2M", "p3L"]); }); it("lists every hand in the status panel", () => { From b6f1cb75c67592f2f38cf55279deecbdb8b2ea6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:08:37 +0000 Subject: [PATCH 13/22] Give the Ice Palace board a minimum footprint and a wider gap The board already grew and shrank with the structures each render, but it did so after every single placement. stacking-3D refits its perspective to the board's width and height, so each change of shape moved every piece, and the Yard, which starts empty every hand, lurched from one cell to three to four over the first few placements. Each structure's region now always covers a five-cell box centred on the origin, where the first pyramid goes, so a structure can grow in any direction for a while before the board changes shape, and past that the board grows only in the direction the structure did. With the ring of padding that gives a 7x7 floor for the Palace alone during the build, and 16x7 during a hand with two empty columns between the Palace and the Yard. A click anywhere in an empty region places the lead at the origin, keeping it in the middle. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 37 +++++++++++++++++++++--------------- test/games/icepalace.test.ts | 21 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 541cfe75..60fc7a03 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -533,7 +533,16 @@ export type Phase = "hand" | "build"; /** Empty cells kept around each structure, so there is somewhere to click when founding. */ const PADDING = 1; /** Empty columns separating the Palace from the Yard when both are on the board. */ -const GAP = 1; +const GAP = 2; +/** + * Each structure's region always covers at least the cells within this reach of the origin, + * where the first pyramid goes. stacking-3D refits its perspective to the board size, so a + * board that changed shape after every placement would lurch each turn; this lets a structure + * grow in any direction for a while before the board has to change, and beyond that the + * board grows only in the direction the structure did. A reach of 2 is a 5-cell span, or + * 7 with padding. Set to 0 for a board that hugs the structures exactly. + */ +const MIN_REACH = 2; /** * How far each array index rises above the last, as a fraction of the cell. It must stay at * the renderer's default, because `stackColumn` relies on one index being exactly one glyph @@ -1415,9 +1424,8 @@ export class IcePalaceGame extends GameBaseSequenced { /** * The Yard is on the board while a hand is being played; the Palace whenever it holds - * anything, and always during the build. Each is padded by a ring of empty cells. An - * empty structure that must still take a placement collapses to a single cell, which - * is where the lead goes. + * anything, and always during the build. Each region covers its structure and the + * minimum box around the origin, padded by a ring of empty cells to click into. */ private layout(): ILayout { const shown: ("palace" | "yard")[] = []; @@ -1433,17 +1441,12 @@ export class IcePalaceGame extends GameBaseSequenced { let height = 0; for (const which of shown) { const struct = which === "palace" ? this.palace : this.yard; - let region: IRegion; - if (struct.size === 0) { - region = { which, col0, minX: 0, minY: 0, cols: 1, rows: 1 }; - } else { - const coords = [...struct.keys()].map(coordsOf); - const minX = Math.min(...coords.map(c => c[0])) - PADDING; - const maxX = Math.max(...coords.map(c => c[0])) + PADDING; - const minY = Math.min(...coords.map(c => c[1])) - PADDING; - const maxY = Math.max(...coords.map(c => c[1])) + PADDING; - region = { which, col0, minX, minY, cols: maxX - minX + 1, rows: maxY - minY + 1 }; - } + const coords = [...struct.keys()].map(coordsOf); + const minX = Math.min(-MIN_REACH, ...coords.map(c => c[0])) - PADDING; + const maxX = Math.max(MIN_REACH, ...coords.map(c => c[0])) + PADDING; + const minY = Math.min(-MIN_REACH, ...coords.map(c => c[1])) - PADDING; + const maxY = Math.max(MIN_REACH, ...coords.map(c => c[1])) + PADDING; + const region: IRegion = { which, col0, minX, minY, cols: maxX - minX + 1, rows: maxY - minY + 1 }; regions.push(region); col0 += region.cols + GAP; height = Math.max(height, region.rows); @@ -1465,6 +1468,10 @@ export class IcePalaceGame extends GameBaseSequenced { if (region.which !== active) { return undefined; } + // The lead goes in the middle: a click anywhere in an empty region is the origin. + if ((active === "palace" ? this.palace : this.yard).size === 0) { + return cellOf(0, 0); + } return cellOf( region.minX + (col - region.col0), region.minY + (layout.height - 1 - row), diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 154169a9..fb74c333 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -403,6 +403,27 @@ describe("Ice Palace: board interaction", () => { expect(rep.pieces[yr][yc]).to.deep.equal(["p1S", "p2M", "p3L"]); }); + it("keeps a minimum footprint around the origin and two columns between structures", () => { + const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); + // Before the lead only the empty Yard shows: a five-cell box plus padding each side. + let rep = g.render() as Rep; + expect(rep.pieces.length).to.equal(7); + 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"); + // With a Palace as well, both regions show, two empty columns apart. + 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 + 2 + 7); + // Growing past the minimum extends the board only in that direction. + g.yard.set("4,0", ["1S"]); + rep = g.render() as Rep; + expect(rep.pieces[0].length).to.equal(7 + 2 + 9); + }); + it("lists every hand in the status panel", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2S"], ["3L", "3M", "3S"]], fatPool()); const statuses = g.sidebarStatuses(); From 069065eb154db7fd0992f3dc9d37e810bc498159 Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Sun, 20 Sep 2026 10:22:22 +0000 Subject: [PATCH 14/22] Put the Ice Palace Yard on the left and the Palace on the right Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 9 +++++---- test/games/icepalace.test.ts | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 60fc7a03..f8900363 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -1423,18 +1423,19 @@ export class IcePalaceGame extends GameBaseSequenced { } /** - * The Yard is on the board while a hand is being played; the Palace whenever it holds + * The Yard (left) is on the board while a hand is being played; the Palace (right) whenever it holds * anything, and always during the build. Each region covers its structure and the * minimum box around the origin, padded by a ring of empty cells to click into. */ private layout(): ILayout { + // Left to right: the Yard, then the Palace. const shown: ("palace" | "yard")[] = []; - if (this.phase === "build" || this.palace.size > 0) { - shown.push("palace"); - } if (this.phase === "hand") { shown.push("yard"); } + if (this.phase === "build" || this.palace.size > 0) { + shown.push("palace"); + } const regions: IRegion[] = []; let col0 = 0; diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index fb74c333..a796c310 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -418,6 +418,9 @@ describe("Ice Palace: board interaction", () => { rep = g.render() as Rep; expect(rep.pieces.length).to.equal(7); expect(rep.pieces[0].length).to.equal(7 + 2 + 7); + // The Yard sits on the left and the Palace on the right. + expect(drawnAt(rep, "1M")[1]).to.equal(3); + expect(drawnAt(rep, "2L")[1]).to.equal(7 + 2 + 3); // Growing past the minimum extends the board only in that direction. g.yard.set("4,0", ["1S"]); rep = g.render() as Rep; From 73d3c99571e09b5cf7016d42ce1192cd9254639f Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Sun, 20 Sep 2026 10:40:41 +0000 Subject: [PATCH 15/22] Dot the legal cells for a picked Ice Palace pyramid and show the button A pyramid picked from the hand or stock now marks every cell it could legally go. The renderer's own dots annotation is not projected onto the stacking-3D board, so each mark is a small glyph placed where the pyramid would land instead. The status panel puts a button, a plain piece in the seventh colour, in front of the hand of whoever leads the current hand. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 56 ++++++++++++++++++++++++++++++------ test/games/icepalace.test.ts | 41 ++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index f8900363..3bc17d55 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -1,4 +1,4 @@ -import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IStatus, IValidationResult } from "./_base.js"; +import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IStatus, IValidationResult, StatusValue } from "./_base.js"; import { GameBaseSequenced } from "./_turn-sequenced.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; import { APRenderRep, AreaPieces, Glyph } from "@abstractplay/renderer/build/schemas/schema"; @@ -550,6 +550,8 @@ const MIN_REACH = 2; * step lower and a large's two lower. */ const STACK_OFFSET = 0.15; +/** Legend key of the marker drawn on every legal cell once a pyramid is picked. */ +const DOT_KEY = "dot"; /** Where one structure sits on the shared board. */ interface IRegion { @@ -657,6 +659,8 @@ export class IcePalaceGame extends GameBaseSequenced { public variants: string[] = []; public stack!: Array; public results: Array = []; + /** The pyramid picked but not yet placed in a partial move; never part of the state. */ + private selected?: PieceId; constructor(state: number | IIcePalaceState | string, variants?: string[]) { super(); @@ -1047,12 +1051,17 @@ export class IcePalaceGame extends GameBaseSequenced { } this.results = []; + this.selected = undefined; if (this.phase === "build") { this.applyBuild(move, partial); } else { this.applyHand(move, partial); } if (partial) { + const last = move.split(";").pop()!; + if (last !== "" && last !== "pass" && !last.includes("@")) { + this.selected = last; + } return this; } @@ -1227,10 +1236,12 @@ export class IcePalaceGame extends GameBaseSequenced { public sidebarStatuses(): IStatus[] { const statuses: IStatus[] = []; for (let p = 1; p <= this.numplayers; p++) { - statuses.push({ - key: this.seatStatusValue(p), - value: this.hands[p - 1].map(piece => this.glyphFor(piece)), - }); + const value: StatusValue[] = this.hands[p - 1].map(piece => this.glyphFor(piece)); + if (p === this.lead) { + // The button marks who leads the current hand; it moves on after each build. + value.unshift(IcePalaceGame.BUTTON); + } + statuses.push({ key: this.seatStatusValue(p), value }); } statuses.push({ key: this.neutralAreaLabel("apgames:status.icepalace.POOL"), @@ -1255,6 +1266,13 @@ export class IcePalaceGame extends GameBaseSequenced { return `p${piece}`; } + /** + * The button, as in poker, is the token that says who leads the hand. There is no + * dedicated glyph for it, so it is a plain piece in the seventh colour, which no seat + * can hold, so that players can customise it separately from the six seat colours. + */ + private static readonly BUTTON: Glyph = { name: "piece", colour: 7 }; + /** * Lays a stack out the way Volcano does: each pyramid sits one index above the last, and * "-" placeholders, which the renderer skips but still counts, are spent only to stop a @@ -1372,9 +1390,7 @@ export class IcePalaceGame extends GameBaseSequenced { for (const region of layout.regions) { const struct = region.which === "palace" ? this.palace : this.yard; for (const [cell, stack] of struct.entries()) { - const [x, y] = coordsOf(cell); - const col = region.col0 + (x - region.minX); - const row = layout.height - 1 - (y - region.minY); + const [row, col] = IcePalaceGame.drawnAt(layout, region, cell); for (const piece of stack) { const key = IcePalaceGame.legendKey(piece); if (!(key in legend)) { @@ -1419,9 +1435,33 @@ export class IcePalaceGame extends GameBaseSequenced { pieces: pieces as [string[][], ...string[][][]], areas: areas.length > 0 ? areas : undefined, }; + + // Once a pyramid is picked, dot every cell it could legally go. The renderer's own + // "dots" annotation is drawn flat on the page instead of on the perspective board, + // so each dot is a small glyph put where the pyramid would land: on the ground of + // an empty cell, or on top of the stack it could join. + if (this.selected !== undefined) { + const active = this.phase === "build" ? "palace" : "yard"; + const region = layout.regions.find(r => r.which === active); + const struct = active === "palace" ? this.palace : this.yard; + const legal = active === "palace" ? legalPalacePlacement : legalYardPlacement; + if (region !== undefined) { + legend[DOT_KEY] = { name: "piece", colour: "_context_annotations", scale: 0.4 }; + for (const cell of legalCellsFor(struct, this.selected, legal)) { + const [row, col] = IcePalaceGame.drawnAt(layout, region, cell); + pieces[row][col].push(DOT_KEY); + } + } + } return rep; } + /** Where a structure cell lands on the board, as `[row, col]`. */ + private static drawnAt(layout: ILayout, region: IRegion, cell: Cell): [number, number] { + const [x, y] = coordsOf(cell); + return [layout.height - 1 - (y - region.minY), region.col0 + (x - region.minX)]; + } + /** * The Yard (left) is on the board while a hand is being played; the Palace (right) whenever it holds * anything, and always during the build. Each region covers its structure and the diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index a796c310..1ae46d63 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -313,7 +313,7 @@ describe("Ice Palace: scoring and ending", () => { }); describe("Ice Palace: board interaction", () => { - type Rep = { pieces: string[][][]; areas?: { pieces: string[] }[] }; + type Rep = { pieces: string[][][]; areas?: { pieces: string[] }[]; annotations?: { type: string; targets: { row: number; col: number }[] }[] }; /** Board row and column at which a cell's stack is drawn. */ const drawnAt = (rep: Rep, piece: string): [number, number] => { @@ -427,13 +427,48 @@ describe("Ice Palace: board interaction", () => { expect(rep.pieces[0].length).to.equal(7 + 2 + 9); }); - it("lists every hand in the status panel", () => { + it("lists every hand in the status panel, with the button beside the lead's", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2S"], ["3L", "3M", "3S"]], fatPool()); const statuses = g.sidebarStatuses(); expect(statuses.length).to.be.greaterThan(3); - expect(statuses[0].value.length).to.equal(2); + // Seat 1 leads the first hand, so its row starts with the button. + expect(statuses[0].value.length).to.equal(3); + expect(statuses[0].value[0]).to.deep.equal({ name: "piece", colour: 7 }); expect(statuses[1].value.length).to.equal(1); expect(statuses[2].value.length).to.equal(3); + g.lead = 2; + expect(g.sidebarStatuses()[0].value.length).to.equal(2); + expect(g.sidebarStatuses()[1].value[0]).to.deep.equal({ name: "piece", colour: 7 }); + }); + + it("dots the legal cells once a pyramid is picked, and only then", () => { + const dotted = (rep: Rep): string[] => { + const cells: string[] = []; + rep.pieces.forEach((line, row) => line.forEach((stack, col) => { + if (stack.includes("dot")) { + cells.push(`${row},${col}`); + } + })); + return cells.sort(); + }; + const g = rig(new IcePalaceGame(3), [["1M"], ["2L", "1S"], ["3S"]], fatPool()); + // Nothing picked, nothing dotted; the empty Yard offers only the origin. + expect(dotted(g.render() as Rep)).to.deep.equal([]); + g.move("1M", { partial: true }); + expect(dotted(g.render() as Rep)).to.deep.equal(["3,3"]); + 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 }); + let rep = g.render() as Rep; + expect(dotted(rep)).to.deep.equal(["3,3"]); + expect(rep.pieces[3][3]).to.deep.equal(["-", "p1M", "dot"]); + // A matching small cannot climb onto the medium, so it founds a stack beside it. + g.move("1S", { partial: true }); + expect(dotted(g.render() as Rep)).to.deep.equal(["2,3", "3,2", "3,4", "4,3"]); + // Completing the placement clears the dots. + g.move("1S@1,0"); + expect(dotted(g.render() as Rep)).to.deep.equal([]); }); }); From 69438f8251794c6006e707861a1eb4f24e8a4906 Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Sun, 20 Sep 2026 10:47:56 +0000 Subject: [PATCH 16/22] Give the Ice Palace status panel the glyph shape the front reads The front draws a status-panel glyph from its `glyph` field, as Catapult and Entropy supply it, not from a legend glyph's `name`. The hands and the button were using the legend shape and would have drawn blank. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 25 ++++++++++++++++++------- test/games/icepalace.test.ts | 6 ++++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 3bc17d55..13b2ccd9 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -1236,10 +1236,12 @@ export class IcePalaceGame extends GameBaseSequenced { public sidebarStatuses(): IStatus[] { const statuses: IStatus[] = []; for (let p = 1; p <= this.numplayers; p++) { - const value: StatusValue[] = this.hands[p - 1].map(piece => this.glyphFor(piece)); + const value: StatusValue[] = this.hands[p - 1].map(piece => this.statusGlyph(piece)); if (p === this.lead) { - // The button marks who leads the current hand; it moves on after each build. - value.unshift(IcePalaceGame.BUTTON); + // The button, as in poker, marks who leads the hand; it moves on after each + // build. There is no dedicated glyph, so it is a plain piece in the seventh + // colour, which no seat holds, so players can customise it on its own. + value.unshift(IcePalaceGame.statusGlyph("piece", 7)); } statuses.push({ key: this.seatStatusValue(p), value }); } @@ -1267,11 +1269,20 @@ export class IcePalaceGame extends GameBaseSequenced { } /** - * The button, as in poker, is the token that says who leads the hand. There is no - * dedicated glyph for it, so it is a plain piece in the seventh colour, which no seat - * can hold, so that players can customise it separately from the six seat colours. + * A status-panel glyph. The front draws these through the renderer's single-glyph + * helper and reads the glyph's name from `glyph`, not `name`, as Catapult and Entropy + * do; so a status value is not quite a legend glyph. */ - private static readonly BUTTON: Glyph = { name: "piece", colour: 7 }; + private static statusGlyph(name: string, colour: number | string): StatusValue { + const value = { glyph: name, colour }; + return value as StatusValue; + } + + /** A pyramid as it appears in the status panel. */ + private statusGlyph(piece: PieceId): StatusValue { + const glyph = this.glyphFor(piece); + return IcePalaceGame.statusGlyph(glyph.name!, glyph.colour as number | string); + } /** * Lays a stack out the way Volcano does: each pyramid sits one index above the last, and diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 1ae46d63..bae3831e 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -433,12 +433,14 @@ describe("Ice Palace: board interaction", () => { expect(statuses.length).to.be.greaterThan(3); // Seat 1 leads the first hand, so its row starts with the button. expect(statuses[0].value.length).to.equal(3); - expect(statuses[0].value[0]).to.deep.equal({ name: "piece", colour: 7 }); + expect(statuses[0].value[0]).to.deep.equal({ glyph: "piece", colour: 7 }); expect(statuses[1].value.length).to.equal(1); + // The front reads a status glyph's name from `glyph`, as Catapult's dagger does. + expect(statuses[1].value[0]).to.deep.equal({ glyph: "pyramid-up-small-3D", colour: 2 }); expect(statuses[2].value.length).to.equal(3); g.lead = 2; expect(g.sidebarStatuses()[0].value.length).to.equal(2); - expect(g.sidebarStatuses()[1].value[0]).to.deep.equal({ name: "piece", colour: 7 }); + expect(g.sidebarStatuses()[1].value[0]).to.deep.equal({ glyph: "piece", colour: 7 }); }); it("dots the legal cells once a pyramid is picked, and only then", () => { From 8bcf2a0dc18c7de0dd37b5ebc4b6e0b5a7b2f3fb Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Sun, 20 Sep 2026 11:08:10 +0000 Subject: [PATCH 17/22] Add an expanding top-down display for Ice Palace The "expanding" display uses the stacking-expanding renderer: the board is seen from above, each stack's pyramids drawn translucently over one another, and hovering a cell lays its stack out beside the board through renderColumn. That renderer draws no pieces area, so the current hand, or the stock during the build, is offered below the board as a local stash of nests, one per colour. The board keeps the same footprint and padding as the perspective display, and the legal-cell dots work in both. Rotation is turned off for both displays with custom-rotation, since neither renderer can turn the board and the front would otherwise offer buttons that do nothing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- locales/en/apgames.json | 6 ++ src/games/icepalace.ts | 194 +++++++++++++++++++++++++---------- test/games/icepalace.test.ts | 104 ++++++++++++++++++- 3 files changed, 249 insertions(+), 55 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 9183a8d6..3115cd12 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -4469,6 +4469,12 @@ "description": "Display the board using vertices instead of hexes." } }, + "icepalace": { + "expanding": { + "description": "Looks straight down on the Yard and the Palace, with each stack's pyramids drawn over one another. Hover over a cell to see its stack laid out beside the board.", + "name": "Expanding" + } + }, "intermedium": { "hide-diagonals": { "description": "Don't show diagonal lines.", diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 13b2ccd9..8d1a40d2 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -1,7 +1,7 @@ import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IStatus, IValidationResult, StatusValue } from "./_base.js"; import { GameBaseSequenced } from "./_turn-sequenced.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; -import { APRenderRep, AreaPieces, Glyph } from "@abstractplay/renderer/build/schemas/schema"; +import { APRenderRep, AreaPieces, AreaStackingExpanded, AreaVolcanoStash, Glyph } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; import { reviver, UserFacingError } from "../common/index.js"; import i18next from "i18next"; @@ -552,6 +552,10 @@ const MIN_REACH = 2; const STACK_OFFSET = 0.15; /** Legend key of the marker drawn on every legal cell once a pyramid is picked. */ const DOT_KEY = "dot"; +/** 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. */ +type PyramidView = "3D" | "top" | "side" | "nest"; /** Where one structure sits on the shared board. */ interface IRegion { @@ -638,7 +642,8 @@ export class IcePalaceGame extends GameBaseSequenced { "components>pyramids", "other>2+players", ], - flags: ["experimental", "scores", "autopass"], + flags: ["experimental", "scores", "autopass", "stacking-expanding", "custom-rotation"], + displays: [{ uid: "expanding" }], }; public numplayers = 3; @@ -1262,10 +1267,12 @@ export class IcePalaceGame extends GameBaseSequenced { /** * Legend keys double as SVG element ids, and an id that starts with a digit is not a - * valid selector in a real browser, so the pyramid id gets a letter in front. + * valid selector in a real browser, so the pyramid id gets a letter in front. A piece + * drawn two ways on one board needs two keys, so the letter says where it is drawn: + * `p` on the board, `s` in the stash below it, `c` in the hovered column. */ - private static legendKey(piece: PieceId): string { - return `p${piece}`; + private static legendKey(piece: PieceId, where: "board" | "stash" | "column" = "board"): string { + return `${where === "board" ? "p" : where === "stash" ? "s" : "c"}${piece}`; } /** @@ -1303,16 +1310,36 @@ export class IcePalaceGame extends GameBaseSequenced { return column; } - private glyphFor(piece: PieceId): Glyph { - const name = `pyramid-up-${SIZE_NAMES[sizeOf(piece) - 1]}-3D`; + private glyphFor(piece: PieceId, view: PyramidView = "3D"): Glyph { + 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); - if (colour === NULL_COLOUR) { - return { name, colour: "#000000" }; + const glyph: Glyph = { + name, + colour: colour === NULL_COLOUR ? "#000000" : colour === WILD_COLOUR ? "#ffffff" : Number(colour), + }; + if (view === "top") { + glyph.opacity = TOP_OPACITY; } - if (colour === WILD_COLOUR) { - return { name, colour: "#ffffff" }; + return glyph; + } + + /** The offered pyramids gathered into nests, one per colour, largest at the bottom. */ + private static nests(offered: PieceId[]): PieceId[][] { + const byColour = new Map(); + for (const piece of [...offered].sort(pieceSort)) { + const nest = byColour.get(colourOf(piece)); + if (nest === undefined) { + byColour.set(colourOf(piece), [piece]); + } else { + nest.push(piece); + } } - return { name, colour: Number(colour) }; + return [...byColour.values()].map(nest => nest.sort((a, b) => sizeOf(b) - sizeOf(a))); } public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { @@ -1320,9 +1347,9 @@ export class IcePalaceGame extends GameBaseSequenced { try { const current = IcePalaceGame.normalise(move); let newmove: string; - const picked = piece === undefined ? undefined : /^P?([1-6BW][SML])$/.exec(piece.toUpperCase()); + const picked = piece === undefined ? undefined : /^[A-Z]?([1-6BW][SML])$/.exec(piece.toUpperCase()); if (picked !== null && picked !== undefined) { - // The pieces area hands back the legend key, which names the pyramid. + // The pieces area, or the stash, hands back the legend key, which names the pyramid. newmove = this.appendToken(current, picked[1]); } else { // Anything else is a board click: an empty cell, or a pyramid already in a @@ -1380,14 +1407,20 @@ export class IcePalaceGame extends GameBaseSequenced { } /** - * One perspective board holds both structures: the Palace on the left and, while a hand - * is being played, the Yard to its right. During the build the Yard has already been - * taken up into the stock, which is offered in the pieces area instead. The pieces area - * is where the current player picks a pyramid from; every hand is also listed in the - * status panel. + * One board holds both structures: the Yard on the left while a hand is being played, + * and the Palace on the right. During the build the Yard has already been taken up into + * the stock, which is offered below the board instead of the current player's hand. + * That area is where the current player picks a pyramid from; every hand is also listed + * in the status panel. + * + * The default display is the perspective one, with a pieces area below the board. The + * "expanding" display looks straight down instead, with each stack's pyramids drawn + * translucently over one another and hovering a cell laying its stack out beside the + * board (see `renderColumn`). That renderer draws no pieces area, so there the offered + * pyramids are a local stash of nests, one per colour. */ public render(opts?: IRenderOpts): APRenderRep { - void opts; + const expanding = opts?.altDisplay === "expanding"; const layout = this.layout(); const legend: { [k: string]: Glyph } = {}; const pieces: string[][][] = []; @@ -1405,42 +1438,54 @@ export class IcePalaceGame extends GameBaseSequenced { for (const piece of stack) { const key = IcePalaceGame.legendKey(piece); if (!(key in legend)) { - legend[key] = this.glyphFor(piece); + legend[key] = this.glyphFor(piece, expanding ? "top" : "3D"); } } - pieces[row][col] = IcePalaceGame.stackColumn(stack); + pieces[row][col] = expanding + ? stack.map(piece => IcePalaceGame.legendKey(piece)) + : IcePalaceGame.stackColumn(stack); } } const offered = this.phase === "build" ? this.stock : this.handOf(this.currplayer); - for (const piece of offered) { - const key = IcePalaceGame.legendKey(piece); - if (!(key in legend)) { - legend[key] = this.glyphFor(piece); + // i18next.t("apgames:icepalace.STOCK") + // i18next.t("apgames:icepalace.HAND") + const label = this.phase === "build" + ? this.neutralAreaLabel("apgames:icepalace.STOCK") + : this.seatAreaLabel(this.currplayer, "apgames:icepalace.HAND"); + const areas: (AreaPieces | AreaVolcanoStash)[] = []; + if (offered.length > 0 && expanding) { + const stash = IcePalaceGame.nests(offered).map(nest => nest.map(piece => { + const key = IcePalaceGame.legendKey(piece, "stash"); + if (!(key in legend)) { + legend[key] = this.glyphFor(piece, "nest"); + } + return key; + })); + areas.push({ type: "localStash", label, stash }); + } else if (offered.length > 0) { + for (const piece of offered) { + const key = IcePalaceGame.legendKey(piece); + if (!(key in legend)) { + legend[key] = this.glyphFor(piece); + } } - } - const areas: AreaPieces[] = []; - if (offered.length > 0) { areas.push({ type: "pieces", pieces: offered.map(piece => IcePalaceGame.legendKey(piece)) as [string, ...string[]], - // i18next.t("apgames:icepalace.STOCK") - // i18next.t("apgames:icepalace.HAND") - label: this.phase === "build" - ? this.neutralAreaLabel("apgames:icepalace.STOCK") - : this.seatAreaLabel(this.currplayer, "apgames:icepalace.HAND"), + label, ownerMark: this.currplayer, }); } const rep: APRenderRep = { - renderer: "stacking-3D", + renderer: expanding ? "stacking-expanding" : "stacking-3D", options: ["hide-labels"], board: { style: "squares", width: layout.width, height: layout.height, - stackOffset: STACK_OFFSET, + stackOffset: expanding ? undefined : STACK_OFFSET, }, legend, pieces: pieces as [string[][], ...string[][][]], @@ -1450,7 +1495,8 @@ export class IcePalaceGame extends GameBaseSequenced { // Once a pyramid is picked, dot every cell it could legally go. The renderer's own // "dots" annotation is drawn flat on the page instead of on the perspective board, // so each dot is a small glyph put where the pyramid would land: on the ground of - // an empty cell, or on top of the stack it could join. + // an empty cell, or on top of the stack it could join. The same glyph serves the + // top-down display, where it sits over the translucent stack. if (this.selected !== undefined) { const active = this.phase === "build" ? "palace" : "yard"; const region = layout.regions.find(r => r.which === active); @@ -1467,6 +1513,32 @@ export class IcePalaceGame extends GameBaseSequenced { return rep; } + /** + * The stack under the hovered cell, for the expanding display: the front draws it beside + * the board, bottom of the stack first, as side-on pyramids. Either structure can be + * looked into, and the gap between them, or an empty cell, shows nothing. + */ + public renderColumn(col: number, row: number): APRenderRep { + const found = this.locate(row, col); + const stack = found === undefined + ? [] + : (found.which === "palace" ? this.palace : this.yard).get(found.cell) ?? []; + const legend: { [k: string]: Glyph } = {}; + const keys = stack.map(piece => { + const key = IcePalaceGame.legendKey(piece, "column"); + legend[key] = this.glyphFor(piece, "side"); + return key; + }); + const column: AreaStackingExpanded = { type: "expandedColumn", stack: keys }; + return { + renderer: "stacking-expanding", + board: null, + legend, + pieces: null, + areas: [column], + }; + } + /** Where a structure cell lands on the board, as `[row, col]`. */ private static drawnAt(layout: ILayout, region: IRegion, cell: Cell): [number, number] { const [x, y] = coordsOf(cell); @@ -1506,32 +1578,46 @@ export class IcePalaceGame extends GameBaseSequenced { return { regions, width: col0 - GAP, height }; } - /** - * Which cell a board click landed on. Only the structure being built into this phase - * takes placements, so a click on the other one, or in the gap, is undefined. - */ - private cellAt(row: number, col: number): Cell | undefined { + /** Which structure a board position lies in, and which of its cells; the gap is undefined. */ + private locate(row: number, col: number): { which: "palace" | "yard"; cell: Cell } | undefined { const layout = this.layout(); - const active = this.phase === "build" ? "palace" : "yard"; for (const region of layout.regions) { if (col < region.col0 || col >= region.col0 + region.cols) { continue; } - if (region.which !== active) { - return undefined; - } - // The lead goes in the middle: a click anywhere in an empty region is the origin. - if ((active === "palace" ? this.palace : this.yard).size === 0) { - return cellOf(0, 0); - } - return cellOf( - region.minX + (col - region.col0), - region.minY + (layout.height - 1 - row), - ); + return { + which: region.which, + cell: cellOf(region.minX + (col - region.col0), region.minY + (layout.height - 1 - row)), + }; } return undefined; } + /** + * Which cell a board click landed on. Only the structure being built into this phase + * takes placements, so a click on the other one, or in the gap, is undefined. + */ + private cellAt(row: number, col: number): Cell | undefined { + const found = this.locate(row, col); + const active = this.phase === "build" ? "palace" : "yard"; + if (found === undefined || found.which !== active) { + return undefined; + } + // The lead goes in the middle: a click anywhere in an empty region is the origin. + if ((active === "palace" ? this.palace : this.yard).size === 0) { + return cellOf(0, 0); + } + return found.cell; + } + + /** + * Neither display can turn: the perspective renderer cannot rotate, and the top-down + * one ignores rotation, so without this the front would offer buttons that do nothing. + */ + public getCustomRotation(): number | undefined { + return 0; + } + public getPlayerColour(player: number): number { return player; } diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index bae3831e..25da4860 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -313,7 +313,14 @@ describe("Ice Palace: scoring and ending", () => { }); describe("Ice Palace: board interaction", () => { - type Rep = { pieces: string[][][]; areas?: { pieces: string[] }[]; annotations?: { type: string; targets: { row: number; col: number }[] }[] }; + type Rep = { + renderer?: string; + board?: { width: number; height: number; stackOffset?: number }; + legend?: { [k: string]: { name?: string; opacity?: number } }; + pieces: string[][][]; + areas?: { type?: string; pieces?: string[]; stash?: string[][] }[]; + annotations?: { type: string; targets: { row: number; col: number }[] }[]; + }; /** Board row and column at which a cell's stack is drawn. */ const drawnAt = (rep: Rep, piece: string): [number, number] => { @@ -474,6 +481,101 @@ describe("Ice Palace: board interaction", () => { }); }); +describe("Ice Palace: expanding display", () => { + type Rep = { + renderer?: string; + board?: { width: number; height: number; stackOffset?: number } | null; + legend?: { [k: string]: { name?: string; opacity?: number } }; + pieces: string[][][] | null; + areas?: { type?: string; pieces?: string[]; stash?: string[][]; stack?: string[] }[]; + }; + const expanding = (g: IcePalaceGame): Rep => g.render({ altDisplay: "expanding" }) as unknown as Rep; + + it("is declared, and turns rotation off for both displays", () => { + expect(IcePalaceGame.gameinfo.displays).to.deep.equal([{ uid: "expanding" }]); + expect(IcePalaceGame.gameinfo.flags).to.include("stacking-expanding"); + expect(IcePalaceGame.gameinfo.flags).to.include("custom-rotation"); + expect(new IcePalaceGame(3).getCustomRotation()).to.equal(0); + }); + + 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"]]]); + const flat = expanding(g); + const deep = g.render() as unknown as Rep; + expect(flat.renderer).to.equal("stacking-expanding"); + expect(flat.board).to.deep.include({ width: deep.board!.width, height: deep.board!.height }); + expect(flat.board!.stackOffset).to.be.undefined; + for (const line of flat.pieces!) { + for (const stack of line) { + expect(stack).to.not.include("-"); + } + } + // The Palace stack is listed bottom first, drawn from above. + expect(flat.pieces![3][7 + 2 + 3]).to.deep.equal(["p2L", "p1M"]); + expect(flat.legend!.p2L).to.deep.equal({ name: "pyramid-up-large-upscaled", colour: 2, opacity: 0.75 }); + expect(flat.legend!.p1M).to.deep.equal({ name: "pyramid-up-medium-upscaled", 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"); + let rep = expanding(g); + expect(rep.areas).to.have.length(1); + expect(rep.areas![0].type).to.equal("localStash"); + // Player 2's hand: a nest per colour, largest at the bottom, in colour order. + expect(rep.areas![0].stash).to.deep.equal([["s1L"], ["s2L", "s2S", "s2S"], ["sWS"]]); + expect(rep.legend!.s2L).to.deep.equal({ name: "pyramid-flattened-large", colour: 2 }); + expect(rep.legend!.sWS).to.deep.equal({ name: "pyramid-flattened-small", colour: "#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"); + // During the build the stock is offered the same way. + g.phase = "build"; + g.currplayer = 1; + g.stock = ["3M", "1S", "3L"]; + g.buildMin = 0; + rep = expanding(g); + expect(rep.areas![0].type).to.equal("localStash"); + expect(rep.areas![0].stash).to.deep.equal([["s1S"], ["s3L", "s3M"]]); + }); + + 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("1S", { partial: true }); + const rep = expanding(g); + const dotted: string[] = []; + rep.pieces!.forEach((line, row) => line.forEach((stack, col) => { + if (stack.includes("dot")) { + dotted.push(`${row},${col}`); + } + })); + expect(dotted.sort()).to.deep.equal(["2,3", "3,2", "3,4", "4,3"]); + }); + + 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"]]]); + // 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 }); + // The Palace can be looked into during a hand too. + rep = g.renderColumn(7 + 2 + 3, 3) as unknown as Rep; + expect(rep.areas).to.deep.equal([{ type: "expandedColumn", stack: ["c2L", "c1M"] }]); + expect(Object.keys(rep.legend!).sort()).to.deep.equal(["c1M", "c2L"]); + // An empty cell, and the gap between the structures, show nothing. + expect((g.renderColumn(0, 0) as unknown as Rep).areas).to.deep.equal([{ type: "expandedColumn", stack: [] }]); + expect((g.renderColumn(7, 3) as unknown as Rep).areas).to.deep.equal([{ type: "expandedColumn", stack: [] }]); + }); +}); + describe("Ice Palace: serialization", () => { it("survives a round trip through its own state", () => { const g = rig(new IcePalaceGame(3), [["1M", "1L"], ["WS"], ["3S"]], fatPool()); From d4cec4607b5b5e8f950394063b9894d3021d594c Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Sun, 20 Sep 2026 14:30:07 +0000 Subject: [PATCH 18/22] Log the Ice Palace build with a worked example, and state the count plainly When a hand ends, the event log now says who builds, how many pyramids that takes, and one legal way to build them, quoted so it can be pasted into the move box. The build messages state the computed maximum without qualification; validation keeps accepting that many or more, in case an arrangement the solver did not find exists. The move log moves to the structured collector, and an implementation note records the at-least-one matching neighbour decision and how White and Black match. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- locales/en/apgames.json | 9 ++++--- locales/en/apresults.json | 3 +++ src/games/icepalace.ts | 44 ++++++++++++++++++++++++-------- test/games/icepalace.test.ts | 49 ++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 15 deletions(-) diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 3115cd12..b90d343e 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -613,6 +613,7 @@ "gonnect": "In the base game, passing is not allowed. The **Cascading Connection Goals** variant allows passing and ends after two consecutive passes. An outright connection wins immediately, otherwise find the largest centered subboard where exactly one player connects opposite sides: that player wins.", "garden": "To make it very clear what happened on a previous turn, each move is displayed over four separate boards. The first board shows the game after the piece was first placed. The second board shows the state after adjacent pieces were flipped. The third board shows any harvests. The fourth board is the final game state and is where you make your moves.\n\nIn our implementation, black is always the \"tome\" or tie-breaker colour. The last player to harvest black will have a `0.1` after their score.", "guerrilla": "Either player wins by removing all of the opponent's pieces. The security player also wins if they survive long enough that the insurgents run out of stones to place.\n\nBy default, a match consists of two games with roles reversed. Each player plays one game as insurgents and one as security. When both players win as insurgents, the match goes to whoever used fewer stones (`66 − remaining` at the end of that game; lower score wins). Equal insurgent scores are the only draw in that case. A security win in a game scores 67. If one player sweeps both games (wins as insurgents in one and security in the other), they win the match outright. If both players only win as security, the match is drawn.", + "icepalace": "Ice Palace's rules say a pyramid placed on the ground must be next to a pyramid of the same colour, but not whether every neighbour has to match. This implementation requires at least one matching orthogonal neighbour; the others may be any colour. White matches any colour except Black, and Black matches nothing.", "scribe": "The glyph reference chart is shown in the left margin. A dot marks each player's most recent placement. When a mini grid fills, glyph points are tallied and the higher score wins that grid (ties go to the player who completed it).", "gyges": "The goal squares are adjacent to all the cells in the back row. The renderer cannot currently handle \"floating\" cells.", "halma": "To prevent the [drawish nature](https://boardgamegeek.com/thread/3706389/unspoiling-halma-redux) of the game, the following rules apply: (a) a player wins if the opposite home-base is complete with at least one friendly stone (David Parlett's criteria); (b) Any piece in the player's home-base must make progress towards the enemy camp whenever this is possible by jumping over an enemy piece. (Zillions rule, to remove drawish strategies); (c) No stone can return to its home-base.\n\n[Halma](https://en.wikipedia.org/wiki/Halma) was one of the first commercial successes for an abstract game. The game was designed by [George Howard Monks](https://en.wikipedia.org/wiki/George_Howard_Monks) in 1883/4. It is said that Halma was inspired by an older British game called *Hoppity*. However, this game has no documentation or surviving boards, turning this lineal statement into a historical mystery. Halma was later adapted (c.1892/3) into an even bigger success: [Chinese Checkers](https://boardgamegeek.com/boardgame/2386/chinese-checkers) (which could easily be played by three or six players).\n\nSuper Halma is a more dynamic, uncredited variant, presented in the 1992 book **New Rules for Classic Games** by Wayne Schmittberger. This variant proposes long jumps, where the number of empty cells before and after the jumped stone must be equal. The base's move restrictions still apply in this implementation of Super Halma.", @@ -4883,7 +4884,7 @@ "INSURGENTS": "Insurgents remaining" }, "icepalace": { - "MUST_USE": "Pyramids that must be used", + "MUST_USE": "Pyramids to build", "POOL": "Pyramids left in the Pool" }, "scribe": { @@ -6347,11 +6348,11 @@ "icepalace": { "BAD_PLACEMENT": "Could not read '{{move}}' as a pyramid and a space.", "BUILD_COMPLETE": "The whole Yard has been built into the Ice Palace. Submit when you are happy with it.", - "BUILD_ENOUGH": "You have used the maximum possible number of pyramids. The remaining {{count}} cannot be placed and will be discarded. Submit when you are happy with the arrangement.", - "BUILD_MORE": "You must place at least {{count}} more, because more of the Yard than that can be built.", + "BUILD_ENOUGH": "The remaining {{count}} pyramids will be discarded. Submit when you are happy with the arrangement.", + "BUILD_MORE": "Place {{count}} more pyramids.", "ILLEGAL_PALACE": "The {{piece}} cannot go at {{cell}}. In the Ice Palace a pyramid may only cover a bigger one, or start a new stack beside a stack of its own colour.", "ILLEGAL_YARD": "The {{piece}} cannot go at {{cell}}. In the Yard a pyramid may only cover a smaller one, or start a new stack beside a stack whose top it matches.", - "INITIAL_BUILD": "You won the hand. Build the Yard into the Ice Palace, using at least {{count}} pyramids.", + "INITIAL_BUILD": "You won the hand. Build the Yard into the Ice Palace, using {{count}} pyramids.", "INITIAL_HAND": "Place a pyramid from your hand into the Yard, or pass.", "LEAD_CANNOT_PASS": "You are leading the hand and must place a pyramid to start the Yard.", "MUST_BUILD": "You cannot decline to build: {{count}} of the Yard's pyramids can be placed.", diff --git a/locales/en/apresults.json b/locales/en/apresults.json index e97695c4..2c07a615 100644 --- a/locales/en/apresults.json +++ b/locales/en/apresults.json @@ -32,6 +32,8 @@ "deckfish": "{{player}} was unable to move and must pass from now on.", "frogger_one": "{{player}} chose to refill the draw pool. He gets another turn to take his {{count}} remaining move.", "frogger_other": "{{player}} chose to refill the draw pool. He gets another turn to take his {{count}} remaining moves.", + "icepalace_build": "{{player}} won the hand and builds the Yard into the Ice Palace, using {{count}} pyramids. One legal way to build it is `{{move}}`.", + "icepalace_nobuild": "{{player}} won the hand, but none of the Yard can be built into the Ice Palace.", "quincunx": "Player {{playerNum}} had the following cards left in their hand: {{cards}}.", "elOso": { "playerSetup": "Player setup", @@ -422,6 +424,7 @@ "druid": "{{player}} passed.", "elOso": "{{player}} passed.", "frogger": "{{player}} passed {{why}}.", + "icepalace": "{{player}} passed.", "linage": "{{player}} passes.", "pie": "{{player}} accepted the komi offer and will continue playing second.", "pigs": "{{player}} idles.", diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 8d1a40d2..7cbb7aca 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -1,4 +1,4 @@ -import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IStatus, IValidationResult, StatusValue } from "./_base.js"; +import { IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IStatus, IValidationResult, StatusValue, type ChatLogCollectContext, type ChatLogLine } from "./_base.js"; import { GameBaseSequenced } from "./_turn-sequenced.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; import { APRenderRep, AreaPieces, AreaStackingExpanded, AreaVolcanoStash, Glyph } from "@abstractplay/renderer/build/schemas/schema"; @@ -624,6 +624,8 @@ export class IcePalaceGame extends GameBaseSequenced { // i18next.t("apgames:descriptions.icepalace") description: "apgames:descriptions.icepalace", urls: ["https://icehousegames.org/wiki/index.php?title=Ice_Palace"], + // i18next.t("apgames:notes.icepalace") + notes: "apgames:notes.icepalace", people: [ { type: "designer", @@ -1122,6 +1124,11 @@ export class IcePalaceGame extends GameBaseSequenced { this.phase = "build"; this.currplayer = this.lastPlacer!; this.passes = 0; + // Tell the log who builds, how many pyramids that takes, and one way to do it. + this.results.push({ + type: "announce", + payload: [this.currplayer, this.buildMin, this.buildMin > 0 ? this.suggestedBuild() : ""], + }); } private applyBuild(move: string, partial: boolean): void { @@ -1622,20 +1629,35 @@ export class IcePalaceGame extends GameBaseSequenced { return player; } - public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean { - let resolved = false; + public collectChatLogLine(lines: ChatLogLine[], r: APMoveResult, ctx: ChatLogCollectContext): boolean { switch (r.type) { case "place": - node.push(i18next.t("apresults:PLACE.icepalace", { player, what: r.what, where: r.where })); - resolved = true; - break; + // i18next.t("apresults:PLACE.icepalace") + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:PLACE.icepalace", { what: r.what, where: r.where }); + return true; case "remove": - node.push(i18next.t("apresults:REMOVE.icepalace", { player, what: r.what })); - resolved = true; - break; + // i18next.t("apresults:REMOVE.icepalace") + this.pushNeutralChatLine(lines, "apresults:REMOVE.icepalace", { what: r.what }); + return true; + case "pass": + // i18next.t("apresults:PASS.icepalace") + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:PASS.icepalace", {}); + return true; + case "announce": { + // The end of a hand: who builds, how many pyramids, and one legal way to do it. + const [seat, count, move] = r.payload as [number, number, string]; + if (count > 0) { + // i18next.t("apresults:ANNOUNCE.icepalace_build") + this.pushSeatChatLine(lines, seat, "apresults:ANNOUNCE.icepalace_build", { count, move }); + } else { + // i18next.t("apresults:ANNOUNCE.icepalace_nobuild") + this.pushSeatChatLine(lines, seat, "apresults:ANNOUNCE.icepalace_nobuild", {}); + } + return true; + } + default: + return super.collectChatLogLine(lines, r, ctx); } - void results; - return resolved; } /** Exposed for tests: the pyramid currently on top of a cell. */ diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 25da4860..8c2083e8 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import "mocha"; import { expect } from "chai"; +import { addResource } from "../../src"; +import { assertChatLogParity } from "../fixtures/chat/helpers"; import { IcePalaceGame, PieceId, @@ -576,6 +578,53 @@ describe("Ice Palace: expanding display", () => { }); }); +describe("Ice Palace: event log", () => { + before(() => { + addResource("en"); + }); + const names = ["Alice", "Bob", "Carol"]; + const lastLines = (g: IcePalaceGame) => { + const entries = g.chatLogEntries(names); + return entries[entries.length - 1].lines; + }; + + 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("pass"); + g.move("pass"); + g.move("pass"); + expect(g.phase).to.equal("build"); + 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" }); + // 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; + expect(check.complete).to.equal(0); + // 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`"); + 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.move("pass"); + g.move("pass"); + g.move("pass"); + expect(g.buildMin).to.equal(0); + expect(g.moves()).to.deep.equal(["pass"]); + const keys = lastLines(g).map(l => l.textKey); + expect(keys).to.include("apresults:ANNOUNCE.icepalace_nobuild"); + expect(keys).to.include("apresults:PASS.icepalace"); + }); +}); + describe("Ice Palace: serialization", () => { it("survives a round trip through its own state", () => { const g = rig(new IcePalaceGame(3), [["1M", "1L"], ["WS"], ["3S"]], fatPool()); From 111ca639fece63feaa2f366156cc1cbaee340eea Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Sun, 20 Sep 2026 21:20:18 +0000 Subject: [PATCH 19/22] Seed the Ice Palace name into the managed locales and fix a lint error Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- locale-src/de/apgames.json | 4 ++++ locale-src/es-US/apgames.json | 4 ++++ locale-src/fr/apgames.json | 4 ++++ locale-src/it/apgames.json | 4 ++++ locales/de/apgames.json | 1 + locales/es-US/apgames.json | 1 + locales/fr/apgames.json | 1 + locales/it/apgames.json | 1 + test/games/icepalace.test.ts | 2 +- 9 files changed, 21 insertions(+), 1 deletion(-) diff --git a/locale-src/de/apgames.json b/locale-src/de/apgames.json index 8a14a586..38dcf32d 100644 --- a/locale-src/de/apgames.json +++ b/locale-src/de/apgames.json @@ -2543,6 +2543,10 @@ "src": "Hula", "out": "Hula" }, + "names.icepalace": { + "src": "Ice Palace", + "out": "Ice Palace" + }, "names.intermedium": { "src": "Intermedium", "out": "Intermedium" diff --git a/locale-src/es-US/apgames.json b/locale-src/es-US/apgames.json index 6e1db5ca..e372ded4 100644 --- a/locale-src/es-US/apgames.json +++ b/locale-src/es-US/apgames.json @@ -2543,6 +2543,10 @@ "src": "Hula", "out": "Hula" }, + "names.icepalace": { + "src": "Ice Palace", + "out": "Ice Palace" + }, "names.intermedium": { "src": "Intermedium", "out": "Intermedium" diff --git a/locale-src/fr/apgames.json b/locale-src/fr/apgames.json index 538b86cc..96dd9507 100644 --- a/locale-src/fr/apgames.json +++ b/locale-src/fr/apgames.json @@ -2543,6 +2543,10 @@ "src": "Hula", "out": "Hula" }, + "names.icepalace": { + "src": "Ice Palace", + "out": "Ice Palace" + }, "names.intermedium": { "src": "Intermedium", "out": "Intermedium" diff --git a/locale-src/it/apgames.json b/locale-src/it/apgames.json index 8a941719..ad0a09ea 100644 --- a/locale-src/it/apgames.json +++ b/locale-src/it/apgames.json @@ -2543,6 +2543,10 @@ "src": "Hula", "out": "Hula" }, + "names.icepalace": { + "src": "Ice Palace", + "out": "Ice Palace" + }, "names.intermedium": { "src": "Intermedium", "out": "Intermedium" diff --git a/locales/de/apgames.json b/locales/de/apgames.json index 7d27a8be..ac59685b 100644 --- a/locales/de/apgames.json +++ b/locales/de/apgames.json @@ -417,6 +417,7 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", + "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", diff --git a/locales/es-US/apgames.json b/locales/es-US/apgames.json index 122c6109..99114a3c 100644 --- a/locales/es-US/apgames.json +++ b/locales/es-US/apgames.json @@ -417,6 +417,7 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", + "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", diff --git a/locales/fr/apgames.json b/locales/fr/apgames.json index 4280ee41..5cbf96b3 100644 --- a/locales/fr/apgames.json +++ b/locales/fr/apgames.json @@ -417,6 +417,7 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", + "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", diff --git a/locales/it/apgames.json b/locales/it/apgames.json index 58bbb611..24680705 100644 --- a/locales/it/apgames.json +++ b/locales/it/apgames.json @@ -417,6 +417,7 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", + "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 8c2083e8..9dc60fde 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -471,7 +471,7 @@ describe("Ice Palace: board interaction", () => { // 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 }); - let rep = g.render() as Rep; + const rep = g.render() as Rep; expect(dotted(rep)).to.deep.equal(["3,3"]); expect(rep.pieces[3][3]).to.deep.equal(["-", "p1M", "dot"]); // A matching small cannot climb onto the medium, so it founds a stack beside it. From fb85a3c13890d793c9074247cbbe8686a5a6024b Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Sun, 20 Sep 2026 22:05:38 +0000 Subject: [PATCH 20/22] Leave the managed locales and sidecars to the translation system Reverts the seeded "Ice Palace" names in de, fr, it and es-US and their locale-src sidecars, as requested in review. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- locale-src/de/apgames.json | 4 ---- locale-src/es-US/apgames.json | 4 ---- locale-src/fr/apgames.json | 4 ---- locale-src/it/apgames.json | 4 ---- locales/de/apgames.json | 1 - locales/es-US/apgames.json | 1 - locales/fr/apgames.json | 1 - locales/it/apgames.json | 1 - 8 files changed, 20 deletions(-) diff --git a/locale-src/de/apgames.json b/locale-src/de/apgames.json index 38dcf32d..8a14a586 100644 --- a/locale-src/de/apgames.json +++ b/locale-src/de/apgames.json @@ -2543,10 +2543,6 @@ "src": "Hula", "out": "Hula" }, - "names.icepalace": { - "src": "Ice Palace", - "out": "Ice Palace" - }, "names.intermedium": { "src": "Intermedium", "out": "Intermedium" diff --git a/locale-src/es-US/apgames.json b/locale-src/es-US/apgames.json index e372ded4..6e1db5ca 100644 --- a/locale-src/es-US/apgames.json +++ b/locale-src/es-US/apgames.json @@ -2543,10 +2543,6 @@ "src": "Hula", "out": "Hula" }, - "names.icepalace": { - "src": "Ice Palace", - "out": "Ice Palace" - }, "names.intermedium": { "src": "Intermedium", "out": "Intermedium" diff --git a/locale-src/fr/apgames.json b/locale-src/fr/apgames.json index 96dd9507..538b86cc 100644 --- a/locale-src/fr/apgames.json +++ b/locale-src/fr/apgames.json @@ -2543,10 +2543,6 @@ "src": "Hula", "out": "Hula" }, - "names.icepalace": { - "src": "Ice Palace", - "out": "Ice Palace" - }, "names.intermedium": { "src": "Intermedium", "out": "Intermedium" diff --git a/locale-src/it/apgames.json b/locale-src/it/apgames.json index ad0a09ea..8a941719 100644 --- a/locale-src/it/apgames.json +++ b/locale-src/it/apgames.json @@ -2543,10 +2543,6 @@ "src": "Hula", "out": "Hula" }, - "names.icepalace": { - "src": "Ice Palace", - "out": "Ice Palace" - }, "names.intermedium": { "src": "Intermedium", "out": "Intermedium" diff --git a/locales/de/apgames.json b/locales/de/apgames.json index ac59685b..7d27a8be 100644 --- a/locales/de/apgames.json +++ b/locales/de/apgames.json @@ -417,7 +417,6 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", - "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", diff --git a/locales/es-US/apgames.json b/locales/es-US/apgames.json index 99114a3c..122c6109 100644 --- a/locales/es-US/apgames.json +++ b/locales/es-US/apgames.json @@ -417,7 +417,6 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", - "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", diff --git a/locales/fr/apgames.json b/locales/fr/apgames.json index 5cbf96b3..4280ee41 100644 --- a/locales/fr/apgames.json +++ b/locales/fr/apgames.json @@ -417,7 +417,6 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", - "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", diff --git a/locales/it/apgames.json b/locales/it/apgames.json index 24680705..58bbb611 100644 --- a/locales/it/apgames.json +++ b/locales/it/apgames.json @@ -417,7 +417,6 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", - "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", From 50e8e3efb92640fe412500f512ac722488048449 Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Mon, 21 Sep 2026 20:23:42 +0000 Subject: [PATCH 21/22] Address review: failsafe on incomplete moves, export exclusion, tests move() now throws VALIDATION_FAILSAFE when a non-partial move validates as incomplete, as Morphos does. The build announcement is excluded from published records while staying in the chat log. Adds the BGG id, and tests for the sequenced turn model and record export, a full serialized round trip mid-build with Map revival, and the failsafe. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 9 ++++ test/games/icepalace.test.ts | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 7cbb7aca..e46091a6 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -624,6 +624,7 @@ export class IcePalaceGame extends GameBaseSequenced { // i18next.t("apgames:descriptions.icepalace") description: "apgames:descriptions.icepalace", urls: ["https://icehousegames.org/wiki/index.php?title=Ice_Palace"], + bggid: "61898", // i18next.t("apgames:notes.icepalace") notes: "apgames:notes.icepalace", people: [ @@ -1055,6 +1056,9 @@ export class IcePalaceGame extends GameBaseSequenced { if (!result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message); } + if (result.complete === -1 && !partial) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", { move: m })); + } } this.results = []; @@ -1660,6 +1664,11 @@ export class IcePalaceGame extends GameBaseSequenced { } } + /** The build announcement is for the chat log only; published records need not carry it. */ + protected recordExportExclude(): string[] { + return ["announce", "eog", "winners"]; + } + /** Exposed for tests: the pyramid currently on top of a cell. */ public topOfCell(struct: "yard" | "palace", cell: Cell): PieceId | undefined { return topOf(struct === "yard" ? this.yard : this.palace, cell); diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 9dc60fde..b088050c 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -638,6 +638,93 @@ describe("Ice Palace: serialization", () => { expect([...clone.yard.entries()]).to.deep.equal([...g.yard.entries()]); expect(clone.pool.length).to.equal(g.pool.length); }); + + 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.lead = 2; + g.currplayer = 2; + 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 }); + const json = g.serialize(); + expect(json).to.be.a("string"); + const revived = new IcePalaceGame(json); + expect(revived.phase).to.equal("build"); + expect(revived.buildMin).to.equal(g.buildMin); + expect(revived.lead).to.equal(2); + expect(revived.currplayer).to.equal(2); + expect(revived.stock).to.deep.equal(stockBefore); + expect(revived.palace).to.be.instanceOf(Map); + expect([...revived.palace.entries()]).to.deep.equal(palaceBefore); + expect(revived.yard).to.be.instanceOf(Map); + 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"]); + }); +}); + +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("pass"); + g.move("pass"); + g.move("pass"); + 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("pass"); + g.move("pass"); + 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.phase).to.equal("hand"); + }); + + it("exports one sparse row per ply, with the hand winner acting twice in a row", () => { + const g = played(); + expect(g.turnModel()).to.equal("sequenced"); + const plies = g.getPlies(); + expect(plies.map(p => p.actor)).to.deep.equal([1, 2, 3, 1, 1]); + const rounds = g.getRounds(); + expect(rounds).to.have.length(5); + for (const row of rounds) { + expect(row).to.have.length(3); + expect(row.filter(slot => slot !== null)).to.have.length(1); + } + expect(rounds[3][0]).to.not.be.null; + expect(rounds[4][0]).to.not.be.null; + expect(rounds[4][1]).to.be.null; + }); + + it("keeps the build announcement out of the published record", () => { + const g = played(); + const record = (g as unknown as { getMoveList(): (string | { result?: { type: string }[] } | null)[][] }).getMoveList(); + const types = record.flat().flatMap(slot => slot !== null && typeof slot === "object" ? (slot.result ?? []).map(r => r.type) : []); + expect(types).to.include("place"); + expect(types).to.not.include("announce"); + // The chat log still carries it. + const keys = g.chatLogEntries(["A", "B", "C"]).flatMap(e => e.lines.map(l => l.textKey)); + expect(keys).to.include("apresults:ANNOUNCE.icepalace_build"); + }); }); const palaceOf = (stacks: Record): Structure => { From 3b40f55dee45d8b7fe122d95370b41d2d3d54052 Mon Sep 17 00:00:00 2001 From: samtcifihi <565455483@protonmail.com> Date: Mon, 21 Sep 2026 20:32:19 +0000 Subject: [PATCH 22/22] Add the Ice Palace coder entry and the rules links Lists the BoardGameGeek page and the archived original rules alongside the Icehouse wiki entry, and credits the implementer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018Wtg37k4AQMgCQjK1fHuuy --- src/games/icepalace.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index e46091a6..102a0427 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -623,7 +623,11 @@ export class IcePalaceGame extends GameBaseSequenced { dateAdded: "2026-09-18", // i18next.t("apgames:descriptions.icepalace") description: "apgames:descriptions.icepalace", - urls: ["https://icehousegames.org/wiki/index.php?title=Ice_Palace"], + urls: [ + "https://icehousegames.org/wiki/index.php?title=Ice_Palace", + "https://boardgamegeek.com/boardgame/61898/ice-palace", + "https://web.archive.org/web/20150314202652/http://icehousegames.com/contest/icedes-2/ice-palace/IcePalace.htm", + ], bggid: "61898", // i18next.t("apgames:notes.icepalace") notes: "apgames:notes.icepalace", @@ -632,6 +636,12 @@ export class IcePalaceGame extends GameBaseSequenced { type: "designer", name: "Geoff Hanna", }, + { + type: "coder", + name: "Samraku", + urls: [], + apid: "6ea91933-1262-41a5-b5f3-a6af70692296", + }, ], categories: [ "goal>score>eog",