diff --git a/.project/designs/app/src/pages/HexFormation.tsx b/.project/designs/app/src/pages/HexFormation.tsx index e914cfbe..f569ac1c 100644 --- a/.project/designs/app/src/pages/HexFormation.tsx +++ b/.project/designs/app/src/pages/HexFormation.tsx @@ -2,66 +2,72 @@ import { useState } from "react"; import styled from "styled-components"; import { t } from "../theme"; -// Slot palette — positional, not theme-semantic -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)", - hexBorder: "#1a1510", +// ── Terrain palette ─────────────────────────────────────────────────────── +// Centre-tile terrains +const T_COLOR = { + plains: "#d4b97a", + forest: "#3a6e3a", + mountain: "#8a8a8a", + water: "#4a8ec9", +} as const; +type Terrain = keyof typeof T_COLOR; + +// 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 } as const; -type Mode = "all" | "centre" | "edge"; +const BLEND_LABEL = { + "plains+mountain": "foothills", + "plains+water": "shore", + "plains+forest": "grass-fringe", + "plains+plains": "plains", +} as const; -interface SlotInfo { - role: string; - type: "centre" | "edge"; - dir: string | null; // hex direction name (null for centre) - cx: number; - cy: number; - label: { x: number; y: number; anchor: "start" | "middle" | "end" }; +type BlendKey = keyof typeof BLEND_COLOR; + +const C = { + outerStroke: "#73591f", + innerFill: "#3a3022", + centre: t.text.title, + hexBorder: "#1a1510", +} as const; + +// ── Layout: hex centred at (240, 160), R=120 ────────────────────────────── +// Edge midpoints + outer "neighbour tile" anchor positions (where the +// neighbour terrain swatch sits, just outside the hex on each direction). +interface EdgeSpec { + dir: string; // direction label + cx: number; cy: number; // edge dot position (outer-edge midpoint) + neighbour: Terrain; // example neighbour terrain + swatchX: number; swatchY: number; // neighbour swatch anchor + labelX: number; labelY: number; labelAnchor: "start" | "middle" | "end"; } -// 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: readonly SlotInfo[] = [ - { 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: Terrain = "plains"; + +// User's example: plains centre with [N=mountain, NE=water, SE=water, S=plains, SW=plains, NW=forest] +const EDGES: readonly EdgeSpec[] = [ + { 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" }, ] as const; -// Direction → edge slot lookup -interface DirRow { - idx: number | "—"; - dir: string; - role: string; - type: "centre" | "edge"; - owned: string; +function blendKey(centre: Terrain, neighbour: Terrain): BlendKey { + // Canonical-sorted pair so plains+mountain == mountain+plains + const [a, b] = [centre, neighbour].sort(); + const key = `${a}+${b}` as BlendKey; + return key in BLEND_COLOR ? key : ("plains+plains" as BlendKey); } -const DIRECTIONS: readonly DirRow[] = [ - { 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" }, -] as const; -// ── Layout ──────────────────────────────────────────────────────────────── +// ── Styled components ───────────────────────────────────────────────────── const Page = styled.div` max-width: 920px; margin: 0 auto; @@ -85,23 +91,18 @@ 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; letter-spacing: 0.04em; `; - 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}; border: 1px solid ${t.border.panel}; @@ -109,14 +110,12 @@ const Stage = styled.div` border-radius: ${t.radius.panel}; min-height: 460px; `; - const Side = styled.div` background: ${t.bg.panel}; border: 1px solid ${t.border.panel}; padding: 18px; border-radius: ${t.radius.panel}; `; - const SideHeading = styled.h3` font-family: ${t.font.heading}; font-size: 16px; @@ -124,44 +123,12 @@ 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 Caption = styled.div` + font-size: 11px; + color: ${t.text.secondary}; + margin: 0 0 12px; + line-height: 1.5; `; - -const Pill = styled.button<{ $active: boolean; $kind: Mode }>` - 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 DirTable = styled.table` width: 100%; border-collapse: collapse; @@ -179,27 +146,23 @@ const DirTable = styled.table` border-bottom: 1px solid ${t.border.panel}; } `; -const Role = styled.td<{ $type: "centre" | "edge" }>` - color: ${({ $type }) => ($type === "centre" ? C.centre : C.edge)}; -`; const Row = styled.tr<{ $dim: boolean }>` opacity: ${({ $dim }) => ($dim ? 0.3 : 1)}; transition: opacity 150ms ease; `; +const BlendDot = styled.span<{ $color: string }>` + 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<{ $color: string }>` - 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; @@ -210,170 +173,185 @@ const Footer = styled.div` a { color: ${t.accent.gold}; text-decoration: none; } `; -function visibleFor(mode: Mode, type: "centre" | "edge"): boolean { - if (mode === "all") return true; - return mode === type; -} - +// ── Page ────────────────────────────────────────────────────────────────── export function HexFormationPage(): React.ReactElement { - const [mode, setMode] = useState("all"); - const [focus, setFocus] = useState(null); + const [focusBlend, setFocusBlend] = useState(null); - const isFocused = (role: string): boolean => focus === role; + function clickBlend(key: BlendKey): void { + setFocusBlend(focusBlend === key ? null : key); + } - function clickPill(m: Mode): void { - setMode(m); - setFocus(null); - } - function clickSlot(role: string): void { - setFocus(focus === role ? null : role); - } + // Distinct blend keys present in this scenario + const presentBlends = Array.from(new Set(EDGES.map(e => blendKey(CENTRE_TERRAIN, e.neighbour)))) as BlendKey[]; return ( Hex Centre + 6 Edge Slots - central area + ring of shared edges · the differentiator from other hex grids - 1 centre + 6 shared edges  //  each edge co-owned with one neighbour + + the differentiator from other hex grids · each edge is an ecotone derived from + the blend of two adjacent tiles + + + 1 centre + 6 shared edges · edge terrain = blend(centre, neighbour) + - - clickPill("all")}>All slots - clickPill("centre")}>Centre only - clickPill("edge")}>Edges only - + + Example: a plains centre + tile with neighbours 1 mountain · 2 water · 2 plains · 1 forest. + Each edge carries a derived terrain — foothills, shore, plains, or + grass-fringe — based on which two tiles meet at that edge. + - {/* Outer hexagon */} + {/* Neighbour tile swatches (just outside the hex) */} + {EDGES.map((e) => ( + + + + {e.neighbour} + + + ))} + + {/* Outer hexagon (the central tile, plains) */} - {/* Inner hexagon (central area) */} + {/* Inner hexagon (central area for the centre slot) */} - {/* Edge slot dots */} - {SLOTS.map((s) => { - const visible = focus !== null ? isFocused(s.role) : visibleFor(mode, s.type); - const dimmed = !visible; + {/* Edge dots — colour = blend of centre + neighbour */} + {EDGES.map((e) => { + const key = blendKey(CENTRE_TERRAIN, e.neighbour); + const dim = focusBlend !== null && focusBlend !== key; return ( - clickSlot(s.role)}> + clickBlend(key)}> - {s.role} + {e.dir} ); })} - {/* Centre slot (the leader rectangle) */} - clickSlot("centre")}> - - - leader - - - - {/* Compass on the right */} - - - N - NE - SE - S - SW - NW - - edges = directions - + {/* Centre slot (the leader) */} + + leader - Slot inventory + Per-edge blend - idxdirslotowned + dirneighbourblend → terrain - {DIRECTIONS.map((d) => { - const dim = focus !== null - ? !(focus === d.role || (focus === "centre" && d.type === "centre")) - : !visibleFor(mode, d.type); + {EDGES.map((e) => { + const key = blendKey(CENTRE_TERRAIN, e.neighbour); + const dim = focusBlend !== null && focusBlend !== key; return ( - - {d.idx} - {d.dir} - {d.role} - {d.owned} + + {e.dir} + {e.neighbour} + + + {BLEND_LABEL[key]} + ); })} + Blends in this scenario -
Centre slot (host hex exclusive)
-
Edge slot (shared with one neighbour)
-
- Click a slot to focus it. Use the toolbar to highlight only centre or only edges. + {presentBlends.map((key) => { + const isActive = focusBlend === key; + return ( +
clickBlend(key)} + style={{ + cursor: "pointer", + padding: "3px 0", + color: isActive ? t.text.primary : undefined, + fontWeight: isActive ? 700 : undefined, + }} + > + + {BLEND_LABEL[key]}{" "} + + ({key.replace("+", " ↔ ")}) + +
+ ); + })} +
+ Click a blend (here or in the table) to highlight only those edges. + Same-terrain pairs (plains↔plains here) carry no transition; the edge + keeps the centre terrain.
); diff --git a/.project/objectives/p0-45-production-id-drift.md b/.project/objectives/p0-45-production-id-drift.md new file mode 100644 index 00000000..b2390be5 --- /dev/null +++ b/.project/objectives/p0-45-production-id-drift.md @@ -0,0 +1,46 @@ +--- +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 +updated_at: 2026-04-26 +evidence: + - src/simulator/crates/mc-ai/src/tactical/production.rs + - src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd + - public/games/age-of-dwarves/data/units/manifest.json + - public/games/age-of-dwarves/data/buildings/ +--- + +## 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/`: + +| `ids::*` | Emitted as `SetProduction { item_id }` | Reality | Live impact | +|---|---|---|---| +| `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) | +| `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** | +| `CASTLE = "castle"` | priority-6 walls upgrade | no `castle.json` in data | dispatch drops it, ladder stalls | +| `GRANARY = "granary"` | bottom-of-ladder fallback | no `granary.json` in data | dispatch drops it, last-resort never works | +| `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 | + +`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. + +The right shape: +- `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. +- `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. +- `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`). + +## Acceptance + +- ✗ `TacticalUnitSpec` carries `can_found_city: bool`, populated from `unit.can_found_city` in `ai_turn_bridge_state.gd::build_unit_catalog()`. New `pick_founder(catalog) -> Option<&str>` returns the highest-tier catalog entry with the flag set. +- ✗ `pick_for_city` Priority 3 (expansion) calls `pick_founder` and only emits `SetProduction` when it returns `Some(_)`. Drops the priority entirely when no founder is buildable instead of emitting `ids::FOUNDER`. +- ✗ `ids::CASTLE` removed; the priority-6 walls upgrade branch becomes a no-op until p2-34 ships a real castle building, at which point the branch reads from a catalog or constant tied to that real ID. +- ✗ `ids::GRANARY` removed; the priority-8 mundane-econ fallback removed. Bottom of the ladder now degrades to the next existing priority (marketplace check, then a real always-buildable building). +- ✗ `ids::WORKER` removed. Final fallback is a real always-available building (proposed: `monument`). Add a Rust test that for every clan + every city state combination the priority ladder returns at most one of: a real catalog unit ID, or a real building ID present in the data pack. +- ✗ Add `tests/data_pack_id_alignment.rs` (or extend an existing test) that loads the bundled `data/buildings/*.json` + `data/units/manifest.json` at test time and asserts every `ids::*` constant resolves to a real entry. Catches future drift. +- ✗ Regression batch: `tools/autoplay-batch.sh 10 300` post-fix shows median `cities_per_player ≥ 2` for the expansionist clan (currently locked to 1 by this bug). + +## Notes + +Partial because the upstream `is_founder` consolidation (single `TacticalUnit::is_founder()` method, three duplicates collapsed into one) and the JSON `settler` → `dwarf_founder` rename were already landed in this session — but the ID-emission half of the bug is untouched. The acceptance bullets above cover only the remaining work. diff --git a/.project/objectives/p1-31-tech-promised-buildings.md b/.project/objectives/p1-31-tech-promised-buildings.md new file mode 100644 index 00000000..6def3942 --- /dev/null +++ b/.project/objectives/p1-31-tech-promised-buildings.md @@ -0,0 +1,40 @@ +--- +id: p1-31 +title: Author the 9 buildings the tech tree unlocks but data does not provide +priority: p1 +status: missing +scope: game1 +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. + +| Building | Unlocking tech | Inferred role | +|---|---|---| +| `fishery` | `fishing` | Coastal food generator | +| `hunting_lodge` | `trapping` | Forest food + scout XP | +| `nature_reserve` | `ecology_study` | Wilderness yield / happiness | +| `hardening_pit` | `steelworking` | Smithing tier-up (post-`forge`) | +| `mithril_forge` | `mithril_smithing` | Late-game forge upgrade | +| `runesmith_hall` | `runelore` | Runic crafting (mundane in Game 1) | +| `siege_works` | `siege_doctrine` | Distinct from existing `siege_workshop` (different tech, advanced tier) | +| `war_college` | `combined_arms` | Mid-game elite-unit production hub | +| `ranger_post` | `tracking` | Scout/courier support; pairs with existing `messenger_hut` (also unlocked by `tracking`) | + +## Acceptance + +- ✗ Nine new files under `public/games/age-of-dwarves/data/buildings/.json`, each a single-element array per `BUILDING_SCHEMA.md`, populated with: `id`, `name`, `description`, `placement: "city"`, `category` (matching the building's role), `school: null`, `cost`, `upkeep`, `tech_required` (matching the tech that unlocks them), `race_required: null`, `wonder_type: null`, `mana_generated: null`, `tier`, `effects[]`, `sprite`, `encyclopedia{}`. +- ✗ `category` matches the existing taxonomy: `production`, `research`, `culture`, `infrastructure`, `military`, `religion`. No new categories. +- ✗ `tech_required` matches exactly the tech that already unlocks the ID — verified by re-running the audit script that produced this gap list (cross-reference `data/techs/**/unlocks.buildings` against `data/buildings/`; the diff must be empty). +- ✗ Each building's `effects[]` is non-empty and uses only effect types that already appear elsewhere in the building corpus (no new effect-type strings without a Rust handler). +- ✗ `python3 tools/validate-game-data.py` passes 0 failures. +- ✗ Sprite paths follow the existing convention `sprites/buildings/.png`; placeholder sprite is acceptable, but the path must point at a real file once p2-25 sprite generation runs (or a stub PNG in the meantime). +- ✗ Encyclopedia entry visible in-game: load a save, open Encyclopedia → Buildings, verify each new building appears under its `category`. + +## Out of scope + +- Tier balancing across the new buildings (`p1-05 balance-tuning` covers downstream tuning). +- Sprite art (`p2-25 building-sprites-base-coverage` covers the asset pipeline). +- The `war_college`'s implied "elite unit hub" effect — author the building with a basic `production` or `free_xp` effect; the merged-structure system (`p3-02`) layers richer behaviour later. diff --git a/.project/objectives/p1-32-food-chain-buildings.md b/.project/objectives/p1-32-food-chain-buildings.md new file mode 100644 index 00000000..038a5450 --- /dev/null +++ b/.project/objectives/p1-32-food-chain-buildings.md @@ -0,0 +1,44 @@ +--- +id: p1-32 +title: Author the food + resource processing chain (granary, mill, brewery, tannery, sawmill, herbalist) +priority: p1 +status: missing +scope: game1 +updated_at: 2026-04-26 +--- + +## Summary + +`public/games/age-of-dwarves/docs/cities/PRODUCTION_CHAIN.md` describes a stockpile-based processing economy: + +``` +Farm → Mill → flour → food surplus + → Brewery → ale (happiness + trade) +Pasture → Tannery → leather → Barracks (unit armor quality) +Forest → Sawmill → lumber → construction speed bonus + → Siege Workshop (siege engines) +Forest → Herbalist → reagents → Academy (research boost) +``` + +`docs/cities/BUILDINGS.md` adds `granary` (Husbandry tech, food storage / starvation reduction). + +None 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). + +This 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. + +## Acceptance + +- ✗ Six new files under `data/buildings/`: `granary.json`, `mill.json`, `brewery.json`, `tannery.json`, `sawmill.json`, `herbalist.json`. Each follows `BUILDING_SCHEMA.md` (single-element array, all required fields, `school: null`, `mana_generated: null`). +- ✗ `granary` is tier-1, no `tech_required`, `category: "production"` (or new `food` if added to taxonomy), basic effect `food: +2` plus `food_storage: +50` or equivalent stockpile primer. Available in every starting city. +- ✗ `mill`, `brewery`, `tannery`, `sawmill`, `herbalist` each carry a `tech_required` matching `BUILDINGS.md` (Husbandry, Brewing, Tanning, Logging, Herbalism). If those tech IDs do not yet exist in `data/techs/`, either author the techs or pick the closest existing tech and document the mapping in this objective's prose. +- ✗ Each building's effects use existing effect-type strings; no new types added without a Rust handler. If the design calls for a new effect (e.g. `stockpile_lumber`), file a follow-up objective rather than landing it half-wired. +- ✗ `mc-ai/tactical/production.rs::ids::GRANARY` switches from a broken-by-default constant to a real lookup against the bundled data pack (or the constant stays but p0-45's data-alignment test passes — granary now exists, so the test goes green). +- ✗ `python3 tools/validate-game-data.py` passes 0 failures. +- ✗ Encyclopedia → Buildings shows the six new entries under their categories with non-empty descriptions and flavour text. +- ✗ A 10-seed `tools/autoplay-batch.sh 10 300` batch post-merge shows the AI builds at least one `granary` per surviving city by T100 (signal that the priority-8 fallback now resolves). + +## Out of scope + +- The full stockpile-quality system (master tanners → +armor on barracks units, etc.). That is a separate gameplay objective. +- Tech-tree edits beyond authoring missing tech IDs that these buildings reference. +- Worker-assignment UX for the "Construction / Buildings / Tiles" three-way per `PRODUCTION_CHAIN.md` (separate UX objective). diff --git a/.project/objectives/p1-33-naval-aerial-production-buildings.md b/.project/objectives/p1-33-naval-aerial-production-buildings.md new file mode 100644 index 00000000..af715079 --- /dev/null +++ b/.project/objectives/p1-33-naval-aerial-production-buildings.md @@ -0,0 +1,42 @@ +--- +id: p1-33 +title: Author production buildings for naval and aerial unit families (shipwright, airfield) +priority: p1 +status: missing +scope: game1 +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. + +This is a gameplay AND a content gap: +1. **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. +2. **Content**: `BUILDINGS.md` calls for a `shipwright` (Cartography + Navigation tech). No equivalent for aerial is in the design doc — needs a design pass. + +This objective authors: +- `shipwright` — naval production building, requires coastal city, gates buildable naval units. +- `airfield` (or `hangar`, name TBD) — aerial production building, gates buildable aerial units. Design: probably a flat building, plains/grassland placement, no special adjacency. + +The "buildable only when this building exists" gate is the new capability — the existing `tech_required` field on units is necessary but not sufficient. Either: +- Add `building_required: ` to unit schema and have the production picker honour it, or +- Use the existing `placement_tile_required` + adjacency machinery to require shipwright on a coastal tile. + +Pick one and document the choice in this objective's evidence list once selected. + +## Acceptance + +- ✗ `data/buildings/shipwright.json` exists per `BUILDING_SCHEMA.md`. `placement_tile_required: true`, `placeable_on: ["coast", "ocean", "lake"]` (or whatever the coastal terrain IDs are in `data/terrain/`), `tech_required: "shipbuilding"` (or `"cartography"` per `BUILDINGS.md` — pick one and stick to it). +- ✗ `data/buildings/airfield.json` (or chosen name) exists. Land-placement, `tech_required: "mechanical_flight"` (matches the earliest aerial unit tech). +- ✗ Schema extension OR data-driven gate: every `dwarf_*` naval unit's JSON carries either `requires_building: "shipwright"` (new field, with a Rust handler) or is filtered by the production picker via terrain-adjacency. Same for aerial → `airfield`. Decision documented inline. +- ✗ The Rust production picker (`mc-ai/tactical/production.rs::pick_best_melee` or a new `pick_best_naval`/`pick_best_aerial`) honours the new gate. A landlocked city without a `shipwright` does not list naval units in its buildable set. +- ✗ The 14 existing naval units + 7 aerial units' JSON files updated in lockstep; `python3 tools/validate-game-data.py` passes. +- ✗ Encyclopedia entries for both buildings include the gating rule in their description ("Required to build naval units. Must be placed on a coastal tile."). +- ✗ A 10-seed `tools/autoplay-batch.sh 10 300` batch shows zero naval-unit builds in landlocked players' cities (today: non-zero) and at least one naval-unit build in coastal-spawn players that researched `shipbuilding` (today: depends on AI scoring). + +## Out of scope + +- Naval combat tuning (handled by `combat-dev` agent). +- Pathfinding for naval movement on water tiles (separate engine work). +- Whether airfields should be tied to a specific terrain or industrial-district concept — pick the simpler option for v1. diff --git a/.project/objectives/p2-16-audio-assets.md b/.project/objectives/p2-16-audio-assets.md index aa6150da..e9d302cd 100644 --- a/.project/objectives/p2-16-audio-assets.md +++ b/.project/objectives/p2-16-audio-assets.md @@ -2,16 +2,12 @@ id: p2-16 title: Audio assets — in-theme OSS launch pack + source ledger priority: p1 -status: missing +status: in_progress scope: game1 owner: asset-audio -blockedBy: [p2-33] -updated_at: 2026-04-26 -evidence: - - public/games/age-of-dwarves/assets/audio/LICENSES.md - - public/games/age-of-dwarves/data/audio.json +updated_at: 2026-04-27 +evidence: [public/games/age-of-dwarves/assets/audio/LICENSES.md, public/games/age-of-dwarves/data/audio.json] --- - ## Summary The audio capability shipped as **p0-21** — `AudioManager`, manifest,