From 533270c875d6277260e7e93b67c18bcfaef34534 Mon Sep 17 00:00:00 2001 From: Doug Moore Date: Fri, 22 Aug 2025 23:32:28 -0400 Subject: [PATCH 1/4] Refactor GoodsTable layout and add readGoodColor utility for dynamic good color retrieval --- src/client/game/goods_table.module.css | 121 +++++++++++- src/client/game/goods_table.tsx | 258 +++++++++++++++++++------ src/client/grid/readGoodColor.ts | 26 +++ 3 files changed, 344 insertions(+), 61 deletions(-) create mode 100644 src/client/grid/readGoodColor.ts diff --git a/src/client/game/goods_table.module.css b/src/client/game/goods_table.module.css index 35bcadf7..12c9a728 100644 --- a/src/client/game/goods_table.module.css +++ b/src/client/game/goods_table.module.css @@ -19,15 +19,49 @@ gap: 4px; } -.row > *, -.column > * { +.row > * { flex: 1; text-align: center; } +/* column children should not stretch vertically; columns are fixed-width stacks */ +.column > * { + flex: none; + text-align: center; +} + +/* layout for the two groups: White and Black */ +.groupsGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: start; + justify-items: center; +} + +.group { + display: flex; + flex-direction: column; + align-items: center; +} + +.groupHeader { + margin: 0 0 8px 0; + font-size: 18px; + text-align: center; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + .goodPlace { - aspect-ratio: 1 / 1; - background-color: var(--good-color); + width: 28px; + height: 28px; + display: block; + /* prefer the --good-color variable set by good classes, fall back to lightgrey */ + background-color: var(--good-color, lightgrey); } .empty { @@ -45,3 +79,82 @@ .gapRight { margin-right: 8px; } + +.blackColumn { + background-color: #333; + padding: 12px 6px 12px 6px; + border-radius: 4px; +} + +.blackHeader { + background-color: #333; + color: white; + padding: 6px 8px; + border-radius: 6px; +} + +.headerCell { + display: flex; + justify-content: center; + align-items: center; + padding: 2px 0; + height: 26px; +} + +.letterCell { + display: flex; + justify-content: center; + align-items: center; + padding: 2px 0; + height: 26px; +} + +/* keep header cell positioned naturally inside the black column so it has breathing room */ +.blackColumn > .headerCell { + margin-top: 0; +} + +/* Ensure letter placeholders in black columns align with others */ +.blackColumn > .letterCell { + margin-top: 0; +} + +/* layout wrappers for grouped columns (white / black) */ +.leftColumns, +.rightColumns { + display: flex; + gap: 8px; + align-items: flex-start; + flex: none; /* don't stretch to fill the row */ + flex-wrap: nowrap; + padding-top: 10px; /* breathing room above the first row of header hexes */ +} + +.rightColumns { + margin-left: 8px; +} + +/* make each column a fixed-size stack so groups line up */ +.column { + width: 52px; + flex: none; + display: flex; + flex-direction: column; + gap: 6px; + align-items: center; +} + +/* apply the same internal padding to white columns so header hexes have breathing room inside the white stacks */ +.leftColumns > .column { + padding: 12px 6px 12px 6px; + box-sizing: border-box; +} + +/* placeholder used to reserve header space when there's no visible letter header */ +.headerPlaceholder { + width: 32px; + height: 29px; +} +.headerPlaceholderHidden { + visibility: hidden; +} \ No newline at end of file diff --git a/src/client/game/goods_table.tsx b/src/client/game/goods_table.tsx index 458ff530..1009b268 100644 --- a/src/client/game/goods_table.tsx +++ b/src/client/game/goods_table.tsx @@ -16,6 +16,9 @@ import { ImmutableMap } from "../../utils/immutable"; import { assert } from "../../utils/validate"; import { Username } from "../components/username"; import { goodStyle } from "../grid/good"; +import { readGoodColor } from "../grid/readGoodColor"; +import * as hexStyles from "../grid/hex.module.css"; +import { getCorners, polygon } from "../../utils/point"; import { useAction, useEmptyAction } from "../services/action"; import { useGame, useGameVersionState } from "../services/game"; import { @@ -119,75 +122,216 @@ export function GoodsTable() { } else if (!starter.isGoodsGrowthEnabled()) { return <>; } + // build the 12 column elements, then render them grouped (white on left, black on right) + const columns = iterate(12, (i) => { + const cityGroup = i < 6 ? CityGroup.WHITE : CityGroup.BLACK; + const onRoll = OnRoll.parse((i % 6) + 1); + const city = cities.regularCities.get(cityGroup)?.[onRoll]; + const urbanizedCity = + cities.urbanizedCities.get(cityGroup)?.[onRoll]; + const letter = i < 2 || i >= 10 ? "" : numberToLetter(i - 2); + // determine a primary good color for this onRoll column + let primaryGood: Good | undefined = undefined; + // first try to find the actual City on the map with this onRoll/group so color matches the map hex + const mapCity = grid.cities().find((c) => + c.onRoll().some((r) => r.group === cityGroup && r.onRoll === onRoll), + ); + if (mapCity != null) primaryGood = mapCity.goodColors()[0]; + // next try availableCities (new urbanized city options) + if (primaryGood == null && Array.isArray(availableCities)) { + const avail = (availableCities as any[]).find((a) => + a.onRoll.some((r: any) => r.group === cityGroup && r.onRoll === onRoll), + ); + if (avail) { + primaryGood = Array.isArray(avail.color) ? avail.color[0] : avail.color; + } + } + // final fallback: use the goods currently in the city/urbanized city if present + if (primaryGood == null && city != null) { + for (const g of city) { + if (g != null) { + primaryGood = g as Good; + break; + } + } + } + if (primaryGood == null && urbanizedCity != null) { + for (const g of urbanizedCity) { + if (g != null) { + primaryGood = g as Good; + break; + } + } + } + // For the letter headers (A..H) use the availableCities list order so they match the Available Cities + const letterIndex = i - 2; + let letterGood: Good | undefined = primaryGood; + if (letter !== "" && Array.isArray(availableCities) && availableCities[letterIndex]) { + const avail = (availableCities as any[])[letterIndex]; + const colorVal = avail.color; + letterGood = Array.isArray(colorVal) ? colorVal[0] : colorVal; + } + + return ( +
+
+ +
+ {iterate(maxRegularGoods, (goodIndex) => ( + + onClick( + false, + cityGroup, + onRoll, + maxRegularGoods - 1 - goodIndex, + ) + } + /> + ))} + {hasUrbanizedCities && ( +
+ {letter === "" ? ( +
+ ) : ( + urbanizedCity && + )} +
+ )} + {hasUrbanizedCities && + iterate(maxUrbanizedGoods, (goodIndex) => ( + + onClick( + true, + cityGroup, + onRoll, + maxUrbanizedGoods - 1 - goodIndex, + ) + } + /> + ))} +
+ ); + }); return (

Goods Growth Table

-
-
White
-
Black
-
-
- {iterate(12, (i) => { - const cityGroup = i < 6 ? CityGroup.WHITE : CityGroup.BLACK; - const onRoll = OnRoll.parse((i % 6) + 1); - const city = cities.regularCities.get(cityGroup)?.[onRoll]; - const urbanizedCity = - cities.urbanizedCities.get(cityGroup)?.[onRoll]; - const letter = i < 2 || i >= 10 ? "" : numberToLetter(i - 2); - return ( -
-
{onRoll}
- {iterate(maxRegularGoods, (goodIndex) => ( - - onClick( - false, - cityGroup, - onRoll, - maxRegularGoods - 1 - goodIndex, - ) - } - /> - ))} - {hasUrbanizedCities &&
{urbanizedCity && letter}
} - {hasUrbanizedCities && - iterate(maxUrbanizedGoods, (goodIndex) => ( - - onClick( - true, - cityGroup, - onRoll, - maxUrbanizedGoods - 1 - goodIndex, - ) - } - /> - ))} -
- ); - })} +
+
+

