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:
parent
2c2c1e4ef5
commit
a8b45cc083
7 changed files with 675 additions and 489 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
504
.project/designs/app/src/components/tree/TreeView.tsx
Normal file
504
.project/designs/app/src/components/tree/TreeView.tsx
Normal 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;
|
||||
}
|
||||
90
.project/designs/app/src/pages/CultureTree.tsx
Normal file
90
.project/designs/app/src/pages/CultureTree.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(" · ") : "—";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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.**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue