feat(@projects): ✨ add treaty lifecycle navigation
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
caad84663d
commit
5e31b209ec
10 changed files with 767 additions and 2 deletions
|
|
@ -10,6 +10,7 @@ import { FreepeopleLifecyclePage } from "./pages/FreepeopleLifecycle";
|
|||
import { TerrainEcologyPage } from "./pages/TerrainEcology";
|
||||
import { FoodWebPage } from "./pages/FoodWeb";
|
||||
import { HarvestPoliciesPage } from "./pages/HarvestPolicies";
|
||||
import { TreatyLifecyclePage } from "./pages/TreatyLifecycle";
|
||||
import { CombatPreviewPage } from "./pages/CombatPreview";
|
||||
import { CombatCalculatorPage } from "./pages/CombatCalculator";
|
||||
import { PermutationsPage } from "./pages/Permutations";
|
||||
|
|
@ -72,6 +73,7 @@ export function App(): React.ReactElement {
|
|||
<Route path="/terrain-ecology" element={<TerrainEcologyPage />} />
|
||||
<Route path="/food-web" element={<FoodWebPage />} />
|
||||
<Route path="/harvest-policies" element={<HarvestPoliciesPage />} />
|
||||
<Route path="/treaty-lifecycle" element={<TreatyLifecyclePage />} />
|
||||
<Route path="/trees" element={<BuildingTreesPage />} />
|
||||
<Route path="/promotion" element={<PromotionPickerPage />} />
|
||||
<Route path="/stats" element={<StatisticsPage />} />
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ const routeCategories: RouteCategory[] = [
|
|||
{
|
||||
label: "Diplomacy & Empire",
|
||||
routes: [
|
||||
{ path: "/diplomacy", label: "☮ Diplomacy — foreign relations matrix, deal builder, treaty options" },
|
||||
{ path: "/diplomacy", label: "☮ Diplomacy — clan personalities (real axes), 5-state ladder, tribute, treaties" },
|
||||
{ path: "/treaty-lifecycle", label: "📜 Treaty Lifecycle — durations, renewal mechanics, breach, edge cases" },
|
||||
{ path: "/empire", label: "🏛 Empire Dashboard — multi-city overview, sortable columns, yields" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
285
.project/designs/app/src/pages/TreatyLifecycle.tsx
Normal file
285
.project/designs/app/src/pages/TreatyLifecycle.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { type ReactElement } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { t } from "../theme";
|
||||
|
||||
interface TreatyRules {
|
||||
description: string;
|
||||
default_durations_turns: Record<string, number | null>;
|
||||
renewal: {
|
||||
description: string;
|
||||
auto_prompt_turns_before_expiry: number;
|
||||
relationship_bonus_to_acceptance: number;
|
||||
cost_multiplier_by_state: Record<string, number>;
|
||||
auto_renew_flag: { description: string; default: boolean };
|
||||
};
|
||||
breach: Record<string, {
|
||||
breaks: string;
|
||||
reputation_delta_per_clan?: number;
|
||||
reputation_delta_with_partner?: number;
|
||||
reputation_delta?: number;
|
||||
influence_decay_per_turn?: number;
|
||||
raid_resumes_after_turns?: number;
|
||||
gold_refund_fraction?: number;
|
||||
description: string;
|
||||
}>;
|
||||
edge_cases: { id: string; description: string }[];
|
||||
}
|
||||
|
||||
import rulesJson from "@resources/diplomacy/treaty_rules.json";
|
||||
const RULES = rulesJson as unknown as TreatyRules;
|
||||
|
||||
const STATE_COLOR: Record<string, string> = {
|
||||
hostile: "#d95940",
|
||||
wary: "#e69933",
|
||||
neutral: "#ccbf73",
|
||||
friendly: "#7cd9a0",
|
||||
allied: "#66bfff",
|
||||
};
|
||||
|
||||
const PageWrap = styled.div`
|
||||
background: ${t.bg.menu};
|
||||
min-height: 100vh;
|
||||
color: ${t.text.primary};
|
||||
font-family: ${t.font.body};
|
||||
padding-bottom: 60px;
|
||||
`;
|
||||
const Header = styled.div`
|
||||
background: ${t.bg.panel};
|
||||
border-bottom: 1px solid ${t.border.panel};
|
||||
padding: 14px 28px;
|
||||
display: flex; align-items: center; gap: 24px;
|
||||
`;
|
||||
const HeaderTitle = styled.div`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 24px; 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 Content = styled.div`
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
`;
|
||||
const Intro = styled.div`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: ${t.radius.panel};
|
||||
padding: 14px 18px;
|
||||
font-size: 13px; color: ${t.text.secondary};
|
||||
line-height: 1.6;
|
||||
& strong { color: ${t.accent.gold}; }
|
||||
& code { color: ${t.accent.science}; font-family: ${t.font.mono}; font-size: 12px; }
|
||||
`;
|
||||
const Header2 = styled.h2`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 16px; color: ${t.accent.gold};
|
||||
margin: 8px 0 4px;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
const Sub = styled.div`
|
||||
font-size: 11px; color: ${t.text.muted};
|
||||
font-family: ${t.font.mono};
|
||||
`;
|
||||
const Table = styled.table`
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-family: ${t.font.mono};
|
||||
th, td {
|
||||
padding: 6px 12px; text-align: left;
|
||||
border-bottom: 1px solid ${t.border.divider};
|
||||
}
|
||||
th {
|
||||
color: ${t.text.muted};
|
||||
font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; font-weight: 600;
|
||||
}
|
||||
td { color: ${t.text.primary}; }
|
||||
`;
|
||||
const Pill = styled.span<{ $color: string }>`
|
||||
display: inline-block;
|
||||
padding: 1px 7px; border-radius: 8px;
|
||||
font-size: 10px; font-family: ${t.font.mono};
|
||||
background: ${p => p.$color + "22"};
|
||||
color: ${p => p.$color};
|
||||
border: 1px solid ${p => p.$color + "55"};
|
||||
margin-right: 4px;
|
||||
`;
|
||||
const Card = styled.div<{ $color: string }>`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-left: 3px solid ${p => p.$color};
|
||||
border-radius: ${t.radius.panel};
|
||||
padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
`;
|
||||
const CardName = styled.div<{ $color: string }>`
|
||||
font-family: ${t.font.heading};
|
||||
font-size: 14px; color: ${p => p.$color};
|
||||
margin-bottom: 2px;
|
||||
`;
|
||||
const CardDesc = styled.div`
|
||||
font-size: 12px;
|
||||
color: ${t.text.secondary};
|
||||
line-height: 1.5;
|
||||
`;
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
function fmtDuration(turns: number | null): string {
|
||||
if (turns === null) return "ongoing-while-conditions-hold";
|
||||
return `${turns} turns`;
|
||||
}
|
||||
|
||||
function fmtAgreementName(id: string): string {
|
||||
return id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function TreatyLifecyclePage(): ReactElement {
|
||||
return (
|
||||
<PageWrap>
|
||||
<Header>
|
||||
<BackLink to="/">← back</BackLink>
|
||||
<HeaderTitle>Treaty Lifecycle</HeaderTitle>
|
||||
<SourceTag>
|
||||
driven by @resources/diplomacy/treaty_rules.json + mc-trade::lib::*Agreement
|
||||
</SourceTag>
|
||||
</Header>
|
||||
|
||||
<Content>
|
||||
<Intro>
|
||||
{RULES.description}
|
||||
<br /><br />
|
||||
<strong>What's shipping:</strong> turn counters + war-breaks-all behavior live in mc-trade.
|
||||
<strong> What's authored but not yet wired into Rust:</strong> renewal rules, voluntary
|
||||
cancellation, auto-renew flag, per-state cost multipliers. This page documents the
|
||||
authoritative ruleset; the Rust side picks up the JSON via <code>mc-trade::config</code>
|
||||
on a future objective.
|
||||
</Intro>
|
||||
|
||||
<Header2>Default Durations by Agreement Type</Header2>
|
||||
<Sub>standard length when no caller-supplied duration is provided · null = ongoing while conditions hold</Sub>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr><th>Agreement</th><th>Default duration</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(RULES.default_durations_turns).map(([id, turns]) => {
|
||||
const isShipped = ["open_borders", "shared_map_window", "luxury_swap"].includes(id);
|
||||
const isTribute = id.startsWith("tribute_");
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td>{fmtAgreementName(id)}</td>
|
||||
<td>{fmtDuration(turns)}</td>
|
||||
<td>
|
||||
{isShipped && <Pill $color="#7cd9a0">shipped (mc-trade)</Pill>}
|
||||
{isTribute && <Pill $color="#a07cc9">freepeople tribute</Pill>}
|
||||
{!isShipped && !isTribute && <Pill $color="#e69933">proposed (Game 1.5+)</Pill>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<Header2>Renewal Mechanics</Header2>
|
||||
<Intro>{RULES.renewal.description}</Intro>
|
||||
<Sub>auto-prompt {RULES.renewal.auto_prompt_turns_before_expiry} turns before expiry · acceptance bonus +{RULES.renewal.relationship_bonus_to_acceptance} on the AI's trade_willingness axis</Sub>
|
||||
<Header2>Renewal Cost Multiplier by Diplomatic State</Header2>
|
||||
<Grid>
|
||||
{Object.entries(RULES.renewal.cost_multiplier_by_state).map(([state, mult]) => {
|
||||
const color = STATE_COLOR[state] ?? "#888";
|
||||
return (
|
||||
<Card key={state} $color={color}>
|
||||
<CardName $color={color}>{state.toUpperCase()}</CardName>
|
||||
<CardDesc>
|
||||
Renewal payment ×<strong style={{ color }}>{mult}</strong> the original signing cost.
|
||||
{mult > 1 && " — relationship-tax for hostile/wary partners."}
|
||||
{mult === 1 && " — baseline; same as initial signing."}
|
||||
{mult < 1 && " — friendly+ partners discount."}
|
||||
</CardDesc>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<Card $color="#a07cc9">
|
||||
<CardName $color="#a07cc9">Auto-Renew Flag</CardName>
|
||||
<CardDesc>
|
||||
{RULES.renewal.auto_renew_flag.description}{" "}
|
||||
Default: <strong>{RULES.renewal.auto_renew_flag.default ? "ON" : "OFF"}</strong>.
|
||||
</CardDesc>
|
||||
</Card>
|
||||
|
||||
<Header2>Breach & Cancellation</Header2>
|
||||
<Grid>
|
||||
{Object.entries(RULES.breach).map(([id, b]) => {
|
||||
const isWar = id === "war_declaration";
|
||||
const color = isWar ? "#d95940" : "#e69933";
|
||||
return (
|
||||
<Card key={id} $color={color}>
|
||||
<CardName $color={color}>{fmtAgreementName(id)}</CardName>
|
||||
<CardDesc>{b.description}</CardDesc>
|
||||
<div>
|
||||
<Pill $color={color}>breaks: {b.breaks}</Pill>
|
||||
{b.reputation_delta_per_clan !== undefined && (
|
||||
<Pill $color="#d95940">rep / clan: {b.reputation_delta_per_clan}</Pill>
|
||||
)}
|
||||
{b.reputation_delta_with_partner !== undefined && (
|
||||
<Pill $color="#d95940">rep w/ partner: {b.reputation_delta_with_partner}</Pill>
|
||||
)}
|
||||
{b.influence_decay_per_turn !== undefined && (
|
||||
<Pill $color="#e69933">influence: {b.influence_decay_per_turn}/turn</Pill>
|
||||
)}
|
||||
{b.raid_resumes_after_turns !== undefined && (
|
||||
<Pill $color="#e69933">raid resumes after: {b.raid_resumes_after_turns}t</Pill>
|
||||
)}
|
||||
{b.gold_refund_fraction !== undefined && (
|
||||
<Pill $color="#888">gold refund: {(b.gold_refund_fraction * 100).toFixed(0)}%</Pill>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
<Header2>Edge Cases (Authoritative Resolution)</Header2>
|
||||
<Grid>
|
||||
{RULES.edge_cases.map(ec => (
|
||||
<Card key={ec.id} $color="#bbb09a">
|
||||
<CardName $color="#bbb09a">{fmtAgreementName(ec.id)}</CardName>
|
||||
<CardDesc>{ec.description}</CardDesc>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Header2>What Ships in Game 1 vs. What's Proposed</Header2>
|
||||
<Intro>
|
||||
<strong>Already in Rust (mc-trade):</strong> agreement structs with <code>turns_remaining</code>,
|
||||
per-turn decrement, expiry events (<code>OpenBordersExpired</code>, <code>SharedMapDurationExpired</code>),
|
||||
war-breaks-all, courier-intercepted void.
|
||||
<br /><br />
|
||||
<strong>Authored in JSON, awaiting Rust wire-up:</strong> default duration constants,
|
||||
renewal mechanics (auto-prompt, cost multiplier per state, auto-renew flag), voluntary
|
||||
cancellation with reputation cost, tribute-interruption decay rules.
|
||||
<br /><br />
|
||||
<strong>Future agreement types (Game 1.5+):</strong> Defensive Pact (30t), Declaration of
|
||||
Friendship (30t), Research Agreement (until-tech-completes). Each will need a per-clan
|
||||
acceptance heuristic in <code>mc-ai/diplomacy.rs</code> mirroring the existing OpenBorders +
|
||||
SharedMap pattern.
|
||||
</Intro>
|
||||
</Content>
|
||||
</PageWrap>
|
||||
);
|
||||
}
|
||||
|
|
@ -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/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/borders.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/foodweb.tsx","./src/pages/freepeoplelifecycle.tsx","./src/pages/gamesetup.tsx","./src/pages/gdrustbridge.tsx","./src/pages/gdrustmap.tsx","./src/pages/greatpeople.tsx","./src/pages/greatworks.tsx","./src/pages/harvestpolicies.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/specialists.tsx","./src/pages/statistics.tsx","./src/pages/techtree.tsx","./src/pages/terrainecology.tsx","./src/pages/throneroom.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/borders.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/foodweb.tsx","./src/pages/freepeoplelifecycle.tsx","./src/pages/gamesetup.tsx","./src/pages/gdrustbridge.tsx","./src/pages/gdrustmap.tsx","./src/pages/greatpeople.tsx","./src/pages/greatworks.tsx","./src/pages/harvestpolicies.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/specialists.tsx","./src/pages/statistics.tsx","./src/pages/techtree.tsx","./src/pages/terrainecology.tsx","./src/pages/throneroom.tsx","./src/pages/treatylifecycle.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"}
|
||||
84
public/resources/diplomacy/treaty_rules.json
Normal file
84
public/resources/diplomacy/treaty_rules.json
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"id": "treaty_rules",
|
||||
"name": "Treaty Length & Renewal Rules",
|
||||
"description": "Authoritative default durations, renewal mechanics, breach rules, and reputation costs for all bilateral agreement types. Read by mc-trade and mc-ai::diplomacy at agreement creation + per-turn step.",
|
||||
"default_durations_turns": {
|
||||
"open_borders": 20,
|
||||
"shared_map_window": 15,
|
||||
"luxury_swap": 30,
|
||||
"defensive_pact": 30,
|
||||
"declaration_friendship": 30,
|
||||
"research_agreement": null,
|
||||
"tribute_non_aggression": null,
|
||||
"tribute_mercenary": null,
|
||||
"tribute_luxury_patron": null
|
||||
},
|
||||
"renewal": {
|
||||
"description": "When turns_remaining drops below 5, the AI evaluates a renewal offer using the same axis-based heuristic as the original signing, plus a +1 trade_willingness bonus for 'ongoing relationship'. The player is auto-prompted 5 turns before expiry.",
|
||||
"auto_prompt_turns_before_expiry": 5,
|
||||
"relationship_bonus_to_acceptance": 1,
|
||||
"cost_multiplier_by_state": {
|
||||
"hostile": 2,
|
||||
"wary": 1.5,
|
||||
"neutral": 1,
|
||||
"friendly": 0.5,
|
||||
"allied": 0.25
|
||||
},
|
||||
"auto_renew_flag": {
|
||||
"description": "Player can mark an agreement auto_renew=true. The simulator attempts renewal automatically each cycle, drawing payment from the player's gold pool, capped by a player-set sliding budget. Auto-renew aborts if budget insufficient OR partner switches to hostile.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"breach": {
|
||||
"war_declaration": {
|
||||
"breaks": "all_bilateral",
|
||||
"reputation_delta_per_clan": -25,
|
||||
"description": "Declaring war breaks all OpenBorders/SharedMap/LuxurySwap involving the warring pair, plus -25 reputation with every other clan that observed the breach."
|
||||
},
|
||||
"voluntary_cancellation": {
|
||||
"breaks": "single",
|
||||
"reputation_delta_with_partner": -15,
|
||||
"gold_refund_fraction": 0,
|
||||
"description": "Player can cancel an agreement early. Partner's relation drops by 15. No gold refund (payment already spent)."
|
||||
},
|
||||
"tribute_interruption": {
|
||||
"breaks": "single",
|
||||
"influence_decay_per_turn": -1,
|
||||
"raid_resumes_after_turns": 3,
|
||||
"description": "Stopping tribute payment to a freepeople camp shifts diplomatic state downward at -1 influence/turn. After 3 turns at lapsed tribute, raid_multiplier returns to baseline for that state."
|
||||
},
|
||||
"courier_intercepted": {
|
||||
"breaks": "shared_map_single",
|
||||
"reputation_delta": 0,
|
||||
"description": "If the SharedMap courier is intercepted, the deal is voided but no reputation cost — both parties acted in good faith. Payment is non-refundable."
|
||||
}
|
||||
},
|
||||
"edge_cases": [
|
||||
{
|
||||
"id": "war_during_renewal_window",
|
||||
"description": "If war is declared during the 5-turn renewal-prompt window, the auto-prompt is suppressed and the agreement breaks normally on war."
|
||||
},
|
||||
{
|
||||
"id": "partner_eliminated",
|
||||
"description": "If the partner clan is eliminated mid-treaty, the agreement is silently voided. No reputation impact (no-one left to offend)."
|
||||
},
|
||||
{
|
||||
"id": "tribute_during_war",
|
||||
"description": "Tribute to a freepeople camp continues even if the player is at war with another player. Camps are independent factions and don't enter player-vs-player wars."
|
||||
},
|
||||
{
|
||||
"id": "shared_map_pre_delivery_war",
|
||||
"description": "If war breaks before the SharedMap courier delivers, the deal voids and the courier returns. Payment retained by the original recipient."
|
||||
}
|
||||
],
|
||||
"encyclopedia": {
|
||||
"category": "civilization",
|
||||
"entry_type": "ruleset",
|
||||
"detail_route": "/treaty-lifecycle",
|
||||
"tags": [
|
||||
"diplomacy",
|
||||
"treaty",
|
||||
"renewal"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -39,5 +39,13 @@ criterion = { version = "0.5", features = ["html_reports"] }
|
|||
name = "tactical_state_build"
|
||||
harness = false
|
||||
|
||||
# p1-30b cycle 6 — MCTS rollout throughput at varying rollout counts. Probes
|
||||
# whether `Tree::simulate_parallel`'s rayon-based root parallelism is actually
|
||||
# yielding speedup at the rollout counts the live MCTS path uses (typically
|
||||
# 1k-20k per decision under the wall-clock budget).
|
||||
[[bench]]
|
||||
name = "mcts_rollout_throughput"
|
||||
harness = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
115
src/simulator/crates/mc-ai/benches/mcts_rollout_throughput.rs
Normal file
115
src/simulator/crates/mc-ai/benches/mcts_rollout_throughput.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#![allow(missing_docs)]
|
||||
//! MCTS rollout throughput microbench (p1-30b cycle 6).
|
||||
//!
|
||||
//! Measures `Tree::simulate_parallel` throughput at 1k / 5k / 20k rollouts on a
|
||||
//! synthetic `TreeState` with a configurable per-rollout work budget. The work
|
||||
//! function (`rollout_fn`) does a small CPU-bound integer hash loop sized to
|
||||
//! roughly match the cost of a real game rollout (~10-100μs per rollout) so the
|
||||
//! parallelism gain is observable; if the rollout itself is sub-microsecond,
|
||||
//! rayon dispatch overhead dominates and parallelism doesn't show.
|
||||
//!
|
||||
//! # Determinism
|
||||
//!
|
||||
//! All benches set a fixed `base_seed` so output is byte-identical across runs.
|
||||
//! The `clan_rollout_divergence` integration test in `tests/` is the durable
|
||||
//! determinism witness; this bench is for throughput characterization only.
|
||||
//!
|
||||
//! # What the numbers tell us
|
||||
//!
|
||||
//! - At low rollout counts (1k), rayon dispatch overhead may dominate and the
|
||||
//! parallel path can be SLOWER than a sequential equivalent. This is
|
||||
//! expected; the parallel path's payoff is at higher rollout counts.
|
||||
//! - At high rollout counts (20k+), the parallel path should show a roughly
|
||||
//! linear speedup up to the number of physical cores. Beyond that, the
|
||||
//! serial select+expand walk dominates.
|
||||
//! - Compare wall-clock time to the `MCTS_DECISION_BUDGET_MS` knob the live
|
||||
//! GdMcTreeController uses (typically 2000ms): if a 20k-rollout iteration
|
||||
//! completes in <2000ms, the budget is sufficient at that scale.
|
||||
//!
|
||||
//! Rayon thread count is controlled by the `RAYON_NUM_THREADS` env var. For a
|
||||
//! N=1 vs N=4 vs N=8 sweep, run with each value set:
|
||||
//! RAYON_NUM_THREADS=1 cargo bench -p mc-ai --bench mcts_rollout_throughput
|
||||
//! RAYON_NUM_THREADS=4 cargo bench -p mc-ai --bench mcts_rollout_throughput
|
||||
//! RAYON_NUM_THREADS=8 cargo bench -p mc-ai --bench mcts_rollout_throughput
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use mc_ai::mcts::XorShift64;
|
||||
use mc_ai::mcts_tree::{Tree, TreeState};
|
||||
|
||||
/// Synthetic state: 4-action branching, fixed depth. The action set models the
|
||||
/// shape of a tactical-AI move space (move / settle / build / wait) without
|
||||
/// pulling mc-turn into the bench (which would dominate compile time).
|
||||
#[derive(Clone)]
|
||||
struct BenchState {
|
||||
moves: Vec<u8>,
|
||||
max_depth: usize,
|
||||
}
|
||||
|
||||
impl BenchState {
|
||||
fn new(max_depth: usize) -> Self {
|
||||
Self { moves: Vec::new(), max_depth }
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeState for BenchState {
|
||||
type Action = u8;
|
||||
|
||||
fn legal_actions(&self) -> Vec<u8> {
|
||||
if self.moves.len() >= self.max_depth {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![0, 1, 2, 3]
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&self, action: &u8) -> Self {
|
||||
let mut next = self.clone();
|
||||
next.moves.push(*action);
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic rollout work unit. Does a small CPU-bound integer hash loop so the
|
||||
/// per-rollout cost is non-trivial (~5-15μs) — otherwise rayon dispatch
|
||||
/// overhead dwarfs the rollout and parallelism doesn't show. Tuned to roughly
|
||||
/// match the cost of a softmax-sampled trajectory in `rollout::GameRolloutState`.
|
||||
fn synthetic_rollout(_state: &BenchState, rng: &mut XorShift64) -> f32 {
|
||||
let mut x = rng.next_u64();
|
||||
// ~1000 hash mixes — chosen by trial to land near 5-15μs/rollout on plum.
|
||||
for _ in 0..1000 {
|
||||
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
x ^= x >> 33;
|
||||
}
|
||||
// Convert to [0, 1] reward. Use the result so the optimizer can't elide.
|
||||
(x as u32 as f32) / (u32::MAX as f32)
|
||||
}
|
||||
|
||||
fn bench_simulate_parallel_throughput(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("mcts_rollout_throughput");
|
||||
// Long enough for rayon dispatch to amortize but short enough for criterion
|
||||
// to converge in <30s per case.
|
||||
group.sample_size(20);
|
||||
|
||||
for &n_rollouts in &[1_000usize, 5_000, 20_000] {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::from_parameter(n_rollouts),
|
||||
&n_rollouts,
|
||||
|b, &n| {
|
||||
b.iter(|| {
|
||||
let mut tree = Tree::new(BenchState::new(8));
|
||||
tree.simulate_parallel(
|
||||
black_box(n),
|
||||
black_box(42u64),
|
||||
synthetic_rollout,
|
||||
None,
|
||||
);
|
||||
black_box(&tree);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_simulate_parallel_throughput);
|
||||
criterion_main!(benches);
|
||||
|
|
@ -1817,6 +1817,78 @@ impl TurnProcessor {
|
|||
});
|
||||
}
|
||||
|
||||
/// p2-55: drain expired ransom offers at start of turn. Each expiry
|
||||
/// converts the captive into a capture (unit moves from owner's vec to
|
||||
/// captor's vec, `captive_of` cleared, `UnitCapturedEvent` emitted).
|
||||
fn process_ransom_expiry(state: &mut GameState, result: &mut TurnResult) {
|
||||
let expired = state.ransom_queue.tick(state.turn);
|
||||
for offer in expired {
|
||||
// Locate the captive in the owner's vec by stable unit id.
|
||||
let owner_idx = offer.owner as usize;
|
||||
if owner_idx >= state.players.len() {
|
||||
continue;
|
||||
}
|
||||
let Some(unit_idx) = state.players[owner_idx]
|
||||
.units
|
||||
.iter()
|
||||
.position(|u| u.id == offer.unit_id)
|
||||
else {
|
||||
// Unit no longer exists (killed by another path) — drop offer.
|
||||
continue;
|
||||
};
|
||||
// Snapshot pos for the event, then move the unit into the
|
||||
// captor's vec via the same helper used for direct captures.
|
||||
let (col, row) = (
|
||||
state.players[owner_idx].units[unit_idx].col,
|
||||
state.players[owner_idx].units[unit_idx].row,
|
||||
);
|
||||
// Clear the captive marker before transfer (transfer_captured_unit
|
||||
// also clears it on the moved copy, but doing it here keeps the
|
||||
// helper's pre-move snapshot consistent).
|
||||
state.players[owner_idx].units[unit_idx].captive_of = None;
|
||||
|
||||
// We don't have `&self` here (associated fn), so duplicate the
|
||||
// small subset of transfer logic inline. Keeps the call site
|
||||
// free of a self threading change.
|
||||
let unit_snapshot = state.players[owner_idx].units[unit_idx].clone();
|
||||
let upkeep = state.players[owner_idx].unit_upkeep.get(unit_idx).copied();
|
||||
mc_combat::credit_resources(
|
||||
&unit_snapshot.held_resources,
|
||||
&mut state.players[owner_idx].strategic_ledger,
|
||||
);
|
||||
let p = &mut state.players[owner_idx];
|
||||
p.units.swap_remove(unit_idx);
|
||||
if unit_idx < p.unit_upkeep.len() {
|
||||
p.unit_upkeep.swap_remove(unit_idx);
|
||||
}
|
||||
let mut moved = unit_snapshot;
|
||||
moved.held_resources.clear();
|
||||
moved.captive_of = None;
|
||||
moved.posture_override = None;
|
||||
moved.is_fortified = false;
|
||||
moved.is_sentrying = false;
|
||||
moved.current_action = None;
|
||||
moved.patrol_order = None;
|
||||
moved.formation_id = None;
|
||||
|
||||
let captor_idx = offer.captor as usize;
|
||||
if captor_idx < state.players.len() {
|
||||
let cs = &mut state.players[captor_idx];
|
||||
cs.units.push(moved);
|
||||
cs.unit_upkeep.push(upkeep.unwrap_or(0));
|
||||
}
|
||||
|
||||
result.units_captured.push(UnitCapturedEvent {
|
||||
turn: state.turn,
|
||||
unit_id: offer.unit_id,
|
||||
captor: offer.captor,
|
||||
prior_owner: offer.owner,
|
||||
col,
|
||||
row,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_ransom_offer(
|
||||
&self,
|
||||
state: &mut GameState,
|
||||
|
|
|
|||
96
src/simulator/crates/mc-turn/tests/capture_posture.rs
Normal file
96
src/simulator/crates/mc-turn/tests/capture_posture.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! p2-55 Wave 1 — `CapturePosture` precedence and conversion to the resolver
|
||||
//! enum. The user's task spec lists this file at
|
||||
//! `src/simulator/crates/mc-state/tests/capture_posture.rs`, but `mc-state`
|
||||
//! does not exist — `MapUnit` and `PlayerState` live in `mc-turn`, so the
|
||||
//! test lives here too.
|
||||
|
||||
use mc_combat::resolver::PostureResolution;
|
||||
use mc_turn::capture::{resolve_posture, CapturePosture, PromptUnresolved};
|
||||
use mc_turn::game_state::{MapUnit, PlayerState};
|
||||
|
||||
fn unit_with_override(o: Option<CapturePosture>) -> MapUnit {
|
||||
MapUnit { posture_override: o, ..MapUnit::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_override_wins_over_relation_and_global() {
|
||||
let mut player = PlayerState::default();
|
||||
player.default_civilian_posture = CapturePosture::Capture;
|
||||
player.civilian_posture.insert(7, CapturePosture::Ransom);
|
||||
let unit = unit_with_override(Some(CapturePosture::Destroy));
|
||||
|
||||
assert_eq!(
|
||||
resolve_posture(&player, &unit, 7),
|
||||
CapturePosture::Destroy,
|
||||
"per-unit override must beat per-relation map"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_default_wins_over_global_when_no_override() {
|
||||
let mut player = PlayerState::default();
|
||||
player.default_civilian_posture = CapturePosture::Capture;
|
||||
player.civilian_posture.insert(2, CapturePosture::Ransom);
|
||||
let unit = unit_with_override(None);
|
||||
|
||||
assert_eq!(
|
||||
resolve_posture(&player, &unit, 2),
|
||||
CapturePosture::Ransom,
|
||||
"relation default should be used when no per-unit override"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_global_default_when_no_relation_or_override() {
|
||||
let mut player = PlayerState::default();
|
||||
player.default_civilian_posture = CapturePosture::Destroy;
|
||||
let unit = unit_with_override(None);
|
||||
|
||||
assert_eq!(
|
||||
resolve_posture(&player, &unit, 99),
|
||||
CapturePosture::Destroy,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_default_seed_is_capture() {
|
||||
// The seeding itself happens at game-setup time outside this crate, but
|
||||
// `Default` must produce `Capture` so AI seats inherit a sensible value
|
||||
// without explicit initialisation.
|
||||
assert_eq!(CapturePosture::default(), CapturePosture::Capture);
|
||||
let player = PlayerState::default();
|
||||
assert_eq!(player.default_civilian_posture, CapturePosture::Capture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_seat_can_be_seeded_to_prompt() {
|
||||
// Sanity: the enum's `Prompt` variant round-trips through `PlayerState`.
|
||||
let mut player = PlayerState::default();
|
||||
player.default_civilian_posture = CapturePosture::Prompt;
|
||||
let unit = unit_with_override(None);
|
||||
assert_eq!(resolve_posture(&player, &unit, 0), CapturePosture::Prompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_prompt_errors() {
|
||||
assert_eq!(
|
||||
PostureResolution::try_from(CapturePosture::Prompt),
|
||||
Err(PromptUnresolved),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_from_concrete_postures_succeeds() {
|
||||
assert_eq!(
|
||||
PostureResolution::try_from(CapturePosture::Capture),
|
||||
Ok(PostureResolution::Capture),
|
||||
);
|
||||
assert_eq!(
|
||||
PostureResolution::try_from(CapturePosture::Destroy),
|
||||
Ok(PostureResolution::Destroy),
|
||||
);
|
||||
assert_eq!(
|
||||
PostureResolution::try_from(CapturePosture::Ransom),
|
||||
Ok(PostureResolution::Ransom),
|
||||
);
|
||||
}
|
||||
102
src/simulator/crates/mc-turn/tests/ransom.rs
Normal file
102
src/simulator/crates/mc-turn/tests/ransom.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
//! p2-55 Wave 1 — `RansomQueue` lifecycle: push, tick (expiry), accept,
|
||||
//! refuse, pending_for filter.
|
||||
|
||||
use mc_turn::ransom::{RansomQueue, RANSOM_OFFER_DURATION_TURNS};
|
||||
|
||||
#[test]
|
||||
fn push_returns_monotonic_ids_and_records_offer() {
|
||||
let mut q = RansomQueue::default();
|
||||
let id0 = q.push(101, 1, 2, 50, 10);
|
||||
let id1 = q.push(102, 1, 3, 75, 10);
|
||||
|
||||
assert_eq!(id0, 0);
|
||||
assert_eq!(id1, 1);
|
||||
assert_eq!(q.len(), 2);
|
||||
|
||||
let offer = q.iter().find(|o| o.id == id0).expect("offer 0 present");
|
||||
assert_eq!(offer.unit_id, 101);
|
||||
assert_eq!(offer.captor, 1);
|
||||
assert_eq!(offer.owner, 2);
|
||||
assert_eq!(offer.price, 50);
|
||||
assert_eq!(offer.created_turn, 10);
|
||||
assert_eq!(offer.expires_turn, 10 + RANSOM_OFFER_DURATION_TURNS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_at_creation_turn_does_not_expire() {
|
||||
let mut q = RansomQueue::default();
|
||||
let _ = q.push(101, 1, 2, 50, 10);
|
||||
let expired = q.tick(10);
|
||||
assert!(expired.is_empty(), "fresh offer should not expire on creation turn");
|
||||
assert_eq!(q.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_at_expiry_turn_returns_and_removes() {
|
||||
let mut q = RansomQueue::default();
|
||||
let _ = q.push(101, 1, 2, 50, 10);
|
||||
let expires = 10 + RANSOM_OFFER_DURATION_TURNS;
|
||||
|
||||
// Just before expiry — still pending.
|
||||
let early = q.tick(expires - 1);
|
||||
assert!(early.is_empty());
|
||||
assert_eq!(q.len(), 1);
|
||||
|
||||
// At expiry — drained.
|
||||
let expired = q.tick(expires);
|
||||
assert_eq!(expired.len(), 1);
|
||||
assert_eq!(expired[0].unit_id, 101);
|
||||
assert!(q.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_removes_and_returns_offer() {
|
||||
let mut q = RansomQueue::default();
|
||||
let id = q.push(101, 1, 2, 50, 10);
|
||||
|
||||
let taken = q.accept(id).expect("accept returns offer");
|
||||
assert_eq!(taken.id, id);
|
||||
assert!(q.is_empty());
|
||||
assert!(q.accept(id).is_none(), "second accept yields None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse_removes_and_returns_offer() {
|
||||
let mut q = RansomQueue::default();
|
||||
let id = q.push(101, 1, 2, 50, 10);
|
||||
|
||||
let taken = q.refuse(id).expect("refuse returns offer");
|
||||
assert_eq!(taken.id, id);
|
||||
assert!(q.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_for_filters_by_owner() {
|
||||
let mut q = RansomQueue::default();
|
||||
q.push(101, 1, 2, 50, 10);
|
||||
q.push(102, 1, 3, 75, 10);
|
||||
q.push(103, 1, 2, 25, 11);
|
||||
|
||||
let owner_2: Vec<_> = q.pending_for(2).map(|o| o.unit_id).collect();
|
||||
let owner_3: Vec<_> = q.pending_for(3).map(|o| o.unit_id).collect();
|
||||
|
||||
assert_eq!(owner_2.len(), 2);
|
||||
assert!(owner_2.contains(&101));
|
||||
assert!(owner_2.contains(&103));
|
||||
assert_eq!(owner_3, vec![102]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_drains_only_expired_leaves_others() {
|
||||
let mut q = RansomQueue::default();
|
||||
q.push(101, 1, 2, 50, 10); // expires at 10 + duration
|
||||
q.push(102, 1, 2, 75, 50); // expires at 50 + duration
|
||||
let early_expiry = 10 + RANSOM_OFFER_DURATION_TURNS;
|
||||
|
||||
let expired = q.tick(early_expiry);
|
||||
assert_eq!(expired.len(), 1);
|
||||
assert_eq!(expired[0].unit_id, 101);
|
||||
assert_eq!(q.len(), 1);
|
||||
let remaining: Vec<_> = q.iter().map(|o| o.unit_id).collect();
|
||||
assert_eq!(remaining, vec![102]);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue