From 91ee619f255ac8a540e9a574556eb2aaca4f9f1f Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 26 Apr 2026 21:08:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20hex=20terrain=20palette=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../designs/app/src/pages/HexFormation.js | 194 ++++----- .../designs/app/src/pages/HexFormation.tsx | 2 +- .project/objectives/README.md | 232 ++++++++--- .../p1-34-unit-metadata-expansion.md | 77 ++++ .../objectives/p1-35-unit-lore-paragraphs.md | 52 +++ .../p1-36-ai-personalities-t1-t10-coverage.md | 63 +++ .project/objectives/p2-16-audio-assets.md | 8 +- .../objectives/p2-36-unit-audio-cues-stubs.md | 68 ++++ ...p2-37-react-calculator-metadata-surface.md | 68 ++++ .../age-of-dwarves/assets/audio/LICENSES.md | 61 ++- .../age-of-dwarves/assets/audio/sources.csv | 18 + .../games/age-of-dwarves/data/objectives.json | 382 ++++++++++++++++-- scripts/run/test.sh | 4 + .../src/modules/victory/victory_manager.gd | 50 ++- tools/audio-licenses-render.py | 259 ++++++++++++ tools/objectives-report.py | 2 +- 16 files changed, 1277 insertions(+), 263 deletions(-) create mode 100644 .project/objectives/p1-34-unit-metadata-expansion.md create mode 100644 .project/objectives/p1-35-unit-lore-paragraphs.md create mode 100644 .project/objectives/p1-36-ai-personalities-t1-t10-coverage.md create mode 100644 .project/objectives/p2-36-unit-audio-cues-stubs.md create mode 100644 .project/objectives/p2-37-react-calculator-metadata-surface.md create mode 100644 public/games/age-of-dwarves/assets/audio/sources.csv create mode 100755 tools/audio-licenses-render.py diff --git a/.project/designs/app/src/pages/HexFormation.js b/.project/designs/app/src/pages/HexFormation.js index 10dfa5e7..3e18f180 100644 --- a/.project/designs/app/src/pages/HexFormation.js +++ b/.project/designs/app/src/pages/HexFormation.js @@ -2,44 +2,51 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { useState } from "react"; import styled from "styled-components"; import { t } from "../theme"; -// Slot palette — positional, not theme-semantic +// ── Terrain palette ─────────────────────────────────────────────────────── +// Centre-tile terrains +const T_COLOR = { + plains: "#d4b97a", + forest: "#3a6e3a", + mountain: "#8a8a8a", + water: "#4a8ec9", +}; +// Edge-blend colours (derived from `blend(centre, neighbour)`) +// Same-terrain pair → centre colour unchanged +const BLEND_COLOR = { + "plains+mountain": "#9a7e5a", // foothills (brown) + "plains+water": "#8aaeb0", // shore (tan-blue) + "plains+forest": "#aebe6a", // grass-fringe (yellow-green) + "plains+plains": T_COLOR.plains, // no transition +}; +const BLEND_LABEL = { + "plains+mountain": "foothills", + "plains+water": "shore", + "plains+forest": "grass-fringe", + "plains+plains": "plains", +}; const C = { - outerFill: "rgba(124, 196, 124, 0.10)", outerStroke: "#73591f", innerFill: "#3a3022", - innerStroke: t.text.title, // gold - centre: t.text.title, // gold - edge: "#d99a4a", - edgeDim: "rgba(217, 154, 74, 0.18)", + centre: t.text.title, hexBorder: "#1a1510", }; -// Regular flat-top hex centred at (240, 160), circumradius 120. -// NW (180, 56) ─── NE (300, 56) -// W (120, 160) E (360, 160) -// SW (180, 264) ─── SE (300, 264) -// -// Edge slots sit at the midpoint of each outer edge. With flat-top orientation, -// the 6 edge midpoints go around as N (top), NE, SE, S (bottom), SW, NW. -// We label them by hex *direction* (E/NE/NW/W/SW/SE) — the direction of the -// neighbour the edge is shared with. -const SLOTS = [ - { role: "N edge", type: "edge", dir: "N", cx: 240, cy: 56, label: { x: 240, y: 44, anchor: "middle" } }, - { role: "NE edge", type: "edge", dir: "NE", cx: 330, cy: 108, label: { x: 346, y: 100, anchor: "start" } }, - { role: "SE edge", type: "edge", dir: "SE", cx: 330, cy: 212, label: { x: 346, y: 220, anchor: "start" } }, - { role: "S edge", type: "edge", dir: "S", cx: 240, cy: 264, label: { x: 240, y: 282, anchor: "middle" } }, - { role: "SW edge", type: "edge", dir: "SW", cx: 150, cy: 212, label: { x: 134, y: 220, anchor: "end" } }, - { role: "NW edge", type: "edge", dir: "NW", cx: 150, cy: 108, label: { x: 134, y: 100, anchor: "end" } }, +const CENTRE_TERRAIN = "plains"; +// User's example: plains centre with [N=mountain, NE=water, SE=water, S=plains, SW=plains, NW=forest] +const EDGES = [ + { dir: "N", cx: 240, cy: 56, neighbour: "mountain", swatchX: 210, swatchY: 12, labelX: 240, labelY: 8, labelAnchor: "middle" }, + { dir: "NE", cx: 330, cy: 108, neighbour: "water", swatchX: 380, swatchY: 60, labelX: 410, labelY: 56, labelAnchor: "start" }, + { dir: "SE", cx: 330, cy: 212, neighbour: "water", swatchX: 380, swatchY: 240, labelX: 410, labelY: 256, labelAnchor: "start" }, + { dir: "S", cx: 240, cy: 264, neighbour: "plains", swatchX: 210, swatchY: 296, labelX: 240, labelY: 312, labelAnchor: "middle" }, + { dir: "SW", cx: 150, cy: 212, neighbour: "plains", swatchX: 40, swatchY: 240, labelX: 70, labelY: 256, labelAnchor: "end" }, + { dir: "NW", cx: 150, cy: 108, neighbour: "forest", swatchX: 40, swatchY: 60, labelX: 70, labelY: 56, labelAnchor: "end" }, ]; -const DIRECTIONS = [ - { idx: "—", dir: "centre", role: "centre", type: "centre", owned: "host" }, - { idx: 0, dir: "E", role: "E edge", type: "edge", owned: "shared" }, - { idx: 1, dir: "NE", role: "NE edge", type: "edge", owned: "shared" }, - { idx: 2, dir: "NW", role: "NW edge", type: "edge", owned: "shared" }, - { idx: 3, dir: "W", role: "W edge", type: "edge", owned: "shared" }, - { idx: 4, dir: "SW", role: "SW edge", type: "edge", owned: "shared" }, - { idx: 5, dir: "SE", role: "SE edge", type: "edge", owned: "shared" }, -]; -// ── Layout ──────────────────────────────────────────────────────────────── +function blendKey(centre, neighbour) { + // Canonical-sorted pair so plains+mountain == mountain+plains + const [a, b] = [centre, neighbour].sort(); + const key = `${a}+${b}`; + return key in BLEND_COLOR ? key : "plains+plains"; +} +// ── Styled components ───────────────────────────────────────────────────── const Page = styled.div ` max-width: 920px; margin: 0 auto; @@ -63,7 +70,7 @@ const Identity = styled.div ` border: 1px solid ${t.border.panel}; color: ${t.text.title}; font-family: ${t.font.mono}; - font-size: 14px; + font-size: 13px; font-weight: 700; padding: 4px 12px; margin: 8px 0 24px; @@ -73,10 +80,7 @@ const Layout = styled.div ` display: grid; grid-template-columns: 1fr 280px; gap: 32px; - - @media (max-width: 760px) { - grid-template-columns: 1fr; - } + @media (max-width: 760px) { grid-template-columns: 1fr; } `; const Stage = styled.div ` background: ${t.bg.panel}; @@ -98,48 +102,11 @@ const SideHeading = styled.h3 ` margin: 0 0 10px; letter-spacing: 0.04em; `; -const Controls = styled.div ` - display: flex; - gap: 8px; - margin-bottom: 16px; - flex-wrap: wrap; -`; -const Pill = styled.button ` - padding: 7px 14px; - background: ${({ $active, $kind }) => { - if (!$active) - return t.bg.btnNormal; - if ($kind === "centre") - return C.centre; - if ($kind === "edge") - return C.edge; - return t.accent.gold; -}}; - border: 1px solid - ${({ $active, $kind }) => { - if (!$active) - return t.border.panel; - if ($kind === "centre") - return C.centre; - if ($kind === "edge") - return C.edge; - return t.accent.gold; -}}; - color: ${({ $active, $kind }) => { - if (!$active) - return t.text.secondary; - if ($kind === "edge") - return "#1a1006"; - return "#1a1606"; -}}; - font-size: 12px; - font-weight: 700; - cursor: pointer; - border-radius: ${t.radius.btn}; - transition: all 150ms ease; - letter-spacing: 0.02em; - font-family: ${t.font.body}; - &:hover { color: ${({ $active }) => ($active ? undefined : t.text.primary)}; } +const Caption = styled.div ` + font-size: 11px; + color: ${t.text.secondary}; + margin: 0 0 12px; + line-height: 1.5; `; const DirTable = styled.table ` width: 100%; @@ -158,27 +125,23 @@ const DirTable = styled.table ` border-bottom: 1px solid ${t.border.panel}; } `; -const Role = styled.td ` - color: ${({ $type }) => ($type === "centre" ? C.centre : C.edge)}; -`; const Row = styled.tr ` opacity: ${({ $dim }) => ($dim ? 0.3 : 1)}; transition: opacity 150ms ease; `; +const BlendDot = styled.span ` + display: inline-block; + width: 10px; height: 10px; + margin-right: 5px; + vertical-align: middle; + border: 1px solid #000; + background: ${({ $color }) => $color}; +`; const Legend = styled.div ` margin-top: 18px; font-size: 11px; color: ${t.text.muted}; - line-height: 1.6; -`; -const Swatch = styled.span ` - display: inline-block; - width: 11px; - height: 11px; - margin-right: 4px; - vertical-align: middle; - border: 1px solid #000; - background: ${({ $color }) => $color}; + line-height: 1.7; `; const Footer = styled.div ` margin-top: 24px; @@ -188,30 +151,29 @@ const Footer = styled.div ` text-align: right; a { color: ${t.accent.gold}; text-decoration: none; } `; -function visibleFor(mode, type) { - if (mode === "all") - return true; - return mode === type; -} +// ── Page ────────────────────────────────────────────────────────────────── export function HexFormationPage() { - const [mode, setMode] = useState("all"); - const [focus, setFocus] = useState(null); - const isFocused = (role) => focus === role; - function clickPill(m) { - setMode(m); - setFocus(null); + const [focusBlend, setFocusBlend] = useState(null); + function clickBlend(key) { + setFocusBlend(focusBlend === key ? null : key); } - function clickSlot(role) { - setFocus(focus === role ? null : role); - } - return (_jsxs(Page, { children: [_jsx(PageTitle, { children: "Hex Centre + 6 Edge Slots" }), _jsx(PageSub, { children: "central area + ring of shared edges \u00B7 the differentiator from other hex grids" }), _jsx(Identity, { children: "1 centre + 6 shared edges \u00A0//\u00A0 each edge co-owned with one neighbour" }), _jsxs(Layout, { children: [_jsxs(Stage, { children: [_jsxs(Controls, { children: [_jsx(Pill, { "$kind": "all", "$active": mode === "all" && focus === null, onClick: () => clickPill("all"), children: "All slots" }), _jsx(Pill, { "$kind": "centre", "$active": mode === "centre" && focus === null, onClick: () => clickPill("centre"), children: "Centre only" }), _jsx(Pill, { "$kind": "edge", "$active": mode === "edge" && focus === null, onClick: () => clickPill("edge"), children: "Edges only" })] }), _jsxs("svg", { width: "100%", viewBox: "0 0 600 360", role: "img", "aria-label": "Annotated hex showing the central area and 6 edge slots", style: { display: "block" }, children: [_jsx("polygon", { points: "180,56 300,56 360,160 300,264 180,264 120,160", fill: C.outerFill, stroke: C.outerStroke, strokeWidth: 2 }), _jsx("polygon", { points: "210,108 270,108 300,160 270,212 210,212 180,160", fill: C.innerFill, stroke: C.innerStroke, strokeWidth: 2, opacity: focus !== null ? (isFocused("centre") ? 1 : 0.25) : (mode === "edge" ? 0.25 : 1) }), SLOTS.map((s) => { - const visible = focus !== null ? isFocused(s.role) : visibleFor(mode, s.type); - const dimmed = !visible; - return (_jsxs("g", { style: { cursor: "pointer" }, onClick: () => clickSlot(s.role), children: [_jsx("circle", { cx: s.cx, cy: s.cy, r: 11, fill: C.edge, stroke: C.hexBorder, strokeWidth: 2, opacity: dimmed ? 0.18 : 1 }), _jsx("text", { x: s.label.x, y: s.label.y, fontSize: 11, fontWeight: 700, fill: C.edge, textAnchor: s.label.anchor, opacity: dimmed ? 0.18 : 1, children: s.role })] }, s.role)); - }), _jsxs("g", { style: { cursor: "pointer" }, onClick: () => clickSlot("centre"), children: [_jsx("rect", { x: 222, y: 148, width: 36, height: 24, rx: 4, fill: C.innerFill, stroke: C.centre, strokeWidth: 2, opacity: focus !== null ? (isFocused("centre") ? 1 : 0.25) : (mode === "edge" ? 0.25 : 1) }), _jsx("text", { x: 240, y: 165, fontSize: 11, fontWeight: 700, fill: C.centre, textAnchor: "middle", opacity: focus !== null ? (isFocused("centre") ? 1 : 0.25) : (mode === "edge" ? 0.25 : 1), children: "leader" })] }), _jsxs("g", { transform: "translate(490,160)", children: [_jsx("circle", { cx: 0, cy: 0, r: 60, fill: "none", stroke: "#73591f", strokeWidth: 1 }), _jsx("text", { x: 0, y: -50, fontSize: 10, fill: t.text.secondary, textAnchor: "middle", children: "N" }), _jsx("text", { x: 55, y: -25, fontSize: 10, fill: t.text.secondary, textAnchor: "start", children: "NE" }), _jsx("text", { x: 55, y: 30, fontSize: 10, fill: t.text.secondary, textAnchor: "start", children: "SE" }), _jsx("text", { x: 0, y: 55, fontSize: 10, fill: t.text.secondary, textAnchor: "middle", children: "S" }), _jsx("text", { x: -55, y: 30, fontSize: 10, fill: t.text.secondary, textAnchor: "end", children: "SW" }), _jsx("text", { x: -55, y: -25, fontSize: 10, fill: t.text.secondary, textAnchor: "end", children: "NW" }), _jsx("circle", { cx: 0, cy: 0, r: 3, fill: C.centre }), _jsx("text", { x: 0, y: -72, fontSize: 10, fill: t.text.secondary, textAnchor: "middle", children: "edges = directions" })] })] })] }), _jsxs(Side, { children: [_jsx(SideHeading, { children: "Slot inventory" }), _jsxs(DirTable, { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "idx" }), _jsx("th", { children: "dir" }), _jsx("th", { children: "slot" }), _jsx("th", { children: "owned" })] }) }), _jsx("tbody", { children: DIRECTIONS.map((d) => { - const dim = focus !== null - ? !(focus === d.role || (focus === "centre" && d.type === "centre")) - : !visibleFor(mode, d.type); - return (_jsxs(Row, { "$dim": dim, children: [_jsx("td", { children: d.idx }), _jsx("td", { children: d.dir }), _jsx(Role, { "$type": d.type, children: d.role }), _jsx("td", { children: d.owned })] }, d.role)); - }) })] }), _jsxs(Legend, { children: [_jsxs("div", { children: [_jsx(Swatch, { "$color": C.centre }), " Centre slot (host hex exclusive)"] }), _jsxs("div", { children: [_jsx(Swatch, { "$color": C.edge }), " Edge slot (shared with one neighbour)"] }), _jsx("div", { style: { marginTop: 10 }, children: "Click a slot to focus it. Use the toolbar to highlight only centre or only edges." })] })] })] }), _jsxs(Footer, { children: ["Source: ", _jsx("a", { href: "../../public/games/age-of-dwarves/docs/HEX_GEOMETRY.md", children: "HEX_GEOMETRY.md" }), "\u00A0\u00B7\u00A0 Companion: ", _jsx("a", { href: "../hex-formation-duality.md", children: "hex-formation-duality.md" })] })] })); + // Distinct blend keys present in this scenario + const presentBlends = Array.from(new Set(EDGES.map(e => blendKey(CENTRE_TERRAIN, e.neighbour)))); + return (_jsxs(Page, { children: [_jsx(PageTitle, { children: "Hex Centre + 6 Edge Slots" }), _jsx(PageSub, { children: "the differentiator from other hex grids \u00B7 each edge is an ecotone derived from the blend of two adjacent tiles" }), _jsx(Identity, { children: "1 centre + 6 shared edges \u00B7 edge terrain = blend(centre, neighbour)" }), _jsxs(Layout, { children: [_jsxs(Stage, { children: [_jsxs(Caption, { children: ["Example: a ", _jsx("strong", { style: { color: T_COLOR.plains }, children: "plains" }), " centre tile with neighbours ", _jsx("em", { children: "1 mountain \u00B7 2 water \u00B7 2 plains \u00B7 1 forest" }), ". Each edge carries a derived terrain \u2014 foothills, shore, plains, or grass-fringe \u2014 based on which two tiles meet at that edge."] }), _jsxs("svg", { width: "100%", viewBox: "0 0 480 340", role: "img", "aria-label": "Plains hex with neighbours of varying terrains showing distinct edge blend zones", style: { display: "block" }, children: [EDGES.map((e) => (_jsxs("g", { children: [_jsx("rect", { x: e.swatchX, y: e.swatchY, width: 60, height: 36, rx: 3, fill: T_COLOR[e.neighbour], stroke: C.hexBorder, strokeWidth: 1, opacity: 0.85 }), _jsx("text", { x: e.swatchX + 30, y: e.swatchY + 22, fontSize: 10, fontWeight: 700, fill: "#1a1510", textAnchor: "middle", children: e.neighbour })] }, `nbr-${e.dir}`))), _jsx("polygon", { points: "180,56 300,56 360,160 300,264 180,264 120,160", fill: T_COLOR[CENTRE_TERRAIN], stroke: C.outerStroke, strokeWidth: 2, opacity: 0.85 }), _jsx("polygon", { points: "210,108 270,108 300,160 270,212 210,212 180,160", fill: C.innerFill, stroke: C.centre, strokeWidth: 2 }), EDGES.map((e) => { + const key = blendKey(CENTRE_TERRAIN, e.neighbour); + const dim = focusBlend !== null && focusBlend !== key; + return (_jsxs("g", { style: { cursor: "pointer" }, onClick: () => clickBlend(key), children: [_jsx("circle", { cx: e.cx, cy: e.cy, r: 12, fill: BLEND_COLOR[key], stroke: C.hexBorder, strokeWidth: 2, opacity: dim ? 0.18 : 1 }), _jsx("text", { x: e.cx, y: e.cy + 4, fontSize: 9, fontWeight: 700, fill: "#1a1510", textAnchor: "middle", opacity: dim ? 0.18 : 1, children: e.dir })] }, `edge-${e.dir}`)); + }), _jsx("rect", { x: "222", y: "148", width: "36", height: "24", rx: "4", fill: C.innerFill, stroke: C.centre, strokeWidth: 2 }), _jsx("text", { x: "240", y: "165", fontSize: 11, fontWeight: 700, fill: C.centre, textAnchor: "middle", children: "leader" })] })] }), _jsxs(Side, { children: [_jsx(SideHeading, { children: "Per-edge blend" }), _jsxs(DirTable, { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "dir" }), _jsx("th", { children: "neighbour" }), _jsx("th", { children: "blend \u2192 terrain" })] }) }), _jsx("tbody", { children: EDGES.map((e) => { + const key = blendKey(CENTRE_TERRAIN, e.neighbour); + const dim = focusBlend !== null && focusBlend !== key; + return (_jsxs(Row, { "$dim": dim, children: [_jsx("td", { children: e.dir }), _jsx("td", { children: e.neighbour }), _jsxs("td", { children: [_jsx(BlendDot, { "$color": BLEND_COLOR[key] }), BLEND_LABEL[key]] })] }, e.dir)); + }) })] }), _jsx(SideHeading, { style: { marginTop: 18 }, children: "Blends in this scenario" }), _jsxs(Legend, { children: [presentBlends.map((key) => { + const isActive = focusBlend === key; + return (_jsxs("div", { onClick: () => clickBlend(key), style: { + cursor: "pointer", + padding: "3px 0", + color: isActive ? t.text.primary : undefined, + fontWeight: isActive ? 700 : undefined, + }, children: [_jsx(BlendDot, { "$color": BLEND_COLOR[key] }), BLEND_LABEL[key], " ", _jsxs("span", { style: { color: t.text.muted }, children: ["(", key.replace("+", " ↔ "), ")"] })] }, key)); + }), _jsx("div", { style: { marginTop: 10, color: t.text.muted }, children: "Click a blend (here or in the table) to highlight only those edges. Same-terrain pairs (plains\u2194plains here) carry no transition; the edge keeps the centre terrain." })] })] })] }), _jsxs(Footer, { children: ["Source: ", _jsx("a", { href: "../../public/games/age-of-dwarves/docs/HEX_GEOMETRY.md", children: "HEX_GEOMETRY.md \u00A78" }), "\u00A0\u00B7\u00A0 Companion: ", _jsx("a", { href: "../hex-formation-duality.md", children: "hex-formation-duality.md" }), "\u00A0\u00B7\u00A0 Data target: ", _jsx("code", { children: "data/terrain/terrain_blends.json" })] })] })); } diff --git a/.project/designs/app/src/pages/HexFormation.tsx b/.project/designs/app/src/pages/HexFormation.tsx index f569ac1c..c2a1f9b6 100644 --- a/.project/designs/app/src/pages/HexFormation.tsx +++ b/.project/designs/app/src/pages/HexFormation.tsx @@ -250,7 +250,7 @@ export function HexFormationPage(): React.ReactElement { diff --git a/.project/objectives/README.md b/.project/objectives/README.md index 13e024e5..8a694851 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -1,10 +1,10 @@ # Objectives — Dashboard -> **Generated by `@lilith/mcp-objectives` — do not hand-edit.** Source of truth is per-file YAML frontmatter in this directory. Completed: [DASHBOARD_COMPLETED.md](DASHBOARD_COMPLETED.md) · By category: [DASHBOARD_CATEGORIES.md](DASHBOARD_CATEGORIES.md). +> **Generated by `tools/objectives-report.py` — do not hand-edit.** Source of truth is per-file YAML frontmatter in this directory. ## Legend -🔵 in-progress · 🟡 partial · 🔴 stub · ❌ missing · ⚫ out-of-scope · ✅ done · ♻️ superseded +✅ done · 🔵 in-progress · 🟡 partial · 🔴 stub · ❌ missing · ⚫ out-of-scope (Game 2 / Game 3) ## Totals @@ -12,13 +12,13 @@ **By Priority** -| Priority | 🔵 | 🟡 | 🔴 | ❌ | ⚫ | ✅ | Total | +| Priority | ✅ | 🔵 | 🟡 | 🔴 | ❌ | ⚫ | Total | |---|---|---|---|---|---|---|---| -| **P0** | 0 | 0 | 0 | 0 | 0 | 43 | 43 | -| **P1** | 1 | 4 | 0 | 9 | 1 | 27 | 42 | -| **P2** | 0 | 2 | 1 | 0 | 0 | 28 | 31 | -| **P3 (oos)** | 0 | 1 | 0 | 0 | 19 | 0 | 20 | -| **total** | **1** | **7** | **1** | **9** | **20** | **98** | **136** | +| **P0** | 43 | 0 | 1 | 0 | 0 | 0 | 44 | +| **P1** | 28 | 1 | 4 | 0 | 11 | 1 | 45 | +| **P2** | 28 | 0 | 2 | 1 | 2 | 0 | 33 | +| **P3 (oos)** | 0 | 0 | 1 | 0 | 1 | 19 | 21 | +| **total** | **99** | **1** | **8** | **1** | **14** | **20** | **143** | @@ -28,82 +28,182 @@ |---|---| | [asset-sprite](../team-leads/asset-sprite.md) | 6 | | [warcouncil](../team-leads/warcouncil.md) | 5 | -| [asset-audio](../team-leads/asset-audio.md) | 2 | -| [envoy](../team-leads/envoy.md) | 1 | | [shipwright](../team-leads/shipwright.md) | 1 | +| [asset-audio](../team-leads/asset-audio.md) | 1 | | [testwright](../team-leads/testwright.md) | 1 | +| [envoy](../team-leads/envoy.md) | 1 | -## 🔵 In Progress +## P0 — Blockers for "completely playable" -> Actively claimed by a team lead. Grouped by owner. - -### [asset-audio](../team-leads/asset-audio.md) - -| ID | Priority | Title | Updated | Blocked | +| ID | Status | Title | Owner | Updated | |---|---|---|---|---| -| [p2-33](p2-33-sound-system-extension.md) | P1 | Sound system extension — categorical fallback, variant pools, per-entity routing | 2026-04-27 | 🟢 unblocked | +| [p0-01](p0-01-mcts-wiring.md) | ✅ done | Wire MCTS into gameplay AI | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | +| [p0-02](p0-02-clan-personalities.md) | ✅ done | Five AI clan personalities drive distinct playstyles | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | +| [p0-03](p0-03-pvp-in-turn.md) | ✅ done | PvP combat resolved inside the authoritative turn processor | — | 2026-04-17 | +| [p0-04](p0-04-wonder-tracking.md) | ✅ done | World wonder tracking in PlayerState and score victory | — | 2026-04-17 | +| [p0-05](p0-05-culture-and-borders.md) | ✅ done | Culture generation and border expansion | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-06](p0-06-economy-integration.md) | ✅ done | Fold gold income / upkeep / improvement yields into turn loop | — | 2026-04-17 | +| [p0-07](p0-07-tech-research-costs.md) | ✅ done | Tech research costs and science pool pacing | — | 2026-04-17 | +| [p0-08](p0-08-domination-victory.md) | ✅ done | Domination victory path in mc-turn::victory | [warcouncil](../team-leads/warcouncil.md) | 2026-04-18 | +| [p0-09](p0-09-ui-completeness.md) | ✅ done | City-screen UI completeness (citizen assign, queue controls, promotion picker) | — | 2026-04-16 | +| [p0-10](p0-10-completion-stability.md) | ✅ done | Game-completion stability — ≥7/10 seeds declare a winner | — | 2026-04-17 | +| [p0-11](p0-11-mystery-item-authoring.md) | ✅ done | Author the four T8–T10 mystery item drops | — | 2026-04-16 | +| [p0-12](p0-12-save-load-autosave.md) | ✅ done | Save / load + autosave on quit | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-13](p0-13-fog-of-war-exploration.md) | ✅ done | Fog of war and exploration / scout loop | — | 2026-04-17 | +| [p0-14](p0-14-map-generation-balanced-starts.md) | ✅ done | Map generation, resource placement, and balanced fair starts | [shipwright](../team-leads/shipwright.md) | 2026-04-16 | +| [p0-15](p0-15-happiness-golden-age.md) | ✅ done | Happiness pool and Golden Age mechanics end-to-end | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-16](p0-16-worker-improvement-loop.md) | ✅ done | Worker / tile-improvement build loop | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-17](p0-17-wild-creature-lair-loop.md) | ✅ done | Wild creature and lair clearing loop | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-18](p0-18-strategic-resource-gate.md) | ✅ done | Strategic resources gate unit production (empire ledger) | — | 2026-04-17 | +| [p0-19](p0-19-biome-economy-integration.md) | ✅ done | Biome-driven collectibles → tile yields → happiness end-to-end | — | 2026-04-16 | +| [p0-21](p0-21-audio-system-capability.md) | ✅ done | Audio system capability — manifest + autoload + EventBus wiring | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-22](p0-22-ultimate-ai-stress-test.md) | ✅ done | "Ultimate AI stress test — 5 clans, huge map, deep lookahead" | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | +| [p0-23](p0-23-sprite-rendering-capability.md) | ✅ done | Sprite rendering capability — replace procedural draw_* with texture rendering | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-24](p0-24-difficulty-calibrated-ai-progression.md) | ✅ done | Difficulty-calibrated AI progression — Easy / Normal / Hard tier-peak distributions | [warcouncil](../team-leads/warcouncil.md) | 2026-04-19 | +| [p0-25](p0-25-game-quality-metrics-instrumentation.md) | ✅ done | Game-quality metrics instrumentation — tier_peak, peak_unit_tier, wonder_count | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-26](p0-26-ai-tactical-rust-port.md) | ✅ done | Port tactical AI from GDScript to mc-ai (Rail-1 compliance) | [warcouncil](../team-leads/warcouncil.md) | 2026-04-18 | +| [p0-27](p0-27-gd-culture-bridge.md) | ✅ done | GdCulture bridge — live game delegates culture to mc-culture | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-28](p0-28-gd-economy-bridge.md) | ✅ done | GdEconomy bridge — live game delegates gold/upkeep to mc-economy | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-29](p0-29-gd-tech-bridge.md) | ✅ done | GdTechWeb bridge — live game delegates research to mc-tech | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p0-30](p0-30-ecology-double-tick-fix.md) | ✅ done | Remove duplicate GDScript ecology tick (single Rust source) | [shipwright](../team-leads/shipwright.md) | 2026-04-18 | +| [p0-31](p0-31-climate-rust-path-restore.md) | ✅ done | Restore Rust ecology path — fix ClimateScript bugs + re-enable per-turn tick | [shipwright](../team-leads/shipwright.md) | 2026-04-18 | +| [p0-32](p0-32-weather-climate-effects-restore.md) | ✅ done | Restore WeatherScript + ClimateEffectsScript — per-turn weather and climate-effects | [shipwright](../team-leads/shipwright.md) | 2026-04-18 | +| [p0-33](p0-33-world-map-input-and-panel-wiring.md) | ✅ done | World-map input wiring — unit selection panel, city click, ESC/F10 menu, panel close | [wireguard](../team-leads/wireguard.md) | 2026-04-19 | +| [p0-34](p0-34-freepeople-tribe-founding.md) | ✅ done | Freepeople tribe-founding cinematic — turn -1 / 0 / 1 start sequence and Dwarf Tribe founder unit | [shipwright](../team-leads/shipwright.md) | 2026-04-18 | +| [p0-37](p0-37-personality-emergent-tactical-thresholds.md) | ✅ done | Personality-emergent tactical thresholds (lift 7 hardcoded constants into axis-derived functions) | [warcouncil](../team-leads/warcouncil.md) | 2026-04-18 | +| [p0-38](p0-38-mcts-personality-priors.md) | ✅ done | Inject personality-utility scores as MCTS UCB1 priors | [warcouncil](../team-leads/warcouncil.md) | 2026-04-24 | +| [p0-39](p0-39-ai-tier-progression-unit-selection.md) | ✅ done | AI tier-progression unit selection — production.rs picks tier-2+ units once tech unlocks | [warcouncil](../team-leads/warcouncil.md) | 2026-04-18 | +| [p0-40](p0-40-iron-ore-resource-density.md) | ✅ done | Iron-ore strategic resource density — unblock tier 3-6 unit chain | [shipwright](../team-leads/shipwright.md) | 2026-04-24 | +| [p0-41](p0-41.md) | ✅ done | Building rally points — produced units auto-deploy to a designated hex | [shipwright](../team-leads/shipwright.md) | 2026-04-24 | +| [p0-41a](p0-41a-rally-smoke.md) | ✅ done | Rally-point smoke — produced unit gets PatrolOrder toward rally hex | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p0-42](p0-42.md) | ✅ done | Formation aggregation — adjacent units link into a shaped formation with terrain reflow | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p0-42a](p0-42a-formation-smoke.md) | ✅ done | Formation aggregation smoke — formations form and evolve at runtime | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p0-43](p0-43.md) | ✅ done | "Formation AI — MCTS plans at formation level, not per-unit" | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | +| [p0-44](p0-44-movement-mode-ux.md) | ✅ done | Movement mode UX — Move button, path preview, right-click confirm, fog-aware pathing | [wireguard](../team-leads/wireguard.md) | 2026-04-19 | +| [p0-45](p0-45-production-id-drift.md) | 🟡 partial | Fix production.rs hardcoded ID drift — AI silently fails to queue founder, castle, granary, worker | — | 2026-04-26 | ## P1 — Ship-readiness -| ID | Status | Title | Tags | Owner | Updated | Blocked | -|---|---|---|---|---|---|---| -| [p0-20](p0-20-gpu-mcts-rollouts.md) | 🟡 partial | GPU-accelerated MCTS rollouts for look-ahead decision-making | — | [warcouncil](../team-leads/warcouncil.md) | 2026-04-19 | 🟢 unblocked | -| [p1-05](p1-05-balance-tuning.md) | 🟡 partial | Balance tuning — pop_peak ≥30 median, worker improvements ≥8 min | — | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | 🟢 unblocked | -| [p1-22](p1-22-mcts-wall-clock-budget.md) | 🟡 partial | MCTS per-decision wall-clock budget — bound per-turn cost on huge maps | — | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | 🟢 unblocked | -| [p2-22](p2-22-sprite-generation-pipeline.md) | 🟡 partial | Sprite generation pipeline — runnable end-to-end | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 | 🟢 unblocked | -| [p1-27](p1-27-mcts-service-extraction.md) | ❌ missing | Extract GPU MCTS into a standalone service/client (model-boss-shaped, magic-civ-only) | — | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | 🟢 unblocked | -| [p1-29](p1-29.md) | ❌ missing | Anti-early-domination: lift game-balance gates that p0-01 v1 measured | balance, pacing | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | 🟢 unblocked | -| [p1-30](p1-30.md) | ❌ missing | Optimize `_build_tactical_state` — 8000-tile GDScript dict-build per AI turn blocks p1-22 huge-map gate | perf, tactical-ai | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | 🟢 unblocked | -| [p2-16](p2-16-audio-assets.md) | ❌ missing | Audio assets — in-theme OSS launch pack + source ledger | — | [asset-audio](../team-leads/asset-audio.md) | 2026-04-26 | 🟢 unblocked | -| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | ❌ missing | Unit sprites — Dwarf-racial roster (m/f variants) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | 🟢 unblocked | -| [p2-24](p2-24-unit-sprites-wild-creatures.md) | ❌ missing | Unit sprites — wild creatures & fauna (generic, no race/sex) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | 🟢 unblocked | -| [p2-25](p2-25-building-sprites-base-coverage.md) | ❌ missing | Building sprites — base game coverage (non-wonder) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | 🟢 unblocked | -| [p2-26](p2-26-mundane-wonder-sprites.md) | ❌ missing | Mundane-wonder sprites — 24 distinct, higher-fidelity art | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | 🟢 unblocked | -| [p2-27](p2-27-city-population-tier-sprites.md) | ❌ missing | City population-tier sprites — city_q1 through city_q5 | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | 🟢 unblocked | +| ID | Status | Title | Owner | Updated | +|---|---|---|---|---| +| [p0-20](p0-20-gpu-mcts-rollouts.md) | 🟡 partial | GPU-accelerated MCTS rollouts for look-ahead decision-making | [warcouncil](../team-leads/warcouncil.md) | 2026-04-19 | +| [p0-35](p0-35-ecology-telemetry-instrumentation.md) | ✅ done | Ecology telemetry instrumentation — flora canopy / undergrowth fields in turn_stats.jsonl | [shipwright](../team-leads/shipwright.md) | 2026-04-18 | +| [p0-36](p0-36-weather-event-telemetry.md) | ✅ done | Weather / climate-effects event telemetry — events.jsonl + turn_stats aggregates | [shipwright](../team-leads/shipwright.md) | 2026-04-18 | +| [p1-01](p1-01-diplomacy-lite.md) | ✅ done | Diplomacy-lite — peace/war toggle plus one trade action | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p1-02](p1-02-strategic-resource-yields.md) | ✅ done | Strategic resource yields feed into production bonuses | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p1-03](p1-03-tutorial-overlay.md) | ✅ done | First-run tutorial / onboarding overlay | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p1-05](p1-05-balance-tuning.md) | 🟡 partial | Balance tuning — pop_peak ≥30 median, worker improvements ≥8 min | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p1-06](p1-06-options-polish.md) | ✅ done | Options screen polish | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p1-07](p1-07-chronicle-coverage.md) | ✅ done | Chronicle notifications coverage | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p1-08](p1-08-victory-screen-content.md) | ✅ done | Victory/defeat screen content — recap, banner, replay seed | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p1-09](p1-09-determinism-gate.md) | ✅ done | Determinism gate — same seed produces byte-identical runs | [testwright](../team-leads/testwright.md) | 2026-04-19 | +| [p1-10](p1-10-game-setup-ux.md) | ✅ done | Game setup UX — new-game dialog, difficulty, clan preview | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p1-11](p1-11-build-output-src-purge.md) | ✅ done | Purge build output from src/ — wasm-pack moves to .local/build/wasm/ | [tourguide](../team-leads/tourguide.md) | 2026-04-17 | +| [p1-12](p1-12-build-output-docs-alignment.md) | ✅ done | Align every doc reference to the relocated wasm-pack output | [tourguide](../team-leads/tourguide.md) | 2026-04-17 | +| [p1-13](p1-13-guide-dev-route-coverage.md) | ✅ done | Guide dev server boots on plum with zero-error route coverage | [tourguide](../team-leads/tourguide.md) | 2026-04-17 | +| [p1-15](p1-15-guide-next-deploy-infra.md) | ✅ done | Deploy dev guide to https://mc.next.black.local | [tourguide](../team-leads/tourguide.md) | 2026-04-17 | +| [p1-16](p1-16-guide-game1-scope-hygiene.md) | ✅ done | Purge Game 2/3 scope bleed from user-visible Game 1 guide copy | [tourguide](../team-leads/tourguide.md) | 2026-04-18 | +| [p1-17](p1-17-guide-next-auto-deploy.md) | ✅ done | Forgejo workflow auto-deploys dev guide on push to main | [tourguide](../team-leads/tourguide.md) | 2026-04-18 | +| [p1-18](p1-18-village-discovery-feedback.md) | ✅ done | Village discovery — world-map feedback (notification, reward popup, minimap ping) | [wireguard](../team-leads/wireguard.md) | 2026-04-19 | +| [p1-19](p1-19-tutorial-opt-in.md) | ✅ done | Tutorial opt-in — HUD button, disappears after turn 5, starts from Step 1 | [wireguard](../team-leads/wireguard.md) | 2026-04-19 | +| [p1-20](p1-20-unit-action-capability-registry.md) | ✅ done | Unit action capability registry — one source of truth for "what can this unit do right now?" | [wireguard](../team-leads/wireguard.md) | 2026-04-19 | +| [p1-21](p1-21-unit-patrol-orders.md) | ✅ done | Unit patrol orders — standing order to loop between waypoint tiles | [wireguard](../team-leads/wireguard.md) | 2026-04-19 | +| [p1-22](p1-22-mcts-wall-clock-budget.md) | 🟡 partial | MCTS per-decision wall-clock budget — bound per-turn cost on huge maps | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | +| [p1-23](p1-23-stats-tracker-restore.md) | ✅ done | Restore StatsTracker — demographics overview broken in shipped builds | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p1-24](p1-24-windows-path-separator.md) | ✅ done | ai_personalities.json fails to load from packed builds (all platforms) — pass JSON contents not path | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p1-25](p1-25-export-script-error-cleanup.md) | ✅ done | Eliminate parse-error spam in export logs (Unit dup decl + SaveManager stray) | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p1-26](p1-26-tile-placement-preview-ux.md) | ✅ done | "Tile-placement UX with effect preview — Civ7-style \\\"where does this go and what changes\\\"" | [shipwright](../team-leads/shipwright.md) | 2026-04-26 | +| [p1-27](p1-27-mcts-service-extraction.md) | ❌ missing | Extract GPU MCTS into a standalone service/client (model-boss-shaped, magic-civ-only) | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | +| [p1-28](p1-28-culture-research-tree.md) | ✅ done | "Culture research tree — real graph, bridge, UI" | [shipwright](../team-leads/shipwright.md) | 2026-04-26 | +| [p1-29](p1-29.md) | ❌ missing | "Anti-early-domination: lift game-balance gates that p0-01 v1 measured" | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | +| [p1-30](p1-30.md) | ❌ missing | "Optimize `_build_tactical_state` — 8000-tile GDScript dict-build per AI turn blocks p1-22 huge-map gate" | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | +| [p1-31](p1-31-tech-promised-buildings.md) | ❌ missing | Author the 9 buildings the tech tree unlocks but data does not provide | — | 2026-04-26 | +| [p1-32](p1-32-food-chain-buildings.md) | ❌ missing | Author the food + resource processing chain (granary, mill, brewery, tannery, sawmill, herbalist) | — | 2026-04-26 | +| [p1-33](p1-33-naval-aerial-production-buildings.md) | ❌ missing | Author production buildings for naval and aerial unit families (shipwright, airfield) | — | 2026-04-26 | +| [p2-06](p2-06-export-pipeline.md) | ✅ done | Export pipeline for Windows / macOS / Linux | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p2-16](p2-16-audio-assets.md) | 🔵 in_progress | Audio assets — in-theme OSS launch pack + source ledger | [asset-audio](../team-leads/asset-audio.md) | 2026-04-27 | +| [p2-22](p2-22-sprite-generation-pipeline.md) | 🟡 partial | Sprite generation pipeline — runnable end-to-end | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 | +| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | ❌ missing | Unit sprites — Dwarf-racial roster (m/f variants) | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | +| [p2-24](p2-24-unit-sprites-wild-creatures.md) | ❌ missing | Unit sprites — wild creatures & fauna (generic, no race/sex) | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | +| [p2-25](p2-25-building-sprites-base-coverage.md) | ❌ missing | Building sprites — base game coverage (non-wonder) | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | +| [p2-26](p2-26-mundane-wonder-sprites.md) | ❌ missing | Mundane-wonder sprites — 24 distinct, higher-fidelity art | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | +| [p2-27](p2-27-city-population-tier-sprites.md) | ❌ missing | City population-tier sprites — city_q1 through city_q5 | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | +| [p2-28](p2-28-sprite-provenance-ledger.md) | ✅ done | Sprite provenance ledger — LICENSES.md per-file attribution | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 | +| [p2-33](p2-33-sound-system-extension.md) | ✅ done | "Sound system extension — categorical fallback, variant pools, per-entity routing" | [asset-audio](../team-leads/asset-audio.md) | 2026-04-27 | ## P2 — Polish -| ID | Status | Title | Tags | Owner | Updated | Blocked | -|---|---|---|---|---|---|---| -| [p2-10](p2-10-regression-ci-gate.md) | 🟡 partial | Automated regression CI gate on every push to main | — | [testwright](../team-leads/testwright.md) | 2026-04-23 | 🟢 unblocked | -| [p2-18](p2-18-guide-public-deployment.md) | 🟡 partial | Guide web app — public hosting + deploy pipeline | — | — | 2026-04-17 | 🟢 unblocked | -| [p2-11a](p2-11a.md) | 🔴 stub | SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path | — | — | 2026-04-26 | 🟢 unblocked | +| ID | Status | Title | Owner | Updated | +|---|---|---|---|---| +| [p2-01](p2-01-minimap-improvements.md) | ✅ done | Minimap — fog reflection and unit markers | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p2-02](p2-02-hud-tooltips.md) | ✅ done | Tooltips on all HUD elements | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p2-03](p2-03-hotkey-cheat-sheet.md) | ✅ done | Hotkey cheat sheet (F1 / ?) | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p2-04](p2-04-localization-audit.md) | ✅ done | Localization audit — no hardcoded strings | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p2-05](p2-05-turn-latency.md) | ✅ done | Sub-second single-player turn latency | — | 2026-04-23 | +| [p2-06b](p2-06b-windows-runner.md) | ✅ done | Cross-compile Windows .exe + .dll from Linux via cargo-xwin (no Windows host) | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p2-07](p2-07-credits-screen.md) | ✅ done | Credits screen accessible from main menu | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p2-08](p2-08-accessibility.md) | ✅ done | Accessibility baseline — colorblind palette + keyboard navigation | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p2-09](p2-09-guide-web-deploy.md) | ✅ done | Player guide web app — builds clean from source | — | 2026-04-17 | +| [p2-10](p2-10-regression-ci-gate.md) | 🟡 partial | Automated regression CI gate on every push to main | [testwright](../team-leads/testwright.md) | 2026-04-23 | +| [p2-10a](p2-10a-gdlint-ungate.md) | ✅ done | "CI: gdlint stage un-gated" | [testwright](../team-leads/testwright.md) | 2026-04-25 | +| [p2-10b](p2-10b-gut-ungate.md) | ✅ done | "CI: headless GUT stage un-gated" | [testwright](../team-leads/testwright.md) | 2026-04-26 | +| [p2-10c](p2-10c-diplomacy-luxury-ids.md) | ✅ done | "Diplomacy: implement _collect_unique_luxury_ids() in happiness.gd" | — | 2026-04-26 | +| [p2-10d](p2-10d-legacy-unit-json.md) | ✅ done | "Data: strip legacy flags/can_found_city/can_build_improvements from unit JSON" | — | 2026-04-26 | +| [p2-10e](p2-10e-data-integrity.md) | ✅ done | "Data: resolve duplicate IDs and dangling unlock refs in game data" | — | 2026-04-26 | +| [p2-10f](p2-10f-save-manager-typed-arrays.md) | ✅ done | "SaveManager: fix typed array property assignment on Player/Unit deserialization" | — | 2026-04-26 | +| [p2-10g](p2-10g-city-bridge-production-cost.md) | ✅ done | "CityBridge: add production_cost field to items JSON fixture" | — | 2026-04-26 | +| [p2-10h](p2-10h-sprite-renderer-build-key.md) | ✅ done | "UnitRenderer: implement _build_sprite_key() helper and fix cache key test" | — | 2026-04-26 | +| [p2-10i](p2-10i-tile-tooltip-scene.md) | ✅ done | "TileTooltip: fix scene node name mismatches and collectibles text formatting" | — | 2026-04-26 | +| [p2-10j](p2-10j-fog-vision-scout-move.md) | ✅ done | "FogOfWar: fix recalculate_vision to not re-reveal already-seen tiles on move" | — | 2026-04-26 | +| [p2-11](p2-11-version-about-screen.md) | ✅ done | Version string + About screen | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p2-11a](p2-11a.md) | 🔴 stub | "SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path" | — | 2026-04-26 | +| [p2-12](p2-12-apricot-weston-install.md) | ✅ done | Install weston on apricot RUN host — unblock display-server smoke tests | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | +| [p2-18](p2-18-guide-public-deployment.md) | 🟡 partial | Guide web app — public hosting + deploy pipeline | — | 2026-04-17 | +| [p2-19](p2-19-guide-progress-report-page.md) | ✅ done | Guide progress report page — dynamic dashboard + missing assets | — | 2026-04-17 | +| [p2-20](p2-20-guide-sim-cache-pnpm-resolve.md) | ✅ done | Fix simCachePlugin pre-warm worker — tsx can't resolve @magic-civ/physics-rs through pnpm symlink | [tourguide](../team-leads/tourguide.md) | 2026-04-17 | +| [p2-21](p2-21-guide-simcache-static-bake.md) | ✅ done | Bake pre-computed sim-cache frames into the static build | [tourguide](../team-leads/tourguide.md) | 2026-04-18 | +| [p2-29](p2-29-guide-welcome-homepage-theme-alignment.md) | ✅ done | Welcome modal + HomePage lore + guide theme align to the player's chosen race/gender | [tourguide](../team-leads/tourguide.md) | 2026-04-18 | +| [p2-30](p2-30-guide-shared-primitives.md) | ✅ done | Consolidate duplicate page styled-components into shared PagePrimitives | [tourguide](../team-leads/tourguide.md) | 2026-04-18 | +| [p2-31](p2-31-guide-url-bound-state.md) | ✅ done | Migrate guide filter + tab state from useState to URL search params | [tourguide](../team-leads/tourguide.md) | 2026-04-18 | +| [p2-32](p2-32-guide-data-driven-enums.md) | ✅ done | Replace hardcoded page enums with JSON data reads | [tourguide](../team-leads/tourguide.md) | 2026-04-18 | +| [p2-34](p2-34-castle-walls-upgrade.md) | ❌ missing | Author castle as the walls upgrade tier (defensive ladder) | — | 2026-04-26 | +| [p2-35](p2-35-palace-evolution-system.md) | ❌ missing | Palace evolution system — longhouse → great_hall → citadel → grand_citadel + courthouse | — | 2026-04-26 | -## Out of Scope +## Out of Scope (Game 2 / Game 3) -> These objectives are explicitly deferred. They are tracked for visibility but not blocking the current release. +> These objectives are explicitly future-scope. **Game 2 (Age of Kzzykt)** items introduce leylines, the Green school, and spacefaring. **Game 3 (Age of Elves)** items cover the full five-school magic system, Archons, and Arcane Ascension. None are part of the Game 1 Early Access release. -| ID | Status | Title | Tags | Owner | Updated | Blocked | -|---|---|---|---|---|---|---| -| [p1-14](p1-14-guide-magic-school-scope-drift.md) | ⚫ oos | Gate Game 2/3/4 magic-school content behind EpisodeGate (future-game scope) | — | — | 2026-04-17 | 🟢 unblocked | -| [g2-01](g2-01-leylines-oos.md) | ⚫ oos | Ley lines — Game 2 (Age of Kzzykt) | — | — | 2026-04-17 | 🟢 unblocked | -| [g2-02](g2-02-additional-races-oos.md) | ⚫ oos | Kzzykt playable race — Game 2 (Age of Kzzykt) | — | — | 2026-04-17 | 🟢 unblocked | -| [g2-03](g2-03-green-school-oos.md) | ⚫ oos | Kzzykt Green school of magic — Game 2 (Age of Kzzykt) | — | — | 2026-04-17 | 🟢 unblocked | -| [g2-04](g2-04-multi-gpu-batch-simulate-oos.md) | ⚫ oos | Multi-GPU sharding for batch_simulate_gpu — out-of-scope (Game 2) | — | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 | 🟢 unblocked | -| [g3-01](g3-01-archons-oos.md) | ⚫ oos | Archons — Game 3 (Age of Elves) | — | — | 2026-04-17 | 🟢 unblocked | -| [g3-02](g3-02-life-school-oos.md) | ⚫ oos | Life school spellbook — Game 3 (Age of Elves) | — | — | 2026-04-17 | 🟢 unblocked | -| [g3-03](g3-03-death-school-oos.md) | ⚫ oos | Death school spellbook — Game 3 (Age of Elves) | — | — | 2026-04-17 | 🟢 unblocked | -| [g3-04](g3-04-chaos-school-oos.md) | ⚫ oos | Chaos school spellbook — Game 3 (Age of Elves) | — | — | 2026-04-17 | 🟢 unblocked | -| [g3-05](g3-05-aether-school-oos.md) | ⚫ oos | Aether school spellbook — Game 3 (Age of Elves) | — | — | 2026-04-17 | 🟢 unblocked | -| [g3-06](g3-06-arcane-ascension-oos.md) | ⚫ oos | Arcane Ascension victory — Game 3 (Age of Elves) | — | — | 2026-04-17 | 🟢 unblocked | -| [g4-01](g4-01-terran-race-oos.md) | ⚫ oos | Terran (Human) playable species — Game 4 (Age of Terrans) | — | — | 2026-04-17 | 🟢 unblocked | -| [g4-02](g4-02-psionics-oos.md) | ⚫ oos | Psionics ability system — Game 4 (Age of Terrans) | — | — | 2026-04-17 | 🟢 unblocked | -| [g4-03](g4-03-religious-victory-oos.md) | ⚫ oos | Religious victory condition — Game 4 (Age of Terrans) | — | — | 2026-04-17 | 🟢 unblocked | -| [g5-01](g5-01-phantasma-oos.md) | ⚫ oos | Phantasma playable species — Game 5 (Age of Ascension) | — | — | 2026-04-17 | 🟢 unblocked | -| [g5-02](g5-02-flugel-oos.md) | ⚫ oos | Flügel playable species — Game 5 (Age of Ascension) | — | — | 2026-04-17 | 🟢 unblocked | -| [g5-03](g5-03-gith-oos.md) | ⚫ oos | Gith playable species (Githyanki + Githzerai) — Game 5 (Age of Ascension) | — | — | 2026-04-17 | 🟢 unblocked | -| [g5-04](g5-04-demonia-oos.md) | ⚫ oos | Demonia playable species — Game 5 (Age of Ascension) | — | — | 2026-04-17 | 🟢 unblocked | -| [g6-01](g6-01-naval-combat-oos.md) | ⚫ oos | Naval combat — out-of-scope (post-v10) | — | — | 2026-04-26 | 🟢 unblocked | -| [g6-02](g6-02-caravan-trade-routes-oos.md) | ⚫ oos | Caravan trade routes — out-of-scope (post-v10) | — | — | 2026-04-26 | 🟢 unblocked | +| ID | Status | Title | Owner | Updated | +|---|---|---|---|---| +| [p1-14](p1-14-guide-magic-school-scope-drift.md) | ⚫ oos | Gate Game 2/3/4 magic-school content behind EpisodeGate (future-game scope) | — | 2026-04-17 | +| [g2-01](g2-01-leylines-oos.md) | ⚫ oos | Ley lines — Game 2 (Age of Kzzykt) | — | 2026-04-17 | +| [g2-02](g2-02-additional-races-oos.md) | ⚫ oos | Kzzykt playable race — Game 2 (Age of Kzzykt) | — | 2026-04-17 | +| [g2-03](g2-03-green-school-oos.md) | ⚫ oos | Kzzykt Green school of magic — Game 2 (Age of Kzzykt) | — | 2026-04-17 | +| [g2-04](g2-04-multi-gpu-batch-simulate-oos.md) | ⚫ oos | Multi-GPU sharding for batch_simulate_gpu — out-of-scope (Game 2) | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 | +| [g3-01](g3-01-archons-oos.md) | ⚫ oos | Archons — Game 3 (Age of Elves) | — | 2026-04-17 | +| [g3-02](g3-02-life-school-oos.md) | ⚫ oos | Life school spellbook — Game 3 (Age of Elves) | — | 2026-04-17 | +| [g3-03](g3-03-death-school-oos.md) | ⚫ oos | Death school spellbook — Game 3 (Age of Elves) | — | 2026-04-17 | +| [g3-04](g3-04-chaos-school-oos.md) | ⚫ oos | Chaos school spellbook — Game 3 (Age of Elves) | — | 2026-04-17 | +| [g3-05](g3-05-aether-school-oos.md) | ⚫ oos | Aether school spellbook — Game 3 (Age of Elves) | — | 2026-04-17 | +| [g3-06](g3-06-arcane-ascension-oos.md) | ⚫ oos | Arcane Ascension victory — Game 3 (Age of Elves) | — | 2026-04-17 | +| [g4-01](g4-01-terran-race-oos.md) | ⚫ oos | Terran (Human) playable species — Game 4 (Age of Terrans) | — | 2026-04-17 | +| [g4-02](g4-02-psionics-oos.md) | ⚫ oos | Psionics ability system — Game 4 (Age of Terrans) | — | 2026-04-17 | +| [g4-03](g4-03-religious-victory-oos.md) | ⚫ oos | Religious victory condition — Game 4 (Age of Terrans) | — | 2026-04-17 | +| [g5-01](g5-01-phantasma-oos.md) | ⚫ oos | Phantasma playable species — Game 5 (Age of Ascension) | — | 2026-04-17 | +| [g5-02](g5-02-flugel-oos.md) | ⚫ oos | Flügel playable species — Game 5 (Age of Ascension) | — | 2026-04-17 | +| [g5-03](g5-03-gith-oos.md) | ⚫ oos | Gith playable species (Githyanki + Githzerai) — Game 5 (Age of Ascension) | — | 2026-04-17 | +| [g5-04](g5-04-demonia-oos.md) | ⚫ oos | Demonia playable species — Game 5 (Age of Ascension) | — | 2026-04-17 | +| [g6-01](g6-01-naval-combat-oos.md) | ⚫ oos | Naval combat — out-of-scope (post-v10) | — | 2026-04-26 | +| [g6-02](g6-02-caravan-trade-routes-oos.md) | ⚫ oos | Caravan trade routes — out-of-scope (post-v10) | — | 2026-04-26 | ## Superseded -> These objectives were split into narrower children. Files are retained as index stubs so external references do not 404. +> These objectives were split into narrower children. Files are retained as index stubs so external references don't 404. The `superseded_by:` frontmatter field names the replacement IDs. -| ID | Status | Title | Tags | Owner | Updated | Blocked | -|---|---|---|---|---|---|---| -| [p1-27d](p1-27d-additive-value-estimate.md) | ♻️ superseded | Add `value_estimate_abstract` GdMcTreeController method — non-lossy MCTS service caller | — | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | 🟢 unblocked | -| [p2-17](p2-17-sprite-assets.md) | ♻️ superseded | Sprite assets — superseded index (split into p2-22 … p2-28) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | 🟢 unblocked | +| ID | Status | Title | Owner | Updated | +|---|---|---|---|---| +| [p1-27d](p1-27d-additive-value-estimate.md) | ♻️ superseded | Add `value_estimate_abstract` GdMcTreeController method — non-lossy MCTS service caller | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | +| [p2-17](p2-17-sprite-assets.md) | ♻️ superseded | Sprite assets — superseded index (split into p2-22 … p2-28) | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | diff --git a/.project/objectives/p1-34-unit-metadata-expansion.md b/.project/objectives/p1-34-unit-metadata-expansion.md new file mode 100644 index 00000000..deda40ac --- /dev/null +++ b/.project/objectives/p1-34-unit-metadata-expansion.md @@ -0,0 +1,77 @@ +--- +id: p1-34 +title: "Unit metadata expansion — flavor, archetype, promotion_tree, clan_affinity fields" +priority: p1 +status: missing +scope: game1 +owner: shipwright +updated_at: 2026-04-27 +assigned_by: shipwright +--- +## Summary + +The newly authored 50-unit dwarven military roster (p1-34 follow-on from the +T1–T10 design pass) currently mashes mechanical role text and lore one-liner +into a single `description` field. The schema is missing four high-value +metadata fields the rest of the system needs: + +- **`flavor`** — the lore one-liner, separate from the mechanical + description. Already the convention in tech files (`combined_arms`, + `runelore`, etc.); units inherit zero of that pattern. +- **`archetype`** — the explicit role categorization (light_melee / + heavy_melee / anti_cavalry / ranged / siege / cavalry_walker / wild). + Currently the React calculator and AI builders infer this from + `unit_type` + `keywords` heuristics, which is fragile and breaks the + moment a new keyword combination lands. +- **`promotion_tree`** — the link from unit to which `promotions.json` + tree applies (`melee` / `ranged` / `siege` / null for wild). Without + this, units can't actually use the promotion system that's already + authored. +- **`clan_affinity`** — list of 1–3 AI clan IDs that favor building this + unit (`ironhold` / `goldvein` / `blackhammer` / `deepforge` / + `runesmith`). Drives clan personality differentiation; currently all + five clans pick units off the same flat priority list. + +This is a **schema-and-data** objective. Touches all 75 existing unit JSONs +(50 newly-authored dwarven + 25 original including wild creatures). React +calculator data loader (`allUnits.ts`) gets cleanup — drop the inference +logic, read fields directly. + +## Acceptance criteria + +- [ ] All 75 unit JSON files in `public/games/age-of-dwarves/data/units/` + contain `flavor` (string), `archetype` (enum), `promotion_tree` + (enum or null), `clan_affinity` (string array) +- [ ] `flavor` for each dwarven military unit pulled out of `description`; + remaining `description` text is purely mechanical role +- [ ] `archetype` values are one of: `light_melee`, `heavy_melee`, + `anti_cavalry`, `ranged`, `siege`, `cavalry_walker`, `wild` +- [ ] `promotion_tree` values are one of: `melee`, `ranged`, `siege`, + or `null` (wild creatures, support units) +- [ ] `clan_affinity` arrays reference only valid clan IDs from + `data/ai_personalities.json` +- [ ] `allUnits.ts` Vite glob loader normalises new fields into `Unit` + type; no inference fallback remains +- [ ] `pnpm --prefix .project/designs/app run build` exits 0 +- [ ] No regression: existing units (warrior, berserker, etc.) keep their + current `description` semantics; new fields are additive + +## Notes for the implementing agent + +The expanded `Unit` type in `.project/designs/app/src/data/units.ts` +already declares `domain` as a union — extend the same way for `archetype` +and `promotionTree`. The wild creature units have no clan_affinity (use +empty array `[]`). + +Suggested clan_affinity defaults (drives AI distinctness): + +- **ironhold** (industrial/defensive): heavy melee line, iron walls, + defender, ironwarden, mountain_king +- **blackhammer** (warmonger): light melee line, berserker, hearth_raider, + goretooth, doomsoul, war_ram +- **goldvein** (mercantile): cheap units, archer, quarrelman, + light_field_gun, scouts +- **deepforge** (isolationist): siege + heavy mech, forge_titan, + rail_cannon, adamantine_tank, ancestral_walker +- **runesmith** (balanced/scholarly): runic units, runesmith, rune_spear, + marksman, soulbolt diff --git a/.project/objectives/p1-35-unit-lore-paragraphs.md b/.project/objectives/p1-35-unit-lore-paragraphs.md new file mode 100644 index 00000000..3798bd4c --- /dev/null +++ b/.project/objectives/p1-35-unit-lore-paragraphs.md @@ -0,0 +1,52 @@ +--- +id: p1-35 +title: "Per-unit lore paragraphs — historical/cultural context for the dwarven roster" +priority: p1 +status: missing +scope: game1 +owner: shipwright +updated_at: 2026-04-27 +assigned_by: shipwright +blockedBy: [p1-34] +--- +## Summary + +The newly authored 50-unit dwarven roster has strong one-liner flavor but no +paragraph-length cultural/historical context. Each unit needs a `lore` field +explaining its place in dwarven society — which clan invented it, what +historical event birthed it, why it survived in the doctrine. + +Existing dwarven voice anchors (from `data/techs/foundations.json` and +`advanced_military.json`): +- "We do not learn the mountain. We remember it." (dwarf_heritage) +- "A spear alone is courage. A line of spears is an empire." (combined_arms) +- "It is not magic. The runes only ask the powder to remember its purpose." (gunpowder, this work) + +The lore field should sit ALONGSIDE `flavor` (added by p1-34) and provide +3–5 sentences of cultural worldbuilding. Surfaced in the player guide +encyclopedia and in unit detail panels. + +## Acceptance criteria + +- [ ] All 50 newly-authored dwarven military units (per the T1–T10 roster + in `.project/objectives/` plus the existing dwarven uniques + berserker, runesmith, ironwarden, forge_titan, mithril_vanguard, + cavalry-as-dwarven if applicable) have a `lore` field +- [ ] `lore` is 3–5 sentences, present-tense or past-tense as the voice + fits, that reference: clan of origin, historical event/era, distinguishing + doctrine, optional ancestor name +- [ ] Voice matches the established dwarven flavor pattern (the tech-file + voice — terse, declarative, mountain/iron/oath imagery) +- [ ] Each lore paragraph fits the unit's tier and place in the chain + (T1 Shield Bearer reads as "first day of training"; T10 Mountain King + reads as "once-per-generation event") +- [ ] No spoilers for Game 2/3 lore (stay in dwarven mundane scope) +- [ ] Wild creatures and existing non-dwarven units (warrior, spearmen, + archer, pikeman) are out of scope for this pass + +## Dispatch hint + +This is a writing-heavy task. The implementing agent should batch-author +in groups of 5–10 units per archetype to maintain voice consistency. The +existing flavor one-liners (now in `flavor` field after p1-34) are the +seed; expand each into a paragraph that justifies the one-liner. diff --git a/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md b/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md new file mode 100644 index 00000000..7e3f6763 --- /dev/null +++ b/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md @@ -0,0 +1,63 @@ +--- +id: p1-36 +title: "AI personalities — T1–T10 build order coverage + clan_affinity routing" +priority: p1 +status: missing +scope: game1 +owner: warcouncil +updated_at: 2026-04-27 +assigned_by: shipwright +blockedBy: [p1-34] +--- +## Summary + +`public/games/age-of-dwarves/data/ai_personalities.json` currently lists +hardcoded early build orders that reference only the old 10-unit roster +(warrior / forge / walls / dwarf_founder). The new T1–T10 roster (50 new +units) is invisible to all five AI clans — they cannot build a Shield +Bearer, a Ballista Crew, a Boar Scout, or any T7+ unit. + +Combined with `clan_affinity` (added by p1-34), this objective wires the +five AI personalities to actually feel distinct in the units they build: + +- **Ironhold** (production 9, aggressive 6) — heavy melee anchored + defender lines, walls, anvil_guard at T6, mountain_king late game +- **Goldvein** (wealth 9, trade 9) — cheap cost-efficient units, mercenary + archers, defensive pikemen, light_field_gun for cavalry counter +- **Blackhammer** (aggression 9) — light melee rush, hearth_raiders, + berserkers, war_rams for cavalry pressure, doomsoul end-game +- **Deepforge** (production 8, isolationist) — siege + walker focus, + forge_titan, rail_cannon, adamantine_tank, ancestral_walker +- **Runesmith** (balanced) — runic units, rune_spears, marksmen, mixed + army with one of each archetype + +## Acceptance criteria + +- [ ] `ai_personalities.json` early_builds and tech_priorities updated for + each clan to include era-appropriate new units (T1: shield_bearer, + ballista_crew, boar_scout, quarrelman as alternatives to warrior) +- [ ] Each clan personality references at minimum 5 unit IDs that match + its `clan_affinity` declarations from p1-34 +- [ ] AI controller (`ai_player.gd` or whichever GDScript handles unit + selection) reads `clan_affinity` from unit JSONs at decision time — + prefers units that include the AI's clan ID +- [ ] No clan personality builds a unit that's not in its `clan_affinity` + unless it's a generic unit (warrior, spearmen, archer — all clans + share these) +- [ ] 10-seed batch on apricot shows distinct unit-mix histograms per + clan (e.g., Blackhammer ≥40% light melee composition; Deepforge ≥30% + siege/walker composition); raw `total_combats` and `tier_peak` metrics + remain inside the warcouncil quality gates +- [ ] AI doesn't crash trying to build a unit it lacks tech/buildings + for — the unit IDs added are reachable from the clan's tech path + +## Verification + +Run `scripts/run/autoplay.sh --batch 10 --seed-base 42 --map standard` +on apricot. Tail the unit-composition section of the report; each clan's +top-3 most-built units should show clan_affinity correlation > 60%. + +## Notes + +This objective depends on p1-34 landing the `clan_affinity` field. Don't +start until that's merged. diff --git a/.project/objectives/p2-16-audio-assets.md b/.project/objectives/p2-16-audio-assets.md index e9d302cd..34ef23b6 100644 --- a/.project/objectives/p2-16-audio-assets.md +++ b/.project/objectives/p2-16-audio-assets.md @@ -6,7 +6,13 @@ status: in_progress scope: game1 owner: asset-audio updated_at: 2026-04-27 -evidence: [public/games/age-of-dwarves/assets/audio/LICENSES.md, public/games/age-of-dwarves/data/audio.json] +evidence: + - "public/games/age-of-dwarves/assets/audio/sources.csv — ledger skeleton with column docs (output_path / source_url / license / attribution / edits / added) and `#` comment lines for inline guidance. Header row in place; 0 data rows pending OSS sourcing." + - "tools/audio-licenses-render.py — renders LICENSES.md from sources.csv. Enforces: (a) -SA / -NC license modifiers rejected outright; (b) license string must match an allowlist (CC0-1.0, CC-BY-3.0, CC-BY-4.0, Pixabay, Sonniss-GDC-YYYY, Public-Domain); (c) CC-BY-* requires non-empty attribution; (d) source_url must be http(s); (e) duplicate output_path rejected. `--check` mode used by CI: re-renders to a buffer and diffs against committed LICENSES.md — hand-edits to LICENSES.md fail." + - "public/games/age-of-dwarves/assets/audio/LICENSES.md — regenerated from sources.csv (0 rows). The previous hand-curated `*(pending production)*` rows have been replaced by the auto-generated workflow doc + 'How to add a new asset' section." + - scripts/run/test.sh — cmd_validate now runs audio-validate.py + audio-licenses-render.py --check after validate-game-data.py. + - "Policy spot-check (manual): seeded sources.csv with `CC-BY-SA-4.0` row → renderer correctly errored with `forbidden modifier '-SA' — ShareAlike and NonCommercial are blocked`. Restored." + - "audio.json shape already populated for the categorical keys (from p2-33). What this objective still owes: the actual ~57 .ogg files + their sources.csv rows. Asset curation requires manual OSS sourcing per .project/objectives/p2-16-audio-assets.md sources list." --- ## Summary diff --git a/.project/objectives/p2-36-unit-audio-cues-stubs.md b/.project/objectives/p2-36-unit-audio-cues-stubs.md new file mode 100644 index 00000000..d6e42d15 --- /dev/null +++ b/.project/objectives/p2-36-unit-audio-cues-stubs.md @@ -0,0 +1,68 @@ +--- +id: p2-36 +title: "Unit audio_cues stub strings — selection/move/attack lines for the dwarven roster" +priority: p2 +status: missing +scope: game1 +owner: asset-audio +updated_at: 2026-04-27 +assigned_by: shipwright +blockedBy: [p1-34] +--- +## Summary + +The 50-unit dwarven roster needs in-character audio cue strings — the +one-liner that plays when a unit is selected, told to move, or ordered +to attack. AoE/Civ/StarCraft conventions: 2–4 lines per cue type, played +randomly so repetition doesn't drone. + +This objective lands the **string content** only. Voice acting and audio +file generation are downstream (asset-audio team's existing p2-16 audio +pack work). The `audio_cues` field unblocks the audio team to know what +lines to record / TTS-generate. + +Each unit gets: +```json +"audio_cues": { + "select": ["...", "...", "..."], + "move": ["...", "..."], + "attack": ["...", "..."], + "death": ["..."] +} +``` + +Lines should reflect the unit's identity: +- Berserker: "BLOOD!" / "I do not need a shield." +- Mountain King: "The crown stands." / "Speak the names." (referring to + ten thousand clan-name engravings) +- EMP Trooper: "What runs on lightning..." / "Quietly." +- Shield Bearer: "Hold." / "We become the place." / "One step. One step." + +## Acceptance criteria + +- [ ] All 50 newly-authored dwarven military units have `audio_cues` + field with select/move/attack/death arrays +- [ ] Each `select` array has 3 lines, `move` has 2, `attack` has 2, + `death` has 1 +- [ ] Lines are 2–8 words each (snappy enough for combat UI) +- [ ] Voice matches established dwarven flavor (terse, declarative, + mountain/iron imagery) +- [ ] Unit-specific identity surfaces (a Berserker doesn't sound like a + Shield Bearer) +- [ ] No lines reference Game 2/3 lore (no magic, no spells) +- [ ] Apex units (mountain_king, doomsoul, ancestral_walker, etc.) get + weightier lines than T1 units + +## Out of scope + +- Actual `.ogg` audio file generation (asset-audio's p2-16 / p2-33 work) +- Voice actor casting / TTS personality picks +- Wild creature audio cues (separate objective) +- Existing 25-unit roster (warrior etc.) — their audio cues already + shipped via p0-21 / p2-33 + +## Notes for implementing agent + +The `audio_cues` field is additive and JSON-safe (string arrays). +Reference how the tech-file flavor lines are written +(`data/techs/dwarven_warfare.json`) for voice consistency. diff --git a/.project/objectives/p2-37-react-calculator-metadata-surface.md b/.project/objectives/p2-37-react-calculator-metadata-surface.md new file mode 100644 index 00000000..832f588f --- /dev/null +++ b/.project/objectives/p2-37-react-calculator-metadata-surface.md @@ -0,0 +1,68 @@ +--- +id: p2-37 +title: "React calculator UI — surface flavor, lore, clan_affinity, archetype filter" +priority: p2 +status: missing +scope: game1 +owner: tourguide +updated_at: 2026-04-27 +assigned_by: shipwright +blockedBy: [p1-34] +--- +## Summary + +The combat calculator at `/calculator` and the permutations matrix at +`/permutations` (in `.project/designs/app/`) currently show stat blocks +but none of the rich metadata p1-34 introduces. The point of writing +flavor lines and clan affinity isn't to bury them in JSON — it's to +make the design legible in the tool that designers use to balance the +roster. + +This objective surfaces the new metadata in the React UI: + +1. **Calculator unit info card**: flavor as italic epigraph below the + unit name; lore as collapsible "Read more" paragraph; clan_affinity + as colored clan badges. +2. **Permutations table**: archetype column (filterable, currently + inferred); clan_affinity dots in the row. +3. **Unit browser**: archetype-based grouping (Light Melee / Heavy Melee + etc. as section headers within Infantry tab) replaces the flat list. +4. **Hover tooltip**: flavor line shows on hover over a unit row. + +## Acceptance criteria + +- [ ] `/calculator?atk=mountain_king&def=apex_artillery` renders the + mountain_king card with: flavor as italic line, archetype tag, + clan_affinity badges (Ironhold, Deepforge), expandable lore section +- [ ] `/permutations` table view has new column "Archetype" (sortable, + filterable via dropdown above the table) +- [ ] Permutations matrix view has archetype color-coding on row/column + headers (light melee = red, heavy melee = white, etc. matching the + MTG-color scheme from the design plan) +- [ ] Unit browser left/right panels group units by archetype within + each category tab (Infantry tab shows: ⚔ Light Melee | 🛡 Heavy Melee + | 🗡 Anti-Cavalry | 🏹 Ranged | 💣 Siege | 🐗 Cavalry/Walker subheaders) +- [ ] `pnpm --prefix .project/designs/app run build` exits 0 with + no TypeScript errors +- [ ] Existing calculator / permutations URLs continue to work + (no breaking route or query-param changes) + +## Files likely modified + +- `.project/designs/app/src/data/allUnits.ts` — Unit type extension +- `.project/designs/app/src/pages/CombatCalculator.tsx` — new card + layout +- `.project/designs/app/src/pages/Permutations.tsx` — archetype filter +- `.project/designs/app/src/components/combat/UnitBrowser.tsx` — group + by archetype +- `.project/designs/app/src/components/combat/CombatantCard.tsx` — + flavor + lore + clan badges + +## Notes + +Clan badge colors should match the existing dwarven palette: +- Ironhold: iron grey / silver +- Goldvein: gold / amber +- Blackhammer: black / dark red +- Deepforge: deep blue / mountain stone +- Runesmith: rune-cyan / scholarly diff --git a/public/games/age-of-dwarves/assets/audio/LICENSES.md b/public/games/age-of-dwarves/assets/audio/LICENSES.md index e824f193..8b3b346f 100644 --- a/public/games/age-of-dwarves/assets/audio/LICENSES.md +++ b/public/games/age-of-dwarves/assets/audio/LICENSES.md @@ -1,40 +1,37 @@ -# Audio Asset Licenses — Age of Dwarves +# Audio Asset Licenses — age-of-dwarves -This file records license provenance for every audio asset shipped under -`public/games/age-of-dwarves/assets/audio/`. Every new asset added to that -tree MUST have a corresponding row here before shipping. +**Auto-generated from `sources.csv` by `tools/audio-licenses-render.py`. Do not edit by hand — edit the CSV and re-render.** -## Format +Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/audio/`. Licence policy: CC0 / CC-BY 3.0 / CC-BY 4.0 / Pixabay / Sonniss-GDC-YYYY / Public-Domain accepted. ShareAlike (`-SA`) and NonCommercial (`-NC`) are rejected by the renderer. -| Path | Kind | Source | License | Attribution | -|------|------|--------|---------|-------------| +**Asset count:** 0 files. (Empty until p2-16 sourcing begins.) ## Assets -| Path | Kind | Source | License | Attribution | -|------|------|--------|---------|-------------| -| `sfx/turn_started.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/turn_ended.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/city_founded.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/tech_researched.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/unit_killed.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/wonder_built.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/era_advanced.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/combat_hit.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/unit_moved.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `sfx/victory_fanfare.ogg` | SFX | *(pending production)* | *(pending)* | *(pending)* | -| `music/overworld_awakening.ogg` | Music | *(pending production)* | *(pending)* | *(pending)* | -| `music/overworld_craft.ogg` | Music | *(pending production)* | *(pending)* | *(pending)* | -| `music/overworld_kingdoms.ogg` | Music | *(pending production)* | *(pending)* | *(pending)* | -| `music/overworld_industry.ogg` | Music | *(pending production)* | *(pending)* | *(pending)* | -| `music/overworld_ascension.ogg` | Music | *(pending production)* | *(pending)* | *(pending)* | -| `music/victory.ogg` | Music | *(pending production)* | *(pending)* | *(pending)* | +*(none yet — drop files into the assets tree and add their +rows to `sources.csv`, then re-run this script)* -## Conventions +## Encoding -- All SFX target a peak of approximately -3 dBFS; per-event `volume_db` in - `public/games/age-of-dwarves/data/audio.json` scales from there. -- All music tracks are seamless OGG Vorbis loops (except `victory`). -- Prefer CC0 / CC-BY sources; record the origin URL in the row above. -- SFX files that ship for the Early Access release must replace - `*(pending)*` in their row before that release cut. +All audio normalised to: + +* Ogg Vorbis container, `.ogg` extension +* 44.1 kHz sample rate +* 128 kbps target bitrate +* Stereo (SFX may be mono) +* Peak ~−3 dBFS — per-event `volume_db` in `audio.json` scales from there +* Music tracks must be seamless loops (except `victory`) + +## How to add a new asset + +1. Source it from one of the approved providers (CC0 / + Pixabay / Sonniss / Freesound CC-BY / OpenGameArt CC-BY). +2. Edit + normalise to the encoding spec above. +3. Drop it under `public/games/age-of-dwarves/assets/audio/...` +4. Append a row to `sources.csv`: + ```csv + audio/sfx/units/melee/attack_01.ogg,https://freesound.org/.../123,CC-BY-4.0,Author Name,trim+normalize,2026-04-26 + ``` +5. Run `python3 tools/audio-licenses-render.py` to regenerate + this file, then `python3 tools/audio-validate.py` to + confirm the manifest still validates. diff --git a/public/games/age-of-dwarves/assets/audio/sources.csv b/public/games/age-of-dwarves/assets/audio/sources.csv new file mode 100644 index 00000000..699962e6 --- /dev/null +++ b/public/games/age-of-dwarves/assets/audio/sources.csv @@ -0,0 +1,18 @@ +# Audio asset source ledger — single source of truth for every .ogg shipped +# under this theme. tools/audio-licenses-render.py reads this file to +# regenerate LICENSES.md and to enforce licence policy. Edit this CSV; do +# not edit LICENSES.md by hand. +# +# Columns: +# output_path relative to public/games/age-of-dwarves/assets/ +# (e.g. audio/sfx/units/melee/attack_01.ogg) +# source_url https URL to the original. MUST resolve. +# license SPDX-ish; one of CC0-1.0, CC-BY-3.0, CC-BY-4.0, Pixabay, +# Sonniss-GDC-YYYY, Public-Domain. ANYTHING containing +# -SA or -NC is rejected. Unknown strings are rejected. +# attribution author + provider (required for CC-BY-*; "—" otherwise) +# edits what was done to the file (e.g. "trim+normalize+pitch -2 st") +# added ISO date the row was added (YYYY-MM-DD) +# +# Lines starting with `#` are ignored. The header row below is required. +output_path,source_url,license,attribution,edits,added diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index bf5a0359..fa37b8af 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,33 +1,33 @@ { - "generated_at": "2026-04-25T22:55:37Z", + "generated_at": "2026-04-27T04:04:52Z", "totals": { - "done": 73, - "oos": 18, - "stub": 0, - "in_progress": 0, - "missing": 9, - "partial": 12, - "total": 112 + "partial": 8, + "done": 99, + "missing": 14, + "in_progress": 1, + "oos": 20, + "stub": 1, + "total": 143 }, "objectives": [ { "id": "p0-01", "title": "Wire MCTS into gameplay AI", "priority": "p0", - "status": "partial", + "status": "done", "scope": "game1", "owner": "warcouncil", - "updated_at": "2026-04-24", + "updated_at": "2026-04-26", "summary": "`GdMcTreeController` (Rust GDExtension) is the unconditional AI driver. `AiTurnBridge.run()` always calls `_apply_mcts_strategic_override()` — no feature flag, no silent fallback. If the extension is absent, `push_error` + `assert(false)` crashes loudly. `SimpleHeuristicAi` handles tactical decisions (movement, combat) after MCTS sets the strategic directive.\n\n**Acceptance re-framed 2026-04-17 (user sign-off):** The prior \"median TTV in 200–350 band\" bullet was measuring the wrong thing. Every game ends at T300 (turn limit → score victory) OR earlier via domination; \"median TTV\" is bimodal (domination cluster + score-cluster-at-T299), and its value shifts based on dom:score ratio rather than game quality. Replaced with a **state-at-end quality metric set** (winner tier-peak, symmetry gap, peak unit tier, wonder count, combat count) that measures whether games reach competitive mid/late-game content *regardless* of whether they resolve via domination or score victory." }, { "id": "p0-02", "title": "Five AI clan personalities drive distinct playstyles", "priority": "p0", - "status": "partial", + "status": "done", "scope": "game1", "owner": "warcouncil", - "updated_at": "2026-04-19", + "updated_at": "2026-04-26", "summary": "`ai_personalities.json` defines Ironhold / Goldvein / Blackhammer / Deepforge / Runesmith with 6-axis `strategic_axes`. `ScoringWeights::from_personality` and `apply_axes` are fully implemented in `mc-ai/src/evaluator.rs`.\n\nWired 2026-04-17: `GdMcTreeController::scoring_weights_for_clan(clan_id, data_dir)` resolves per-clan weights via GDExtension. `ai_turn_bridge.gd::_build_game_state_json` now calls this per player and injects the result into `\"scoring_weights\":` — previously always `{}`. `AI_PIN_PERSONALITY` env var added to `personality_assigner.gd` for per-clan batch testing. Smoke run confirms `player_clans: {\"1\": \"blackhammer\"}` in meta.json, EXIT_CODE=0.\n\n**5 × 10-seed batch results (2026-04-17, `.local/iter/p0-02-clans/` — PRE-REFRAME EVIDENCE):**\n\n> These batches ran BEFORE p0-25's instrumentation landed, so `player_stats` does NOT carry\n> `tier_peak` / `peak_unit_tier` / `wonder_count`. The TTV column is preserved as the\n> contemporaneous signal; it is NOT the current acceptance metric. Per p0-01's 2026-04-17\n> reframe, the primary divergence gate is **tier_peak** (era-progression, which scales with\n> difficulty per p0-24) — tracked as a \"needs re-run\" in Remaining to reach done below.\n\n| Clan | Wins | TTV_med (legacy) | p1_gold | p1_mil | p1_techs |\n|---|---|---|---|---|---|\n| ironhold | 10/10 | T185.5 | 266 | 3.0 | 27.5 |\n| goldvein | 10/10 | T155.5 | **543** | 3.5 | 25.5 |\n| blackhammer | 9/9 | T189 | 327 | 3.0 | 28 |\n| deepforge | 10/10 | T185.5 | 266 | 3.0 | 27.5 |\n| runesmith | 10/10 | T155.5 | 543 | 3.5 | 25.5 |\n\nSignals that DON'T depend on TTV (still valid post-reframe):\n- **Balance**: 49 total games, each clan 3 AI-wins, max 33% — passes.\n- **Gold axis**: goldvein 2× ironhold (wealth=9 vs 3) — passes.\n- **First-combat**: identical at T9 across all clans (map-forced start proximity, not AI-driven).\n- **Pair metric-identical**: deepforge/ironhold and goldvein/runesmith pairs show overlapping weight profiles; same 10 seeds converge.\n\nSignals that DO depend on TTV (need tier_peak re-run to close the reframed gate):\n- TTV delta between clan pairs — the \"goldvein/runesmith finish 30 turns faster than ironhold/deepforge\" claim doesn't translate into the tier_peak framework until re-measured.\n\n**B5 re-run (2026-04-17, `.local/iter/b5-manual-20260417_061957/`, 50 games, post-determinism-fix binary):** blackhammer 0/10 wins; AI wins only 9/50 overall (18%). Win-rate balance bullet fails. See \"Remaining to done\" for tuning plan.\n\n**Axis ablation sweep (2026-04-17, `.local/iter/ablate__20260417_072921/`, 10 seeds T300 per axis — PRE-REFRAME EVIDENCE):** Each axis neutralized to 5 for all clans. Measured under pre-p0-25 instrumentation; metrics are TTV / gold / mil from the legacy `player_stats` schema. All 6 axes show ≥10% delta on their correlated legacy metric vs pooled baseline (TTV=185, gold=379, mil=3):\n\n| Axis | Correlated metric (legacy) | Baseline | Ablated | Delta |\n|---|---|---|---|---|\n| aggression | mil_med | 3.0 | 2.5 | -16.7% |\n| expansion | ttv_med | 185 | 134 | -27.6% |\n| grudge_persistence | ttv_med | 185 | 131.5 | -28.9% |\n| production | ttv_med | 185 | 139 | -24.9% |\n| trade_willingness | gold_med | 379 | 193.5 | -48.9% |\n| wealth | gold_med | 379 | 227.5 | -40.0% |\n\nNote: ablated TTV drops (not rises) because most games hit T300 stalemate when the axis is neutralized — domination wins collapse from 49/49 to 1–8/10 per axis. The TTV delta reflects game degradation, not faster play. All axes CONFIRMED LIVE under the legacy metric set. Re-measurement under tier_peak is needed before the reframed acceptance (below) can be cited." }, { @@ -392,32 +392,42 @@ }, { "id": "p0-41a", - "title": "Rally-point smoke test — unit moves toward rally hex on next turn", + "title": "Rally-point smoke — produced unit gets PatrolOrder toward rally hex", "priority": "p0", - "status": "partial", + "status": "done", "scope": "game1", "owner": "shipwright", "updated_at": "2026-04-25", - "summary": "End-to-end smoke verification for the rally-point feature (p0-41): set a rally hex on a barracks, produce a unit, and confirm the produced unit moves toward the rally hex on the next turn. Requires a weston display-server session on apricot. Split off from p0-41 on 2026-04-25 to keep the parent closeable while the display-server gate is deferred." + "summary": "End-to-end smoke verification for the rally-point feature (p0-41). Originally framed as needing a weston display server on apricot (\"set rally on barracks UI → produce unit → screenshot the move\"), but the same coverage is achievable via Rust unit tests against `try_spawn_unit` — the rally behavior is fully encoded in `processor.rs:768-782` and the resulting `PatrolOrder` is the contract that drives subsequent movement." }, { "id": "p0-42", "title": "Formation aggregation — adjacent units link into a shaped formation with terrain reflow", "priority": "p0", - "status": "partial", + "status": "done", "scope": "game1", "owner": "shipwright", - "updated_at": "2026-04-24", + "updated_at": "2026-04-25", "summary": "Units in adjacent hexes (same owner, both with auto_join enabled) automatically link into a Formation. Each unit retains its own hex — no stacking. The formation has a defined shape (Line, Column, Wedge, Diamond) expressed as relative hex offsets from a leader unit. When the formation moves, a reflow solver computes target hexes for all members: if the preferred shape doesn't fit terrain (e.g. a 5-wide Line entering a 2-hex canyon), it automatically compresses to a Column and re-expands on exit. Combat with formation_count set from the number of linked units uses the existing `dmg × count^0.75` and `HP × count` scaling already in mc-combat/resolver.rs. Selection: single-click selects formation; double-click selects the individual unit. 'Exit Formation' button in unit panel splits the unit back to solo." }, + { + "id": "p0-42a", + "title": "Formation aggregation smoke — formations form and evolve at runtime", + "priority": "p0", + "status": "done", + "scope": "game1", + "owner": "shipwright", + "updated_at": "2026-04-25", + "summary": "End-to-end smoke verification for the formation aggregation feature (p0-42). Spun off from p0-42 on 2026-04-25 originally framed as needing weston (display server) but resolved 2026-04-25 via headless run — formation evidence surfaces in `game.log` via `AiTurnBridge: formations turn=N player=P count=C sizes=[...] tiers=[...]` lines, no display server required." + }, { "id": "p0-43", "title": "\"Formation AI — MCTS plans at formation level, not per-unit\"", "priority": "p0", - "status": "partial", + "status": "done", "scope": "game1", "owner": "warcouncil", - "updated_at": "2026-04-24", + "updated_at": "2026-04-25", "summary": "After p0-42 lands, the MCTS strategic planner should treat formations as the atomic military entity rather than individual units. The abstract rollout state (AbstractPlayerState in mc-ai/src/abstract_state.rs) is updated to track formation count + tier + strength instead of raw unit_counts. Action candidates include CommandFormation (advance formation to hex) scored by military axis. The AI builds up a formation at a rally point then commands it to advance — matching the TA-style intended gameplay. This also makes GPU MCTS rollouts viable: M=3-8 formations per player vs N=50 individual units dramatically shrinks per-rollout work, making the batch-size threshold for GPU benefit reachable." }, { @@ -430,6 +440,16 @@ "updated_at": "2026-04-19", "summary": "Movement is currently a silent left-click on a reachable hex — no path shown, no\nconfirmation step. Players expect the Civ-style flow: enter movement mode (M key\nor Move button), see a path preview, right-click to confirm. This objective\nadds the full movement-mode state machine, path rendering, fog-of-war-aware\npathing, and the Move button on the unit action panel with disabled-state\ntooltips for all action buttons.\n\nDepends on **p0-33** (unit panel must be in the scene tree before the Move\nbutton can be wired)." }, + { + "id": "p0-45", + "title": "Fix production.rs hardcoded ID drift — AI silently fails to queue founder, castle, granary, worker", + "priority": "p0", + "status": "partial", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`mc-ai/tactical/production.rs::ids` hardcodes 5 short-form IDs (`WARRIOR`, `WORKER`, `FOUNDER`, `CASTLE`, `GRANARY`). Four of them do not match anything in `data/`:\n\n| `ids::*` | Emitted as `SetProduction { item_id }` | Reality | Live impact |\n|---|---|---|---|\n| `WARRIOR = \"warrior\"` | only fallback when `unit_catalog` empty | unit ID is `dwarf_warrior`; `pick_best_melee` returns the catalog ID, not this fallback | **fine** in real games (catalog populated by bridge) |\n| `FOUNDER = \"founder\"` | every time AI wants to expand | unit ID is `dwarf_founder` | bridge calls `DataLoader.get_unit(\"founder\")` → `{}` → `dispatch_set_production` returns `false`, queue not set; **AI never founds a 2nd city via the Rust path** |\n| `CASTLE = \"castle\"` | priority-6 walls upgrade | no `castle.json` in data | dispatch drops it, ladder stalls |\n| `GRANARY = \"granary\"` | bottom-of-ladder fallback | no `granary.json` in data | dispatch drops it, last-resort never works |\n| `WORKER = \"worker\"` | absolute final fallback | no `worker` unit; PRODUCTION_CHAIN.md says workers are pop-assigned, not built | **conceptual error** — should not exist as a build target |\n\n`pick_for_city` therefore can — and does, on long ladders — return an ID the bridge cannot resolve. The dispatch silently `return false`s and the city sits with an empty production queue.\n\nThe right shape:\n- `FOUNDER` becomes a catalog lookup (units with `can_found_city: true`) so it picks up `dwarf_founder` and any future race-themed founders, not a hardcoded constant.\n- `CASTLE` and `GRANARY` are real-content gaps deferred to p2-34 (castle) and p1-32 (granary). Until those land, `pick_for_city` must skip those priority levels rather than emit a known-broken ID.\n- `WORKER` deletes outright. Workers are pop-assigned via the city menu per `public/games/age-of-dwarves/docs/cities/PRODUCTION_CHAIN.md` \"Construction Workforce\". The fallback should be a *real* always-buildable item (likely `monument`, the only tier-1 culture building with no tech gate, or `barracks`)." + }, { "id": "p0-20", "title": "GPU-accelerated MCTS rollouts for look-ahead decision-making", @@ -497,7 +517,7 @@ "status": "partial", "scope": "game1", "owner": "shipwright", - "updated_at": "2026-04-17", + "updated_at": "2026-04-25", "summary": "Post-p0-16 batch (`.local/iter/p016b_20260417_024754/`, 10 seeds T300,\ncaptured 2026-04-17 02:54): the worker-production fix for p0-16 had a\nlarge downstream lift on pop + combats. Per-seed p0_pop_peak =\n[58,46,76,65,77,74,53,113,73,36]; median **69**, min 36, max 113. Worker\nimprovements per seed = [45,24,73,43,49,21,15,120,25,62]; median **44**,\nmin **15**. Combats median **808**, techs median **39**. All four primary\nacceptance metrics now clear their thresholds decisively — the 29.5-vs-30\ngap from score_fix3 dissolved once workers consistently drop farms.\n\nShipwright passes applied:\n- `farm.json` food yield **2 → 3** (prior tune, validated in p016b where\n per-seed farm counts 3-20 drive pop_peak 36-113).\n- Worker AI surfaced in both `auto_play.gd::_maybe_prioritize_worker` and\n `simple_heuristic_ai.gd::_decide_worker_action` via p0-16; those are\n p0-16's code changes but their effect shows up here as the pop lift.\n\nRemaining gaps are structural, not tunable via JSON alone:\n- **Luxury variance** regressed from score_fix3's min=3 down to min=0 in\n p016b because faster combat resolution (median domination turn ~85 in\n p016b vs ~200+ in score_fix3) ends many games before the player has\n time to research trapping/scholarship/herbalism AND claim tiles with\n those luxuries AND improve them. 14 of 15 luxuries are tech-gated in\n `resources.json`. Tuning would need to either un-gate early-luxuries\n (ivory/furs/salt) or slow combat — both are cross-cutting changes\n (p0-06 economy + p0-08 domination tempo) that exceed p1-05's\n tuning-only scope.\n- **Personality win balance** is warcouncil-owned (p0-02) and requires a\n 50-game sample, not shipwright scope.\n\n**Partial** because luxury variance + personality_win_balance cannot be\nclosed purely in JSON within p1-05's bounds. Other 4 primary metrics are\ndone.\n\n**2026-04-17 ecology handoff from p0-30:** duplicate GDScript ecology tick\n(`ecosystem.gd` + `flora.gd`) deleted; ecology is dormant until\n`ClimateScript.process_turn` is re-enabled, at which point\n`GdEcologyPhysics::process_step` becomes the sole canonical tick. Any\nwilds/food/lair knobs tuned against the previous 1× GDScript rate may\nneed re-tuning against the Rust rate in a follow-up pass." }, { @@ -661,43 +681,153 @@ "summary": "Both the human player and the AI clans need a *standing order* that keeps a\nunit moving along a fixed route turn after turn without per-turn micro-\nmanagement. Canonical use cases: escorting a worker loop, covering a\nchokepoint, sweeping scout fog between two outposts.\n\nToday a unit has two durable states: idle-on-tile, or fortified. `Skip`\nends the turn but does not persist. A player who wants a scout to pace\nbetween two tiles must hand-move it every single turn — which breaks down\nonce the empire has more than a few units, and which the AI cannot express\nat all because `mc-ai/tactical/movement.rs` re-plans from scratch each turn.\n\nThis objective adds a third durable state — **patrol** — with a small\nwaypoint list, a direction cursor, and a loop mode. While patrolling, the\nunit auto-advances along its route during the turn processor before the\nplayer's input phase, so turn N+1 opens with the unit already at the next\nstep on its loop.\n\n**This objective assumes p1-20 (unit action capability registry) has\nshipped.** Patrol plugs into the registry as one new `ActionKind` variant\nplus its handlers — no bespoke unit-panel buttons, no scattered\n`is_patrolling` checks in GDScript. If p1-20 slips, reassess whether to\nland a narrower patrol-only version first." }, { - "id": "p1-22a", + "id": "p1-22", "title": "MCTS per-decision wall-clock budget — bound per-turn cost on huge maps", "priority": "p1", - "status": "missing", + "status": "partial", "scope": "game1", "owner": "warcouncil", "updated_at": "2026-04-25", "summary": "Spun out from p0-22 (Ultimate AI stress test) on 2026-04-25 after the 7 root-cause fixes (combat method typos, per-slot pinning, score-victory fallback, NOTIFICATION_PREDELETE, autoplay-batch.sh MCTS branch, etc.) verified the pipeline produces `outcome:victory` at T500 on the huge-map config. The remaining gap blocking `ultimate_stress: PASS` is **purely MCTS per-turn wall-clock cost on game-state complexity**: with deterministic seeds, some maps produce game states where each MCTS decision takes 30-60+ seconds (vs <5s on simpler states). Even at `PARALLEL=2 SAFETY_TIMEOUT_OVERRIDE=3600s`, slow seeds reach only T55-T236 in the 3600s budget (would need 4-8 hours wall-clock per game). Fast seeds reach T500 in ~45min.\n\nThis is engineering work, not test calibration: the AI is ALWAYS faster when it commits to a decision under a bounded budget. The current MCTS runs to a fixed iteration count regardless of wall-clock cost; on a complex 5-player huge-map state the iteration cost balloons." }, + { + "id": "p1-23", + "title": "Restore StatsTracker — demographics overview broken in shipped builds", + "priority": "p1", + "status": "done", + "scope": "game1", + "owner": "shipwright", + "updated_at": "2026-04-25", + "summary": "`engine/scenes/overviews/demographics.gd` (and `end_game_stats.gd`) referenced `StatsTracker.CATEGORIES`, `CATEGORY_LABELS`, `get_rankings`, `get_history`, `get_player_series` but no `StatsTracker` class_name or autoload existed. Surfaced 2026-04-25 in `p2-06-verify-20260425` export logs as 4× `SCRIPT ERROR: Identifier \"StatsTracker\" not declared`. The demographics screen was shipped broken.\n\nResolved by implementing `StatsTracker` as an autoload that subscribes to `EventBus.turn_ended`, captures per-player snapshots (score / population / military / cities / techs / wonders), and exposes the rankings + historical-series API the overlays expect." + }, + { + "id": "p1-24", + "title": "ai_personalities.json fails to load from packed builds (all platforms) — pass JSON contents not path", + "priority": "p1", + "status": "done", + "scope": "game1", + "owner": "shipwright", + "updated_at": "2026-04-25", + "summary": "When the Windows .exe cross-compiled via cargo-xwin runs under Wine on apricot (p2-06b smoke 2026-04-25), it floods the log with:\n\n```\nERROR: GdMcTreeController::scoring_weights_for_clan load error for 'goldvein':\n failed to read ai_personalities.json at public/games/age-of-dwarves/data\\ai_personalities.json:\n Path not found. (os error 3)\n```\n\nThe mixed-separator path is one symptom; the deeper issue is that `ai_turn_bridge.gd:79` passes `ProjectSettings.globalize_path(\"res://public/games/age-of-dwarves/data\")` to the Rust side, then Rust uses `std::fs::read_to_string` on `/ai_personalities.json`. **For packed builds (any platform), `res://` content lives inside the .pck — `globalize_path` returns a fake/non-existent OS path, so `std::fs::read_to_string` always fails.** It silently fell back to default weights everywhere — the macOS smoke (`p0_pop_peak ~290 turns to victory`) likely had the same error invisibly.\n\n**Game still completes** — the AI falls back to default scoring weights. So this is non-blocking but ships a degraded MCTS-personality-aware AI on EVERY platform from packed builds, not just Windows. The wine smoke just made the error finally visible." + }, + { + "id": "p1-25", + "title": "Eliminate parse-error spam in export logs (Unit dup decl + SaveManager stray)", + "priority": "p1", + "status": "done", + "scope": "game1", + "owner": "shipwright", + "updated_at": "2026-04-25", + "summary": "Every Linux/Windows export log was emitting two families of parse errors despite producing a working binary:\n\n1. **`Class \"SaveManager\" hides a global script class.`** — apricot had a stray duplicate `src/game/engine/src/map/save_manager.gd` (byte-identical to `src/core/save_manager.gd`, both declaring `class_name SaveManager`). The Mac source tree was clean; the apricot stray must have come from a prior agent's misplaced rsync. Deleted on apricot 2026-04-25; export logs now register SaveManager exactly once.\n\n2. **`The member \"type_id\" / \"hp\" / \"max_hp\" / \"movement_remaining\" / \"position\" / \"equipped_items\" already exists in parent class Unit.`** — `engine/scenes/tests/crafting_complete_proof.gd` had a `class UnitStub: extends Unit` block that redeclared 6 fields the parent `Unit` class already owns. Comment in the file claimed \"Unit.gd is a 2-line stub\" but Unit.gd evolved past that point. Refactored UnitStub to set those values in `_init()` instead of redeclaring them.\n\nBoth error families surfaced in **every** export run since shipwright started exporting builds; not a regression introduced today, just never triaged because the export still produced a working binary." + }, + { + "id": "p1-26", + "title": "\"Tile-placement UX with effect preview — Civ7-style \\\\\\\"where does this go and what changes\\\\\\\"\"", + "priority": "p1", + "status": "done", + "scope": "game1", + "owner": "shipwright", + "updated_at": "2026-04-26", + "summary": "When a player queues a building or tile improvement today, the placement is a black box:\n- **Buildings**: every building entry in `public/games/age-of-dwarves/data/buildings/*.json` has `placement: \"city\"` — buildings just appear in the city center, no tile choice, no spatial decision.\n- **Improvements**: a worker drops a farm/mine/road at the worker's hex; the player sees no preview of yield-delta or adjacency effects before committing.\n\nCiv7 (and Civ6 districts) made this a primary expressive lever: pick a tile, see live yield projections + adjacency bonuses + terrain restrictions before locking in. Without this surface in Game 1 the player loses a major strategic dimension and the city map feels like decoration.\n\nThis objective covers the **UX + supporting data extension** for tile-targeted building/improvement placement with live preview. The simulation already supports per-tile improvements; the gap is presentation + a small data extension to mark which buildings become tile-placed and any adjacency rules." + }, + { + "id": "p1-27", + "title": "Extract GPU MCTS into a standalone service/client (model-boss-shaped, magic-civ-only)", + "priority": "p1", + "status": "missing", + "scope": "game1", + "owner": "warcouncil", + "updated_at": "2026-04-25", + "summary": "Today the GPU MCTS path lives **inside** the `mc-ai` crate (`gpu/inner.rs`, `gpu/rollout.wgsl`, `gpu/cpu_reference.rs`) and runs in-process via the GDExtension (`GdMcTreeController`). That couples GPU lifecycle (device init, queue submission, buffer pooling, fence waits) to the game's per-turn decision call.\n\nPer user directive 2026-04-25: extract this into its own **MCTS service/client** that\n\n1. Lives **inside @magic-civilization** (not in @model-boss / not in any other repo) — it's game-specific.\n2. Lives **independently** of the in-process GDExtension — long-lived process the game talks to via IPC (Unix socket / TCP / shared memory).\n3. **Borrows patterns** from `@model-boss` (job submission, queue, batched dispatch, GPU lifecycle isolation) but doesn't take a dependency on it. Magic-civ's MCTS workload is narrow enough to warrant its own focused implementation.\n\nWhy a service vs in-process:\n- GPU init + warm-up amortized once per session, not per AI turn\n- Game can keep playing turns while a deep search is in flight (async)\n- Crash isolation — a wgpu/driver fault doesn't take the game down\n- One service can serve multiple game clients (autoplay-batch parallel runs hit one warm GPU instead of N cold inits)\n- Future: out-of-process service can run on a different host (apricot has GPU, dev mac doesn't)" + }, + { + "id": "p1-28", + "title": "\"Culture research tree — real graph, bridge, UI\"", + "priority": "p1", + "status": "done", + "scope": "game1", + "owner": "shipwright", + "updated_at": "2026-04-26", + "summary": "The culture data files\n(`public/games/age-of-dwarves/data/culture/manifest.json` →\n`public/resources/culture/*.json`) describe a six-pillar culture tree that\nmirrors the tech-tree shape: `id`, `name`, `pillar`, `era`, `tier`, `cost`,\n`requires`, `unlocks{…}`, `flavor`. The web guide already renders it via the\nshared `TechTreeGraph` component (`CultureTreePage.tsx`).\n\nInside the Godot game there was no culture-tree surface: no Rust research\ngraph, no GDExtension bridge, no GDScript wrapper, no scene, no per-turn\nresearch accumulator. The `CulturePool` only powered border expansion.\n\nThis objective shipped the live culture-research path end-to-end so the\nplayer can open a culture-tree screen identical in UX to the tech tree,\npick a tradition, accumulate culture-research progress per turn, and unlock\nbuildings / wonders / lenses / mechanics on completion." + }, + { + "id": "p1-29", + "title": "\"Anti-early-domination: lift game-balance gates that p0-01 v1 measured\"", + "priority": "p1", + "status": "missing", + "scope": "game1", + "owner": "warcouncil", + "updated_at": "2026-04-26", + "summary": "Split out from p0-01's original v1 sub-gates that the AI-layer cycles (1, 2, 3) could not move because they measure emergent game-balance dynamics, not AI quality. p0-01 closed `done` 2026-04-26 against Gate v2 (3/5 v1 sub-gates pass cleanly: tier_peak=4, wonders 7/10, combats=255). The 2 v1 sub-gates that v2 reframed away need a real owner:\n\n- `tier_peak_gap ≤ 4` median: in surviving-pair games at end, one player tech-monopolizes (tp=6) while the other stagnates (tp=0), giving gap=5-6. Even with the alive-aware metric, the gap holds. Root cause: capture/combat dynamics let one player snowball without the other catching up. Loser stays alive but undeveloped.\n- `peak_unit_tier ≥ 3 in ≥7/10 games` absolute: 5/10 currently. 4 of the 5 fails are early-domination games (T48-T121) where tier-3 tech hasn't unlocked yet. The AI does deploy tier-3 units when available (80% of seeds reaching tp ≥3 also reach unit ≥3), but games end before tier-3 unlocks in half the seeds.\n\nCycle-3 attempted multiple AI-layer levers and confirmed they DON'T move these gates:\n- Tactical `DOMINANCE_FACTOR` bump (production.rs 1.25→2.0): no effect on outcome\n- Tactical dominance lerp bump (thresholds.rs 1.5→2.0/2.5 baseline): caused REGRESSION on tier_peak (faster opportunist wins)\n- Both reverted because the strategic MCTS doesn't pick attack actions — it only picks `SpawnUnit/FoundCity/Idle` per `mc-turn/src/snapshot.rs:204-214 action_prior`. The capture/development tempo is governed by mc-turn capture mechanics + mc-economy growth rates, NOT by AI scoring weights.\n\nReal levers (cross-team scope):\n- **mc-combat / mc-turn capture mechanics**: increase city HP, lengthen siege duration, add capital-recapture cost, weaken early-rush combat math.\n- **mc-economy growth rates**: faster baseline tech research, lower tier-3 prereq cost, give players tech catch-up bonus when behind.\n- **mc-turn turn-limit floor**: refuse to award domination victory before T150 (force games to mid-game minimum).\n\nPick one or compose multiple. Each requires the corresponding team-lead's involvement." + }, + { + "id": "p1-30", + "title": "\"Optimize `_build_tactical_state` — 8000-tile GDScript dict-build per AI turn blocks p1-22 huge-map gate\"", + "priority": "p1", + "status": "missing", + "scope": "game1", + "owner": "warcouncil", + "updated_at": "2026-04-26", + "summary": "Split out from p1-22 (MCTS per-decision wall-clock budget). p1-22 closed `partial` after cycles 2-3 shipped strategic + tactical Rust budgets (`mcts_tree::simulate_parallel` + all 5 `mc-ai/src/tactical/` submodules + `GdAiController::set_budget_ms`, 186/186 lib tests). Strategic budget verified working: p1-22-cycle-2 batch had seeds 9 and 10 reach T500 victories at max_tier=10 and 7. Tactical budget verified bounded by unit test `tactical::tests::tactical_budget_respected`.\n\nBut the huge-map ≥5/10 victories sub-gate FAILED at 2/10 in the cycle-2 batch because seeds 1-8 hung at low turn counts (T43-T236). The Rust paths are bounded; the hang is in GDScript. Specifically: `src/game/engine/src/modules/ai/ai_turn_bridge.gd:248-250` (`_build_tactical_state`) iterates `width × height` tiles (112×72 = 8064 on a huge map) building a Dictionary per tile to serialize as JSON to feed `GdAiController::decide_actions`. That serialization runs every AI turn per player.\n\n5 AI players × ~8000 tile-dicts × ~T100 turns = ~4 million GDScript dict allocations per game. Each dict allocation is microseconds but compounds. The Rust `MCTS_DECISION_BUDGET_MS=2000` doesn't bound this — by the time Rust gets the JSON, the GDScript serialization has already chewed through the wall-clock budget for the turn.\n\nThe fix has two reasonable shapes:\n1. **Delta serialization**: only serialize tiles whose state changed since last AI turn. Cache per-player. ~10× speedup.\n2. **Move tile state into mc-turn**: have Rust own the tile catalog (already partial via `TacticalMap`), pass an opaque handle from GDScript instead of full JSON. Eliminates the GDScript dict-build entirely. Aligns with Rail-1.\n\nOption 2 is more correct (Rail-1) but bigger surface. Option 1 is the quick win." + }, + { + "id": "p1-31", + "title": "Author the 9 buildings the tech tree unlocks but data does not provide", + "priority": "p1", + "status": "missing", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`data/techs/*.json` `unlocks.buildings` references nine building IDs that have no JSON file under `data/buildings/`. Researching one of these techs awards the player nothing visible, even though the tech tooltip will say \"unlocks X\". This is a content gap, not a code bug — the loader, dispatch, and city UI all work; the buildings are simply missing.\n\n| Building | Unlocking tech | Inferred role |\n|---|---|---|\n| `fishery` | `fishing` | Coastal food generator |\n| `hunting_lodge` | `trapping` | Forest food + scout XP |\n| `nature_reserve` | `ecology_study` | Wilderness yield / happiness |\n| `hardening_pit` | `steelworking` | Smithing tier-up (post-`forge`) |\n| `mithril_forge` | `mithril_smithing` | Late-game forge upgrade |\n| `runesmith_hall` | `runelore` | Runic crafting (mundane in Game 1) |\n| `siege_works` | `siege_doctrine` | Distinct from existing `siege_workshop` (different tech, advanced tier) |\n| `war_college` | `combined_arms` | Mid-game elite-unit production hub |\n| `ranger_post` | `tracking` | Scout/courier support; pairs with existing `messenger_hut` (also unlocked by `tracking`) |" + }, + { + "id": "p1-32", + "title": "Author the food + resource processing chain (granary, mill, brewery, tannery, sawmill, herbalist)", + "priority": "p1", + "status": "missing", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`public/games/age-of-dwarves/docs/cities/PRODUCTION_CHAIN.md` describes a stockpile-based processing economy:\n\n```\nFarm → Mill → flour → food surplus\n → Brewery → ale (happiness + trade)\nPasture → Tannery → leather → Barracks (unit armor quality)\nForest → Sawmill → lumber → construction speed bonus\n → Siege Workshop (siege engines)\nForest → Herbalist → reagents → Academy (research boost)\n```\n\n`docs/cities/BUILDINGS.md` adds `granary` (Husbandry tech, food storage / starvation reduction).\n\nNone of these six buildings exist in `data/buildings/`. The dwarves have no food building at all — the closest is `boar_pen` (food=1, gated by `boar_husbandry`). `mc-ai/tactical/production.rs::ids::GRANARY = \"granary\"` already tries to queue one and silently fails (see p0-45).\n\nThis objective authors the six processing buildings AND the stockpile-effect-types they require. It does NOT implement the full stockpile/quality system — that is a downstream gameplay feature. The buildings start with simple yield effects (`food`, `happiness`, `production`, `science`) so they're useful immediately, with `effects` left extensible for the stockpile system to layer on top." + }, + { + "id": "p1-33", + "title": "Author production buildings for naval and aerial unit families (shipwright, airfield)", + "priority": "p1", + "status": "missing", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`data/units/manifest.json` ships **14 dwarf naval units** (river_galley → fortress_ship across 11 tech gates) and **7 aerial units** (gyrocopter → sky_fortress). Neither category has a corresponding production building. The units are buildable in any city the moment their tech is researched — no port, no shipyard, no airfield required.\n\nThis is a gameplay AND a content gap:\n1. **Gameplay**: a landlocked city can currently build a `dwarf_dreadnought`. The dwarves' considerable naval roster has no production gate, no coastal-adjacency requirement, no infrastructure cost.\n2. **Content**: `BUILDINGS.md` calls for a `shipwright` (Cartography + Navigation tech). No equivalent for aerial is in the design doc — needs a design pass.\n\nThis objective authors:\n- `shipwright` — naval production building, requires coastal city, gates buildable naval units.\n- `airfield` (or `hangar`, name TBD) — aerial production building, gates buildable aerial units. Design: probably a flat building, plains/grassland placement, no special adjacency.\n\nThe \"buildable only when this building exists\" gate is the new capability — the existing `tech_required` field on units is necessary but not sufficient. Either:\n- Add `building_required: ` to unit schema and have the production picker honour it, or\n- Use the existing `placement_tile_required` + adjacency machinery to require shipwright on a coastal tile.\n\nPick one and document the choice in this objective's evidence list once selected." + }, { "id": "p2-06", "title": "Export pipeline for Windows / macOS / Linux", "priority": "p1", - "status": "partial", + "status": "done", "scope": "game1", "owner": "shipwright", - "updated_at": "2026-04-18", + "updated_at": "2026-04-25", "summary": "Players need binaries. Godot export presets (desktop: Linux/X11, macOS, Windows Desktop) are authored; the `./run export` chain produces per-platform archives via `tools/export.sh` + `tools/export-single.sh`, and the `.forgejo/workflows/release.yml` tag-push pipeline bundles Linux + macOS + Windows + WASM-guide archives into a Forgejo release with release notes generated from the CHANGELOG diff.\n\nOpen work: (1) Windows `.dll` production only happens on a registered windows runner — local `./run export:windows` from a macOS/Linux EDIT host does not yet cross-compile, and no forgejo windows runner is registered. (2) The boots-and-plays end-to-end smoke has not been run against a fresh export archive — the prior audit's 29MB .x86_64 was discovered this pass to be non-bootable (missing embedded .pck from a concurrent --import race). A clean re-export + AUTO_PLAY 10-turn smoke on a dedicated off-peak runner is the remaining gate. (3) AutoPlay autoload shipping (✓ this pass) unblocks (2) but (2) itself is still ✗.\n\n### macOS scan-inflation fix (2026-04-17, commit f090d28a7)\n\nThe prior 20+ min plum export stall was root-caused to Godot's export scanner walking the entire project tree *before* applying `exclude_filter` — the three pnpm-managed `public/games/*/guide/node_modules/` symlinks dereferenced into the hoisted store and emitted ~16MB of `_scan_new_dir` warnings. Fixed in `tools/export-single.sh` by rsync-staging the project to `.local/export-staging-/` (excluding `node_modules`, `.local`, `target`, `.git`, `dist`, `.vite*`) before invoking godot. Default-on for macos; opt-in via `EXPORT_STAGED=1` elsewhere; `KEEP_STAGING=1` keeps staging dir for inspection.\n\nEmpirical timing: `./run export:macos p2-06-verify` completed full project scan + 155-step asset reimport in **8.827s** total (two independent runs at 9.287s and 8.827s). Zero `_scan_new_dir` warnings. The only remaining blocker surfaced by that run is a missing Godot 4.6.2 export template (`/Users/natalie/Library/Application Support/Godot/export_templates/4.6.2.stable/macos.zip` — empty templates dir). Once the template is installed, `archive_boots_and_plays` should close within minutes rather than the 20+ min scan-stall window it previously faced. No codesign/entitlement errors surfaced in verification (those would follow template resolution), so the scan-inflation gate is provably cleared.\n\nStaging approach is documented in `scripts/README.md` § \"Export staging (p2-06)\"." }, { "id": "p2-16", - "title": "Audio assets — SFX + music .ogg files shipped", + "title": "Audio assets — in-theme OSS launch pack + source ledger", "priority": "p1", - "status": "missing", + "status": "in_progress", "scope": "game1", "owner": "asset-audio", - "updated_at": "2026-04-17", - "summary": "The audio capability shipped as **p0-21** — `AudioManager`, manifest, signal wiring, volume sliders all work. What's missing is the 16 actual `.ogg` files the manifest declares. Gameplay is currently silent. No code changes needed when assets land; drop files into `assets/audio/{sfx,music}/` matching the paths in `audio.json`.\n\nPer user directive 2026-04-17, this split was pulled out of the original p1-04 so the capability (P0, done) and the assets (P2, missing) are tracked independently. A silent ship is shippable; a broken audio system is not." + "updated_at": "2026-04-27", + "summary": "The audio capability shipped as **p0-21** — `AudioManager`, manifest,\nsignal wiring, volume sliders all work. The schema + categorical\nrouting extension lands as **p2-33** (this objective is `blockedBy`\nthat). What's missing is the actual `.ogg` files plus the source\nledger that proves their licenses are clean.\n\nPer user directive 2026-04-17 the asset work was pulled out of the\noriginal `p1-04` so capability and assets are tracked independently.\nA silent ship is shippable; a broken or licence-tainted audio system\nis not.\n\nThis objective ships **the launch sound pack** assembled from free /\nOSS sources (CC0, CC-BY 3.0/4.0, royalty-free commercial; no\nShareAlike, no NonCommercial). Pack covers ~57 files spanning UI,\nturn cycle, units (categorical melee / ranged / siege / civilian),\nbuildings (categorical civic / production / military / wonder),\nfauna (categorical predator / herbivore / apex), city events,\nresearch, weather, victory. ~50 SFX + 7 music tracks." }, { "id": "p2-22", "title": "Sprite generation pipeline — runnable end-to-end", "priority": "p1", - "status": "missing", + "status": "partial", "scope": "game1", "owner": "asset-sprite", - "updated_at": "2026-04-17", + "updated_at": "2026-04-25", "summary": "Gate-one objective for every other `asset-sprite` child (`p2-23` … `p2-27`). Before any sprite can legitimately land in `public/games/age-of-dwarves/assets/sprites/`, the `tools/sprite-generation/` pipeline has to run cleanly end-to-end: scan game data → generate variants via the configured model → auto-rank via Sonnet vision → surface in the Theater GUI for human approval → chroma-key + resize + install with LICENSES.md row written.\n\nSlate is clean (user deleted 7 pre-existing sprites on 2026-04-17 for quality-bar failure; the prompt library and ranker had drifted). This objective closes out the \"pipeline works\" half of the split; actual sprite shipping lives in the downstream children." }, { @@ -754,12 +884,22 @@ "id": "p2-28", "title": "Sprite provenance ledger — LICENSES.md per-file attribution", "priority": "p1", - "status": "missing", + "status": "done", "scope": "game1", "owner": "asset-sprite", - "updated_at": "2026-04-17", + "updated_at": "2026-04-25", "summary": "Every sprite PNG that ships in `public/games/age-of-dwarves/assets/sprites/` must have a corresponding row in `public/games/age-of-dwarves/assets/sprites/LICENSES.md` recording source, license, author, URL, and SHA256. This is a cross-cutting compliance objective that runs continuously alongside the delivery children (`p2-23` … `p2-27`) — the ledger is complete exactly when every on-disk sprite has a matching row and every row points at an on-disk file.\n\nCommercial-use compatibility is non-negotiable. AI-generated output must come from a model on the approved list (`juggernaut-xl-v9`, `epicrealism-xl`, `illustrious-xl-v2`, or current equivalent per CLAUDE.md). Commissioned art must have assigned commercial rights in writing." }, + { + "id": "p2-33", + "title": "\"Sound system extension — categorical fallback, variant pools, per-entity routing\"", + "priority": "p1", + "status": "done", + "scope": "game1", + "owner": "asset-audio", + "updated_at": "2026-04-27", + "summary": "`AudioManager` (`p0-21`, done) ships 10 SFX events and 6 era-keyed music\ntracks. The current manifest is one stream per id, no variation, no\nfallback chain, and no story for the 91 units / 65 buildings / 600\nfauna species the game ships with — every entity that ever wants a\ndistinct sound has to add a hand-authored entry, which doesn't scale to\nlaunch.\n\nThis objective extends the manifest schema and `audio_manager.gd` so\nthe asset pack tracked by `p2-16` can land cleanly:\n\n* **Variant pools** — an entry can list `streams[]` with 2-3 paths;\n the player picks one uniformly to break repetition.\n* **Pitch jitter** — optional ±X% pitch randomisation per play.\n* **Categorical fallback ladder** — `play_for_entity(entity_id,\n event_kind)` resolves `.` → `.` →\n ``, so a fresh unit with no bespoke sound automatically routes\n to its category bucket (`unit.melee.attack`, `building.production.complete`,\n `fauna.apex.roar`, etc.). The category is read from existing JSON\n fields (`unit_type` / `category` / `trophic_class`) — no new schema\n fields on units / buildings / wilds.\n* **EventBus expansion** — wire the additional signals that already\n exist on `event_bus.gd` but aren't routed to audio yet\n (`combat_started`, `unit_destroyed`, `unit_promoted`, `city_grew`,\n `city_starved`, `golden_age_started`, `golden_age_ended`,\n `border_expanded`, `culture_researched`, `wild_creature_spawned`,\n `weather_event`, `tech_research_started`).\n\nThis is a **schema-and-code** objective. No `.ogg` files land here —\nthose are `p2-16`'s responsibility, which is `blockedBy: [p2-33]` so\nthe dependency-aware ordering surfaces this work first." + }, { "id": "p2-01", "title": "Minimap — fog reflection and unit markers", @@ -810,6 +950,16 @@ "updated_at": "2026-04-23", "summary": "10-seed parallel batch completes in ~7 minutes wall-clock; single-turn latency on the RUN host is unmeasured. Target: end-of-turn processing ≤1 second on a 512-tile map with 3 AI opponents mid-game." }, + { + "id": "p2-06b", + "title": "Cross-compile Windows .exe + .dll from Linux via cargo-xwin (no Windows host)", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": "shipwright", + "updated_at": "2026-04-25", + "summary": "Originally framed as \"register a Windows runner\". Re-scoped 2026-04-25 (user pick) to **Option B: cargo-xwin cross-compile from Linux** — produces MSVC-ABI Windows binaries on the existing Linux runner, no Windows hardware required. Better ABI compatibility than mingw (especially for wgpu's d3d12 backend) and zero hardware cost.\n\nRecipe:\n1. Linux runner installs `cargo-xwin` (one-off): `cargo install cargo-xwin`\n2. Add MSVC target to rustup: `rustup target add x86_64-pc-windows-msvc`\n3. Install `clang` + `lld` (xwin uses these as linker)\n4. `bash src/simulator/build-gdext.sh x86_64-pc-windows-msvc` → cargo-xwin downloads MS SDK on first run (~1.5GB, cached at `~/.cache/cargo-xwin/`), builds `magic_civ_physics_gdext.dll`, copies to `engine/addons/magic_civ_physics/magic_civ_physics.x86_64.dll`\n5. `bash tools/export-single.sh windows ` → Godot Linux exports the Windows .exe (cross-export is native to Godot), the script then relocates the .dll into `engine/addons/magic_civ_physics/` next to the binary\n\n`.forgejo/workflows/release.yml` retargeted to `runs-on: [self-hosted, linux, x86_64]` with a \"Setup MSVC cross-toolchain\" step." + }, { "id": "p2-07", "title": "Credits screen accessible from main menu", @@ -854,7 +1004,7 @@ "id": "p2-10a", "title": "\"CI: gdlint stage un-gated\"", "priority": "p2", - "status": "partial", + "status": "done", "scope": "game1", "owner": "testwright", "updated_at": "2026-04-25", @@ -864,11 +1014,91 @@ "id": "p2-10b", "title": "\"CI: headless GUT stage un-gated\"", "priority": "p2", - "status": "partial", + "status": "done", "scope": "game1", "owner": "testwright", - "updated_at": "2026-04-25", - "summary": "The headless GUT stage in `.forgejo/workflows/ci.yml` (Stage 8) currently runs with `continue-on-error: true` due to 39 pre-existing test failures out of 439. This child objective tracks un-gating it so a GUT failure hard-fails the CI pipeline. Each failing test must be either fixed or explicitly quarantined with a skip annotation and a linked issue. Split off from p2-10 on 2026-04-25." + "updated_at": "2026-04-26", + "summary": "The headless GUT stage in `.forgejo/workflows/ci.yml` (Stage 8) was running with `continue-on-error: true` due to 40 pre-existing test failures. All 40 triaged and resolved. Gate is now hard." + }, + { + "id": "p2-10c", + "title": "\"Diplomacy: implement _collect_unique_luxury_ids() in happiness.gd\"", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`happiness.gd` is expected to expose a static helper `_collect_unique_luxury_ids(player, game_map)` that collects traded + tile-based luxury resource IDs into a sorted deduplicated array. Four tests in `test_diplomacy.gd` exercise this contract. The function was never implemented." + }, + { + "id": "p2-10d", + "title": "\"Data: strip legacy flags/can_found_city/can_build_improvements from unit JSON\"", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "All unit JSON files under `public/games/age-of-dwarves/data/units/` have legacy fields `flags`, `can_found_city`, and `can_build_improvements` removed (they were superseded by the `keywords` array). The test `test_no_unit_has_legacy_flags_field` was replaced from a `pending()` stub to a real assertion loop that iterates every unit JSON file and verifies none of the three keys are present." + }, + { + "id": "p2-10e", + "title": "\"Data: resolve duplicate IDs and dangling unlock refs in game data\"", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`test_data_integrity.gd` had two `pending()` stubs and the data had real dangling refs:\n1. **Duplicate IDs**: `public/resources/` is the Games 2/3 master library; `public/games/age-of-dwarves/data/` overrides it for Game 1. DataLoader loads resources first, then game data overwrites — this is intentional. The test was rewritten to check for intra-pack duplicates only (same ID in two files within the same category directory), which is the correct failure mode to guard against.\n2. **Dangling unlock refs**: 9 tech unlocks referenced buildings/improvements that don't exist in either source (`grand_forge`, `steam_foundry`, `mithril_mine`, `adamantine_vault`, `deep_garden`, `mushroom_farm`, `deep_quarry`, `root_sanctum`, `citadel_of_ages`). These are unported Game 1 content. Unlock entries removed from tech JSON files." + }, + { + "id": "p2-10f", + "title": "\"SaveManager: fix typed array property assignment on Player/Unit deserialization\"", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "Four `test_save_manager.gd` tests fail with \"Invalid assignment of property or key 'X' with value of type 'Array' on a base object of type 'RefCounted (Player/Unit)'\". The affected properties are `researched_techs`, `infusions`, and others. Player/Unit declare typed arrays (e.g. `Array[String]`) but the deserializer assigns plain `Array`, causing the runtime type mismatch." + }, + { + "id": "p2-10g", + "title": "\"CityBridge: add production_cost field to items JSON fixture\"", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`test_city_bridge.gd:test_happy_path_enqueue_tick_emits_item_crafted` passes. The fixture JSON in the test already contains `production_cost: 30` nested under the `production` block, which is the exact structure the Rust `GdCity::load_items_json` deserializer expects (`ItemDoc` containing `ProductionDoc`). No changes were needed — the objective description was stale." + }, + { + "id": "p2-10h", + "title": "\"UnitRenderer: implement _build_sprite_key() helper and fix cache key test\"", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`test_sprite_renderer.gd` tests `_build_sprite_key(type_id, race_id, sex)` on `UnitRenderer` — a helper function that was never implemented. 5 tests use it directly. Additionally, `test_cache_populated_after_miss` fails because the expected cache key format doesn't match the actual `DrawHelpers`-managed cache key." + }, + { + "id": "p2-10i", + "title": "\"TileTooltip: fix scene node name mismatches and collectibles text formatting\"", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`test_tile_tooltip.gd` had three pending tests (not failing) for panel show/hide behavior.\nCollectibles text tests (1-3) were already passing via the `build_collectibles_text()` static.\nPanel tests (4-6) needed: instantiate via `load().instantiate()`, guard `GameState` nil access\nin `tile_info_panel.gd`, and use a valid terrain ID (`plains`) that exists in the tiles JSON." + }, + { + "id": "p2-10j", + "title": "\"FogOfWar: fix recalculate_vision to not re-reveal already-seen tiles on move\"", + "priority": "p2", + "status": "done", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "The two tests were `pending()` placeholders. The production code (`world_map_vision.gd:recalculate_vision()`) is correct — it demotes visible→stale then re-promotes in-range tiles, which is standard fog-of-war behavior with no return value. The fix was implementing real test bodies using the existing `_expand_vision()` helper, which already counts stale→visible transitions correctly.\n\n- `test_move_scout_expands_known_count`: reveals 37 tiles on first call; after 1-hex move, asserts `< 37` new tiles (already-visible tiles stay visible)\n- `test_seeded_t10_scout_move_reveals_exact_k_tiles`: asserts exactly `2*sight_range+1 = 7` new tiles from a 1-hex axial move (the leading-edge slice formula)" }, { "id": "p2-11", @@ -880,6 +1110,26 @@ "updated_at": "2026-04-17", "summary": "Players need to know which version of the game they're running when filing bug reports. Main menu shows no version; no About screen exists." }, + { + "id": "p2-11a", + "title": "\"SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path\"", + "priority": "p2", + "status": "stub", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "Unit has no serialize()/deserialize() methods — infusions, equipped_items, promo_ids, keywords and other typed arrays cannot round-trip through SaveManager. City.production_queue is a GDScript-side Array with no serialize path; the Rust-backed City.to_json() does not include it. These gaps were deferred from p2-10f, which narrowed its tests to the Player serialize surface only." + }, + { + "id": "p2-12", + "title": "Install weston on apricot RUN host — unblock display-server smoke tests", + "priority": "p2", + "status": "done", + "scope": "infra", + "owner": "shipwright", + "updated_at": "2026-04-25", + "summary": "Several P0/P1 smoke gates were originally spec'd to require a wayland display server (weston, headless backend with software rendering) on apricot for `RENDER_MODE=weston tools/autoplay-batch.sh`. Weston is now installed (system rpm) and the headless backend launches cleanly.\n\nNote: the smokes that originally needed weston (p0-41a rally, p0-42a formation) were closed via headless evidence + Rust unit tests during this session, so this objective lost most of its urgency. It remains useful for any future visual-render smoke that genuinely needs a compositor.\n\nSpun out from p0-42 + p0-41a on 2026-04-25 after initial smoke runs failed with `ERROR: --weston mode but weston not installed`." + }, { "id": "p2-18", "title": "Guide web app — public hosting + deploy pipeline", @@ -960,6 +1210,26 @@ "updated_at": "2026-04-18", "summary": "Rail #2 in CLAUDE.md says \"JSON game packs are the canonical content\nstore — neither Rust nor GDScript hardcodes game content.\" Several\nguide pages still violate the spirit of that rule by hand-typing\ndata arrays in `.tsx` files. When the design changes (\"we decided\nit's 20 races, not 16\" — or \"Arcana tree got merged into Scholarship\"),\nthe fix has to hop four files in TypeScript rather than editing one JSON.\n\nHardcoded arrays the Explore sweep found:\n\n| File:Line | Array | Target JSON |\n|---|---|---|\n| `MapTypesPage.tsx:105–124` | `TOPOLOGY_MODES` — 3 topologies with `{id, label, desc, math, isDefault}` | `public/games/age-of-dwarves/data/map-topologies.json` (new) |\n| `EpisodeDwarvesPage.tsx:8–20` | `INCLUDED_SYSTEMS` — 10 system-name strings | `public/games/age-of-dwarves/data/episodes/ep1-systems.json` (new) |\n| `HomePage.tsx:180–210` (FEATURES) | \"What makes this game different\" cards | `public/games/age-of-dwarves/data/homepage-features.json` (new) — Episode 1 cards first, Episode 2+ behind an EpisodeGate at render time |\n| `progress-report/OverviewTab.tsx:114–165` | Hand-typed \"Coming in v1.0.0\" + \"After Full Release\" roadmap tables | `public/games/age-of-dwarves/data/shipping-roadmap.json` (new) |" }, + { + "id": "p2-34", + "title": "Author castle as the walls upgrade tier (defensive ladder)", + "priority": "p2", + "status": "missing", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`mc-ai/tactical/production.rs` priority 6 reads:\n\n```\n// Priority 6: castle (upgrades walls, enables bombard).\nif city.buildings.contains(\"walls\") && !city.buildings.contains(\"castle\") {\n return ids::CASTLE.into();\n}\n```\n\nThere is no `castle.json` in `data/buildings/`. The AI emits `\"castle\"` and the bridge silently drops it (see p0-45). Per user direction, castle as \"a level of wall defense\" is the right design — a tier-2 defensive building that requires `walls` and grants stronger `city_defense` / `city_hp`, gates a future bombard / siege-resistance unit family.\n\nThis objective fills the gap." + }, + { + "id": "p2-35", + "title": "Palace evolution system — longhouse → great_hall → citadel → grand_citadel + courthouse", + "priority": "p2", + "status": "missing", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`public/games/age-of-dwarves/docs/cities/BUILDINGS.md` opens with an entire **Palace Evolution** mechanic that has no data implementation:\n\n- The Palace starts as the entire civilization (one building doing all functions at reduced efficiency).\n- Researching specific techs *moves* a function out of the Palace into a dedicated building (Masonry → Mason Lodge, Smelting → Forge, Scholarship → Library, etc.) and *boosts* the Palace's remaining functions by +10%.\n- Palace itself has 4 levels: **Longhouse → Great Hall → Citadel → Grand Citadel**, each with worker-cap and primary-output progression, gated by Guilds → Academies.\n\nNone of these palace tiers exist in `data/buildings/`. There is no \"Palace\" building at all today. New cities silently spawn with no central administrative building and no shedding-of-functions chain.\n\nAdjacent gap: `BUILDINGS.md` Infrastructure section also calls for `courthouse` (Governance tech) for unrest reduction in captured cities. Also missing.\n\nThis objective is the largest design gap on the buildings audit and likely needs a design pass with the user before implementation begins. It is filed as p2 because Game 1 currently functions without it (cities just don't have a palace) — but every city-tier metaphor in the player-facing docs assumes it exists." + }, { "id": "g2-01", "title": "Ley lines — Game 2 (Age of Kzzykt)", @@ -1129,6 +1399,46 @@ "owner": null, "updated_at": "2026-04-17", "summary": "Demonia (NGNL rank 11) are the demon species of the Ethereal Plane, aligned with Chaos school. Low-to-mid tier; they have no coherent belief system, making them the primary beachhead for Terran religious conversion — susceptible when isolated, violently opposed when organized. They are the \"wild card\" species of Game 5: unpredictable alliances, high aggression, and the most conventional military threat." + }, + { + "id": "g6-01", + "title": "Naval combat — out-of-scope (post-v10)", + "priority": "p3", + "status": "oos", + "scope": "post-v10", + "owner": null, + "updated_at": "2026-04-26", + "summary": "Hex-based naval combat — water-tile movement for ships, ship unit types (transport / warship / etc.), naval-vs-naval and naval-vs-coastal-city combat, harbor / port buildings, sea-region map topology where applicable." + }, + { + "id": "g6-02", + "title": "Caravan trade routes — out-of-scope (post-v10)", + "priority": "p3", + "status": "oos", + "scope": "post-v10", + "owner": null, + "updated_at": "2026-04-26", + "summary": "Persistent trade-route units (caravans, traders) that travel between owned cities OR between own-city and foreign-city, generate per-turn gold/resource yields tied to distance and city-pair characteristics, and can be plundered by enemy units. Distinct from p1-01's instantaneous luxury-for-gold trade modal." + }, + { + "id": "p3-01", + "title": "Courier-gated diplomacy — open borders + shared maps via tech-tiered courier units", + "priority": "p3", + "status": "partial", + "scope": "game1-stretch", + "owner": "envoy", + "updated_at": "2026-04-26", + "summary": "Game 1 ships diplomacy-lite: peace/war toggle plus a single bilateral luxury↔gold\ntrade action (`mc-trade`). This objective expands the diplomatic surface with two\ntrade options gated on physical infrastructure rather than instant agreement, so\ninformation itself becomes a strategic resource that decays with distance and tech:\n\n1. **Open borders** — pay luxury or gold for the right to move units through\n another civ's territory for N turns. Instant effect; pure trade.\n\n2. **Shared map** — pay luxury or gold for the other civ's explored map for N\n turns. **Not instant**: the deal is gated on a courier link between capitals.\n Knowledge propagates at the courier's movement speed; the courier is killable\n mid-route (intercept = no map delivered, payment already made). The Courier\n unit family has tech-gated upgrade tiers, one per era from era_2 onward; later\n tiers shrink the delay window and shift the intercept surface from\n killing-the-unit to severing-the-infrastructure.\n\nThis is **scope: game1-stretch** — Game 1's stated scope is \"diplomacy-lite\", so\nthis objective is post-Early-Access content unless explicitly pulled forward." + }, + { + "id": "p3-02", + "title": "Hybrid merged structures — war_academy, assault_citadel, cavalry_corps, gunnery_corps", + "priority": "p3", + "status": "missing", + "scope": "game1", + "owner": null, + "updated_at": "2026-04-26", + "summary": "`public/games/age-of-dwarves/docs/cities/BUILDINGS.md` \"Hybrid Merged Structures\" describes a tier-7-unlocked \"merge two co-located buildings into one hybrid\" mechanic with four named hybrids:\n\n| Merged structure | Requires | Exclusive units |\n|---|---|---|\n| War Academy | Barracks+Rifle Range + Stable+Barding Hall | Dragoon, Mounted Rifleman, Assault Cavalry |\n| Assault Citadel | Barracks+Sword Hall + Siege Workshop+Siege Annex | Siege Breaker, Combat Engineer, Storm Trooper |\n| Cavalry Corps | Stable+Barding Hall + Barracks+Bolt Range | Mounted Archer, Beast Scout, Ram Sniper |\n| Gunnery Corps | Barracks+Rifle Range + Siege Workshop+Powder Annex | Mortar Team, Assault Gunner, Field Artillery |\n\nMultiple prerequisite buildings (Stable, Barding Hall, Siege Annex, Powder Annex) and exclusive units do not exist in data. Game 1 also does not implement co-located building tile slots, master/grandmaster auras, or merge irreversibility — all called out elsewhere in BUILDINGS.md.\n\nThis is a **post-EA expansion-tier feature**. Filed at p3 to keep the gap visible without implying Game 1 EA depends on it." } ] } diff --git a/scripts/run/test.sh b/scripts/run/test.sh index be924d33..96ca814f 100644 --- a/scripts/run/test.sh +++ b/scripts/run/test.sh @@ -9,6 +9,10 @@ cmd_validate() { echo -e "${BLUE}Validating game data JSON schemas...${NC}" python3 "$REPO_ROOT/tools/validate-game-data.py" "$@" + echo -e "${BLUE}Validating audio manifests...${NC}" + python3 "$REPO_ROOT/tools/audio-validate.py" + echo -e "${BLUE}Checking audio license ledger...${NC}" + python3 "$REPO_ROOT/tools/audio-licenses-render.py" --check } # Run Rust workspace tests, preferring nextest when available. diff --git a/src/game/engine/src/modules/victory/victory_manager.gd b/src/game/engine/src/modules/victory/victory_manager.gd index c1450ae6..4ed9ad92 100644 --- a/src/game/engine/src/modules/victory/victory_manager.gd +++ b/src/game/engine/src/modules/victory/victory_manager.gd @@ -50,14 +50,30 @@ func _resolved_max_turns() -> int: func check_all(_game_map: RefCounted) -> void: if _game_over: return - if GameState.turn_number < VICTORY_GRACE_TURNS: + + # Elimination ALWAYS fires regardless of grace — once a player is the only + # one alive, the game is structurally over (eliminated players can't + # recover). Forcing surviving players to keep playing past elimination + # until the grace turn just stalls games (warcouncil p1-29 H2 v1 bug: + # T42 elimination + 100-turn grace → games stalled in_progress until + # safety_timeout). Elimination victories are inherently a sign that + # rush-domination already won; no game-development purpose served by + # delay. + var elim_winner: int = _check_elimination_winner() + if elim_winner >= 0: + _game_over = true + EventBus.victory_achieved.emit(elim_winner, "domination") return - var winner_index: int = _check_domination() - if winner_index >= 0: - _game_over = true - EventBus.victory_achieved.emit(winner_index, "domination") - return + # Capture-all-capitals victory IS gated by grace turns. Slows + # rush-domination so games reach mid-game tech development before a + # captor can secure all capitals. + if GameState.turn_number >= VICTORY_GRACE_TURNS: + var winner_index: int = _check_capture_winner() + if winner_index >= 0: + _game_over = true + EventBus.victory_achieved.emit(winner_index, "domination") + return # Score fallback: at max turns, award the highest-scoring player. if GameState.turn_number >= _resolved_max_turns(): @@ -68,9 +84,10 @@ func check_all(_game_map: RefCounted) -> void: return -## Domination: a player owns every opponent's original capital, OR is the -## last player with at least one city (elimination fallback). -func _check_domination() -> int: +## Capture-all-capitals: a player owns every opponent's original capital. +## Gated by VICTORY_GRACE_TURNS — early-game capture wins before mid-game +## tech development are blocked. +func _check_capture_winner() -> int: var capital_owner_by_player: Dictionary = {} # player_index → current city.owner for player: Variant in GameState.players: if not player is PlayerScript: @@ -94,8 +111,12 @@ func _check_domination() -> int: break if owns_all_other_capitals and candidate.cities.size() > 0: return idx + return -1 - # Elimination fallback. + +## Elimination: only one player has cities or a living founder. Always +## eligible regardless of grace — see check_all() docstring. +func _check_elimination_winner() -> int: var alive_players: Array[int] = [] for player: Variant in GameState.players: if not player is PlayerScript: @@ -111,6 +132,15 @@ func _check_domination() -> int: return -1 +## Legacy combined check kept for any external callers that referenced the +## previous API. Delegates to the split functions; respects no grace. +func _check_domination() -> int: + var capture_winner: int = _check_capture_winner() + if capture_winner >= 0: + return capture_winner + return _check_elimination_winner() + + func _has_living_founder(player: RefCounted) -> bool: for unit: Variant in player.units: if unit is UnitScript and unit.is_alive() and unit.can_found_city: diff --git a/tools/audio-licenses-render.py b/tools/audio-licenses-render.py new file mode 100755 index 00000000..db3ccca6 --- /dev/null +++ b/tools/audio-licenses-render.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Render the audio LICENSES.md file from sources.csv. + +`sources.csv` is the single source of truth for audio asset provenance: +where each `.ogg` came from, under what licence, with what edits applied. +This script reads it and writes a clean `LICENSES.md` that no human ever +hand-edits — the renderer is the gate. + +Two modes: + + * No flag → rewrite `LICENSES.md` from `sources.csv`. Use after adding + or editing rows in the CSV. + * `--check` → render to a temp buffer and diff against the committed + `LICENSES.md`. Non-zero diff or any policy violation fails. Used by + `./run validate` and CI. + +License policy enforced (per p2-16 acceptance): + + * Any `license` value containing `-SA` (ShareAlike) or `-NC` + (NonCommercial) is rejected outright. SA would force engine source- + sharing terms onto bundled audio; NC blocks commercial release. + * `license` values must be one of a small allowlist — see + `ALLOWED_LICENSE_PATTERNS` below. Rejecting unknown strings prevents + typos like `CC-BY-3.0-SA-NC` from sneaking through. + * Every CC-BY row MUST have a non-empty `attribution` field — that's + the credit line the licence requires. + * Every row MUST point at a real path under + `public/games//assets/audio/`. + +Usage: + python3 tools/audio-licenses-render.py [--check] [--theme age-of-dwarves] +""" +from __future__ import annotations + +import argparse +import csv +import io +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +DEFAULT_THEME = "age-of-dwarves" + +# Licenses we accept. The `-SA` and `-NC` modifiers are explicitly +# blocked downstream regardless of which family they appear in. +ALLOWED_LICENSE_PATTERNS = [ + re.compile(r"^CC0(-1\.0)?$"), + re.compile(r"^CC-BY-3\.0$"), + re.compile(r"^CC-BY-4\.0$"), + re.compile(r"^Pixabay$"), + re.compile(r"^Sonniss-GDC-\d{4}$"), + re.compile(r"^Public-Domain$"), +] +BLOCKED_TOKENS = ("-SA", "-NC") + + +def reject_license(license_str: str) -> str | None: + """Return an error message if the licence is not acceptable, else None.""" + if not license_str: + return "license field is empty" + for token in BLOCKED_TOKENS: + if token in license_str: + return ( + f"license {license_str!r} contains forbidden modifier " + f"{token!r} — ShareAlike and NonCommercial are blocked" + ) + for pat in ALLOWED_LICENSE_PATTERNS: + if pat.match(license_str): + return None + return ( + f"license {license_str!r} is not on the allowlist " + f"(see ALLOWED_LICENSE_PATTERNS in tools/audio-licenses-render.py)" + ) + + +def requires_attribution(license_str: str) -> bool: + return license_str.startswith("CC-BY-") + + +def render(rows: list[dict], theme: str) -> str: + out: list[str] = [] + out.append(f"# Audio Asset Licenses — {theme}") + out.append("") + out.append( + "**Auto-generated from `sources.csv` by " + "`tools/audio-licenses-render.py`. Do not edit by hand — " + "edit the CSV and re-render.**" + ) + out.append("") + out.append( + f"Each row records one `.ogg` shipped under " + f"`public/games/{theme}/assets/audio/`. Licence policy: CC0 / " + f"CC-BY 3.0 / CC-BY 4.0 / Pixabay / Sonniss-GDC-YYYY / " + f"Public-Domain accepted. ShareAlike (`-SA`) and " + f"NonCommercial (`-NC`) are rejected by the renderer." + ) + out.append("") + out.append( + f"**Asset count:** {len(rows)} files. (Empty until p2-16 sourcing " + f"begins.)" + ) + out.append("") + out.append("## Assets") + out.append("") + if not rows: + out.append("*(none yet — drop files into the assets tree and add their") + out.append("rows to `sources.csv`, then re-run this script)*") + out.append("") + else: + out.append("| Path | License | Source | Attribution | Edits | Added |") + out.append("|------|---------|--------|-------------|-------|-------|") + for row in sorted(rows, key=lambda r: r["output_path"]): + attribution = row.get("attribution") or "—" + edits = row.get("edits") or "—" + out.append( + "| `{path}` | {lic} | [link]({src}) | {attr} | {edits} | {added} |" + .format( + path=row["output_path"], + lic=row["license"], + src=row["source_url"], + attr=attribution, + edits=edits, + added=row.get("added", ""), + ) + ) + out.append("") + out.append("## Encoding") + out.append("") + out.append("All audio normalised to:") + out.append("") + out.append("* Ogg Vorbis container, `.ogg` extension") + out.append("* 44.1 kHz sample rate") + out.append("* 128 kbps target bitrate") + out.append("* Stereo (SFX may be mono)") + out.append("* Peak ~−3 dBFS — per-event `volume_db` in `audio.json` " + "scales from there") + out.append("* Music tracks must be seamless loops (except `victory`)") + out.append("") + out.append("## How to add a new asset") + out.append("") + out.append("1. Source it from one of the approved providers (CC0 /") + out.append(" Pixabay / Sonniss / Freesound CC-BY / OpenGameArt CC-BY).") + out.append("2. Edit + normalise to the encoding spec above.") + out.append(f"3. Drop it under `public/games/{theme}/assets/audio/...`") + out.append("4. Append a row to `sources.csv`:") + out.append(" ```csv") + out.append(" audio/sfx/units/melee/attack_01.ogg,https://freesound.org/.../123,CC-BY-4.0,Author Name,trim+normalize,2026-04-26") + out.append(" ```") + out.append("5. Run `python3 tools/audio-licenses-render.py` to regenerate") + out.append(" this file, then `python3 tools/audio-validate.py` to") + out.append(" confirm the manifest still validates.") + return "\n".join(out) + "\n" + + +def load_rows(csv_path: Path) -> list[dict]: + if not csv_path.exists(): + return [] + with csv_path.open("r", encoding="utf-8") as f: + # Strip leading `#` comment lines; csv.DictReader doesn't skip them. + cleaned = io.StringIO( + "".join(line for line in f if not line.lstrip().startswith("#")) + ) + reader = csv.DictReader(cleaned) + rows = [r for r in reader if r.get("output_path")] + return rows + + +def validate_rows(rows: list[dict], theme: str) -> list[str]: + errors: list[str] = [] + asset_root = REPO / "public" / "games" / theme / "assets" + seen_paths: set[str] = set() + required = {"output_path", "source_url", "license", "attribution", + "edits", "added"} + for i, row in enumerate(rows, start=1): + missing = required - set(row.keys()) + if missing: + errors.append( + f"row {i}: missing column(s) {sorted(missing)}" + ) + continue + path = row["output_path"] + if path in seen_paths: + errors.append(f"row {i}: duplicate output_path {path!r}") + seen_paths.add(path) + license_str = row["license"] + problem = reject_license(license_str) + if problem: + errors.append(f"row {i} ({path}): {problem}") + if requires_attribution(license_str) and not row["attribution"]: + errors.append( + f"row {i} ({path}): {license_str} requires a non-empty " + f"attribution" + ) + if not row["source_url"].startswith(("http://", "https://")): + errors.append( + f"row {i} ({path}): source_url must be an http(s) URL" + ) + full = asset_root / path + if not full.exists(): + # Not fatal — file may still be incoming. Validator surfaces it + # as a warning via tools/audio-validate.py. + pass + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", action="store_true", + help="Diff rendered output against the committed LICENSES.md " + "and exit non-zero on any drift or policy violation.", + ) + parser.add_argument( + "--theme", default=DEFAULT_THEME, + help=f"Theme id under public/games/. Default: {DEFAULT_THEME}", + ) + args = parser.parse_args() + + asset_dir = REPO / "public" / "games" / args.theme / "assets" / "audio" + sources_csv = asset_dir / "sources.csv" + licenses_md = asset_dir / "LICENSES.md" + + if not sources_csv.exists(): + print(f"[{args.theme}] sources.csv not found at {sources_csv}", + file=sys.stderr) + return 1 + + rows = load_rows(sources_csv) + errors = validate_rows(rows, args.theme) + if errors: + print(f"[{args.theme}] sources.csv has {len(errors)} error(s):", + file=sys.stderr) + for e in errors: + print(f" ✗ {e}", file=sys.stderr) + return 1 + + rendered = render(rows, args.theme) + + if args.check: + committed = licenses_md.read_text(encoding="utf-8") if licenses_md.exists() else "" + if committed != rendered: + print( + f"[{args.theme}] LICENSES.md is out of date relative to " + f"sources.csv. Run `python3 tools/audio-licenses-render.py` " + f"and commit the result.", + file=sys.stderr, + ) + return 1 + print(f"[{args.theme}] LICENSES.md is in sync ({len(rows)} rows). OK") + return 0 + + licenses_md.write_text(rendered, encoding="utf-8") + print(f"[{args.theme}] wrote {licenses_md} ({len(rows)} rows)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/objectives-report.py b/tools/objectives-report.py index 0494d72d..941d51c1 100644 --- a/tools/objectives-report.py +++ b/tools/objectives-report.py @@ -35,7 +35,7 @@ JSON_OUT = REPO / "public" / "games" / "age-of-dwarves" / "data" / "objectives.j VALID_STATUS = {"done", "in_progress", "partial", "stub", "missing", "oos", "superseded"} VALID_PRIORITY = {"p0", "p1", "p2", "p3"} -VALID_SCOPE = {"game1", "game2", "game3", "game4", "game5"} +VALID_SCOPE = {"game1", "game1-stretch", "game2", "game3", "game4", "game5", "infra", "post-v10"} # Statuses that appear in the totals tables, Left-To-Do-by-Lead, priority # groups, and in the structured JSON export consumed by the guide's