feat(@projects/@magic-civilization): update hex terrain visuals and edge labels

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 20:07:37 -07:00
parent f02d3efecb
commit ee5b16c834
6 changed files with 362 additions and 216 deletions

View file

@ -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<Mode>("all");
const [focus, setFocus] = useState<string | null>(null);
const [focusBlend, setFocusBlend] = useState<BlendKey | null>(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 (
<Page>
<PageTitle>Hex Centre + 6 Edge Slots</PageTitle>
<PageSub>central area + ring of shared edges · the differentiator from other hex grids</PageSub>
<Identity>1 centre + 6 shared edges &nbsp;//&nbsp; each edge co-owned with one neighbour</Identity>
<PageSub>
the differentiator from other hex grids · each edge is an ecotone derived from
the blend of two adjacent tiles
</PageSub>
<Identity>
1 centre + 6 shared edges · edge terrain = blend(centre, neighbour)
</Identity>
<Layout>
<Stage>
<Controls>
<Pill $kind="all" $active={mode === "all" && focus === null} onClick={() => clickPill("all")}>All slots</Pill>
<Pill $kind="centre" $active={mode === "centre" && focus === null} onClick={() => clickPill("centre")}>Centre only</Pill>
<Pill $kind="edge" $active={mode === "edge" && focus === null} onClick={() => clickPill("edge")}>Edges only</Pill>
</Controls>
<Caption>
Example: a <strong style={{ color: T_COLOR.plains }}>plains</strong> centre
tile with neighbours <em>1 mountain · 2 water · 2 plains · 1 forest</em>.
Each edge carries a derived terrain foothills, shore, plains, or
grass-fringe based on which two tiles meet at that edge.
</Caption>
<svg
width="100%"
viewBox="0 0 600 360"
viewBox="0 0 480 340"
role="img"
aria-label="Annotated hex showing the central area and 6 edge slots"
aria-label="Plains hex with neighbours of varying terrains showing distinct edge blend zones"
style={{ display: "block" }}
>
{/* Outer hexagon */}
{/* Neighbour tile swatches (just outside the hex) */}
{EDGES.map((e) => (
<g key={`nbr-${e.dir}`}>
<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}
/>
<text
x={e.swatchX + 30}
y={e.swatchY + 22}
fontSize={10}
fontWeight={700}
fill="#1a1510"
textAnchor="middle"
>
{e.neighbour}
</text>
</g>
))}
{/* Outer hexagon (the central tile, plains) */}
<polygon
points="180,56 300,56 360,160 300,264 180,264 120,160"
fill={C.outerFill}
fill={T_COLOR[CENTRE_TERRAIN]}
stroke={C.outerStroke}
strokeWidth={2}
opacity={0.85}
/>
{/* Inner hexagon (central area) */}
{/* Inner hexagon (central area for the centre slot) */}
<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)}
/>
{/* 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 (
<g key={s.role} style={{ cursor: "pointer" }} onClick={() => clickSlot(s.role)}>
<g key={`edge-${e.dir}`} style={{ cursor: "pointer" }} onClick={() => clickBlend(key)}>
<circle
cx={s.cx}
cy={s.cy}
r={11}
fill={C.edge}
cx={e.cx}
cy={e.cy}
r={12}
fill={BLEND_COLOR[key]}
stroke={C.hexBorder}
strokeWidth={2}
opacity={dimmed ? 0.18 : 1}
opacity={dim ? 0.18 : 1}
/>
<text
x={s.label.x}
y={s.label.y}
fontSize={11}
x={e.cx}
y={e.cy + 4}
fontSize={9}
fontWeight={700}
fill={C.edge}
textAnchor={s.label.anchor}
opacity={dimmed ? 0.18 : 1}
fill="#1a1510"
textAnchor="middle"
opacity={dim ? 0.18 : 1}
>
{s.role}
{e.dir}
</text>
</g>
);
})}
{/* Centre slot (the leader rectangle) */}
<g style={{ cursor: "pointer" }} onClick={() => clickSlot("centre")}>
<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)}
/>
<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)}
>
leader
</text>
</g>
{/* Compass on the right */}
<g transform="translate(490,160)">
<circle cx={0} cy={0} r={60} fill="none" stroke="#73591f" strokeWidth={1} />
<text x={0} y={-50} fontSize={10} fill={t.text.secondary} textAnchor="middle">N</text>
<text x={55} y={-25} fontSize={10} fill={t.text.secondary} textAnchor="start">NE</text>
<text x={55} y={30} fontSize={10} fill={t.text.secondary} textAnchor="start">SE</text>
<text x={0} y={55} fontSize={10} fill={t.text.secondary} textAnchor="middle">S</text>
<text x={-55} y={30} fontSize={10} fill={t.text.secondary} textAnchor="end">SW</text>
<text x={-55} y={-25} fontSize={10} fill={t.text.secondary} textAnchor="end">NW</text>
<circle cx={0} cy={0} r={3} fill={C.centre} />
<text x={0} y={-72} fontSize={10} fill={t.text.secondary} textAnchor="middle">edges = directions</text>
</g>
{/* Centre slot (the leader) */}
<rect x="222" y="148" width="36" height="24" rx="4" fill={C.innerFill} stroke={C.centre} strokeWidth={2} />
<text x="240" y="165" fontSize={11} fontWeight={700} fill={C.centre} textAnchor="middle">leader</text>
</svg>
</Stage>
<Side>
<SideHeading>Slot inventory</SideHeading>
<SideHeading>Per-edge blend</SideHeading>
<DirTable>
<thead>
<tr><th>idx</th><th>dir</th><th>slot</th><th>owned</th></tr>
<tr><th>dir</th><th>neighbour</th><th>blend terrain</th></tr>
</thead>
<tbody>
{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 (
<Row key={d.role} $dim={dim}>
<td>{d.idx}</td>
<td>{d.dir}</td>
<Role $type={d.type}>{d.role}</Role>
<td>{d.owned}</td>
<Row key={e.dir} $dim={dim}>
<td>{e.dir}</td>
<td>{e.neighbour}</td>
<td>
<BlendDot $color={BLEND_COLOR[key]} />
{BLEND_LABEL[key]}
</td>
</Row>
);
})}
</tbody>
</DirTable>
<SideHeading style={{ marginTop: 18 }}>Blends in this scenario</SideHeading>
<Legend>
<div><Swatch $color={C.centre} /> Centre slot (host hex exclusive)</div>
<div><Swatch $color={C.edge} /> Edge slot (shared with one neighbour)</div>
<div style={{ marginTop: 10 }}>
Click a slot to focus it. Use the toolbar to highlight only centre or only edges.
{presentBlends.map((key) => {
const isActive = focusBlend === key;
return (
<div
key={key}
onClick={() => clickBlend(key)}
style={{
cursor: "pointer",
padding: "3px 0",
color: isActive ? t.text.primary : undefined,
fontWeight: isActive ? 700 : undefined,
}}
>
<BlendDot $color={BLEND_COLOR[key]} />
{BLEND_LABEL[key]}{" "}
<span style={{ color: t.text.muted }}>
({key.replace("+", " ↔ ")})
</span>
</div>
);
})}
<div style={{ marginTop: 10, color: t.text.muted }}>
Click a blend (here or in the table) to highlight only those edges.
Same-terrain pairs (plainsplains here) carry no transition; the edge
keeps the centre terrain.
</div>
</Legend>
</Side>
</Layout>
<Footer>
Source: <a href="../../public/games/age-of-dwarves/docs/HEX_GEOMETRY.md">HEX_GEOMETRY.md</a>
Source: <a href="../../public/games/age-of-dwarves/docs/HEX_GEOMETRY.md">HEX_GEOMETRY.md §8</a>
&nbsp;·&nbsp;
Companion: <a href="../hex-formation-duality.md">hex-formation-duality.md</a>
&nbsp;·&nbsp;
Data target: <code>data/terrain/terrain_blends.json</code>
</Footer>
</Page>
);

View file

@ -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.

View file

@ -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/<id>.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/<id>.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.

View file

@ -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).

View file

@ -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: <id>` 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.

View file

@ -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,