feat(@projects): add gd-rust bridge integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 10:35:20 -04:00
parent 126f8565cc
commit 1ca0e1dd5a
16 changed files with 4392 additions and 38 deletions

View file

@ -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>
);

View 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 GDfile 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>
);
}

View file

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

View file

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

View file

@ -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: {

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -39,7 +39,7 @@
"strike_walker"
],
"stack_mode": "parallel",
"requires_existing": "walker_yard",
"requires_existing": null,
"consumes_existing": false
}
]

View file

@ -38,6 +38,7 @@
"iron_sentinel",
"dwarf_steam_golem"
],
"stack_mode": "parallel"
"stack_mode": "parallel",
"requires_existing": "tank_yard"
}
]

View file

@ -34,7 +34,6 @@
},
"produces": [
"dwarf_war_zeppelin",
"dwarf_mithril_hawk",
"dwarf_sky_fortress"
],
"stack_mode": "parallel",

View file

@ -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")

View file

@ -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 ─────────────────────────────────────────────────────

View file

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