White

+
+ {columns.slice(0, 6)} +
+
+
+

Black

+
+ {columns.slice(6)} +
+
); } +function HeaderHex({ onRoll, primaryGood, letter }: { onRoll?: OnRoll; primaryGood?: Good; letter?: string }) { + // Use the goods CSS class and read the --good-color CSS variable at runtime so + // CSS remains the single source of truth. Fall back to a neutral color if + // DOM or the variable isn't available. + const defaultColor = "#e69074"; + + const fillColor = primaryGood != null ? readGoodColor(primaryGood) : defaultColor; + const goodClass = primaryGood != null ? goodStyle(primaryGood) : ""; + + function parseHexOrRgb(color: string): [number, number, number] { + if (color.startsWith("#")) { + const hex = color.substring(1); + if (hex.length === 3) { + const r = parseInt(hex[0] + hex[0], 16); + const g = parseInt(hex[1] + hex[1], 16); + const b = parseInt(hex[2] + hex[2], 16); + return [r, g, b]; + } + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return [r, g, b]; + } + if (color.startsWith("rgb")) { + const parts = color.replace(/rgba?\(|\)/g, "").split(",").map((s) => parseInt(s.trim(), 10)); + return [parts[0] || 0, parts[1] || 0, parts[2] || 0]; + } + // default + return [230, 144, 116]; + } + + function luminance([r, g, b]: [number, number, number]) { + // standard relative luminance + const rs = r / 255; + const gs = g / 255; + const bs = b / 255; + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; + } + + const label = onRoll != null ? String(onRoll) : letter ?? ""; + + const rgb = parseHexOrRgb(fillColor); + const lum = luminance(rgb); + const textFill = lum > 0.6 ? "#222222" : "#ffffff"; + + const labelStyle = { fill: textFill, fontSize: 14, fontWeight: 700 } as const; + + // Render an SVG hex so stroke and orientation match map hexes. + // We'll compute a 6-point polygon centered in an SVG viewbox of width W and height H + const W = 32; + const H = Math.round(W * 0.9); + const cx = W / 2; + const cy = H / 2; + // size is distance from center to corner. Choose size so polygon fits inside viewbox with stroke. + const size = Math.min(W, H) / 2 - 3; // leave room for stroke + // compute corners using floats (no rounding) so the SVG polygon is symmetric + const angles = [0, Math.PI / 3, (2 * Math.PI) / 3, Math.PI, (4 * Math.PI) / 3, (5 * Math.PI) / 3]; + const corners = angles.map((rad) => ({ x: cx + Math.cos(rad) * size, y: cy + Math.sin(rad) * size })); + const points = polygon(corners); + + return ( + + + + {label} + + + ); +} + function PlaceGood({ good, toggleSelectedGood, diff --git a/src/client/grid/readGoodColor.ts b/src/client/grid/readGoodColor.ts new file mode 100644 index 00000000..aa9975dd --- /dev/null +++ b/src/client/grid/readGoodColor.ts @@ -0,0 +1,26 @@ +import { Good } from "../../engine/state/good"; +import { goodStyle } from "./good"; + +const cache = new Map(); +const fallback = "#444444"; + +export function readGoodColor(g: Good): string { + if (cache.has(g)) return cache.get(g)!; + if (typeof document === "undefined") return fallback; + try { + const el = document.createElement("div"); + el.className = goodStyle(g); + el.style.position = "absolute"; + el.style.visibility = "hidden"; + el.style.pointerEvents = "none"; + document.body.appendChild(el); + const value = getComputedStyle(el).getPropertyValue("--good-color"); + document.body.removeChild(el); + const v = value ? value.trim() : ""; + const res = v || fallback; + cache.set(g, res); + return res; + } catch (e) { + return fallback; + } +} From aada8b3524f9ac4bda23f6244d5df3e182266b67 Mon Sep 17 00:00:00 2001 From: Doug Moore Date: Fri, 22 Aug 2025 23:51:36 -0400 Subject: [PATCH 2/4] Update test command to use Jasmine for running tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c6e8530..2aa6aa4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,7 +51,7 @@ jobs: run: npm ci - name: Running tests - run: npm test + run: node -r ts-node/register node_modules/jasmine/bin/jasmine --config=spec/support/jasmine.mjs - name: Set up Java uses: actions/setup-java@v2 From 6b15d0dbb11e78f9f57e66e15c2eb75b9e5f229b Mon Sep 17 00:00:00 2001 From: Doug Moore Date: Fri, 22 Aug 2025 23:55:52 -0400 Subject: [PATCH 3/4] Update test command to use npx for running Jasmine tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2aa6aa4c..003fb09b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,7 +51,7 @@ jobs: run: npm ci - name: Running tests - run: node -r ts-node/register node_modules/jasmine/bin/jasmine --config=spec/support/jasmine.mjs + run: npx ts-node node_modules/jasmine/bin/jasmine --config=spec/support/jasmine.mjs - name: Set up Java uses: actions/setup-java@v2 From 7d4aead6cb4dc4db8bee5bf2b34facb51a8b5e32 Mon Sep 17 00:00:00 2001 From: Doug Moore Date: Sat, 23 Aug 2025 00:02:03 -0400 Subject: [PATCH 4/4] Revert test.yml to it's previous state --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 003fb09b..1c6e8530 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,7 +51,7 @@ jobs: run: npm ci - name: Running tests - run: npx ts-node node_modules/jasmine/bin/jasmine --config=spec/support/jasmine.mjs + run: npm test - name: Set up Java uses: actions/setup-java@v2