diff --git a/locales/en/apgames.json b/locales/en/apgames.json index c0a755fc..67188ded 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -4537,9 +4537,21 @@ } }, "icepalace": { - "expanding": { + "#areas": { + "description": "Hand and Pool seen from above, in two dimensions.", + "name": "Top-down hand and Pool" + }, + "#board": { "description": "Two dimensional board, with stacks expanding when hovered over.", "name": "Expanding" + }, + "perspective": { + "description": "Three dimensional board, with pyramids stacked in perspective.", + "name": "3D stacks" + }, + "perspective-areas": { + "description": "Hand and Pool in three dimensions, with the pyramids drawn in perspective.", + "name": "3D hand and Pool" } }, "intermedium": { diff --git a/src/games/icepalace.ts b/src/games/icepalace.ts index 76d721e3..7706795a 100644 --- a/src/games/icepalace.ts +++ b/src/games/icepalace.ts @@ -1,7 +1,7 @@ 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 { APRenderRep, AreaPieces, AreaStackingExpanded, AreaVolcanoStash, Colourfuncs, 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"; @@ -533,7 +533,7 @@ 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; +const GAP = 1; /** * 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 @@ -552,10 +552,31 @@ const MIN_REACH = 2; const STACK_OFFSET = 0.15; /** Legend key of the marker drawn on every legal cell once a pyramid is picked. */ const DOT_KEY = "dot"; +/** + * Black and White are black and white unless the viewer has a palette saved for this + * game, in which case they take palette slots 7 and 8, as documented in `customizations`. + */ +const BLACK: Colourfuncs = { func: "custom", default: "#000000", palette: 7 }; +const WHITE: Colourfuncs = { func: "custom", default: "#ffffff", palette: 8 }; +/** Empty steps added above each perspective stash column, to clear the area's label. */ +const STASH_HEADROOM = 2; +/** + * Empty steps under a nest in a perspective stash. The renderer leaves no room between + * one area and the next, and a nest fills its cell to the bottom edge, where a 3D pyramid + * stops short; without these the nests would sit on the Pool's label. + */ +const NEST_CLEARANCE = 2; +/** Legend key of an invisible spacer, used to lay the Pool out in columns with gaps. */ +const GAP_KEY = "gap"; +/** An invisible cell-sized square: a spacer, and a larger click target behind a pyramid. */ +const BLANK: Glyph = { name: "piece-square-borderless", colour: "_context_background", opacity: 0 }; /** Seen from above, a stack's pyramids overlap; this lets the lower ones show through. */ const TOP_OPACITY = 0.75; +/** The side of the square a legend glyph is composed in, which is the unit of a `nudge`. */ +const GLYPH_UNITS = 500; /** How a pyramid is drawn: in perspective, from above, from the side, or nested in a stash. */ type PyramidView = "3D" | "top" | "side" | "nest"; +type Legend = { [k: string]: Glyph | [Glyph, ...Glyph[]] }; /** Where one structure sits on the shared board. */ interface IRegion { @@ -656,7 +677,21 @@ export class IcePalaceGame extends GameBaseSequenced { "other>2+players", ], flags: ["experimental", "scores", "autopass", "stacking-expanding", "custom-rotation"], - displays: [{ uid: "expanding" }], + customizations: [ + { num: 1, default: 1, explanation: "Colour of player 1's pyramids", player: 1 }, + { num: 2, default: 2, explanation: "Colour of player 2's pyramids", player: 2 }, + { num: 3, default: 3, explanation: "Colour of player 3's pyramids", player: 3 }, + { num: 4, default: 4, explanation: "Colour of player 4's pyramids", player: 4 }, + { num: 5, default: 5, explanation: "Colour of player 5's pyramids", player: 5 }, + { num: 6, default: 6, explanation: "Colour of player 6's pyramids", player: 6 }, + { num: 7, default: "#000000", explanation: "Colour of the Black pyramids" }, + { num: 8, default: "#ffffff", explanation: "Colour of the White pyramids" }, + ], + displays: [ + // Two choices, made separately: how the board is drawn, and how the hand and Pool are. + { uid: "perspective", group: "board" }, + { uid: "perspective-areas", group: "areas" }, + ], }; public numplayers = 3; @@ -1273,9 +1308,8 @@ export class IcePalaceGame extends GameBaseSequenced { 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)); + // build. It is a meeple in the leading player's own colour. + value.unshift(IcePalaceGame.statusGlyph("meeple", p)); } statuses.push({ key: this.seatStatusValue(p), value }); } @@ -1298,11 +1332,12 @@ export class IcePalaceGame extends GameBaseSequenced { * Legend keys double as SVG element ids, and an id that starts with a digit is not a * valid selector in a real browser, so the pyramid id gets a letter in front. 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, and `b` for + * `p` on the board, `a` in the top-down Palace (drawn opaque, unlike the Yard), `h` in + * the perspective hand, `s` in the top-down hand, `c` in the hovered column, and `b` for * the Pool shown below the hand. */ - private static legendKey(piece: PieceId, where: "board" | "stash" | "column" | "pool" = "board"): string { - const prefix = { board: "p", stash: "s", column: "c", pool: "b" }[where]; + private static legendKey(piece: PieceId, where: "board" | "palace" | "hand" | "stash" | "column" | "pool" = "board"): string { + const prefix = { board: "p", palace: "a", hand: "h", stash: "s", column: "c", pool: "b" }[where]; return `${prefix}${piece}`; } @@ -1311,15 +1346,15 @@ export class IcePalaceGame extends GameBaseSequenced { * 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 { + private static statusGlyph(name: string, colour: number | string | Colourfuncs): 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); + const glyph = this.glyphFor(piece, "nest"); + return IcePalaceGame.statusGlyph(glyph.name!, glyph.colour as number | string | Colourfuncs); } /** @@ -1351,7 +1386,7 @@ export class IcePalaceGame extends GameBaseSequenced { const colour = colourOf(piece); const glyph: Glyph = { name, - colour: colour === NULL_COLOUR ? "#000000" : colour === WILD_COLOUR ? "#ffffff" : Number(colour), + colour: colour === NULL_COLOUR ? BLACK : colour === WILD_COLOUR ? WHITE : Number(colour), }; if (view === "top") { glyph.opacity = TOP_OPACITY; @@ -1359,6 +1394,137 @@ export class IcePalaceGame extends GameBaseSequenced { return glyph; } + /** + * The offered pyramids as a stash, one per column, smallest first. Seen from above each + * is a nest, a filled shape that takes clicks by itself. In 3D each sits on an invisible + * square, so a click anywhere in its cell picks it rather than only a click on the thin + * outline; those get keys of their own, since on the board the square would reach into + * neighbouring cells. A 3D glyph's apex is fixed, so left alone the smaller pyramids + * would hang from the top: the perspective renderer lifts mediums and larges with + * placeholders, and the top-down renderer, which takes none, has the smaller ones + * nudged down instead. Either way every base rests on one line. Nests in the + * perspective renderer are lifted clear of the area below instead. + */ + private handStash(offered: PieceId[], legend: Legend, expanding: boolean, areas3D: boolean): string[][] { + return [...offered].sort(pieceSort).map(piece => { + const key = IcePalaceGame.legendKey(piece, areas3D ? "hand" : "stash"); + if (areas3D) { + legend[key] = [BLANK, expanding ? IcePalaceGame.baseAligned(piece, this.glyphFor(piece)) : this.glyphFor(piece)]; + } else if (!(key in legend)) { + legend[key] = this.glyphFor(piece, "nest"); + } + if (expanding) { + return [key]; + } + const lift = areas3D ? sizeOf(piece) - 1 : NEST_CLEARANCE; + return IcePalaceGame.withHeadroom([...Array(lift).fill("-"), key]); + }); + } + + /** + * The Pool as a stash, one column per colour, largest at the bottom, each pyramid a + * fixed distance above the last with an extra gap between sizes. Seen from above the + * pyramids are turned to diamonds and drawn opaque; a diamond is wider than a stash + * column, so a spacer column keeps the colours apart. In 3D the glyphs are aligned as + * in the hand. + */ + private poolStash(legend: Legend, expanding: boolean, areas3D: boolean): string[][] { + const sorted = [...this.pool].sort(pieceSort); + for (const piece of sorted) { + const key = IcePalaceGame.legendKey(piece, "pool"); + if (key in legend) { + continue; + } + if (areas3D) { + legend[key] = expanding ? IcePalaceGame.baseAligned(piece, this.glyphFor(piece)) : this.glyphFor(piece); + } else { + const glyph = this.glyphFor(piece, "top"); + delete glyph.opacity; + glyph.rotate = 45; + legend[key] = glyph; + } + } + legend[GAP_KEY] = BLANK; + const columns = expanding ? IcePalaceGame.poolColumns(sorted) : IcePalaceGame.perspectivePool(sorted, areas3D); + // The renderer sets stash columns a fixed slot apart, so an empty one is the only + // way to widen the gap. + return areas3D ? columns : columns.flatMap((column, i) => i === 0 ? [column] : [[GAP_KEY], column]); + } + + /** + * A 3D glyph nudged down so that its base rests where a large's does. The pyramids + * share an apex, with a large's base at 0.80 of the cell, a medium's at 0.65 and a + * small's at 0.50; a large is returned as it is. + */ + private static baseAligned(piece: PieceId, glyph: Glyph): Glyph { + const steps = SIZE_NAMES.length - sizeOf(piece); + return steps === 0 ? glyph : { ...glyph, nudge: { dy: steps * STACK_OFFSET * GLYPH_UNITS } }; + } + + /** + * The Pool for the top-down renderer, whose stash sets each index a fixed 0.35 of a + * cell above the last: one column of legend keys per colour, bottom first, larges, + * then mediums, then smalls, with a spacer between sizes. + */ + private static poolColumns(sorted: PieceId[]): string[][] { + return IcePalaceGame.nests(sorted).map(nest => { + const column: string[] = []; + let lastSize: number | undefined; + for (const piece of nest) { + if (lastSize !== undefined && sizeOf(piece) !== lastSize) { + column.push(GAP_KEY); + } + column.push(IcePalaceGame.legendKey(piece, "pool")); + lastSize = sizeOf(piece); + } + return column; + }); + } + + /** + * The Pool for the perspective renderer: a stash column per colour, bottom first, with + * each pyramid's base a fixed distance above the one below and an extra step between + * sizes, so the columns overlap evenly as in the top-down renderer. + * + * The stash raises each index by 0.15 of a cell, and a 3D glyph's base already sits one + * such step higher for each size down (large 0.80, medium 0.65, small 0.50), so heights + * are whole steps. `"-"` placeholders spend the steps. A diamond seen from above is + * centred whatever its size, so its whole height is spent with them. + */ + private static perspectivePool(sorted: PieceId[], pyramids3D: boolean): string[][] { + const between = 2; + const betweenSizes = 2; + return IcePalaceGame.nests(sorted).map(nest => { + const column: string[] = []; + let lastBase: number | undefined; + let lastSize: number | undefined; + for (const piece of nest) { + // Where this size's base sits unraised, in steps below a small's. + const own = pyramids3D ? sizeOf(piece) - 1 : 0; + // The first pyramid rests on the ground, a small's unraised base line. + const target = lastBase === undefined + ? 0 + : lastBase - between - (sizeOf(piece) !== lastSize ? betweenSizes : 0); + const index = own - target; + while (column.length < index) { + column.push("-"); + } + column.push(IcePalaceGame.legendKey(piece, "pool")); + lastBase = target; + lastSize = sizeOf(piece); + } + return IcePalaceGame.withHeadroom(column); + }); + } + + /** + * The renderer hangs a stash's tallest column just under its label, leaving little room + * for a label drawn in a large font. Empty steps on top push everything down a little. + */ + private static withHeadroom(column: string[]): string[] { + return [...column, ...Array(STASH_HEADROOM).fill("-")]; + } + /** The offered pyramids gathered into nests, one per colour, largest at the bottom. */ private static nests(offered: PieceId[]): PieceId[][] { const byColour = new Map(); @@ -1379,7 +1545,7 @@ export class IcePalaceGame extends GameBaseSequenced { const current = IcePalaceGame.normalise(move); let newmove: string; const picked = piece === undefined ? undefined : /^([a-z])?([1-6BW][SML])$/i.exec(piece); - if (picked?.[1]?.toLowerCase() === "b") { + if (picked?.[1]?.toLowerCase() === "b" || piece === GAP_KEY) { // The Pool is shown for reference; nothing in it can be played. result.move = current; result.message = i18next.t("apgames:validation.icepalace.POOL_CLICK"); @@ -1391,19 +1557,17 @@ export class IcePalaceGame extends GameBaseSequenced { } 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); + // Map the click against the board as drawn, which includes any placements + // already made in this build; the committed Palace may be smaller. + const cell = this.inProgress(current).cellAt(row, col); if (cell === undefined) { - result.move = current; - result.message = i18next.t("apgames:validation.icepalace.OFF_STRUCTURE"); - return result; + return this.cancelSelection(current, result, i18next.t("apgames:validation.icepalace.OFF_STRUCTURE")); } newmove = this.appendToken(current, `@${cell}`); } const validated = this.validateMove(newmove); if (!validated.valid) { - result.move = current; - result.message = validated.message; - return result; + return this.cancelSelection(current, result, validated.message); } result.move = newmove; result.valid = true; @@ -1417,6 +1581,43 @@ export class IcePalaceGame extends GameBaseSequenced { } } + /** A copy of the game with the finished placements of a partial build applied. */ + private inProgress(current: string): IcePalaceGame { + if (this.phase !== "build") { + return this; + } + const placed = current.split(";").filter(t => t.includes("@")); + if (placed.length === 0) { + return this; + } + const scratch = this.clone(); + scratch.move(placed.join(";"), { partial: true, trusted: true }); + return scratch; + } + + /** + * A board click that cannot take the picked pyramid drops the pick, so the next click + * starts afresh. With nothing picked, the click is simply refused. + */ + private cancelSelection(current: string, result: IClickResult, message: string): IClickResult { + const parts = current === "" ? [] : current.split(";"); + const last = parts[parts.length - 1]; + if (last === undefined || last.includes("@") || last === "pass") { + result.move = current; + result.message = message; + return result; + } + parts.pop(); + const remaining = parts.join(";"); + const validated = this.validateMove(remaining); + result.move = remaining; + result.valid = validated.valid; + result.complete = validated.complete; + result.canrender = validated.canrender; + result.message = 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("@")) { @@ -1450,17 +1651,21 @@ export class IcePalaceGame extends GameBaseSequenced { * 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. Either way, whatever is still in - * the Pool is shown below that, for reference only. + * The default display looks straight down, with each stack's pyramids drawn + * translucently over one another, and hovering a cell lays its stack out beside the + * board (see `renderColumn`). The "perspective" display draws the pyramids in 3D + * instead. Both show the offered pyramids, then whatever is still in the Pool, as + * stashes below the board; the Pool is for reference only. The stashes are chosen + * separately: seen from above by default, or in 3D with "perspective-areas". */ public render(opts?: IRenderOpts): APRenderRep { - const expanding = this.hasDisplay(opts, "expanding"); + // The top-down board is the default; the perspective board is the alternate. The + // board picks the renderer, which draws the stashes too, so a stash style is a + // matter of glyphs, laid out in columns the way that renderer allows. + const expanding = !this.hasDisplay(opts, "perspective"); + const areas3D = this.hasDisplay(opts, "perspective-areas"); const layout = this.layout(); - const legend: { [k: string]: Glyph } = {}; + const legend: Legend = {}; const pieces: string[][][] = []; for (let row = 0; row < layout.height; row++) { const line: string[][] = []; @@ -1471,16 +1676,23 @@ export class IcePalaceGame extends GameBaseSequenced { } for (const region of layout.regions) { const struct = region.which === "palace" ? this.palace : this.yard; + // Seen from above, the Palace is drawn opaque while the Yard stays translucent, + // so it gets keys of its own; in perspective every pyramid is opaque anyway. + const where = expanding && region.which === "palace" ? "palace" : "board"; 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); + const key = IcePalaceGame.legendKey(piece, where); if (!(key in legend)) { - legend[key] = this.glyphFor(piece, expanding ? "top" : "3D"); + const glyph = this.glyphFor(piece, expanding ? "top" : "3D"); + if (where === "palace") { + delete glyph.opacity; + } + legend[key] = glyph; } } pieces[row][col] = expanding - ? stack.map(piece => IcePalaceGame.legendKey(piece)) + ? stack.map(piece => IcePalaceGame.legendKey(piece, where)) : IcePalaceGame.stackColumn(stack); } } @@ -1491,56 +1703,21 @@ export class IcePalaceGame extends GameBaseSequenced { const label = this.phase === "build" ? this.neutralAreaLabel("apgames:icepalace.STOCK") : this.seatAreaLabel(this.currplayer, "apgames:icepalace.HAND"); + // Stashes rather than pieces areas: the renderer puts both directly under the + // board, so the hand must be a stash once the Pool is one. 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, - }); + if (offered.length > 0) { + areas.push({ type: "localStash", label, stash: this.handStash(offered, legend, expanding, areas3D) }); } - // Below that, everything still in the Pool, for reference. Draws are made at random // when hands are refilled, so showing the contents gives nothing away. if (this.pool.length > 0) { // i18next.t("apgames:icepalace.POOL") const poolLabel = this.neutralAreaLabel("apgames:icepalace.POOL"); - const sorted = [...this.pool].sort(pieceSort); - for (const piece of sorted) { - const key = IcePalaceGame.legendKey(piece, "pool"); - if (!(key in legend)) { - legend[key] = this.glyphFor(piece, expanding ? "nest" : "3D"); - } - } - if (expanding) { - // One stack per colour, largest at the bottom, so the stash stays narrow. - const stash = IcePalaceGame.nests(sorted).map(nest => nest.map(piece => IcePalaceGame.legendKey(piece, "pool"))); - areas.push({ type: "localStash", label: poolLabel, stash }); - } else { - areas.push({ - type: "pieces", - pieces: sorted.map(piece => IcePalaceGame.legendKey(piece, "pool")) as [string, ...string[]], - label: poolLabel, - }); - } + areas.push({ type: "localStash", label: poolLabel, stash: this.poolStash(legend, expanding, areas3D) }); } + const blocked = IcePalaceGame.unreachable(layout, this.palace, this.yard); const rep: APRenderRep = { renderer: expanding ? "stacking-expanding" : "stacking-3D", options: ["hide-labels"], @@ -1549,6 +1726,7 @@ export class IcePalaceGame extends GameBaseSequenced { width: layout.width, height: layout.height, stackOffset: expanding ? undefined : STACK_OFFSET, + blocked: blocked.length > 0 ? blocked as [{ row: number; col: number }, ...{ row: number; col: number }[]] : undefined, }, legend, pieces: pieces as [string[][], ...string[][][]], @@ -1566,7 +1744,7 @@ export class IcePalaceGame extends GameBaseSequenced { 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 }; + legend[DOT_KEY] = { name: "piece", colour: "_context_annotations", scale: 0.27, opacity: 0.5 }; for (const cell of legalCellsFor(struct, this.selected, legal)) { const [row, col] = IcePalaceGame.drawnAt(layout, region, cell); pieces[row][col].push(DOT_KEY); @@ -1602,6 +1780,37 @@ export class IcePalaceGame extends GameBaseSequenced { }; } + /** + * The cells nothing can ever go into right now, for both displays to leave blank: + * the gap between the structures, and every empty cell with no pyramid beside it + * orthogonally. An empty structure keeps only its centre, where the lead goes. + */ + private static unreachable(layout: ILayout, palace: Structure, yard: Structure): { row: number; col: number }[] { + const open = new Set(); + for (const region of layout.regions) { + const struct = region.which === "palace" ? palace : yard; + const keep: Cell[] = struct.size === 0 ? [cellOf(0, 0)] : [...struct.keys()]; + if (struct.size > 0) { + for (const cell of struct.keys()) { + keep.push(...neighbours(cell)); + } + } + for (const cell of keep) { + const [row, col] = IcePalaceGame.drawnAt(layout, region, cell); + open.add(`${row},${col}`); + } + } + const blocked: { row: number; col: number }[] = []; + for (let row = 0; row < layout.height; row++) { + for (let col = 0; col < layout.width; col++) { + if (!open.has(`${row},${col}`)) { + blocked.push({ row, col }); + } + } + } + return blocked; + } + /** 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); diff --git a/test/games/icepalace.test.ts b/test/games/icepalace.test.ts index 576ce1b9..c2fb097a 100644 --- a/test/games/icepalace.test.ts +++ b/test/games/icepalace.test.ts @@ -326,10 +326,13 @@ describe("Ice Palace: board interaction", () => { }; /** Board row and column at which a cell's stack is drawn. */ + /** The perspective display, which several checks here are about. */ + const persp = (g: IcePalaceGame): Rep => g.render({ altDisplays: ["perspective", "perspective-areas"] }) as unknown as Rep; 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)) { + // Yard pyramids use `p` keys; the top-down Palace has `a` keys of its own. + if (rep.pieces[row][col].includes("p" + piece) || rep.pieces[row][col].includes("a" + piece)) { return [row, col]; } } @@ -357,13 +360,75 @@ describe("Ice Palace: board interaction", () => { expect(click.move).to.equal("1S@1,0"); }); - it("refuses clicks on the Palace while a hand is being played", () => { + it("never places on the Palace during a hand; a click there drops the pick", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["1S"], ["3S"]], fatPool()); g.palace = new Map([["0,0", ["2L"]]]); g.move("1M@0,0"); const [row, col] = drawnAt(g.render() as Rep, "2L"); const click = g.handleClick("1S", row, col, "0"); - expect(click.valid).to.be.false; + expect(click.move).to.equal(""); + // With nothing picked, the same click is simply refused. + expect(g.handleClick("", row, col, "0").valid).to.be.false; + }); + + it("switches the pick when another pyramid is clicked", () => { + const g = rig(new IcePalaceGame(3), [["1M"], ["1S", "2L"], ["3S"]], fatPool()); + g.move("1M@0,0"); + const click = g.handleClick("1S", -1, -1, "p2L"); + expect(click.valid).to.be.true; + expect(click.move).to.equal("2L"); + // During a build, only the unplaced pick is switched. + const b = rig(new IcePalaceGame(3), [["1M", "1S"], ["2S"], ["3S"]], fatPool()); + b.move("1M@0,0"); + b.move("pass"); + b.move("pass"); + b.move("1S@1,0"); + b.move("pass"); + b.move("pass"); + b.move("pass"); + expect(b.phase).to.equal("build"); + expect(b.handleClick("1M@0,0;1M", -1, -1, "p1S").move).to.equal("1M@0,0;1S"); + }); + + it("maps build clicks against the Palace as drawn, pending placements included", () => { + const b = rig(new IcePalaceGame(3), [["1M", "1S"], ["2S"], ["3S"]], fatPool()); + b.palace = new Map([["0,0", ["1L"]], ["1,0", ["1L"]], ["2,0", ["1L"]]]); + b.move("1M@0,0"); + b.move("pass"); + b.move("pass"); + b.move("1S@1,0"); + b.move("pass"); + b.move("pass"); + b.move("pass"); + expect(b.phase).to.equal("build"); + // Founding at 3,0 widens the Palace by a column, shifting the board as drawn. + const partial = b.clone(); + partial.move("1M@3,0;1S", { partial: true }); + const rep = partial.render() as Rep; + const [row, col] = drawnAt(rep, "1M"); + const click = b.handleClick("1M@3,0;1S", row, col, ""); + expect(click.move).to.equal("1M@3,0;1S@3,0"); + }); + + it("drops the pick when clicking a cell it cannot go to, keeping earlier placements", () => { + const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); + g.move("1M@0,0"); + // A player-2 small cannot found beside a player-1 medium; the Yard origin is row 3, col 3. + const click = g.handleClick("2S", 3, 4, ""); + expect(click.valid).to.be.true; + expect(click.move).to.equal(""); + const b = rig(new IcePalaceGame(3), [["1M", "1S"], ["2S"], ["3S"]], fatPool()); + b.move("1M@0,0"); + b.move("pass"); + b.move("pass"); + b.move("1S@1,0"); + b.move("pass"); + b.move("pass"); + b.move("pass"); + // A far corner of the Palace touches nothing the small could found beside. + const cancel = b.handleClick("1M@0,0;1S", 0, 0, ""); + expect(cancel.move).to.equal("1M@0,0"); + expect(cancel.valid).to.be.true; }); it("selects a pyramid when its entry in the pieces area is clicked", () => { @@ -378,7 +443,12 @@ describe("Ice Palace: board interaction", () => { it("offers the current hand while a hand is played, and the stock while building", () => { const g = rig(new IcePalaceGame(3), [["1L", "1M"], ["2L", "2S"], ["3L"]], fatPool()); g.move("1M@0,0"); - expect((g.render() as Rep).areas?.[0].pieces).to.deep.equal(["p2L", "p2S"]); + // The hand is a stash of one-pyramid stacks, labelled with the player's name. + const hand = (persp(g) as Rep).areas![0].stash!; + expect(hand.flat().filter(k => k !== "-")).to.deep.equal(["h2S", "h2L"]); + // Bases rest on one line: a large is raised two steps, a small not at all, and + // every column has two empty steps of headroom under the label. + expect(hand).to.deep.equal([["h2S", "-", "-"], ["-", "-", "h2L", "-", "-"]]); g.move("pass"); g.move("pass"); g.move("1L@0,0"); @@ -386,7 +456,7 @@ describe("Ice Palace: board interaction", () => { g.move("pass"); } expect(g.phase).to.equal("build"); - expect((g.render() as Rep).areas?.[0].pieces.sort()).to.deep.equal(["p1L", "p1M"]); + expect((persp(g) as Rep).areas?.[0].stash?.flat().filter(k => k !== "-").sort()).to.deep.equal(["h1L", "h1M"]); }); it("stacks pyramids the way Volcano does, one index above the last", () => { @@ -397,7 +467,7 @@ describe("Ice Palace: board interaction", () => { // 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 }; + const rep = persp(g) as unknown as Rep & { board: { stackOffset?: number }; legend: Record }; expect(rep.board.stackOffset).to.equal(0.15); expect(rep.legend.p1L.nudge).to.be.undefined; // A Palace tower: the large is lifted onto the ground, then each piece sits one up. @@ -413,7 +483,7 @@ describe("Ice Palace: board interaction", () => { expect(rep.pieces[yr][yc]).to.deep.equal(["p1S", "p2M", "p3L"]); }); - it("keeps a minimum footprint around the origin and two columns between structures", () => { + it("keeps a minimum footprint around the origin and one column 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; @@ -423,27 +493,41 @@ describe("Ice Palace: board interaction", () => { 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. + // With a Palace as well, both regions show, one empty column 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); + expect(rep.pieces[0].length).to.equal(7 + 1 + 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); + expect(drawnAt(rep, "2L")[1]).to.equal(7 + 1 + 3); // Growing past the minimum extends the board only in that direction. g.yard.set("4,0", ["1S"]); rep = g.render() as Rep; - expect(rep.pieces[0].length).to.equal(7 + 2 + 9); + expect(rep.pieces[0].length).to.equal(7 + 1 + 9); }); it("shows the Pool below the hand for reference, and ignores clicks on it", () => { const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], ["2S", "1L", "WM", "1S"]); - const rep = g.render() as Rep & { areas: { type?: string; pieces?: string[]; label?: { textKey?: string } }[] }; + const rep = persp(g) as Rep & { areas: { type?: string; stash?: string[][]; label?: { textKey?: string } }[] }; expect(rep.areas).to.have.length(2); - expect(rep.areas[1].type).to.equal("pieces"); + expect(rep.areas[0].type).to.equal("localStash"); + expect(rep.areas[1].type).to.equal("localStash"); expect(rep.areas[1].label?.textKey).to.equal("apgames:icepalace.POOL"); - expect(rep.areas[1].pieces).to.deep.equal(["b1S", "b1L", "b2S", "bWM"]); + // One stash column per colour, largest at the bottom, every column resting on the + // same ground line. Placeholders raise each pyramid a fixed distance above the + // last, with an extra step between sizes, and add two steps of headroom on top. + expect(rep.areas[1].stash).to.deep.equal([ + ["-", "-", "b1L", "-", "b1S", "-", "-"], + ["b2S", "-", "-"], + ["-", "bWM", "-", "-"], + ]); + // A hand pyramid sits on an invisible square that widens its click target. + const legend = (persp(g) as unknown as { legend: Record }).legend; + expect(legend.h1M).to.be.an("array").with.length(2); + expect((legend.h1M as { opacity?: number }[])[0].opacity).to.equal(0); + // Clicking a spacer is like clicking the Pool. + expect(g.handleClick("", -1, -1, "gap").valid).to.be.false; // A click on the Pool changes nothing, whether or not a pyramid is picked. let click = g.handleClick("", -1, -1, "b1S"); expect(click.valid).to.be.false; @@ -453,7 +537,7 @@ describe("Ice Palace: board interaction", () => { expect(click.move).to.equal("1M"); // An empty Pool shows no area at all. g.pool = []; - expect((g.render() as Rep).areas).to.have.length(1); + expect((persp(g) as Rep).areas).to.have.length(1); }); it("counts the pyramids placed so far against the number to build", () => { @@ -482,14 +566,15 @@ describe("Ice Palace: board interaction", () => { expect(statuses.length).to.be.greaterThan(3); // Seat 1 leads the first hand, so its row starts with the button. expect(statuses[0].value.length).to.equal(3); - expect(statuses[0].value[0]).to.deep.equal({ glyph: "piece", colour: 7 }); + // The button is a meeple in the leader's colour. + expect(statuses[0].value[0]).to.deep.equal({ glyph: "meeple", colour: 1 }); 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[1].value[0]).to.deep.equal({ glyph: "pyramid-flattened-small", 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 }); + expect(g.sidebarStatuses()[1].value[0]).to.deep.equal({ glyph: "meeple", colour: 2 }); }); it("dots the legal cells once a pyramid is picked, and only then", () => { @@ -504,26 +589,26 @@ describe("Ice Palace: board interaction", () => { }; 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([]); + expect(dotted(persp(g) as Rep)).to.deep.equal([]); g.move("1M", { partial: true }); - expect(dotted(g.render() as Rep)).to.deep.equal(["3,3"]); + expect(dotted(persp(g) 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; + const rep = persp(g) 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"]); + expect(dotted(persp(g) as Rep)).to.deep.equal(["2,3", "3,2", "3,4", "4,3"]); // Completing the placement clears the dots. g.move("1S@1,0"); - expect(dotted(g.render() as Rep)).to.deep.equal([]); + expect(dotted(persp(g) as Rep)).to.deep.equal([]); }); }); -describe("Ice Palace: expanding display", () => { +describe("Ice Palace: top-down display", () => { type Rep = { renderer?: string; board?: { width: number; height: number; stackOffset?: number } | null; @@ -531,10 +616,14 @@ describe("Ice Palace: expanding display", () => { 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; + const expanding = (g: IcePalaceGame): Rep => g.render() as unknown as Rep; - it("is declared, and turns rotation off for both displays", () => { - expect(IcePalaceGame.gameinfo.displays).to.deep.equal([{ uid: "expanding" }]); + it("is the default, with perspective as the alternate, and neither rotates", () => { + expect(IcePalaceGame.gameinfo.displays).to.deep.equal([ + { uid: "perspective", group: "board" }, + { uid: "perspective-areas", group: "areas" }, + ]); + expect(new IcePalaceGame(3).alternativeDisplays()!.map(d => d.uid)).to.deep.equal(["#areas", "#board", "perspective", "perspective-areas"]); expect(IcePalaceGame.gameinfo.flags).to.include("stacking-expanding"); expect(IcePalaceGame.gameinfo.flags).to.include("custom-rotation"); expect(new IcePalaceGame(3).getCustomRotation()).to.equal(0); @@ -546,7 +635,7 @@ describe("Ice Palace: expanding display", () => { 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; + const deep = g.render({ altDisplays: ["perspective"] }) 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; @@ -556,8 +645,11 @@ describe("Ice Palace: expanding display", () => { } } // 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 }); + // The Palace stack is opaque, with keys of its own; the Yard stays translucent. + expect(flat.pieces![3][7 + 1 + 3]).to.deep.equal(["a2L", "a1M"]); + expect(flat.legend!.a2L).to.deep.equal({ name: "pyramid-up-large-upscaled", colour: 2 }); + expect(flat.legend!.a1M).to.deep.equal({ name: "pyramid-up-medium-upscaled", colour: 1 }); + expect(flat.pieces![3][3]).to.deep.equal(["p1M"]); expect(flat.legend!.p1M).to.deep.equal({ name: "pyramid-up-medium-upscaled", colour: 1, opacity: 0.75 }); }); @@ -568,10 +660,14 @@ describe("Ice Palace: expanding display", () => { // The hand, then the Pool. expect(rep.areas).to.have.length(2); 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"]]); + // Player 2's hand, one pyramid per stack, left to right. + expect(rep.areas![0].stash).to.deep.equal([["s1L"], ["s2S"], ["s2S"], ["s2L"], ["sWS"]]); expect(rep.legend!.s2L).to.deep.equal({ name: "pyramid-flattened-large", colour: 2 }); - expect(rep.legend!.sWS).to.deep.equal({ name: "pyramid-flattened-small", colour: "#ffffff" }); + // White is white by default, or palette slot 8 for a viewer with a saved palette. + expect(rep.legend!.sWS).to.deep.equal({ + name: "pyramid-flattened-small", + colour: { func: "custom", default: "#ffffff", palette: 8 }, + }); // 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. @@ -581,13 +677,43 @@ describe("Ice Palace: expanding display", () => { 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"]]); + expect(rep.areas![0].stash).to.deep.equal([["s1S"], ["s3M"], ["s3L"]]); + }); + + it("blanks out cells nothing can reach, leaving stacks and their orthogonal neighbours", () => { + const g = rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], fatPool()); + // An empty Yard keeps only its centre, where the lead goes. + let board = expanding(g).board as unknown as { width: number; height: number; blocked: { row: number; col: number }[] }; + expect(board.blocked).to.have.length(board.width * board.height - 1); + expect(board.blocked).to.not.deep.include({ row: 3, col: 3 }); + g.move("1M@0,0"); + board = expanding(g).board as unknown as typeof board; + const open = []; + for (let row = 0; row < board.height; row++) { + for (let col = 0; col < board.width; col++) { + if (!board.blocked.some(b => b.row === row && b.col === col)) { + open.push(`${row},${col}`); + } + } + } + expect(open.sort()).to.deep.equal(["2,3", "3,2", "3,3", "3,4", "4,3"]); + // The perspective display blocks the same cells. + expect((g.render({ altDisplays: ["perspective"] }) as unknown as { board: { blocked?: unknown } }).board.blocked).to.deep.equal(board.blocked); }); - it("accepts the display as a list of active uids, as the front now sends it", () => { + it("documents every palette slot it uses, Black and White included", () => { + const slots = (IcePalaceGame.gameinfo.customizations as { num: number; default: string | number }[]); + expect(slots.map(c => c.num)).to.deep.equal([1, 2, 3, 4, 5, 6, 7, 8]); + expect(slots.find(c => c.num === 7)!.default).to.equal("#000000"); + expect(slots.find(c => c.num === 8)!.default).to.equal("#ffffff"); + }); + + it("switches to perspective when asked, in either form the front sends", () => { const g = new IcePalaceGame(3); - expect((g.render({ altDisplays: ["expanding"] }) as unknown as Rep).renderer).to.equal("stacking-expanding"); - expect((g.render({ altDisplays: [] }) as unknown as Rep).renderer).to.equal("stacking-3D"); + expect((g.render() as unknown as Rep).renderer).to.equal("stacking-expanding"); + expect((g.render({ altDisplays: [] }) as unknown as Rep).renderer).to.equal("stacking-expanding"); + expect((g.render({ altDisplays: ["perspective"] }) as unknown as Rep).renderer).to.equal("stacking-3D"); + expect((g.render({ altDisplay: "perspective" }) as unknown as Rep).renderer).to.equal("stacking-3D"); }); it("shows the Pool below the hand as one stack per colour", () => { @@ -595,8 +721,10 @@ describe("Ice Palace: expanding display", () => { const rep = expanding(g); const pool = rep.areas![1]; expect(pool.type).to.equal("localStash"); - expect(pool.stash).to.deep.equal([["b1L", "b1L", "b1S"], ["b2S"], ["bWM"]]); - expect(rep.legend!.b1L).to.deep.equal({ name: "pyramid-flattened-large", colour: 1 }); + // A spacer column sits between each pair of colours. + expect(pool.stash).to.deep.equal([["b1L", "b1L", "gap", "b1S"], ["gap"], ["b2S"], ["gap"], ["bWM"]]); + // Pool pyramids are seen from above, as opaque diamonds. + expect(rep.legend!.b1L).to.deep.equal({ name: "pyramid-up-large-upscaled", colour: 1, rotate: 45 }); }); it("still dots the legal cells once a pyramid is picked", () => { @@ -625,7 +753,7 @@ describe("Ice Palace: expanding display", () => { 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; + rep = g.renderColumn(7 + 1 + 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. @@ -634,6 +762,52 @@ describe("Ice Palace: expanding display", () => { }); }); +describe("Ice Palace: mixing the board and stash displays", () => { + type Part = { name?: string; rotate?: number; opacity?: number; nudge?: { dy?: number } }; + type Rep = { + renderer: string; + legend: Record; + areas?: { stash?: string[][] }[]; + }; + const rigged = () => rig(new IcePalaceGame(3), [["1M"], ["2S"], ["3S"]], ["2S", "1L", "WM", "1S"]); + + it("draws the hand and Pool from above under the 3D board unless asked otherwise", () => { + const rep = rigged().render({ altDisplays: ["perspective"] }) as unknown as Rep; + expect(rep.renderer).to.equal("stacking-3D"); + // The hand: nests, one per column, lifted two steps clear of the Pool's label below + // and with two steps of headroom under the hand's own label. + expect(rep.areas![0].stash).to.deep.equal([["-", "-", "s1M", "-", "-"]]); + expect(rep.legend.s1M).to.include({ name: "pyramid-flattened-medium" }); + // The Pool: opaque diamonds two steps apart and four between sizes, with a spacer + // column between colours, as the top-down renderer draws them. + expect(rep.areas![1].stash).to.deep.equal([ + ["b1L", "-", "-", "-", "b1S", "-", "-"], + ["gap"], + ["b2S", "-", "-"], + ["gap"], + ["bWM", "-", "-"], + ]); + expect(rep.legend.b1L).to.include({ name: "pyramid-up-large-upscaled", rotate: 45 }); + expect(rep.legend.b1L).to.not.have.any.keys("opacity", "nudge"); + }); + + it("draws the hand and Pool in 3D under the expanding board on request", () => { + const rep = rigged().render({ altDisplays: ["perspective-areas"] }) as unknown as Rep; + expect(rep.renderer).to.equal("stacking-expanding"); + // This renderer takes no placeholders, so bases are aligned by nudging the smaller + // pyramids down instead: 0.15 of a 500-unit cell per size. + expect(rep.areas![0].stash).to.deep.equal([["h1M"]]); + const hand = rep.legend.h1M as Part[]; + expect(hand).to.have.length(2); + expect(hand[1]).to.deep.include({ name: "pyramid-up-medium-3D", nudge: { dy: 75 } }); + // The Pool on the renderer's fixed steps: a spacer between sizes, no spacer columns. + expect(rep.areas![1].stash).to.deep.equal([["b1L", "gap", "b1S"], ["b2S"], ["bWM"]]); + expect(rep.legend.b1L).to.not.have.any.keys("nudge"); + expect(rep.legend.b1S).to.deep.include({ nudge: { dy: 150 } }); + expect(rep.areas!.flatMap(a => a.stash!.flat())).to.not.include("-"); + }); +}); + describe("Ice Palace: event log", () => { before(() => { addResource("en");