feat(@projects/@magic-civilization): add tree-based tech and culture navigation system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-03 13:36:50 -04:00
parent 2c2c1e4ef5
commit a8b45cc083
7 changed files with 675 additions and 489 deletions

View file

@ -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 {
<Route path="/hud" element={<HudPage />} />
<Route path="/city" element={<CityScreenPage />} />
<Route path="/menu" element={<MainMenuPage />} />
<Route path="/tech" element={<TechTreePage />} />
<Route path="/tech-tree" element={<TechTreePage />} />
<Route path="/tech" element={<Navigate to="/tech-tree" replace />} />
<Route path="/culture-tree" element={<CultureTreePage />} />
<Route path="/trees" element={<BuildingTreesPage />} />
<Route path="/promotion" element={<PromotionPickerPage />} />
<Route path="/stats" element={<StatisticsPage />} />

View file

@ -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<number, TreeItem[]>();
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<string>(ALL_CATEGORY_ID);
const [hoverId, setHoverId] = useState<string | null>(null);
const colorByCat = useMemo(() => {
const m = new Map<string, string>();
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<string, PositionedItem>();
for (const p of positioned) m.set(p.item.id, p);
return m;
}, [positioned]);
const tabCounts = useMemo(() => {
const counts = new Map<string, number>();
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 (
<PageWrap>
<Header>
<BackLink to="/"> back</BackLink>
<HeaderTitle>{title}</HeaderTitle>
<SourceTag>{sourceTag}</SourceTag>
</Header>
<TabBar>
{categories.map(c => (
<TabBtn
key={c.id}
$active={activeTab === c.id}
$accent={c.color}
onClick={() => setActiveTab(c.id)}
>
{c.label}
<TabCount>{tabCounts.get(c.id) ?? 0}</TabCount>
</TabBtn>
))}
</TabBar>
<EraBar>
{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 (
<EraCell key={era.id}>
<EraNum>Era {eraNum}</EraNum>
<EraName>{era.display_name}</EraName>
<EraMeta>
{matchInEra !== null
? `${matchInEra} of ${inEra} in this tab`
: `${inEra} ${unitNoun} in era`}
</EraMeta>
<EraMeta>{formatEraUnlock(era)}</EraMeta>
</EraCell>
);
})}
</EraBar>
<TreeScroll>
<TreeCanvas $w={canvasWidth} $h={canvasHeight}>
<ConnSvg width={canvasWidth} height={canvasHeight}>
{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 (
<line
key={`${reqId}->${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}
/>
);
}))}
</ConnSvg>
{positioned.map(p => {
const acc = accent(p.item.category);
return (
<NodeBox
key={p.item.id}
$x={p.x}
$y={p.y}
$w={p.width}
$h={p.height}
$dim={!isVisible(p)}
onMouseEnter={() => setHoverId(p.item.id)}
onMouseLeave={() => setHoverId(null)}
title={`${p.item.name}\n\n${p.item.description}${p.item.flavor ? "\n\n" + p.item.flavor : ""}`}
>
<NodeHead $accent={acc}>
<NodeName>{p.item.name}</NodeName>
<NodeTier>t{p.item.tier}</NodeTier>
</NodeHead>
<NodeBody>
<NodeMeta> {p.item.cost}</NodeMeta>
<div>
<NodeChip $accent={acc}>
{categoryLabel(categories, p.item.category)}
</NodeChip>
</div>
<NodeUnlocks>
{summariseUnlocks(p.item.unlocks)}
</NodeUnlocks>
</NodeBody>
</NodeBox>
);
})}
</TreeCanvas>
</TreeScroll>
</PageWrap>
);
}
function categoryLabel(cats: readonly CategoryDef[], id: string): string {
return cats.find(c => c.id === id)?.label ?? id;
}

View file

@ -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 (
<TreeView
title="Culture Tree"
sourceTag={`driven by @resources/culture/*.json · ${ITEMS.length} policies · ${ERAS.length} eras`}
items={ITEMS}
eras={ERAS}
categories={CATEGORIES}
unitNoun="policies"
formatEraUnlock={formatEraUnlock}
/>
);
}

View file

@ -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" },
],

View file

@ -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<number, Tech[]>();
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<Domain, string> = {
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<Domain>("All");
const [hoverId, setHoverId] = useState<string | null>(null);
const { positioned, canvasWidth, canvasHeight } = useMemo(
() => layoutTechs(ALL_TECHS, activeTab),
[activeTab],
);
const positionMap = useMemo(() => {
const m = new Map<string, PositionedTech>();
for (const p of positioned) m.set(p.tech.id, p);
return m;
}, [positioned]);
const tabCounts = useMemo(() => {
const counts: Record<Domain, number> = { ...Object.fromEntries(DOMAINS.map(d => [d, 0])) } as Record<Domain, number>;
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 (
<PageWrap>
<Header>
<BackLink to="/"> back</BackLink>
<HeaderTitle>Tech Tree</HeaderTitle>
<SourceTag>
driven by @resources/techs/*.json · {ALL_TECHS.length} techs · {ERAS.length} eras
</SourceTag>
</Header>
<TabBar>
{DOMAINS.map(d => (
<TabBtn
key={d}
$active={activeTab === d}
onClick={() => setActiveTab(d)}
>
{d}
<TabCount>{tabCounts[d]}</TabCount>
</TabBtn>
))}
</TabBar>
<EraBar>
{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 (
<EraCell key={era.id}>
<EraNum>Era {eraNum}</EraNum>
<EraName>{era.display_name}</EraName>
<EraThreshold>
{matchInEra !== null
? `${matchInEra} of ${inEra} in this tab`
: `${inEra} techs in era`}
</EraThreshold>
<EraThreshold>{unlockLabel}</EraThreshold>
</EraCell>
);
})}
</EraBar>
<TreeScroll>
<TreeCanvas $w={canvasWidth} $h={canvasHeight}>
<ConnSvg width={canvasWidth} height={canvasHeight}>
{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 (
<line
key={`${reqId}->${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}
/>
);
}))}
</ConnSvg>
{positioned.map(p => (
<NodeBox
key={p.tech.id}
$x={p.x}
$y={p.y}
$w={p.width}
$h={p.height}
$dim={!isVisible(p)}
onMouseEnter={() => setHoverId(p.tech.id)}
onMouseLeave={() => setHoverId(null)}
title={`${p.tech.name}\n\n${p.tech.description}${p.tech.flavor ? "\n\n" + p.tech.flavor : ""}`}
>
<NodeHead $domain={p.domain}>
<NodeName>{p.tech.name}</NodeName>
<NodeTier>t{p.tech.tier}</NodeTier>
</NodeHead>
<NodeBody>
<NodeMeta> {p.tech.cost}</NodeMeta>
<div>
<NodePillar $domain={p.domain}>{p.domain}</NodePillar>
</div>
<NodeUnlocks>
{summariseUnlocks(p.tech.unlocks)}
</NodeUnlocks>
</NodeBody>
</NodeBox>
))}
</TreeCanvas>
</TreeScroll>
</PageWrap>
<TreeView
title="Tech Tree"
sourceTag={`driven by @resources/techs/*.json · ${ITEMS.length} techs · ${ERAS.length} eras`}
items={ITEMS}
eras={ERAS}
categories={CATEGORIES}
unitNoun="techs"
/>
);
}
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(" · ") : "—";
}

View file

@ -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"}
{"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"}

View file

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