diff --git a/locales/en/apgames.json b/locales/en/apgames.json index 33b40436..db4576bc 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.", @@ -287,6 +288,10 @@ "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": { + "HAND": "{{player}}'s hand", + "STOCK": "Pyramids from the Yard, to be built into the Palace" + }, "names": { "abande": "Abande", "accasta": "Accasta", @@ -417,6 +422,7 @@ "hexy": "Hexagonal Y", "homeworlds": "Homeworlds", "hula": "Hula", + "icepalace": "Ice Palace", "intermedium": "Intermedium", "invector": "Invector", "iqishiqi": "Iqishiqi", @@ -607,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.", @@ -4467,6 +4474,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.", @@ -4875,6 +4888,10 @@ "GAME2": "Game 2", "INSURGENTS": "Insurgents remaining" }, + "icepalace": { + "MUST_USE": "Pyramids to build", + "POOL": "Pyramids left in the Pool" + }, "scribe": { "MINI_GRIDS": "Mini grids won" }, @@ -6344,6 +6361,22 @@ }, "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": "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 {{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.", + "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.", "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 07150f02..bca82204 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", @@ -424,6 +426,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.", @@ -485,6 +488,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}}.", @@ -594,6 +598,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.", diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts new file mode 100644 index 00000000..102a0427 --- /dev/null +++ b/src/games/icepalace.ts @@ -0,0 +1,1686 @@ +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"; +import type { APMoveResult } from "../schemas/moveresults.js"; +import { reviver, UserFacingError } from "../common/index.js"; +import i18next from "i18next"; + +/* ------------------------------------------------- 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"; + +/** 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 = 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 + * 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. + */ +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 { + which: "palace" | "yard"; + col0: number; + minX: number; + minY: number; + cols: number; + rows: number; +} + +interface ILayout { + regions: IRegion[]; + 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. */ +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", + "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", + people: [ + { + type: "designer", + name: "Geoff Hanna", + }, + { + type: "coder", + name: "Samraku", + urls: [], + apid: "6ea91933-1262-41a5-b5f3-a6af70692296", + }, + ], + 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", "autopass", "stacking-expanding", "custom-rotation"], + displays: [{ uid: "expanding" }], + }; + + 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 = []; + /** 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(); + 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; + } + + /** 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(";"); + } + + /** + * 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 []; + } + if (this.phase === "build") { + return this.buildMin === 0 ? ["pass"] : []; + } + 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") { + 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; + } + 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 }); + 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]; + 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 }); + 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); + } + 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 = tokens.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); + } + if (result.complete === -1 && !partial) { + throw new UserFacingError("VALIDATION_FAILSAFE", i18next.t("apgames:validation._general.FAILSAFE", { move: m })); + } + } + + 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; + } + + 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); + 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); + 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; + // 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 { + 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 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 }]; + } + + /** Every hand, drawn as pyramids, plus the Pool and what the builder still has to use. */ + public sidebarStatuses(): IStatus[] { + const statuses: IStatus[] = []; + for (let p = 1; p <= this.numplayers; p++) { + const value: StatusValue[] = this.hands[p - 1].map(piece => this.statusGlyph(piece)); + if (p === this.lead) { + // 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 }); + } + 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"), + value: [`${this.buildMin} / ${this.stock.length}`], + }); + } + return statuses; + } + + /* --------------------------------------------------------------- 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. 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, where: "board" | "stash" | "column" = "board"): string { + return `${where === "board" ? "p" : where === "stash" ? "s" : "c"}${piece}`; + } + + /** + * 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 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 + * "-" 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[] = []; + for (const piece of stack) { + const ground = sizeOf(piece) - 1; + while (column.length < ground) { + column.push("-"); + } + column.push(IcePalaceGame.legendKey(piece)); + } + return column; + } + + 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); + const glyph: Glyph = { + name, + colour: colour === NULL_COLOUR ? "#000000" : colour === WILD_COLOUR ? "#ffffff" : Number(colour), + }; + if (view === "top") { + glyph.opacity = TOP_OPACITY; + } + 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 [...byColour.values()].map(nest => nest.sort((a, b) => sizeOf(b) - sizeOf(a))); + } + + public handleClick(move: string, row: number, col: number, piece?: string): IClickResult { + const result: IClickResult = { move, valid: false, message: "" }; + try { + const current = IcePalaceGame.normalise(move); + let newmove: string; + const picked = piece === undefined ? undefined : /^[A-Z]?([1-6BW][SML])$/.exec(piece.toUpperCase()); + if (picked !== null && picked !== undefined) { + // 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 + // 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"); + return result; + } + newmove = this.appendToken(current, `@${cell}`); + } + const validated = this.validateMove(newmove); + if (!validated.valid) { + result.move = 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(";"); + } + + /** + * 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 { + const expanding = opts?.altDisplay === "expanding"; + const layout = this.layout(); + const legend: { [k: string]: Glyph } = {}; + 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 [row, col] = IcePalaceGame.drawnAt(layout, region, cell); + for (const piece of stack) { + const key = IcePalaceGame.legendKey(piece); + if (!(key in legend)) { + legend[key] = this.glyphFor(piece, expanding ? "top" : "3D"); + } + } + pieces[row][col] = expanding + ? stack.map(piece => IcePalaceGame.legendKey(piece)) + : IcePalaceGame.stackColumn(stack); + } + } + + const offered = this.phase === "build" ? this.stock : this.handOf(this.currplayer); + // 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); + } + } + areas.push({ + type: "pieces", + pieces: offered.map(piece => IcePalaceGame.legendKey(piece)) as [string, ...string[]], + label, + ownerMark: this.currplayer, + }); + } + + const rep: APRenderRep = { + renderer: expanding ? "stacking-expanding" : "stacking-3D", + options: ["hide-labels"], + board: { + style: "squares", + width: layout.width, + height: layout.height, + stackOffset: expanding ? undefined : STACK_OFFSET, + }, + legend, + 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. 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); + 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; + } + + /** + * 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); + 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 + * 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 === "hand") { + shown.push("yard"); + } + if (this.phase === "build" || this.palace.size > 0) { + shown.push("palace"); + } + + const regions: IRegion[] = []; + let col0 = 0; + let height = 0; + for (const which of shown) { + const struct = which === "palace" ? this.palace : this.yard; + 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); + } + return { regions, width: col0 - GAP, height }; + } + + /** 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(); + for (const region of layout.regions) { + if (col < region.col0 || col >= region.col0 + region.cols) { + continue; + } + 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; + } + + public collectChatLogLine(lines: ChatLogLine[], r: APMoveResult, ctx: ChatLogCollectContext): boolean { + switch (r.type) { + case "place": + // i18next.t("apresults:PLACE.icepalace") + this.pushSeatChatLine(lines, ctx.defaultSeat, "apresults:PLACE.icepalace", { what: r.what, where: r.where }); + return true; + case "remove": + // 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); + } + } + + /** 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 new file mode 100644 index 00000000..b088050c --- /dev/null +++ b/test/games/icepalace.test.ts @@ -0,0 +1,927 @@ +/* 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, + 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; + +/** 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: 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()); + 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: board interaction", () => { + 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] => { + 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("p" + 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 [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"], ["3S"]], fatPool()); + g.move("1M@0,0"); + const [row, col] = drawnAt(g.render() as Rep, "1M"); + // The cell to the right is empty padding, and an empty-cell click carries "". + const click = g.handleClick("1S", row, col + 1, ""); + expect(click.valid, click.message).to.be.true; + expect(click.move).to.equal("1S@1,0"); + }); + + 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"); + // 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"); + }); + + 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(["p2L", "p2S"]); + g.move("pass"); + g.move("pass"); + 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(["p1L", "p1M"]); + }); + + 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()); + // 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: 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"]); + // 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"]); + 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"]); + }); + + 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); + // 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; + expect(rep.pieces[0].length).to.equal(7 + 2 + 9); + }); + + 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); + // 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({ 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({ glyph: "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 }); + 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. + 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([]); + }); +}); + +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: 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()); + 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); + }); + + 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 => { + 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), + ); + }); + } + } +});