From a8b45cc08331ea308623ddc3df341019193cee5d Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 3 May 2026 13:36:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20tree-based=20tech=20and=20culture=20navigatio?= =?UTF-8?q?n=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/designs/app/src/App.tsx | 7 +- .../app/src/components/tree/TreeView.tsx | 504 ++++++++++++++++ .../designs/app/src/pages/CultureTree.tsx | 90 +++ .project/designs/app/src/pages/Index.tsx | 3 +- .project/designs/app/src/pages/TechTree.tsx | 537 ++---------------- .project/designs/app/tsconfig.tsbuildinfo | 2 +- .../p1-36-ai-personalities-t1-t10-coverage.md | 21 + 7 files changed, 675 insertions(+), 489 deletions(-) create mode 100644 .project/designs/app/src/components/tree/TreeView.tsx create mode 100644 .project/designs/app/src/pages/CultureTree.tsx diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx index 5e74264b..dcaeb4f2 100644 --- a/.project/designs/app/src/App.tsx +++ b/.project/designs/app/src/App.tsx @@ -1,5 +1,6 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { IndexPage } from "./pages/Index"; +import { CultureTreePage } from "./pages/CultureTree"; import { CombatPreviewPage } from "./pages/CombatPreview"; import { CombatCalculatorPage } from "./pages/CombatCalculator"; import { PermutationsPage } from "./pages/Permutations"; @@ -50,7 +51,9 @@ export function App(): React.ReactElement { } /> } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/.project/designs/app/src/components/tree/TreeView.tsx b/.project/designs/app/src/components/tree/TreeView.tsx new file mode 100644 index 00000000..f60414f9 --- /dev/null +++ b/.project/designs/app/src/components/tree/TreeView.tsx @@ -0,0 +1,504 @@ +import { useMemo, useState, type ReactElement } from "react"; +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import { t } from "../../theme"; +import { + ALL_CATEGORY_ID, + type CategoryDef, + type TreeEra, + type TreeItem, + type TreeUnlocks, +} from "./types"; + +// ── Layout constants ────────────────────────────────────────────────────────── + +const COL_WIDTH = 200; +const COL_GAP = 16; +const NODE_HEIGHT = 110; +const NODE_VGAP = 14; +const TOP_PAD = 24; + +interface PositionedItem { + item: TreeItem; + x: number; + y: number; + cx: number; + cy: number; + width: number; + height: number; +} + +interface LayoutResult { + positioned: PositionedItem[]; + canvasWidth: number; + canvasHeight: number; +} + +function layout( + items: readonly TreeItem[], + eras: readonly TreeEra[], + activeTab: string, +): LayoutResult { + const byEra = new Map(); + for (const it of items) { + const list = byEra.get(it.era) ?? []; + list.push(it); + byEra.set(it.era, list); + } + for (const list of byEra.values()) { + list.sort((a, b) => { + if (activeTab !== ALL_CATEGORY_ID) { + const aMatch = a.category === activeTab ? 0 : 1; + const bMatch = b.category === activeTab ? 0 : 1; + if (aMatch !== bMatch) return aMatch - bMatch; + } + return a.tier - b.tier || a.name.localeCompare(b.name); + }); + } + + const positioned: PositionedItem[] = []; + let maxRows = 0; + for (let era = 1; era <= eras.length; era++) { + const list = byEra.get(era) ?? []; + maxRows = Math.max(maxRows, list.length); + list.forEach((it, row) => { + const x = (era - 1) * (COL_WIDTH + COL_GAP); + const y = TOP_PAD + row * (NODE_HEIGHT + NODE_VGAP); + positioned.push({ + item: it, + x, y, + cx: x + COL_WIDTH / 2, + cy: y + NODE_HEIGHT / 2, + width: COL_WIDTH, + height: NODE_HEIGHT, + }); + }); + } + const canvasWidth = eras.length * (COL_WIDTH + COL_GAP); + const canvasHeight = TOP_PAD + maxRows * (NODE_HEIGHT + NODE_VGAP) + TOP_PAD; + return { positioned, canvasWidth, canvasHeight }; +} + +// ── Styled components ───────────────────────────────────────────────────────── + +const PageWrap = styled.div` + background: ${t.bg.menu}; + min-height: 100vh; + display: flex; + flex-direction: column; + color: ${t.text.primary}; + font-family: ${t.font.body}; +`; + +const Header = styled.div` + background: ${t.bg.panel}; + border-bottom: 1px solid ${t.border.panel}; + padding: 12px 24px; + display: flex; + align-items: center; + gap: 24px; + flex-shrink: 0; +`; + +const HeaderTitle = styled.div` + font-family: ${t.font.heading}; + font-size: 22px; + color: ${t.text.title}; + letter-spacing: 0.04em; +`; + +const BackLink = styled(Link)` + color: ${t.text.muted}; + text-decoration: none; + font-size: 13px; + font-family: ${t.font.mono}; + &:hover { color: ${t.accent.gold}; } +`; + +const SourceTag = styled.div` + margin-left: auto; + font-family: ${t.font.mono}; + font-size: 11px; + color: ${t.text.muted}; +`; + +const TabBar = styled.div` + background: rgba(23, 18, 30, 0.9); + border-bottom: 1px solid ${t.border.divider}; + display: flex; + flex-shrink: 0; + padding: 0 16px; + overflow-x: auto; +`; + +const TabBtn = styled.button<{ $active: boolean; $accent: string }>` + flex: 0 0 auto; + padding: 10px 18px; + font-size: 12px; + font-family: ${t.font.body}; + text-transform: uppercase; + letter-spacing: 0.08em; + background: transparent; + border: none; + border-bottom: 2px solid ${p => p.$active ? p.$accent : "transparent"}; + color: ${p => p.$active ? p.$accent : t.text.muted}; + cursor: pointer; + transition: color 150ms, border-color 150ms; + + &:hover { + color: ${p => p.$accent}; + } +`; + +const TabCount = styled.span` + margin-left: 6px; + font-size: 10px; + font-family: ${t.font.mono}; + opacity: 0.65; +`; + +const EraBar = styled.div` + display: flex; + flex-shrink: 0; + background: ${t.bg.panel}; + border-bottom: 1px solid ${t.border.divider}; +`; + +const EraCell = styled.div` + width: ${COL_WIDTH + COL_GAP}px; + flex-shrink: 0; + padding: 6px 8px; + border-right: 1px dashed ${t.border.divider}; + font-family: ${t.font.mono}; + font-size: 10px; + color: ${t.text.muted}; + text-transform: uppercase; + letter-spacing: 0.1em; + &:last-child { border-right: none; } +`; + +const EraNum = styled.div` + color: ${t.accent.gold}; + font-size: 9px; + margin-bottom: 2px; +`; + +const EraName = styled.div` + color: ${t.text.primary}; + font-size: 11px; + text-transform: none; + letter-spacing: 0; +`; + +const EraMeta = styled.div` + font-size: 9px; + color: ${t.text.disabled}; + margin-top: 2px; +`; + +const TreeScroll = styled.div` + flex: 1; + overflow: auto; + position: relative; +`; + +const TreeCanvas = styled.div<{ $w: number; $h: number }>` + position: relative; + width: ${p => p.$w}px; + height: ${p => p.$h}px; + padding: 0; +`; + +const ConnSvg = styled.svg` + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; +`; + +const NodeBox = styled.div<{ $x: number; $y: number; $w: number; $h: number; $dim: boolean }>` + position: absolute; + left: ${p => p.$x}px; + top: ${p => p.$y}px; + width: ${p => p.$w}px; + height: ${p => p.$h}px; + border-radius: ${t.radius.panel}; + background: ${t.bg.panel}; + border: 1px solid ${t.border.panel}; + z-index: 1; + cursor: pointer; + display: flex; + flex-direction: column; + opacity: ${p => p.$dim ? 0.3 : 1}; + filter: ${p => p.$dim ? "saturate(0.4)" : "none"}; + transition: all 150ms ease; + overflow: hidden; + + &:hover { + border-color: ${t.accent.goldBright}; + transform: translateY(-2px); + box-shadow: 0 6px 18px #000a; + z-index: 3; + opacity: 1; + filter: none; + } +`; + +const NodeHead = styled.div<{ $accent: string }>` + padding: 6px 8px 4px; + border-bottom: 1px solid ${t.border.divider}; + display: flex; + align-items: baseline; + gap: 6px; + background: ${p => p.$accent + "18"}; +`; + +const NodeName = styled.div` + font-size: 12px; + font-weight: bold; + color: ${t.text.primary}; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const NodeTier = styled.div` + font-size: 9px; + font-family: ${t.font.mono}; + color: ${t.text.muted}; + text-transform: uppercase; + letter-spacing: 0.06em; +`; + +const NodeBody = styled.div` + padding: 5px 8px 6px; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +`; + +const NodeMeta = styled.div` + font-size: 10px; + font-family: ${t.font.mono}; + color: ${t.accent.science}; +`; + +const NodeChip = styled.span<{ $accent: string }>` + display: inline-block; + padding: 1px 6px; + border-radius: 8px; + font-size: 9px; + font-family: ${t.font.mono}; + color: ${p => p.$accent}; + background: ${p => p.$accent + "22"}; + border: 1px solid ${p => p.$accent + "55"}; + text-transform: uppercase; + letter-spacing: 0.05em; +`; + +const NodeUnlocks = styled.div` + font-size: 10px; + color: ${t.text.muted}; + line-height: 1.35; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function summariseUnlocks(u: TreeUnlocks): string { + const fmt = (id: string) => id.replace(/_/g, " "); + const parts: string[] = []; + if (u.buildings?.length) parts.push(...u.buildings.map(fmt)); + if (u.units?.length) parts.push(...u.units.map(fmt)); + if (u.improvements?.length) parts.push(...u.improvements.map(fmt)); + if (u.lenses?.length) parts.push(...u.lenses.map(id => `lens: ${fmt(id)}`)); + if (u.mechanics?.length) parts.push(...u.mechanics.map(m => m.label)); + return parts.length ? parts.join(" · ") : "—"; +} + +function defaultUnlockLabel(era: TreeEra): string { + if (era.trigger.type === "game_start") return "starts game"; + if (era.trigger.required_techs !== undefined) { + return `unlock: ${era.trigger.required_techs} researched`; + } + return era.trigger.type; +} + +// ── Public component ────────────────────────────────────────────────────────── + +export interface TreeViewProps { + /** Page heading (e.g. "Tech Tree"). */ + title: string; + /** Right-justified meta line (e.g. "driven by @resources/techs/*.json"). */ + sourceTag: string; + /** Pre-loaded, pre-normalised items. Each item.category must match a CategoryDef.id. */ + items: readonly TreeItem[]; + /** Era column definitions, in order. */ + eras: readonly TreeEra[]; + /** Tabs. The "All" sentinel must be present (id = ALL_CATEGORY_ID). */ + categories: readonly CategoryDef[]; + /** Singular noun for the per-era count subtitle (e.g. "techs", "policies"). */ + unitNoun: string; + /** Optional custom era unlock-condition formatter. */ + formatEraUnlock?: (era: TreeEra) => string; +} + +export function TreeView({ + title, + sourceTag, + items, + eras, + categories, + unitNoun, + formatEraUnlock = defaultUnlockLabel, +}: TreeViewProps): ReactElement { + const [activeTab, setActiveTab] = useState(ALL_CATEGORY_ID); + const [hoverId, setHoverId] = useState(null); + + const colorByCat = useMemo(() => { + const m = new Map(); + for (const c of categories) m.set(c.id, c.color); + return m; + }, [categories]); + + const accent = (catId: string): string => + colorByCat.get(catId) ?? colorByCat.get(ALL_CATEGORY_ID) ?? t.accent.gold; + + const { positioned, canvasWidth, canvasHeight } = useMemo( + () => layout(items, eras, activeTab), + [items, eras, activeTab], + ); + + const positionMap = useMemo(() => { + const m = new Map(); + for (const p of positioned) m.set(p.item.id, p); + return m; + }, [positioned]); + + const tabCounts = useMemo(() => { + const counts = new Map(); + counts.set(ALL_CATEGORY_ID, items.length); + for (const it of items) { + counts.set(it.category, (counts.get(it.category) ?? 0) + 1); + } + return counts; + }, [items]); + + const isVisible = (p: PositionedItem): boolean => + activeTab === ALL_CATEGORY_ID || p.item.category === activeTab; + + return ( + +
+ ← back + {title} + {sourceTag} +
+ + + {categories.map(c => ( + setActiveTab(c.id)} + > + {c.label} + {tabCounts.get(c.id) ?? 0} + + ))} + + + + {eras.map((era, i) => { + const eraNum = i + 1; + const inEra = items.filter(it => it.era === eraNum).length; + const matchInEra = activeTab === ALL_CATEGORY_ID + ? null + : items.filter(it => it.era === eraNum && it.category === activeTab).length; + return ( + + Era {eraNum} + {era.display_name} + + {matchInEra !== null + ? `${matchInEra} of ${inEra} in this tab` + : `${inEra} ${unitNoun} in era`} + + {formatEraUnlock(era)} + + ); + })} + + + + + + {positioned.flatMap(p => p.item.requires.map(reqId => { + const src = positionMap.get(reqId); + if (!src) return null; + const dim = !(isVisible(p) && isVisible(src)); + const isHover = hoverId === p.item.id || hoverId === src.item.id; + const stroke = accent(p.item.category); + const opacity = dim ? 0.06 : (isHover ? 0.9 : 0.35); + return ( + ${p.item.id}`} + x1={src.x + src.width} + y1={src.cy} + x2={p.x} + y2={p.cy} + stroke={stroke} + strokeWidth={isHover ? 2 : 1} + opacity={opacity} + /> + ); + }))} + + + {positioned.map(p => { + const acc = accent(p.item.category); + return ( + setHoverId(p.item.id)} + onMouseLeave={() => setHoverId(null)} + title={`${p.item.name}\n\n${p.item.description}${p.item.flavor ? "\n\n" + p.item.flavor : ""}`} + > + + {p.item.name} + t{p.item.tier} + + + ⚗ {p.item.cost} +
+ + {categoryLabel(categories, p.item.category)} + +
+ + {summariseUnlocks(p.item.unlocks)} + +
+
+ ); + })} +
+
+
+ ); +} + +function categoryLabel(cats: readonly CategoryDef[], id: string): string { + return cats.find(c => c.id === id)?.label ?? id; +} diff --git a/.project/designs/app/src/pages/CultureTree.tsx b/.project/designs/app/src/pages/CultureTree.tsx new file mode 100644 index 00000000..a1fda637 --- /dev/null +++ b/.project/designs/app/src/pages/CultureTree.tsx @@ -0,0 +1,90 @@ +import { type ReactElement } from "react"; +import erasJson from "@resources/eras/eras.json"; +import { TreeView } from "../components/tree/TreeView"; +import { + ALL_CATEGORY_ID, + type CategoryDef, + type TreeEra, + type TreeItem, + type TreeUnlocks, +} from "../components/tree/types"; + +// ── Raw shape of authored culture-policy JSON ───────────────────────────────── + +interface RawPolicy { + id: string; + name: string; + description: string; + pillar: string; + era: number; + tier: number; + cost: number; + requires: string[]; + unlocks: TreeUnlocks; + flavor?: string; +} + +// ── Data load (Single Source of Truth: per-tree JSON in @resources/culture) ── + +const policyModules = import.meta.glob<{ default: RawPolicy[] }>( + "../../../../../public/resources/culture/*.json", + { eager: true }, +); +const ALL_POLICIES: RawPolicy[] = Object.values(policyModules).flatMap(m => m.default); + +// Normalise to the generic TreeItem shape. Culture has no `domain` field — +// the pillar IS the tab axis, so we use it directly as the category key. +const ITEMS: TreeItem[] = ALL_POLICIES.map(p => ({ + id: p.id, + name: p.name, + description: p.description, + category: p.pillar, + era: p.era, + tier: p.tier, + cost: p.cost, + requires: p.requires, + unlocks: p.unlocks, + flavor: p.flavor, +})); + +const ERAS = erasJson as readonly TreeEra[]; + +// ── Tab catalogue ───────────────────────────────────────────────────────────── +// Six dwarven culture trees — distinct theme colors per Civ5 inspiration. + +const CATEGORIES: readonly CategoryDef[] = [ + { id: ALL_CATEGORY_ID, label: "All", color: "#c9a84c" }, + { id: "ancestor_worship", label: "Ancestor Worship", color: "#a07cc9" }, + { id: "artisanship", label: "Artisanship", color: "#e69933" }, + { id: "legacy", label: "Legacy", color: "#ccbf73" }, + { id: "oral_tradition", label: "Oral Tradition", color: "#66e666" }, + { id: "philosophy", label: "Philosophy", color: "#66bfff" }, + { id: "statecraft", label: "Statecraft", color: "#d95940" }, +]; + +// ── Era unlock formatter ────────────────────────────────────────────────────── +// Eras are tech-driven — so the threshold reads "X techs to advance". + +function formatEraUnlock(era: TreeEra): string { + if (era.trigger.type === "game_start") return "starts game"; + if (era.trigger.required_techs !== undefined) { + return `era unlocks at ${era.trigger.required_techs} techs`; + } + return era.trigger.type; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export function CultureTreePage(): ReactElement { + return ( + + ); +} diff --git a/.project/designs/app/src/pages/Index.tsx b/.project/designs/app/src/pages/Index.tsx index db626293..f53e92a9 100644 --- a/.project/designs/app/src/pages/Index.tsx +++ b/.project/designs/app/src/pages/Index.tsx @@ -92,7 +92,8 @@ const routeCategories: RouteCategory[] = [ { path: "/hud", label: "🗺 World Map HUD — top bar, unit panel, minimap" }, { path: "/city", label: "🏛 City Screen — production queue, buildings, citizens" }, { path: "/menu", label: "⚒ Main Menu — atmospheric title screen" }, - { path: "/tech", label: "⚗ Tech Tree — node graph, era bands, research" }, + { path: "/tech-tree", label: "⚗ Tech Tree — node graph, era bands, research" }, + { path: "/culture-tree", label: "✦ Culture Tree — six dwarven trees, mechanics-driven" }, { path: "/trees", label: "🏗 Building / Unit Trees — live data, stack chains, produces rosters" }, { path: "/promotion", label: "★ Promotion Picker — grid, lock states, prereqs" }, ], diff --git a/.project/designs/app/src/pages/TechTree.tsx b/.project/designs/app/src/pages/TechTree.tsx index af5ff7a4..3ea4a355 100644 --- a/.project/designs/app/src/pages/TechTree.tsx +++ b/.project/designs/app/src/pages/TechTree.tsx @@ -1,513 +1,80 @@ -import { useMemo, useState } from "react"; -import { Link } from "react-router-dom"; -import styled from "styled-components"; -import { t } from "../theme"; +import { type ReactElement } from "react"; import erasJson from "@resources/eras/eras.json"; +import { TreeView } from "../components/tree/TreeView"; +import { + ALL_CATEGORY_ID, + type CategoryDef, + type TreeEra, + type TreeItem, + type TreeUnlocks, +} from "../components/tree/types"; -// ── Types ───────────────────────────────────────────────────────────────────── +// ── Raw shape of authored tech JSON ────────────────────────────────────────── -interface TechUnlocks { - buildings?: string[]; - units?: string[]; - improvements?: string[]; - lenses?: string[]; -} - -interface Tech { +interface RawTech { id: string; name: string; description: string; pillar: string; - domain: Domain; + domain: string; era: number; tier: number; cost: number; requires: string[]; - unlocks: TechUnlocks; + unlocks: TreeUnlocks; flavor?: string; } -interface Era { - id: string; - display_name: string; - description: string; - trigger: { type: string; required_techs?: number }; - flavor_text: string; -} +// ── Data load (Single Source of Truth: per-pillar JSON in @resources/techs) ── -// ── Data load ───────────────────────────────────────────────────────────────── - -const ERAS = erasJson as readonly Era[]; - -const techModules = import.meta.glob<{ default: Tech[] }>( +const techModules = import.meta.glob<{ default: RawTech[] }>( "../../../../../public/resources/techs/*.json", { eager: true }, ); -const ALL_TECHS: Tech[] = Object.values(techModules).flatMap(m => m.default); +const ALL_TECHS: RawTech[] = Object.values(techModules).flatMap(m => m.default); -// ── Domains ─────────────────────────────────────────────────────────────────── -// "All" is a UI-only filter sentinel; the other 9 are authored on each tech -// in the JSON (`tech.domain`). No client-side categorization map. +// Normalise to the generic TreeItem shape (category = authored domain). +const ITEMS: TreeItem[] = ALL_TECHS.map(t => ({ + id: t.id, + name: t.name, + description: t.description, + category: t.domain, + era: t.era, + tier: t.tier, + cost: t.cost, + requires: t.requires, + unlocks: t.unlocks, + flavor: t.flavor, +})); -const DOMAINS = [ - "All", "Military", "Economy", "Industry", "Agriculture", - "Governance", "Culture", "Exploration", "Engineering", "Medicine", -] as const; -type Domain = typeof DOMAINS[number]; +const ERAS = erasJson as readonly TreeEra[]; -// ── Layout helpers ──────────────────────────────────────────────────────────── +// ── Tab catalogue ───────────────────────────────────────────────────────────── -const COL_WIDTH = 200; -const COL_GAP = 16; -const NODE_HEIGHT = 110; -const NODE_VGAP = 14; -const TOP_PAD = 24; - -interface PositionedTech { - tech: Tech; - domain: Domain; - x: number; - y: number; - cx: number; - cy: number; - width: number; - height: number; -} - -function layoutTechs( - techs: readonly Tech[], - activeTab: Domain, -): { positioned: PositionedTech[]; canvasWidth: number; canvasHeight: number } { - const byEra = new Map(); - for (const tk of techs) { - const list = byEra.get(tk.era) ?? []; - list.push(tk); - byEra.set(tk.era, list); - } - for (const [era, list] of byEra) { - list.sort((a, b) => { - // When a tab is active, matching techs sort to the top of the column. - if (activeTab !== "All") { - const aMatch = a.domain === activeTab ? 0 : 1; - const bMatch = b.domain === activeTab ? 0 : 1; - if (aMatch !== bMatch) return aMatch - bMatch; - } - return a.tier - b.tier || a.name.localeCompare(b.name); - }); - byEra.set(era, list); - } - - const positioned: PositionedTech[] = []; - let maxRows = 0; - for (let era = 1; era <= ERAS.length; era++) { - const list = byEra.get(era) ?? []; - maxRows = Math.max(maxRows, list.length); - list.forEach((tk, row) => { - const x = (era - 1) * (COL_WIDTH + COL_GAP); - const y = TOP_PAD + row * (NODE_HEIGHT + NODE_VGAP); - positioned.push({ - tech: tk, - domain: tk.domain, - x, y, - cx: x + COL_WIDTH / 2, - cy: y + NODE_HEIGHT / 2, - width: COL_WIDTH, - height: NODE_HEIGHT, - }); - }); - } - const canvasWidth = ERAS.length * (COL_WIDTH + COL_GAP); - const canvasHeight = TOP_PAD + maxRows * (NODE_HEIGHT + NODE_VGAP) + TOP_PAD; - return { positioned, canvasWidth, canvasHeight }; -} - -// ── Styled components ───────────────────────────────────────────────────────── - -const PageWrap = styled.div` - background: ${t.bg.menu}; - min-height: 100vh; - display: flex; - flex-direction: column; - color: ${t.text.primary}; - font-family: ${t.font.body}; -`; - -const Header = styled.div` - background: ${t.bg.panel}; - border-bottom: 1px solid ${t.border.panel}; - padding: 12px 24px; - display: flex; - align-items: center; - gap: 24px; - flex-shrink: 0; -`; - -const HeaderTitle = styled.div` - font-family: ${t.font.heading}; - font-size: 22px; - color: ${t.text.title}; - letter-spacing: 0.04em; -`; - -const BackLink = styled(Link)` - color: ${t.text.muted}; - text-decoration: none; - font-size: 13px; - font-family: ${t.font.mono}; - &:hover { color: ${t.accent.gold}; } -`; - -const SourceTag = styled.div` - margin-left: auto; - font-family: ${t.font.mono}; - font-size: 11px; - color: ${t.text.muted}; -`; - -const TabBar = styled.div` - background: rgba(23, 18, 30, 0.9); - border-bottom: 1px solid ${t.border.divider}; - display: flex; - flex-shrink: 0; - padding: 0 16px; - overflow-x: auto; -`; - -const TabBtn = styled.button<{ $active: boolean }>` - flex: 0 0 auto; - padding: 10px 18px; - font-size: 12px; - font-family: ${t.font.body}; - text-transform: uppercase; - letter-spacing: 0.08em; - background: transparent; - border: none; - border-bottom: 2px solid ${p => p.$active ? t.accent.gold : "transparent"}; - color: ${p => p.$active ? t.accent.gold : t.text.muted}; - cursor: pointer; - transition: color 150ms, border-color 150ms; - - &:hover { - color: ${t.accent.goldBright}; - } -`; - -const TabCount = styled.span` - margin-left: 6px; - font-size: 10px; - font-family: ${t.font.mono}; - opacity: 0.65; -`; - -const EraBar = styled.div` - display: flex; - flex-shrink: 0; - background: ${t.bg.panel}; - border-bottom: 1px solid ${t.border.divider}; -`; - -const EraCell = styled.div` - width: ${COL_WIDTH + COL_GAP}px; - flex-shrink: 0; - padding: 6px 8px; - border-right: 1px dashed ${t.border.divider}; - font-family: ${t.font.mono}; - font-size: 10px; - color: ${t.text.muted}; - text-transform: uppercase; - letter-spacing: 0.1em; - &:last-child { border-right: none; } -`; - -const EraNum = styled.div` - color: ${t.accent.gold}; - font-size: 9px; - margin-bottom: 2px; -`; - -const EraName = styled.div` - color: ${t.text.primary}; - font-size: 11px; - text-transform: none; - letter-spacing: 0; -`; - -const EraThreshold = styled.div` - font-size: 9px; - color: ${t.text.disabled}; - margin-top: 2px; -`; - -const TreeScroll = styled.div` - flex: 1; - overflow: auto; - position: relative; -`; - -const TreeCanvas = styled.div<{ $w: number; $h: number }>` - position: relative; - width: ${p => p.$w}px; - height: ${p => p.$h}px; - padding: 0; -`; - -const ConnSvg = styled.svg` - position: absolute; - inset: 0; - pointer-events: none; - z-index: 0; -`; - -const NodeBox = styled.div<{ $x: number; $y: number; $w: number; $h: number; $dim: boolean }>` - position: absolute; - left: ${p => p.$x}px; - top: ${p => p.$y}px; - width: ${p => p.$w}px; - height: ${p => p.$h}px; - border-radius: ${t.radius.panel}; - background: ${t.bg.panel}; - border: 1px solid ${t.border.panel}; - z-index: 1; - cursor: pointer; - display: flex; - flex-direction: column; - opacity: ${p => p.$dim ? 0.3 : 1}; - filter: ${p => p.$dim ? "saturate(0.4)" : "none"}; - transition: all 150ms ease; - overflow: hidden; - - &:hover { - border-color: ${t.accent.goldBright}; - transform: translateY(-2px); - box-shadow: 0 6px 18px #000a; - z-index: 3; - opacity: 1; - filter: none; - } -`; - -const NodeHead = styled.div<{ $domain: Domain }>` - padding: 6px 8px 4px; - border-bottom: 1px solid ${t.border.divider}; - display: flex; - align-items: baseline; - gap: 6px; - background: ${({ $domain }) => DOMAIN_COLOR[$domain] + "18"}; -`; - -const NodeName = styled.div` - font-size: 12px; - font-weight: bold; - color: ${t.text.primary}; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const NodeTier = styled.div` - font-size: 9px; - font-family: ${t.font.mono}; - color: ${t.text.muted}; - text-transform: uppercase; - letter-spacing: 0.06em; -`; - -const NodeBody = styled.div` - padding: 5px 8px 6px; - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; -`; - -const NodeMeta = styled.div` - font-size: 10px; - font-family: ${t.font.mono}; - color: ${t.accent.science}; -`; - -const NodePillar = styled.span<{ $domain: Domain }>` - display: inline-block; - padding: 1px 6px; - border-radius: 8px; - font-size: 9px; - font-family: ${t.font.mono}; - color: ${({ $domain }) => DOMAIN_COLOR[$domain]}; - background: ${({ $domain }) => DOMAIN_COLOR[$domain] + "22"}; - border: 1px solid ${({ $domain }) => DOMAIN_COLOR[$domain] + "55"}; - text-transform: uppercase; - letter-spacing: 0.05em; -`; - -const NodeUnlocks = styled.div` - font-size: 10px; - color: ${t.text.muted}; - line-height: 1.35; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -`; - -const DOMAIN_COLOR: Record = { - All: "#c9a84c", - Military: "#d95940", - Economy: "#ccbf73", - Industry: "#e69933", - Agriculture: "#66e666", - Governance: "#e68c73", - Culture: "#a07cc9", - Exploration: "#66bfff", - Engineering: "#bbb09a", - Medicine: "#7cd9a0", -}; +const CATEGORIES: readonly CategoryDef[] = [ + { id: ALL_CATEGORY_ID, label: "All", color: "#c9a84c" }, + { id: "Military", label: "Military", color: "#d95940" }, + { id: "Economy", label: "Economy", color: "#ccbf73" }, + { id: "Industry", label: "Industry", color: "#e69933" }, + { id: "Agriculture", label: "Agriculture", color: "#66e666" }, + { id: "Governance", label: "Governance", color: "#e68c73" }, + { id: "Culture", label: "Culture", color: "#a07cc9" }, + { id: "Exploration", label: "Exploration", color: "#66bfff" }, + { id: "Engineering", label: "Engineering", color: "#bbb09a" }, + { id: "Medicine", label: "Medicine", color: "#7cd9a0" }, +]; // ── Component ───────────────────────────────────────────────────────────────── -export function TechTreePage(): React.ReactElement { - const [activeTab, setActiveTab] = useState("All"); - const [hoverId, setHoverId] = useState(null); - - const { positioned, canvasWidth, canvasHeight } = useMemo( - () => layoutTechs(ALL_TECHS, activeTab), - [activeTab], - ); - - const positionMap = useMemo(() => { - const m = new Map(); - for (const p of positioned) m.set(p.tech.id, p); - return m; - }, [positioned]); - - const tabCounts = useMemo(() => { - const counts: Record = { ...Object.fromEntries(DOMAINS.map(d => [d, 0])) } as Record; - counts.All = ALL_TECHS.length; - for (const tk of ALL_TECHS) { - const d = tk.domain; - counts[d]++; - } - return counts; - }, []); - - const isVisible = (p: PositionedTech): boolean => - activeTab === "All" || p.domain === activeTab; - +export function TechTreePage(): ReactElement { return ( - -
- ← back - Tech Tree - - driven by @resources/techs/*.json · {ALL_TECHS.length} techs · {ERAS.length} eras - -
- - - {DOMAINS.map(d => ( - setActiveTab(d)} - > - {d} - {tabCounts[d]} - - ))} - - - - {ERAS.map((era, i) => { - const eraNum = i + 1; - const inEra = ALL_TECHS.filter(tk => tk.era === eraNum).length; - const matchInEra = activeTab === "All" - ? null - : ALL_TECHS.filter(tk => tk.era === eraNum && tk.domain === activeTab).length; - const unlockLabel = - era.trigger.type === "game_start" - ? "starts game" - : era.trigger.required_techs !== undefined - ? `unlock: ${era.trigger.required_techs} researched` - : era.trigger.type; - return ( - - Era {eraNum} - {era.display_name} - - {matchInEra !== null - ? `${matchInEra} of ${inEra} in this tab` - : `${inEra} techs in era`} - - {unlockLabel} - - ); - })} - - - - - - {positioned.flatMap(p => p.tech.requires.map(reqId => { - const src = positionMap.get(reqId); - if (!src) return null; - const dim = !(isVisible(p) && isVisible(src)); - const isHover = hoverId === p.tech.id || hoverId === src.tech.id; - const stroke = isHover ? DOMAIN_COLOR[p.domain] : DOMAIN_COLOR[p.domain]; - const opacity = dim ? 0.06 : (isHover ? 0.9 : 0.35); - return ( - ${p.tech.id}`} - x1={src.x + src.width} - y1={src.cy} - x2={p.x} - y2={p.cy} - stroke={stroke} - strokeWidth={isHover ? 2 : 1} - opacity={opacity} - /> - ); - }))} - - - {positioned.map(p => ( - setHoverId(p.tech.id)} - onMouseLeave={() => setHoverId(null)} - title={`${p.tech.name}\n\n${p.tech.description}${p.tech.flavor ? "\n\n" + p.tech.flavor : ""}`} - > - - {p.tech.name} - t{p.tech.tier} - - - ⚗ {p.tech.cost} -
- {p.domain} -
- - {summariseUnlocks(p.tech.unlocks)} - -
-
- ))} -
-
-
+ ); } - -function summariseUnlocks(u: TechUnlocks): string { - const parts: string[] = []; - const fmt = (id: string) => id.replace(/_/g, " "); - if (u.buildings?.length) parts.push(...u.buildings.map(fmt)); - if (u.units?.length) parts.push(...u.units.map(fmt)); - if (u.improvements?.length) parts.push(...u.improvements.map(fmt)); - if (u.lenses?.length) parts.push(...u.lenses.map(id => `lens: ${fmt(id)}`)); - return parts.length ? parts.join(" · ") : "—"; -} diff --git a/.project/designs/app/tsconfig.tsbuildinfo b/.project/designs/app/tsconfig.tsbuildinfo index c7b49dc0..2f5c6d4f 100644 --- a/.project/designs/app/tsconfig.tsbuildinfo +++ b/.project/designs/app/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/audiosynth.ts","./src/main.tsx","./src/theme.ts","./src/components/combat/combatantcard.tsx","./src/components/combat/damagematrix.tsx","./src/components/combat/hpafterbar.tsx","./src/components/combat/hpslider.tsx","./src/components/combat/kwbanner.tsx","./src/components/combat/modifierlist.tsx","./src/components/combat/probabilitybar.tsx","./src/components/combat/unitbrowser.tsx","./src/components/combat/unitrow.tsx","./src/components/ui/button.tsx","./src/components/ui/panel.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tag.tsx","./src/data/allbuildings.ts","./src/data/allunits.ts","./src/data/audiopacks.ts","./src/data/scenarios.ts","./src/data/units.ts","./src/pages/audiopackdetail.tsx","./src/pages/audiopacks.tsx","./src/pages/audiosystem.tsx","./src/pages/buildingtrees.tsx","./src/pages/cityscreen.tsx","./src/pages/civilopedia.tsx","./src/pages/clanpersonality.tsx","./src/pages/combatcalculator.tsx","./src/pages/combatpreview.tsx","./src/pages/credits.tsx","./src/pages/designgallery.tsx","./src/pages/diplomacy.tsx","./src/pages/empiredashboard.tsx","./src/pages/endgamesummary.tsx","./src/pages/eraprogression.tsx","./src/pages/gamesetup.tsx","./src/pages/gdrustbridge.tsx","./src/pages/gdrustmap.tsx","./src/pages/hexformation.tsx","./src/pages/hud.tsx","./src/pages/index.tsx","./src/pages/mainmenu.tsx","./src/pages/notifications.tsx","./src/pages/pastgames.tsx","./src/pages/permutations.tsx","./src/pages/promotionpicker.tsx","./src/pages/replay.tsx","./src/pages/settings.tsx","./src/pages/statistics.tsx","./src/pages/techtree.tsx","./src/pages/turnsummary.tsx","./src/pages/unitactions.tsx","./src/pages/worldgen.tsx","./src/pages/hud/minimap.tsx","./src/pages/hud/chrome.ts","./src/pages/hud/positions.ts","./src/pages/worldgen/biometransitions.tsx","./src/pages/worldgen/climate.tsx","./src/pages/worldgen/ecology.tsx","./src/pages/worldgen/hydrology.tsx","./src/pages/worldgen/lab.tsx","./src/pages/worldgen/mappanel.tsx","./src/pages/worldgen/noiseanatomy.tsx","./src/pages/worldgen/pipelinepanel.tsx","./src/pages/worldgen/presets.tsx","./src/pages/worldgen/rng.tsx","./src/pages/worldgen/substrate.tsx","./src/pages/worldgen/tectonics.tsx","./src/pages/worldgen/terraincatalog.tsx","./src/pages/worldgen/whittakerplot.tsx","./src/pages/worldgen/_layerpage.tsx","./src/pages/worldgen/lab/mapcanvas.tsx","./src/pages/worldgen/lab/overlaytoggles.tsx","./src/pages/worldgen/lab/presetbar.tsx","./src/pages/worldgen/lab/tileinspector.tsx","./src/pages/worldgen/lab/constants.ts","./src/pages/worldgen/lab/mapinteractions.ts","./src/pages/worldgen/lab/observations.ts","./src/pages/worldgen/lab/render.ts","./src/pages/worldgen/lab/types.ts","./src/utils/combatcalc.ts","./src/utils/wasm/smoke.ts","./src/utils/wasm/usewasmgrid.ts","./src/utils/worldgen/hexcanvas.test.ts","./src/utils/worldgen/hexcanvas.ts","./src/utils/worldgen/indicatordecorations.ts","./src/utils/worldgen/noise.ts","./src/utils/worldgen/poisson.ts","./src/utils/worldgen/terrain.ts","../../reports/gd-rust-relationships.json","../../../public/resources/audio/library.json","../../../public/games/age-of-dwarves/data/audio/manifest.json","../../../public/games/age-of-dwarves/data/audio/pools.json"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/audiosynth.ts","./src/main.tsx","./src/theme.ts","./src/components/combat/combatantcard.tsx","./src/components/combat/damagematrix.tsx","./src/components/combat/hpafterbar.tsx","./src/components/combat/hpslider.tsx","./src/components/combat/kwbanner.tsx","./src/components/combat/modifierlist.tsx","./src/components/combat/probabilitybar.tsx","./src/components/combat/unitbrowser.tsx","./src/components/combat/unitrow.tsx","./src/components/tree/treeview.tsx","./src/components/tree/types.ts","./src/components/ui/button.tsx","./src/components/ui/panel.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tag.tsx","./src/data/allbuildings.ts","./src/data/allunits.ts","./src/data/audiopacks.ts","./src/data/scenarios.ts","./src/data/units.ts","./src/pages/audiopackdetail.tsx","./src/pages/audiopacks.tsx","./src/pages/audiosystem.tsx","./src/pages/buildingtrees.tsx","./src/pages/cityscreen.tsx","./src/pages/civilopedia.tsx","./src/pages/clanpersonality.tsx","./src/pages/combatcalculator.tsx","./src/pages/combatpreview.tsx","./src/pages/credits.tsx","./src/pages/culturetree.tsx","./src/pages/designgallery.tsx","./src/pages/diplomacy.tsx","./src/pages/empiredashboard.tsx","./src/pages/endgamesummary.tsx","./src/pages/eraprogression.tsx","./src/pages/gamesetup.tsx","./src/pages/gdrustbridge.tsx","./src/pages/gdrustmap.tsx","./src/pages/hexformation.tsx","./src/pages/hud.tsx","./src/pages/index.tsx","./src/pages/mainmenu.tsx","./src/pages/notifications.tsx","./src/pages/pastgames.tsx","./src/pages/permutations.tsx","./src/pages/promotionpicker.tsx","./src/pages/replay.tsx","./src/pages/settings.tsx","./src/pages/statistics.tsx","./src/pages/techtree.tsx","./src/pages/turnsummary.tsx","./src/pages/unitactions.tsx","./src/pages/worldgen.tsx","./src/pages/hud/minimap.tsx","./src/pages/hud/chrome.ts","./src/pages/hud/positions.ts","./src/pages/worldgen/biometransitions.tsx","./src/pages/worldgen/climate.tsx","./src/pages/worldgen/ecology.tsx","./src/pages/worldgen/hydrology.tsx","./src/pages/worldgen/lab.tsx","./src/pages/worldgen/mappanel.tsx","./src/pages/worldgen/noiseanatomy.tsx","./src/pages/worldgen/pipelinepanel.tsx","./src/pages/worldgen/presets.tsx","./src/pages/worldgen/rng.tsx","./src/pages/worldgen/substrate.tsx","./src/pages/worldgen/tectonics.tsx","./src/pages/worldgen/terraincatalog.tsx","./src/pages/worldgen/whittakerplot.tsx","./src/pages/worldgen/_layerpage.tsx","./src/pages/worldgen/lab/mapcanvas.tsx","./src/pages/worldgen/lab/overlaytoggles.tsx","./src/pages/worldgen/lab/presetbar.tsx","./src/pages/worldgen/lab/tileinspector.tsx","./src/pages/worldgen/lab/constants.ts","./src/pages/worldgen/lab/mapinteractions.ts","./src/pages/worldgen/lab/observations.ts","./src/pages/worldgen/lab/render.ts","./src/pages/worldgen/lab/types.ts","./src/utils/combatcalc.ts","./src/utils/wasm/smoke.ts","./src/utils/wasm/usewasmgrid.ts","./src/utils/worldgen/hexcanvas.test.ts","./src/utils/worldgen/hexcanvas.ts","./src/utils/worldgen/indicatordecorations.ts","./src/utils/worldgen/noise.ts","./src/utils/worldgen/poisson.ts","./src/utils/worldgen/terrain.ts","../../reports/gd-rust-relationships.json","../../../public/resources/audio/library.json","../../../public/games/age-of-dwarves/data/audio/manifest.json","../../../public/games/age-of-dwarves/data/audio/pools.json"],"version":"5.9.3"} \ No newline at end of file diff --git a/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md b/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md index 38846642..1ca051eb 100644 --- a/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md +++ b/.project/objectives/p1-36-ai-personalities-t1-t10-coverage.md @@ -98,3 +98,24 @@ User-requested R2 fix: "bump the clan_affinity scoring constant in `tactical/pro **Real failure: `pick_best_melee` only considers `unit_type == "melee"`.** `production.rs:373` filters to melee units only. Deepforge's signature units (`forge_titan`, `steam_walker`, `iron_strider`, `rail_cannon`) are `unit_type: "siege"` or `"walker"` — they will NEVER be picked by `pick_best_melee`, regardless of affinity weight. There is currently no `pick_best_siege` or `pick_best_walker` selector. Deepforge's AI defaulting to warrior is **structurally correct given the function only considers melee**. **Real R2 fix (deferred):** add parallel selectors `pick_best_siege`, `pick_best_walker`, `pick_best_ranged` (mirror of `pick_best_melee` with different `unit_type` filter) and a strategic-axis-driven dispatcher that picks among them based on the AI's clan personality (Deepforge prefers siege/walker; Goldvein prefers ranged; Ironhold prefers heavy melee; Blackhammer prefers light melee/cavalry; Runesmith mixes). This is a `game-ai` `mc-ai` enhancement, ~3-5 hours of work. Bumping the existing constant would not move the metric. + +## Cycle 3 — production selector expansion (LANDED 2026-05-03) + +`src/simulator/crates/mc-ai/src/tactical/production.rs` refactored: `pick_best_melee` extracted into generic `pick_best_unit_of_type(unit_type, …)` parameterized by unit_type string. New `pick_best_unit_for_clan(clan_id, …)` dispatcher implements the per-clan archetype ladder: + +| Clan | Preferred ladder | +|---|---| +| deepforge | siege → ranged → melee | +| goldvein | ranged → melee → siege | +| blackhammer | melee → siege → ranged | +| ironhold | melee → siege → ranged | +| runesmith | ranged → melee → siege | +| (default) | melee → siege → ranged | + +Algorithm: for each preferred type, pick the highest-tier buildable unit; only return when the result is a CLAN-AFFINITY match (score=2). Generic units fall through so the next type can fire. If no archetype match exists in any preferred type, fall back to the legacy `pick_best_melee` (returns generic warrior/etc.). `pick_for_city` callsite at line 224 swapped to call `pick_best_unit_for_clan`. The local `melee_id` variable name retained for diff stability — semantic remains "the AI's chosen military unit for this city". + +7 new tests cover: siege selector returns siege only, dispatcher routes deepforge → siege, goldvein → ranged, blackhammer → melee affinity (not generic), fallback to melee when no archetype buildable, affinity-over-generic preference within same type, empty catalog → None, unknown clan → default ladder. **mc-ai 222/222 lib tests pass** (was 215, +7 new). Cargo workspace check clean. + +Verification batch chained with p1-30 cycle 3 retune (`~/Code/project-buildspace/magic-civilization/.local/iter/huge-map-5clan-20260503_103147` on apricot, 10 seeds × T500 × 5 AI personalities). The autoplay-batch.sh p1-45 prebuild step rebuilds the GDExt with the dispatcher in effect; results will populate the table below once batch completes. + +**Status remains `partial` until the batch confirms 4/5 clans show distinct top-3 production histograms.**