From db2b88ab6431a7c38ad5a794993e64c3f0635384 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:05:32 +0000 Subject: [PATCH] Add spoiler status aids for Mega-Volcano and Tintas Mega-Volcano gains two spoiler-tagged score sections: the non-white colours each player has not yet captured, drawn as core `piece` glyphs in palette order, and each player's count of captured pyramids, white included. Its existing Scores section is unchanged. Tintas gains two spoiler-tagged score sections, again as core `piece` glyphs in palette order: the colours each player can still take all seven of (their opponent has none of it), and the colours in which they hold a majority (four or more discs). To carry rows of glyphs, an IScores entry may now be a list of status values as well as a single value. The front must be able to draw such rows before this ships. The developer playground draws them, and a comment on IScores.name notes how section titles could take glyphs too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XB8BRhXCX8rrWsJKVx8nA8 --- locales/en/apgames.json | 8 ++++++ playground/playground.js | 12 +++++++- src/games/_base.ts | 9 +++++- src/games/mvolcano.ts | 14 ++++++++-- src/games/tintas.ts | 16 ++++++++++- test/games/mvolcano.test.ts | 55 +++++++++++++++++++++++++++++++++++++ test/games/tintas.test.ts | 53 +++++++++++++++++++++++++++++++++++ 7 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 test/games/mvolcano.test.ts create mode 100644 test/games/tintas.test.ts diff --git a/locales/en/apgames.json b/locales/en/apgames.json index c0a755fce..fdeabb8c4 100644 --- a/locales/en/apgames.json +++ b/locales/en/apgames.json @@ -4984,6 +4984,10 @@ "OFFENSE": "Offensive player", "COUNTDOWN": "Plies remaining" }, + "mvolcano": { + "PYRAMIDSCAPTURED": "Pyramids captured", + "UNCAPTUREDCOLOURS": "Colours not yet captured" + }, "pacru": { "TARGET": "Tiles to win", "TILES": "Tiles on board" @@ -5005,6 +5009,10 @@ "RESERVE": "Reserve", "SCORES": "Scores", "STACKSIZES": "Stack heights", + "tintas": { + "MAJORITIES": "Majorities won", + "MONOCHROME": "Monochrome potential" + }, "tumbleweed": "Score (pieces + influence)", "ESTIMATEDSCORES": "Estimated scores", "killallgo": { diff --git a/playground/playground.js b/playground/playground.js index fd62ab172..16a4f84e3 100644 --- a/playground/playground.js +++ b/playground/playground.js @@ -1146,6 +1146,9 @@ function isPerPlayerStatusLabel(status) { } function formatSidebarScoreValue(value, playerNames, glyphRenderOptions) { + if (Array.isArray(value)) { + return value.map(v => formatSidebarStatusValue(v, playerNames, glyphRenderOptions)).join(" "); + } if (APGames?.isStructuredRenderLabel?.(value)) { return APGames.resolveRenderLabel(value, playerNames, playgroundTranslate); } @@ -1406,7 +1409,14 @@ function _renderScoresSection(game, gamename, playerNames, gameFlags, glyphRende playerDiv.appendChild(swatch); const scoreTextSpan = document.createElement('span'); - scoreTextSpan.textContent = `${playerName}: ${formatSidebarScoreValue(playerScore, playerNames, glyphRenderOptions)}`; + const scoreText = formatSidebarScoreValue(playerScore, playerNames, glyphRenderOptions); + if (Array.isArray(playerScore)) { + // A row of status values may hold glyph markup + scoreTextSpan.textContent = `${playerName}: `; + scoreTextSpan.insertAdjacentHTML('beforeend', scoreText); + } else { + scoreTextSpan.textContent = `${playerName}: ${scoreText}`; + } playerDiv.appendChild(scoreTextSpan); scoresBlockHTML += playerDiv.outerHTML; }); diff --git a/src/games/_base.ts b/src/games/_base.ts index c72869e8e..39c63505d 100644 --- a/src/games/_base.ts +++ b/src/games/_base.ts @@ -90,13 +90,20 @@ export interface IStashEntry { /** * Represents a set of scores for the players. + * A player's entry may also be a list of status values (text or glyphs, as in `IStatus`), + * which the front shows in a row, such as a row of coloured pieces. * * @export * @interface IScores */ export interface IScores { + // `name`, the section's title, is text only. To allow glyphs in `name` (say, a token icon + // above plain counts), widen its type to `RenderLabel | StatusValue[]` as `scores` is, and + // have the front resolve it in `resolveSidebarScores` and draw it in `GameStatus` (game + // and Lab) as it does a list entry. The front must ship that before any game sends one, + // or the panel breaks. name: RenderLabel; - scores: (number | string | RenderLabel)[]; + scores: (number | string | RenderLabel | StatusValue[])[]; spoiler?: boolean; } diff --git a/src/games/mvolcano.ts b/src/games/mvolcano.ts index 37a33f964..dcaa5a1fe 100644 --- a/src/games/mvolcano.ts +++ b/src/games/mvolcano.ts @@ -1,4 +1,4 @@ -import { GameBase, IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IValidationResult, type ChatLogCollectContext, type ChatLogEntry, type ChatLogLine, type RenderLabel } from "./_base.js"; +import { GameBase, IAPGameState, IClickResult, IIndividualState, IRenderOpts, IScores, IValidationResult, type ChatLogCollectContext, type ChatLogEntry, type ChatLogLine, type RenderLabel, type StatusValue } from "./_base.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; import { APRenderRep, AreaStackingExpanded, Glyph } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; @@ -1008,7 +1008,17 @@ export class MvolcanoGame extends GameBase { } public sidebarScores(): IScores[] { - return [{ name: this.neutralAreaLabel("apgames:status.SCORES"), scores: [this.getPlayerScore(1), this.getPlayerScore(2)] }] + // The non-white colours each player has yet to capture (capturing all seven ends + // the game), drawn in their customisable colours in palette order. + const missing = ([1, 2] as playerid[]).map(player => { + const capped = new Set(this.captured[player - 1].map(p => p[0])); + return allColours.filter(c => !capped.has(c)).map(c => ({ glyph: "piece", colour: allColours.indexOf(c) + 1 }) as StatusValue); + }); + return [ + { name: this.neutralAreaLabel("apgames:status.SCORES"), scores: [this.getPlayerScore(1), this.getPlayerScore(2)] }, + { name: this.neutralAreaLabel("apgames:status.mvolcano.UNCAPTUREDCOLOURS"), scores: missing, spoiler: true }, + { name: this.neutralAreaLabel("apgames:status.mvolcano.PYRAMIDSCAPTURED"), scores: [this.captured[0].length, this.captured[1].length], spoiler: true }, + ] } protected recordExportExclude(): string[] { diff --git a/src/games/tintas.ts b/src/games/tintas.ts index f2b874169..ef371b2fe 100644 --- a/src/games/tintas.ts +++ b/src/games/tintas.ts @@ -1,5 +1,5 @@ import { Direction, Grid, rectangle, defineHex, Orientation } from "honeycomb-grid"; -import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base.js"; +import { GameBase, IAPGameState, IClickResult, IIndividualState, IScores, IValidationResult, type StatusValue } from "./_base.js"; import type { APGamesInformation } from "../schemas/gameinfo.js"; import { APRenderRep, BoardBasic, RowCol } from "@abstractplay/renderer/build/schemas/schema"; import type { APMoveResult } from "../schemas/moveresults.js"; @@ -852,6 +852,20 @@ export class TintasGame extends GameBase { return rep; } + public sidebarScores(): IScores[] { + // Colours are listed in palette order and drawn as they are on the board. + const colours = [1, 2, 3, 4, 5, 6, 7] as CellContents[]; + const glyphs = (list: CellContents[]) => list.map(c => ({ glyph: "piece", colour: c }) as StatusValue); + // A player can still take all seven of a colour only while their opponent has none of it. + const monochrome = ([1, 2] as playerid[]).map(player => glyphs(colours.filter(c => !this.captured[player % 2].includes(c)))); + // Four of a colour's seven discs are a majority of it. + const majorities = ([1, 2] as playerid[]).map(player => glyphs(colours.filter(c => this.captured[player - 1].filter(n => n === c).length >= 4))); + return [ + { name: this.neutralAreaLabel("apgames:status.tintas.MONOCHROME"), scores: monochrome, spoiler: true }, + { name: this.neutralAreaLabel("apgames:status.tintas.MAJORITIES"), scores: majorities, spoiler: true }, + ]; + } + public inCheck(): number[] { const checked: number[] = []; for (const p of [1,2] as playerid[]) { diff --git a/test/games/mvolcano.test.ts b/test/games/mvolcano.test.ts new file mode 100644 index 000000000..b9f365a45 --- /dev/null +++ b/test/games/mvolcano.test.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import "mocha"; +import { expect } from "chai"; +import { MvolcanoGame, CellContents } from "../../src/games/mvolcano"; +import type { IScores } from "../../src/games/_base"; +import { isStructuredRenderLabel } from "../../src/common/render-label"; + +function findScores(scores: IScores[], key: string): IScores { + const found = scores.find((s) => isStructuredRenderLabel(s.name) && s.name.textKey === key); + expect(found, key).to.not.be.undefined; + return found!; +} + +function colours(entry: unknown): number[] { + expect(entry).to.be.an("array"); + return (entry as { glyph: string, colour: number }[]).map((v) => { + expect(v.glyph).to.equal("piece"); + return v.colour; + }); +} + +describe("Mega-Volcano", () => { + describe("sidebarScores", () => { + it("lists every colour as uncaptured and no pyramids captured at the start", () => { + const scores = new MvolcanoGame().sidebarScores(); + expect(scores).to.have.lengthOf(3); + expect(findScores(scores, "apgames:status.SCORES").spoiler).to.not.equal(true); + + const uncaptured = findScores(scores, "apgames:status.mvolcano.UNCAPTUREDCOLOURS"); + expect(uncaptured.spoiler).to.be.true; + expect(colours(uncaptured.scores[0])).to.deep.equal([1, 2, 3, 4, 5, 6, 7]); + expect(colours(uncaptured.scores[1])).to.deep.equal([1, 2, 3, 4, 5, 6, 7]); + + const captured = findScores(scores, "apgames:status.mvolcano.PYRAMIDSCAPTURED"); + expect(captured.spoiler).to.be.true; + expect(captured.scores).to.deep.equal([0, 0]); + }); + + it("drops captured colours in palette order and counts white pyramids", () => { + const g = new MvolcanoGame(); + g.captured = [ + [["RD", 1], ["WH", 2], ["GN", 3], ["RD", 2]] as CellContents[], + [["BN", 1]] as CellContents[], + ]; + const scores = g.sidebarScores(); + + const uncaptured = findScores(scores, "apgames:status.mvolcano.UNCAPTUREDCOLOURS"); + expect(colours(uncaptured.scores[0])).to.deep.equal([2, 4, 5, 6, 7]); + expect(colours(uncaptured.scores[1])).to.deep.equal([1, 2, 3, 4, 5, 6]); + + const captured = findScores(scores, "apgames:status.mvolcano.PYRAMIDSCAPTURED"); + expect(captured.scores).to.deep.equal([4, 1]); + }); + }); +}); diff --git a/test/games/tintas.test.ts b/test/games/tintas.test.ts new file mode 100644 index 000000000..77dd2cb9a --- /dev/null +++ b/test/games/tintas.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import "mocha"; +import { expect } from "chai"; +import { TintasGame } from "../../src/games/tintas"; +import type { IScores } from "../../src/games/_base"; +import { isStructuredRenderLabel } from "../../src/common/render-label"; + +function findScores(scores: IScores[], key: string): IScores { + const found = scores.find((s) => isStructuredRenderLabel(s.name) && s.name.textKey === key); + expect(found, key).to.not.be.undefined; + return found!; +} + +function colours(entry: unknown): number[] { + expect(entry).to.be.an("array"); + return (entry as { glyph: string, colour: number }[]).map((v) => { + expect(v.glyph).to.equal("piece"); + return v.colour; + }); +} + +describe("Tintas", () => { + describe("sidebarScores", () => { + it("lists every colour as monochrome potential and no majorities at the start", () => { + const scores = new TintasGame().sidebarScores(); + expect(scores).to.have.lengthOf(2); + + const monochrome = findScores(scores, "apgames:status.tintas.MONOCHROME"); + expect(monochrome.spoiler).to.be.true; + expect(colours(monochrome.scores[0])).to.deep.equal([1, 2, 3, 4, 5, 6, 7]); + expect(colours(monochrome.scores[1])).to.deep.equal([1, 2, 3, 4, 5, 6, 7]); + + const majorities = findScores(scores, "apgames:status.tintas.MAJORITIES"); + expect(majorities.spoiler).to.be.true; + expect(colours(majorities.scores[0])).to.deep.equal([]); + expect(colours(majorities.scores[1])).to.deep.equal([]); + }); + + it("keeps a colour as monochrome potential only while the opponent has none of it", () => { + const g = new TintasGame(); + g.captured = [[1, 1, 1, 1, 2, 3], [5, 2, 5, 5, 5]]; + const scores = g.sidebarScores(); + + const monochrome = findScores(scores, "apgames:status.tintas.MONOCHROME"); + expect(colours(monochrome.scores[0])).to.deep.equal([1, 3, 4, 6, 7]); + expect(colours(monochrome.scores[1])).to.deep.equal([4, 5, 6, 7]); + + const majorities = findScores(scores, "apgames:status.tintas.MAJORITIES"); + expect(colours(majorities.scores[0])).to.deep.equal([1]); + expect(colours(majorities.scores[1])).to.deep.equal([5]); + }); + }); +});