feat(@projects/@magic-civilization): ✨ update hex terrain visuals and edge labels
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
f02d3efecb
commit
ee5b16c834
6 changed files with 362 additions and 216 deletions
|
|
@ -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 // 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 (plains↔plains 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>
|
||||
·
|
||||
Companion: <a href="../hex-formation-duality.md">hex-formation-duality.md</a>
|
||||
·
|
||||
Data target: <code>data/terrain/terrain_blends.json</code>
|
||||
</Footer>
|
||||
</Page>
|
||||
);
|
||||
|
|
|
|||
46
.project/objectives/p0-45-production-id-drift.md
Normal file
46
.project/objectives/p0-45-production-id-drift.md
Normal 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.
|
||||
40
.project/objectives/p1-31-tech-promised-buildings.md
Normal file
40
.project/objectives/p1-31-tech-promised-buildings.md
Normal 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.
|
||||
44
.project/objectives/p1-32-food-chain-buildings.md
Normal file
44
.project/objectives/p1-32-food-chain-buildings.md
Normal 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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue