feat(@projects): ✨ add gd-rust bridge integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
126f8565cc
commit
1ca0e1dd5a
16 changed files with 4392 additions and 38 deletions
|
|
@ -19,6 +19,7 @@ import { StatisticsPage } from "./pages/Statistics";
|
|||
import { EndGameSummaryPage } from "./pages/EndGameSummary";
|
||||
import { PastGamesPage } from "./pages/PastGames";
|
||||
import { ReplayPage } from "./pages/Replay";
|
||||
import { GdRustBridgePage } from "./pages/GdRustBridge";
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
return (
|
||||
|
|
@ -44,6 +45,7 @@ export function App(): React.ReactElement {
|
|||
<Route path="/end-game" element={<EndGameSummaryPage />} />
|
||||
<Route path="/past-games" element={<PastGamesPage />} />
|
||||
<Route path="/replay" element={<ReplayPage />} />
|
||||
<Route path="/gd-rust" element={<GdRustBridgePage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
|
|
|||
582
.project/designs/app/src/pages/GdRustBridge.tsx
Normal file
582
.project/designs/app/src/pages/GdRustBridge.tsx
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { t } from "../theme";
|
||||
import reportData from "@reports/gd-rust-relationships.json";
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CallerLine {
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
interface ClassEntry {
|
||||
name: string;
|
||||
rustStruct: string;
|
||||
base: string | null;
|
||||
file: string;
|
||||
line: number;
|
||||
funcCount: number;
|
||||
callerFiles: string[];
|
||||
callerLines: CallerLine[];
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
generatedAt: string;
|
||||
classes: ClassEntry[];
|
||||
gdClassNames: Record<string, string>;
|
||||
collisions: string[];
|
||||
crateDeps: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const data = reportData as ReportData;
|
||||
|
||||
// ── styled ────────────────────────────────────────────────────────────────
|
||||
|
||||
const Page = styled.div`
|
||||
min-height: 100vh;
|
||||
background: ${t.bg.deepest};
|
||||
color: ${t.text.primary};
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
padding: 24px 32px;
|
||||
`;
|
||||
|
||||
const BackLink = styled(Link)`
|
||||
color: ${t.text.muted};
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
font-family: ${t.font.mono};
|
||||
&:hover { color: ${t.accent.gold}; }
|
||||
`;
|
||||
|
||||
const Header = styled.header`
|
||||
margin: 10px 0 20px;
|
||||
h1 {
|
||||
font-family: "Cinzel", serif;
|
||||
color: ${t.text.title};
|
||||
margin: 0 0 4px;
|
||||
font-size: 26px;
|
||||
}
|
||||
p {
|
||||
color: ${t.text.muted};
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-family: ${t.font.mono};
|
||||
}
|
||||
`;
|
||||
|
||||
const Stats = styled.div`
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const Stat = styled.div`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 4px;
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: ${t.text.muted};
|
||||
span { display: block; font-size: 22px; color: ${t.accent.gold}; font-family: ${t.font.mono}; }
|
||||
`;
|
||||
|
||||
const Tabs = styled.nav`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid ${t.bg.raised};
|
||||
`;
|
||||
|
||||
const Tab = styled.button<{ $active: boolean }>`
|
||||
background: ${p => p.$active ? t.bg.raised : "transparent"};
|
||||
border: none;
|
||||
border-bottom: 2px solid ${p => p.$active ? t.accent.gold : "transparent"};
|
||||
color: ${p => p.$active ? t.text.primary : t.text.muted};
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: -1px;
|
||||
&:hover { color: ${t.text.primary}; }
|
||||
`;
|
||||
|
||||
const FilterRow = styled.div`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 3px;
|
||||
color: ${t.text.primary};
|
||||
font-family: ${t.font.mono};
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
width: 260px;
|
||||
&::placeholder { color: ${t.text.disabled}; }
|
||||
&:focus { outline: none; border-color: ${t.accent.gold}; }
|
||||
`;
|
||||
|
||||
const Select = styled.select`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 3px;
|
||||
color: ${t.text.primary};
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Table = styled.table`
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const Th = styled.th<{ $align?: "left" | "right" | "center" }>`
|
||||
text-align: ${p => p.$align ?? "left"};
|
||||
color: ${t.text.muted};
|
||||
font-weight: 600;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid ${t.border.divider};
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
&:hover { color: ${t.accent.gold}; }
|
||||
`;
|
||||
|
||||
const Tr = styled.tr<{ $selected?: boolean }>`
|
||||
background: ${p => p.$selected ? t.bg.listSel : "transparent"};
|
||||
&:hover { background: ${t.bg.raised}; }
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Td = styled.td<{ $align?: "left" | "right" | "center"; $mono?: boolean }>`
|
||||
text-align: ${p => p.$align ?? "left"};
|
||||
font-family: ${p => p.$mono ? t.font.mono : "inherit"};
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid ${t.border.divider};
|
||||
color: ${t.text.secondary};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const Code = styled.code`
|
||||
font-family: ${t.font.mono};
|
||||
color: ${t.accent.goldRes};
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
const Badge = styled.span<{ $variant?: "unused" | "normal" }>`
|
||||
background: ${p => p.$variant === "unused" ? t.sem.negative + "33" : t.accent.gold + "22"};
|
||||
color: ${p => p.$variant === "unused" ? t.sem.negative : t.accent.gold};
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-family: ${t.font.mono};
|
||||
`;
|
||||
|
||||
// ── detail panel ──────────────────────────────────────────────────────────
|
||||
|
||||
const DetailPanel = styled.div`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 4px;
|
||||
padding: 18px 20px;
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const DetailTitle = styled.h3`
|
||||
font-family: ${t.font.mono};
|
||||
color: ${t.text.title};
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
const CallerList = styled.ul`
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const CallerItem = styled.li`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid ${t.border.divider};
|
||||
font-family: ${t.font.mono};
|
||||
font-size: 11px;
|
||||
color: ${t.text.muted};
|
||||
&:last-child { border-bottom: none; }
|
||||
`;
|
||||
|
||||
const CallerFile = styled.span`
|
||||
color: ${t.accent.science};
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const CallerLines = styled.span`
|
||||
color: ${t.text.secondary};
|
||||
`;
|
||||
|
||||
// ── dep graph ────────────────────────────────────────────────────────────
|
||||
|
||||
const DepGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const DepCard = styled.div<{ $isBridge?: boolean }>`
|
||||
background: ${p => p.$isBridge ? t.bg.raised : t.bg.panel};
|
||||
border: 1px solid ${p => p.$isBridge ? t.border.focus : t.border.panel};
|
||||
border-radius: 4px;
|
||||
padding: 12px 14px;
|
||||
`;
|
||||
|
||||
const DepCardTitle = styled.div`
|
||||
font-family: ${t.font.mono};
|
||||
font-size: 12px;
|
||||
color: ${t.accent.gold};
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const DepCardDeps = styled.ul`
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const DepCardDep = styled.li`
|
||||
font-family: ${t.font.mono};
|
||||
font-size: 11px;
|
||||
color: ${t.text.muted};
|
||||
padding: 2px 0;
|
||||
&::before { content: "→ "; color: ${t.accent.goldRes}; }
|
||||
`;
|
||||
|
||||
// ── gd-class tab ─────────────────────────────────────────────────────────
|
||||
|
||||
const GdList = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const GdRow = styled.div<{ $collision?: boolean }>`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: ${p => p.$collision ? t.sem.negative + "18" : t.bg.panel};
|
||||
border: 1px solid ${p => p.$collision ? t.sem.negative + "55" : t.border.panel};
|
||||
border-radius: 3px;
|
||||
padding: 6px 10px;
|
||||
font-family: ${t.font.mono};
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
const GdName = styled.span`
|
||||
color: ${t.accent.sage};
|
||||
min-width: 180px;
|
||||
`;
|
||||
|
||||
const GdFile = styled.span`
|
||||
color: ${t.text.muted};
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
// ── sort helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type SortKey = "name" | "callerFiles" | "funcCount";
|
||||
|
||||
function sortClasses(list: ClassEntry[], key: SortKey, asc: boolean) {
|
||||
return [...list].sort((a, b) => {
|
||||
let va: string | number, vb: string | number;
|
||||
if (key === "name") { va = a.name; vb = b.name; }
|
||||
else if (key === "callerFiles") { va = a.callerFiles.length; vb = b.callerFiles.length; }
|
||||
else { va = a.funcCount; vb = b.funcCount; }
|
||||
if (va < vb) return asc ? -1 : 1;
|
||||
if (va > vb) return asc ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ── tabs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabId = "surface" | "callers" | "deps" | "gd-classes";
|
||||
|
||||
function SurfaceTab() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "used" | "unused">("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("callerFiles");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const classes = useMemo(() => {
|
||||
let list = data.classes;
|
||||
if (search) list = list.filter(c => c.name.toLowerCase().includes(search.toLowerCase()));
|
||||
if (filter === "used") list = list.filter(c => c.callerFiles.length > 0);
|
||||
if (filter === "unused") list = list.filter(c => c.callerFiles.length === 0);
|
||||
return sortClasses(list, sortKey, sortAsc);
|
||||
}, [search, filter, sortKey, sortAsc]);
|
||||
|
||||
const selectedClass = selected ? data.classes.find(c => c.name === selected) ?? null : null;
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortAsc(a => !a);
|
||||
else { setSortKey(key); setSortAsc(false); }
|
||||
}
|
||||
|
||||
function sortArrow(key: SortKey) {
|
||||
if (sortKey !== key) return "";
|
||||
return sortAsc ? " ↑" : " ↓";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterRow>
|
||||
<SearchInput
|
||||
placeholder="Filter by class name…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<Select value={filter} onChange={e => setFilter(e.target.value as typeof filter)}>
|
||||
<option value="all">All ({data.classes.length})</option>
|
||||
<option value="used">Referenced ({data.classes.filter(c => c.callerFiles.length > 0).length})</option>
|
||||
<option value="unused">Unreferenced ({data.classes.filter(c => c.callerFiles.length === 0).length})</option>
|
||||
</Select>
|
||||
</FilterRow>
|
||||
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th onClick={() => toggleSort("name")}>Godot class{sortArrow("name")}</Th>
|
||||
<Th>base</Th>
|
||||
<Th $align="right" onClick={() => toggleSort("funcCount")}>#[func]{sortArrow("funcCount")}</Th>
|
||||
<Th $align="right" onClick={() => toggleSort("callerFiles")}>GD files{sortArrow("callerFiles")}</Th>
|
||||
<Th>Rust source</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classes.map(c => (
|
||||
<Tr
|
||||
key={c.name}
|
||||
$selected={selected === c.name}
|
||||
onClick={() => setSelected(selected === c.name ? null : c.name)}
|
||||
>
|
||||
<Td $mono>
|
||||
<Code>{c.name}</Code>
|
||||
{c.callerFiles.length === 0 && (
|
||||
<> <Badge $variant="unused">unused</Badge></>
|
||||
)}
|
||||
</Td>
|
||||
<Td $mono><Code>{c.base ?? "—"}</Code></Td>
|
||||
<Td $align="right" $mono>{c.funcCount}</Td>
|
||||
<Td $align="right" $mono>
|
||||
<Badge>{c.callerFiles.length}</Badge>
|
||||
</Td>
|
||||
<Td $mono style={{ color: t.text.muted, fontSize: 10, maxWidth: 300 }}>
|
||||
{c.file}:{c.line}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
{selectedClass && (
|
||||
<DetailPanel>
|
||||
<DetailTitle>{selectedClass.name}</DetailTitle>
|
||||
<div style={{ fontSize: 11, color: t.text.muted, marginBottom: 10, fontFamily: t.font.mono }}>
|
||||
Rust struct <Code>{selectedClass.rustStruct}</Code> · base <Code>{selectedClass.base ?? "—"}</Code>
|
||||
{" · "}{selectedClass.funcCount} #[func] · defined at <Code>{selectedClass.file}:{selectedClass.line}</Code>
|
||||
</div>
|
||||
{selectedClass.callerFiles.length === 0 ? (
|
||||
<div style={{ color: t.sem.negative, fontSize: 12 }}>No GDScript references found.</div>
|
||||
) : (
|
||||
<CallerList>
|
||||
{selectedClass.callerFiles.map(file => {
|
||||
const lines = selectedClass.callerLines
|
||||
.filter(cl => cl.file === file)
|
||||
.map(cl => cl.line)
|
||||
.slice(0, 10);
|
||||
return (
|
||||
<CallerItem key={file}>
|
||||
<CallerFile>{file}</CallerFile>
|
||||
<CallerLines>L{lines.join(", L")}</CallerLines>
|
||||
</CallerItem>
|
||||
);
|
||||
})}
|
||||
</CallerList>
|
||||
)}
|
||||
</DetailPanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CallersTab() {
|
||||
const [search, setSearch] = useState("");
|
||||
const sorted = useMemo(() => {
|
||||
const list = data.classes.filter(c => c.callerFiles.length > 0);
|
||||
if (search) return list.filter(c =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.callerFiles.some(f => f.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
return list;
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterRow>
|
||||
<SearchInput
|
||||
placeholder="Filter by class or file…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: t.text.muted }}>{sorted.length} classes</span>
|
||||
</FilterRow>
|
||||
{sorted.map(c => (
|
||||
<DetailPanel key={c.name} style={{ marginBottom: 8, marginTop: 0, padding: "12px 16px" }}>
|
||||
<DetailTitle style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
<Code>{c.name}</Code>
|
||||
<span style={{ marginLeft: 10, fontFamily: "inherit", fontSize: 11, color: t.text.muted }}>
|
||||
{c.callerFiles.length} file{c.callerFiles.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</DetailTitle>
|
||||
<CallerList>
|
||||
{c.callerFiles.map(file => {
|
||||
const lines = c.callerLines.filter(cl => cl.file === file).map(cl => cl.line).slice(0, 12);
|
||||
return (
|
||||
<CallerItem key={file}>
|
||||
<CallerFile>{file}</CallerFile>
|
||||
<CallerLines>L{lines.join(", L")}{c.callerLines.filter(cl => cl.file === file).length > 12 ? "…" : ""}</CallerLines>
|
||||
</CallerItem>
|
||||
);
|
||||
})}
|
||||
</CallerList>
|
||||
</DetailPanel>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DepsTab() {
|
||||
const BRIDGES = new Set(["api-gdext", "api-wasm"]);
|
||||
const entries = Object.entries(data.crateDeps).sort((a, b) => {
|
||||
const ab = BRIDGES.has(a[0]), bb = BRIDGES.has(b[0]);
|
||||
if (ab && !bb) return -1;
|
||||
if (!ab && bb) return 1;
|
||||
return b[1].length - a[1].length;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: t.text.muted, marginBottom: 16 }}>
|
||||
{Object.keys(data.crateDeps).length} workspace crates.
|
||||
Bridge crates (<Code>api-gdext</Code>, <Code>api-wasm</Code>) highlighted.
|
||||
</div>
|
||||
<DepGrid>
|
||||
{entries.map(([crate, deps]) => (
|
||||
<DepCard key={crate} $isBridge={BRIDGES.has(crate)}>
|
||||
<DepCardTitle>{crate}</DepCardTitle>
|
||||
{deps.length === 0 ? (
|
||||
<div style={{ fontSize: 11, color: t.text.disabled }}>no internal deps</div>
|
||||
) : (
|
||||
<DepCardDeps>
|
||||
{deps.map(d => <DepCardDep key={d}>{d}</DepCardDep>)}
|
||||
</DepCardDeps>
|
||||
)}
|
||||
</DepCard>
|
||||
))}
|
||||
</DepGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GdClassesTab() {
|
||||
const [search, setSearch] = useState("");
|
||||
const collisionSet = new Set(data.collisions);
|
||||
const entries = useMemo(() => {
|
||||
const all = Object.entries(data.gdClassNames);
|
||||
return search
|
||||
? all.filter(([n, f]) => n.toLowerCase().includes(search.toLowerCase()) || f.toLowerCase().includes(search.toLowerCase()))
|
||||
: all;
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterRow>
|
||||
<SearchInput
|
||||
placeholder="Filter by name or file…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{data.collisions.length > 0 && (
|
||||
<Badge $variant="unused">⚠ {data.collisions.length} name collision{data.collisions.length !== 1 ? "s" : ""}</Badge>
|
||||
)}
|
||||
<span style={{ fontSize: 12, color: t.text.muted }}>{entries.length} classes</span>
|
||||
</FilterRow>
|
||||
<GdList>
|
||||
{entries.map(([name, file]) => (
|
||||
<GdRow key={name} $collision={collisionSet.has(name)}>
|
||||
<GdName>{name}</GdName>
|
||||
<GdFile title={file}>{file}</GdFile>
|
||||
{collisionSet.has(name) && <Badge $variant="unused">collision</Badge>}
|
||||
</GdRow>
|
||||
))}
|
||||
</GdList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TAB_LABELS: Record<TabId, string> = {
|
||||
surface: "Binding surface",
|
||||
callers: "Per-class callers",
|
||||
deps: "Crate deps",
|
||||
"gd-classes": "GD class_names",
|
||||
};
|
||||
|
||||
export function GdRustBridgePage(): React.ReactElement {
|
||||
const [tab, setTab] = useState<TabId>("surface");
|
||||
const ts = new Date(data.generatedAt).toLocaleString();
|
||||
const usedCount = data.classes.filter(c => c.callerFiles.length > 0).length;
|
||||
const totalGdRefs = data.classes.reduce((s, c) => s + c.callerFiles.length, 0);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<BackLink to="/">← back</BackLink>
|
||||
<Header>
|
||||
<h1>GDScript ↔ Rust Bridge</h1>
|
||||
<p>Generated {ts} · re-run: <code>python3 tools/gd-rust-relationships.py</code></p>
|
||||
</Header>
|
||||
|
||||
<Stats>
|
||||
<Stat><span>{data.classes.length}</span>exported classes</Stat>
|
||||
<Stat><span>{usedCount}</span>referenced from GD</Stat>
|
||||
<Stat><span>{totalGdRefs}</span>total GD–file links</Stat>
|
||||
<Stat><span>{Object.keys(data.crateDeps).length}</span>workspace crates</Stat>
|
||||
<Stat><span>{Object.keys(data.gdClassNames).length}</span>GD class_names</Stat>
|
||||
</Stats>
|
||||
|
||||
<Tabs>
|
||||
{(Object.keys(TAB_LABELS) as TabId[]).map(id => (
|
||||
<Tab key={id} $active={tab === id} onClick={() => setTab(id)}>
|
||||
{TAB_LABELS[id]}
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{tab === "surface" && <SurfaceTab />}
|
||||
{tab === "callers" && <CallersTab />}
|
||||
{tab === "deps" && <DepsTab />}
|
||||
{tab === "gd-classes" && <GdClassesTab />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -64,8 +64,9 @@ const routes = [
|
|||
{ path: "/promotion", label: "★ Promotion Picker — grid, lock states, prereqs" },
|
||||
{ path: "/stats", label: "📊 Statistics — Demographics, Graphs, Rankings, Histories" },
|
||||
{ path: "/end-game", label: "🏆 End-of-Game Summary — outcome, standings, awards, score graph" },
|
||||
{ path: "/past-games",label: "📚 Past Games — archived game cards, filters, sorts" },
|
||||
{ path: "/replay", label: "⏯ Replay Viewer — scrubber, event ticker, stats overlay" },
|
||||
{ path: "/past-games", label: "📚 Past Games — archived game cards, filters, sorts" },
|
||||
{ path: "/replay", label: "⏯ Replay Viewer — scrubber, event ticker, stats overlay" },
|
||||
{ path: "/gd-rust", label: "🦀 GD ↔ Rust Bridge — binding surface, callers, crate deps" },
|
||||
];
|
||||
|
||||
export function IndexPage(): React.ReactElement {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@
|
|||
"@game-data/*": ["../../../public/games/age-of-dwarves/data/*"],
|
||||
"@game-assets/*": ["../../../public/games/age-of-dwarves/assets/*"],
|
||||
"@resources/*": ["../../../public/resources/*"],
|
||||
"@audio-alts/*": ["../../../.local/audio-alternatives/*"]
|
||||
"@audio-alts/*": ["../../../.local/audio-alternatives/*"],
|
||||
"@reports/*": ["../../reports/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"../../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"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default defineConfig({
|
|||
"@resources": path.resolve(__dirname, "../../../public/resources"),
|
||||
"@audio-alts": path.resolve(__dirname, "../../../.local/audio-alternatives"),
|
||||
"@audio-staging": path.resolve(__dirname, "../../../.local/audio-staging"),
|
||||
"@reports": path.resolve(__dirname, "../../reports"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
@ -24,6 +25,7 @@ export default defineConfig({
|
|||
path.resolve(__dirname, "../../../public"),
|
||||
path.resolve(__dirname, "../../../.local/audio-alternatives"),
|
||||
path.resolve(__dirname, "../../../.local/audio-staging"),
|
||||
path.resolve(__dirname, "../../reports"),
|
||||
],
|
||||
},
|
||||
watch: {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@
|
|||
id: p2-45
|
||||
title: "Player elimination reconciliation — emit `player_eliminated` on every transition"
|
||||
priority: p2
|
||||
status: missing
|
||||
status: done
|
||||
scope: game1
|
||||
updated_at: 2026-04-30
|
||||
assigned_by: shipwright
|
||||
evidence:
|
||||
- "src/game/engine/src/entities/player.gd:36 — `is_eliminated: bool` flag added (default false), latches forever once set"
|
||||
- "src/game/engine/src/modules/victory/victory_manager.gd:54 — `_reconcile_eliminations()` called on every check_all() at turn-end; sweeps all players, sets is_eliminated + emits EventBus.player_eliminated for any newly-eliminated player exactly once"
|
||||
- "src/game/engine/src/modules/combat/combat_utils.gd:139 + src/game/engine/src/entities/combat_utils.gd:138 — both city-capture paths now check the latch before emitting and set it themselves; dedupes against the reconciliation sweep"
|
||||
- "src/game/engine/tests/unit/test_victory_manager.gd — 5/5 GUT tests passing on apricot (rehydrated from stub): test_reconciliation_emits_for_eliminated_player, test_reconciliation_latches_is_eliminated_flag, test_second_sweep_is_silent, test_survivor_does_not_trigger, test_already_eliminated_flag_skips_emit"
|
||||
- "Regression-clean: test_audio_manager.gd 18/18 + test_chronicle_coverage.gd all-pass after the change (apricot headless, 2026-04-30)"
|
||||
---
|
||||
## Summary
|
||||
|
||||
|
|
|
|||
3563
.project/reports/gd-rust-relationships.json
Normal file
3563
.project/reports/gd-rust-relationships.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -226,6 +226,7 @@ The model in this doc is the **target** spec; the code is partial.
|
|||
| Pathfinding (Rust A*) | (does not yet exist in Rust) | ⚠️ No Rust A* found in `mc-core/src/algorithms/`; existing pathfinding lives GDScript-side. `validate_centre_to_centre_move` is ready for the future Rust pathfinder to call. |
|
||||
| Renderer | `src/packages/guide/src/components/climate-sim/HexGLRenderer.tsx` | ✅ Hex tile rendering correct; no inner-hex / edge-slot overlay (debug-only feature pending) |
|
||||
| AI evaluator | `src/simulator/crates/mc-ai/src/evaluator.rs:414-480` | ⚠️ Scores formations by size + threat; does not yet model edge-slot positioning |
|
||||
| GDExtension bridge | `src/simulator/api-gdext/src/lib.rs::GdGameState::engagement_interceptor / validate_centre_to_centre_move` | ✅ Two Godot-callable primitives expose the edge model to GDScript. Combat preview UI consults `engagement_interceptor`; movement preview consults `validate_centre_to_centre_move` and branches on `reason` (`not_adjacent / wall_blocks / edge_occupied / adjacent_clean`). |
|
||||
|
||||
**Test coverage** (`cargo test -p mc-core --lib`): 70 tests (was 30 before this work) covering edge identity, passability, move validation, engagement interception, ZOC reach (centre + edge), formation slot model, blend-table lookup with production-JSON round-trip, cross-file schema guard (every blend → defined terrain), river-edges migration. `cargo test -p mc-mapgen --lib`: 24 tests including 3 river-determinism guards.
|
||||
|
||||
|
|
|
|||
|
|
@ -62,18 +62,15 @@
|
|||
"dwarf_grand_scout",
|
||||
"dwarf_grand_smith",
|
||||
"dwarf_graven_warrior",
|
||||
"dwarf_gyrocopter",
|
||||
"dwarf_hammerguard",
|
||||
"dwarf_high_engineer",
|
||||
"dwarf_high_sapper",
|
||||
"dwarf_high_smith",
|
||||
"dwarf_iron_hawk",
|
||||
"dwarf_iron_submarine",
|
||||
"dwarf_iron_vanguard",
|
||||
"dwarf_ironwarden",
|
||||
"dwarf_master_woodcutter",
|
||||
"dwarf_mithril_cruiser",
|
||||
"dwarf_mithril_hawk",
|
||||
"dwarf_mithril_vanguard",
|
||||
"dwarf_prospector",
|
||||
"dwarf_repeating_arbalest",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "airfield",
|
||||
"name": "Airfield",
|
||||
"description": "A flat expanse carved from the mountainside with launch rails and landing strips. Required for all air unit production.",
|
||||
"name": "Airship Yard",
|
||||
"description": "Mooring masts, gas-bag bays, and a small steam-pumping station. The first dwarven foothold in the air — built to assemble and launch armored airships. Fixed-wing flight remains beyond Game-1 dwarven engineering.",
|
||||
"placement": "city",
|
||||
"category": "military",
|
||||
"school": null,
|
||||
|
|
@ -32,8 +32,6 @@
|
|||
]
|
||||
},
|
||||
"produces": [
|
||||
"dwarf_gyrocopter",
|
||||
"dwarf_iron_hawk",
|
||||
"dwarf_steam_bomber"
|
||||
],
|
||||
"stack_mode": "parallel"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
"strike_walker"
|
||||
],
|
||||
"stack_mode": "parallel",
|
||||
"requires_existing": "walker_yard",
|
||||
"requires_existing": null,
|
||||
"consumes_existing": false
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"iron_sentinel",
|
||||
"dwarf_steam_golem"
|
||||
],
|
||||
"stack_mode": "parallel"
|
||||
"stack_mode": "parallel",
|
||||
"requires_existing": "tank_yard"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@
|
|||
},
|
||||
"produces": [
|
||||
"dwarf_war_zeppelin",
|
||||
"dwarf_mithril_hawk",
|
||||
"dwarf_sky_fortress"
|
||||
],
|
||||
"stack_mode": "parallel",
|
||||
|
|
|
|||
|
|
@ -1,26 +1,84 @@
|
|||
extends GutTest
|
||||
## VictoryManager unit tests — PENDING until VictoryManager is implemented.
|
||||
## VictoryManager — elimination reconciliation pass (p2-45).
|
||||
##
|
||||
## `src/game/engine/src/modules/victory/victory_manager.gd` is a
|
||||
## 2-line stub (`class_name VictoryManager extends RefCounted`).
|
||||
## None of the methods the original tests exercised (`get_score`,
|
||||
## `get_scores`, `check_victory`) and none of the score constants
|
||||
## (`SCORE_CITY`, `SCORE_POP`, `SCORE_TECH`, `SCORE_UNIT`) exist.
|
||||
## `victory_manager._reconcile_eliminations` fires
|
||||
## `EventBus.player_eliminated` exactly once per player whose
|
||||
## (cities ∪ living-founder) set transitions to empty. The
|
||||
## `is_eliminated` latch on PlayerScript dedupes against the existing
|
||||
## combat-utils emit so per-turn sweeps don't double-fire.
|
||||
##
|
||||
## There is also no Rust-side bridge — `mc-turn::victory` or a
|
||||
## `GdVictoryManager` extension has not been authored yet.
|
||||
## Reported to team-lead for iter 7n+.
|
||||
##
|
||||
## The original tests also referenced Player/City/Unit fields that
|
||||
## were dropped in iter 7i/7l (`is_player_controlled`, `city_name`,
|
||||
## `population`, `owned_tiles`, `type_id`, `id`). Rehydrating these
|
||||
## tests will require re-authoring both sides against the current
|
||||
## entity API.
|
||||
## (The earlier stub of this file pre-dates the VictoryManager port —
|
||||
## the manager has been real since iter 7n. Rehydrated here for p2-45.)
|
||||
|
||||
const VictoryManagerScript: GDScript = preload("res://engine/src/modules/victory/victory_manager.gd")
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
|
||||
|
||||
func test_victory_manager_pending() -> void:
|
||||
pending(
|
||||
"VictoryManager is a 2-line stub in iter 7n — see"
|
||||
+ " src/game/engine/src/modules/victory/victory_manager.gd."
|
||||
+ " No methods to exercise yet; rehydrate when implemented."
|
||||
)
|
||||
func before_all() -> void:
|
||||
DataLoader.load_theme("age-of-dwarves")
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
GameState.players.clear()
|
||||
var human: RefCounted = PlayerScript.new()
|
||||
human.player_name = "Player"
|
||||
human.is_human = true
|
||||
human.index = 0
|
||||
# Survivor: keeps a phantom city Dictionary so cities.size() > 0.
|
||||
human.cities = [{"display_name": "Khazad-dum"}]
|
||||
GameState.players.append(human)
|
||||
var ai: RefCounted = PlayerScript.new()
|
||||
ai.player_name = "Rival"
|
||||
ai.is_human = false
|
||||
ai.index = 1
|
||||
# Eliminated: no cities, no founder. Reconciliation should fire on first sweep.
|
||||
ai.cities = []
|
||||
ai.units = []
|
||||
GameState.players.append(ai)
|
||||
GameState.current_player_index = 0
|
||||
|
||||
|
||||
func _capture_eliminations() -> Array[int]:
|
||||
var captured: Array[int] = []
|
||||
var handler: Callable = func(idx: int) -> void: captured.append(idx)
|
||||
EventBus.player_eliminated.connect(handler)
|
||||
var vm: RefCounted = VictoryManagerScript.new()
|
||||
vm.call("_reconcile_eliminations")
|
||||
EventBus.player_eliminated.disconnect(handler)
|
||||
return captured
|
||||
|
||||
|
||||
func test_reconciliation_emits_for_eliminated_player() -> void:
|
||||
var captured: Array[int] = _capture_eliminations()
|
||||
assert_eq(captured.size(), 1, "AI with no cities + no founder must trigger one emit")
|
||||
assert_eq(captured[0], 1, "the eliminated player's index must be the rival (1)")
|
||||
|
||||
|
||||
func test_reconciliation_latches_is_eliminated_flag() -> void:
|
||||
var ai: PlayerScript = GameState.players[1] as PlayerScript
|
||||
assert_false(ai.is_eliminated, "starts un-latched")
|
||||
_capture_eliminations()
|
||||
assert_true(ai.is_eliminated, "first sweep latches is_eliminated to true")
|
||||
|
||||
|
||||
func test_second_sweep_is_silent() -> void:
|
||||
# First sweep emits and latches; a second sweep on the same turn must
|
||||
# NOT re-emit. This is the dedupe contract.
|
||||
_capture_eliminations()
|
||||
var second: Array[int] = _capture_eliminations()
|
||||
assert_eq(second.size(), 0, "second reconciliation sweep must not re-emit")
|
||||
|
||||
|
||||
func test_survivor_does_not_trigger() -> void:
|
||||
var captured: Array[int] = _capture_eliminations()
|
||||
for idx: int in captured:
|
||||
assert_ne(idx, 0, "the human (still has a city) must NOT be in the eliminated set")
|
||||
|
||||
|
||||
func test_already_eliminated_flag_skips_emit() -> void:
|
||||
# Simulates combat_utils having already announced the elimination —
|
||||
# reconciliation must respect the latch and stay silent.
|
||||
var ai: PlayerScript = GameState.players[1] as PlayerScript
|
||||
ai.is_eliminated = true
|
||||
var captured: Array[int] = _capture_eliminations()
|
||||
assert_eq(captured.size(), 0, "reconciliation must not re-emit when is_eliminated already true")
|
||||
|
|
|
|||
|
|
@ -2387,6 +2387,106 @@ impl GdGameState {
|
|||
}
|
||||
arr
|
||||
}
|
||||
|
||||
// ── Edge slot bridge (HEX_GEOMETRY.md §5, §7) ─────────────────────────
|
||||
//
|
||||
// Three Godot-callable primitives exposing the centre + 6 edge slots
|
||||
// model to GDScript: combat preview asks "is there an interceptor on
|
||||
// the edge?", movement preview asks "can this unit cross the edge?".
|
||||
|
||||
/// Look up the edge interceptor between two adjacent hex centres.
|
||||
///
|
||||
/// Returns a Dictionary:
|
||||
/// - `has_interceptor`: bool
|
||||
/// - `unit_id`: int (only valid when has_interceptor)
|
||||
/// - `owner_player_id`: int (only valid when has_interceptor)
|
||||
/// - `aligned_to`: Vector2i (parent hex of the interceptor, only valid when present)
|
||||
///
|
||||
/// Returns `has_interceptor: false` for non-adjacent hexes or vacant edges.
|
||||
/// Per `HEX_GEOMETRY.md` §5, an edge unit is hit before the defender's
|
||||
/// centre — combat preview UIs must surface this to the player.
|
||||
#[func]
|
||||
pub fn engagement_interceptor(
|
||||
&self,
|
||||
atk_q: i64,
|
||||
atk_r: i64,
|
||||
def_q: i64,
|
||||
def_r: i64,
|
||||
) -> Dictionary {
|
||||
let mut d = Dictionary::new();
|
||||
let interceptor = self.inner.grid.as_ref().and_then(|g| {
|
||||
g.engagement_interceptor((atk_q as i32, atk_r as i32), (def_q as i32, def_r as i32))
|
||||
});
|
||||
match interceptor {
|
||||
Some(occ) => {
|
||||
d.set("has_interceptor", true);
|
||||
d.set("unit_id", occ.unit_id as i64);
|
||||
d.set("owner_player_id", occ.owner_player_id as i64);
|
||||
d.set(
|
||||
"aligned_to",
|
||||
Vector2i::new(occ.aligned_to.0, occ.aligned_to.1),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
d.set("has_interceptor", false);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Validate a single-step centre-to-centre move for `player_id`.
|
||||
///
|
||||
/// Returns a Dictionary:
|
||||
/// - `ok`: bool
|
||||
/// - `reason`: String — one of `"adjacent_clean"` (when ok=true),
|
||||
/// `"not_adjacent"`, `"wall_blocks"`, `"edge_occupied"` (when ok=false)
|
||||
///
|
||||
/// Movement preview UIs branch on `reason` to show the appropriate
|
||||
/// player feedback (greyed path, wall icon, enemy unit at edge).
|
||||
#[func]
|
||||
pub fn validate_centre_to_centre_move(
|
||||
&self,
|
||||
from_q: i64,
|
||||
from_r: i64,
|
||||
to_q: i64,
|
||||
to_r: i64,
|
||||
player_id: i64,
|
||||
) -> Dictionary {
|
||||
use mc_core::grid::MoveBlockedReason;
|
||||
let mut d = Dictionary::new();
|
||||
let result = self.inner.grid.as_ref().map(|g| {
|
||||
g.validate_centre_to_centre_move(
|
||||
(from_q as i32, from_r as i32),
|
||||
(to_q as i32, to_r as i32),
|
||||
player_id as u32,
|
||||
)
|
||||
});
|
||||
match result {
|
||||
Some(Ok(_edge)) => {
|
||||
d.set("ok", true);
|
||||
d.set("reason", GString::from("adjacent_clean"));
|
||||
}
|
||||
Some(Err(MoveBlockedReason::NotAdjacent)) => {
|
||||
d.set("ok", false);
|
||||
d.set("reason", GString::from("not_adjacent"));
|
||||
}
|
||||
Some(Err(MoveBlockedReason::WallBlocks)) => {
|
||||
d.set("ok", false);
|
||||
d.set("reason", GString::from("wall_blocks"));
|
||||
}
|
||||
Some(Err(MoveBlockedReason::EdgeOccupied)) => {
|
||||
d.set("ok", false);
|
||||
d.set("reason", GString::from("edge_occupied"));
|
||||
}
|
||||
None => {
|
||||
// No grid loaded — treat as not-adjacent fallback so UI
|
||||
// doesn't try to draw a movement preview.
|
||||
d.set("ok", false);
|
||||
d.set("reason", GString::from("no_grid"));
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
// ── GdTurnProcessor ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -201,6 +201,43 @@ def parse_crate_deps(sim_root: Path) -> dict[str, set[str]]:
|
|||
return deps
|
||||
|
||||
|
||||
def build_json(
|
||||
classes: list[RustClass],
|
||||
gd_classes: dict[str, Path],
|
||||
deps: dict[str, set[str]],
|
||||
) -> dict:
|
||||
rust_names = {c.name for c in classes}
|
||||
return {
|
||||
"generatedAt": __import__("datetime").datetime.utcnow().isoformat() + "Z",
|
||||
"classes": [
|
||||
{
|
||||
"name": c.name,
|
||||
"rustStruct": c.rust_struct,
|
||||
"base": c.base,
|
||||
"file": str(c.file.relative_to(REPO_ROOT)),
|
||||
"line": c.line,
|
||||
"funcCount": c.func_count,
|
||||
"callerFiles": sorted(
|
||||
{str(p.relative_to(REPO_ROOT)) for p, _ in c.callers}
|
||||
),
|
||||
"callerLines": [
|
||||
{"file": str(p.relative_to(REPO_ROOT)), "line": ln}
|
||||
for p, ln in sorted(c.callers, key=lambda x: (str(x[0]), x[1]))
|
||||
],
|
||||
}
|
||||
for c in sorted(classes, key=lambda x: x.name)
|
||||
],
|
||||
"gdClassNames": {
|
||||
name: str(path.relative_to(REPO_ROOT))
|
||||
for name, path in sorted(gd_classes.items())
|
||||
},
|
||||
"collisions": sorted(set(gd_classes) & rust_names),
|
||||
"crateDeps": {
|
||||
crate: sorted(ds) for crate, ds in sorted(deps.items())
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def render_report(
|
||||
classes: list[RustClass],
|
||||
gd_classes: dict[str, Path],
|
||||
|
|
@ -296,6 +333,8 @@ def render_report(
|
|||
|
||||
|
||||
def main() -> int:
|
||||
import json
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument(
|
||||
"--out",
|
||||
|
|
@ -303,6 +342,7 @@ def main() -> int:
|
|||
default=REPO_ROOT / ".project" / "reports" / "gd-rust-relationships.md",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
json_out = args.out.with_suffix(".json")
|
||||
|
||||
if not GDEXT_SRC.is_dir():
|
||||
print(f"error: {GDEXT_SRC} not found", file=sys.stderr)
|
||||
|
|
@ -314,9 +354,11 @@ def main() -> int:
|
|||
gd_classes = parse_gd_class_names(gd_files)
|
||||
deps = parse_crate_deps(SIM_ROOT)
|
||||
|
||||
report = render_report(classes, gd_classes, deps)
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(report, encoding="utf-8")
|
||||
args.out.write_text(render_report(classes, gd_classes, deps), encoding="utf-8")
|
||||
json_out.write_text(
|
||||
json.dumps(build_json(classes, gd_classes, deps), indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
referenced = sum(1 for c in classes if c.callers)
|
||||
print(
|
||||
|
|
@ -324,7 +366,7 @@ def main() -> int:
|
|||
f"{len(list(GDEXT_SRC.rglob('*.rs')))} api-gdext files; "
|
||||
f"{referenced} referenced from {len(gd_files)} .gd files. "
|
||||
f"Crates: {len(deps)}. "
|
||||
f"Report: {args.out.relative_to(REPO_ROOT)}"
|
||||
f"Markdown: {args.out.relative_to(REPO_ROOT)} JSON: {json_out.relative_to(REPO_ROOT)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue