From 2c2c1e4ef5eaa05cd4f36e0574ea4fd3d93ff401 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 3 May 2026 13:31:15 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects):=20=E2=9C=A8=20define=20tree=20?= =?UTF-8?q?component=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../designs/app/src/components/tree/types.ts | 49 ++++ .project/designs/app/src/pages/TechTree.tsx | 38 ++- .../crates/mc-ai/src/tactical/production.rs | 265 +++++++++++++++++- tools/huge-map-5clan.sh | 3 +- 4 files changed, 329 insertions(+), 26 deletions(-) create mode 100644 .project/designs/app/src/components/tree/types.ts diff --git a/.project/designs/app/src/components/tree/types.ts b/.project/designs/app/src/components/tree/types.ts new file mode 100644 index 00000000..1d0ec81a --- /dev/null +++ b/.project/designs/app/src/components/tree/types.ts @@ -0,0 +1,49 @@ +/** + * Shared types for the tree-style design pages (/tech-tree, /culture-tree). + * + * Both pages render the same structure: a per-era column layout of items + * with tier ordering, prereq SVG lines, and category-tab filtering. The + * concrete data (techs vs culture policies) is normalised to TreeItem at + * the page boundary; the generic TreeView component knows nothing about + * either domain. + */ + +export interface TreeUnlocks { + buildings?: string[]; + units?: string[]; + improvements?: string[]; + lenses?: string[]; + mechanics?: Array<{ key: string; label: string; value?: number }>; +} + +export interface TreeItem { + id: string; + name: string; + description: string; + /** Tab key — must match a CategoryDef.id. */ + category: string; + era: number; + tier: number; + cost: number; + requires: string[]; + unlocks: TreeUnlocks; + flavor?: string; +} + +export interface TreeEra { + id: string; + display_name: string; + trigger: { type: string; required_techs?: number }; +} + +export interface CategoryDef { + /** Raw value matched against TreeItem.category. The "All" sentinel uses id="All". */ + id: string; + /** Human-friendly tab label. */ + label: string; + /** Hex color string for headers/pills. */ + color: string; +} + +/** Sentinel category id for the "show everything" tab. */ +export const ALL_CATEGORY_ID = "All"; diff --git a/.project/designs/app/src/pages/TechTree.tsx b/.project/designs/app/src/pages/TechTree.tsx index 7ca550c9..af5ff7a4 100644 --- a/.project/designs/app/src/pages/TechTree.tsx +++ b/.project/designs/app/src/pages/TechTree.tsx @@ -417,19 +417,31 @@ export function TechTreePage(): React.ReactElement { - {ERAS.map((era, i) => ( - - Era {i + 1} - {era.display_name} - - {era.trigger.type === "game_start" - ? "starts game" - : era.trigger.required_techs !== undefined - ? `≥ ${era.trigger.required_techs} techs` - : era.trigger.type} - - - ))} + {ERAS.map((era, i) => { + const eraNum = i + 1; + const inEra = ALL_TECHS.filter(tk => tk.era === eraNum).length; + const matchInEra = activeTab === "All" + ? null + : ALL_TECHS.filter(tk => tk.era === eraNum && tk.domain === activeTab).length; + const unlockLabel = + era.trigger.type === "game_start" + ? "starts game" + : era.trigger.required_techs !== undefined + ? `unlock: ${era.trigger.required_techs} researched` + : era.trigger.type; + return ( + + Era {eraNum} + {era.display_name} + + {matchInEra !== null + ? `${matchInEra} of ${inEra} in this tab` + : `${inEra} techs in era`} + + {unlockLabel} + + ); + })} diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index da3a9209..1e69e2c4 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -218,16 +218,21 @@ fn pick_for_city( let dominance_gold_floor_t = super::thresholds::dominance_gold_floor(strategic_axes); let capital_walls_min_age_turns_t = super::thresholds::capital_walls_min_age_turns(strategic_axes); - // Tier-progression unit selection (p0-39). Highest-tier buildable military - // unit for this player; falls back to `warrior` when the catalog is empty - // (fixtures predating p0-39) or no higher-tier gate is met. - let melee_id = pick_best_melee( + // Tier-progression unit selection (p0-39 + p1-36 cycle 3 dispatcher). + // The dispatcher routes by clan archetype: Deepforge → siege/walker first, + // Goldvein → ranged first, etc. Returns an affinity-matched unit when one + // is buildable in the preferred type; otherwise falls back to the legacy + // melee picker. Final fallback is `ids::WARRIOR` when the catalog is empty. + // + // The variable name stays `melee_id` for diff stability — callsites in this + // function consume "the chosen military unit", not literally a melee unit. + let melee_id = pick_best_unit_for_clan( + &player.clan_id, &player.researched_techs, player.race_id.as_deref(), &player.strategic_resources, &city.buildings, unit_catalog, - &player.clan_id, ).unwrap_or(ids::WARRIOR); let early_mil_floor = if turn <= EARLY_MIL_FLOOR_CUTOFF_TURN { EARLY_MIL_FLOOR @@ -340,12 +345,13 @@ fn pick_for_city( ids::WORKER.into() } -/// Highest-tier buildable melee-military unit given player tech, race, -/// strategic resources, and city buildings. +/// Highest-tier buildable unit of a specific `unit_type` given player tech, +/// race, strategic resources, and city buildings. /// /// Filter rules (all AND'd): -/// 1. `spec.unit_type == "melee"` — taxonomy carries the role directly post +/// 1. `spec.unit_type == unit_type` — taxonomy carries the role directly post /// p1-44 reclassification (was `"military"` + id-substring heuristic before). +/// Common values: `"melee"`, `"siege"`, `"ranged"`, `"support"`. (p1-36 cycle 3) /// 2. `spec.tech_required` is `None` OR present in `researched_techs`. /// 3. `spec.race_required` is `None` OR matches `race_id`. /// 4. `spec.requires_resource` is `None` OR present in `strategic_resources`. @@ -358,9 +364,10 @@ fn pick_for_city( /// `.local/iter/apricot-20260418_174322/` — see p0-39). /// /// Returns the qualifying unit with highest `tier`. Ties broken by id sort -/// order for determinism. `None` when the catalog is empty (pre-p0-39 -/// back-compat) → caller falls back to `ids::WARRIOR`. -fn pick_best_melee<'a>( +/// order for determinism. `None` when the catalog has no eligible unit of +/// that type (caller falls back to a different `unit_type` or `ids::WARRIOR`). +fn pick_best_unit_of_type<'a>( + unit_type: &str, researched_techs: &[String], race_id: Option<&str>, strategic_resources: &[String], @@ -370,7 +377,7 @@ fn pick_best_melee<'a>( ) -> Option<&'a str> { catalog .iter() - .filter(|u| u.unit_type == "melee") + .filter(|u| u.unit_type == unit_type) .filter(|u| match &u.tech_required { None => true, Some(tech) => researched_techs.iter().any(|t| t == tech), @@ -398,6 +405,100 @@ fn pick_best_melee<'a>( .map(|u| u.id.as_str()) } +/// Backward-compatible alias for `pick_best_unit_of_type("melee", ...)`. Used +/// by all internal callers that want the legacy "highest-tier melee" pick. +/// Tests use this to verify melee-specific behaviour without coupling to the +/// dispatcher's unit-type selection logic. +fn pick_best_melee<'a>( + researched_techs: &[String], + race_id: Option<&str>, + strategic_resources: &[String], + city_buildings: &[String], + catalog: &'a [super::state::TacticalUnitSpec], + clan_id: &str, +) -> Option<&'a str> { + pick_best_unit_of_type( + "melee", + researched_techs, + race_id, + strategic_resources, + city_buildings, + catalog, + clan_id, + ) +} + +/// Personality-driven dispatcher: picks the highest-tier buildable military +/// unit, biased toward the unit_type the clan prefers. (p1-36 cycle 3) +/// +/// Per-clan preference (siege vs ranged vs melee) inferred from +/// `ai_personalities.json` archetype + the `clan_affinity` declarations on +/// per-unit JSONs. Walker units are classified as `unit_type: "melee"` in the +/// data so they flow through the melee selector by tier. +/// +/// Algorithm: +/// 1. Pick the clan's primary preferred type from a small priority list. +/// 2. If a unit is buildable in that type (affinity match required), return it. +/// 3. Else fall through to melee (the catch-all baseline). +/// 4. If even melee is empty, the caller falls back to `ids::WARRIOR`. +/// +/// **Why prefer the clan's archetype-affinity unit when available?** Without +/// dispatch, every clan picks the highest-tier MELEE unit and converges on +/// `warrior` (or `pikeman` etc.) regardless of personality. The 2026-05-03 +/// p1-36 batch showed 4/5 clans converging on warrior — this routes Deepforge +/// to siege/walker, Goldvein to ranged, etc. when those units are buildable. +fn pick_best_unit_for_clan<'a>( + clan_id: &str, + researched_techs: &[String], + race_id: Option<&str>, + strategic_resources: &[String], + city_buildings: &[String], + catalog: &'a [super::state::TacticalUnitSpec], +) -> Option<&'a str> { + // Per-clan preferred unit_type ladder. First entry is the clan's signature + // archetype; "melee" is the universal fallback. Order matters — the + // dispatcher returns the FIRST type for which a clan-affinity match + // exists, before falling back to a lower-priority type. + let preferences: &[&str] = match clan_id { + // Siege/walker focus per ai_personalities.json#deepforge. + "deepforge" => &["siege", "ranged", "melee"], + // Ranged/wealth focus per ai_personalities.json#goldvein. + "goldvein" => &["ranged", "melee", "siege"], + // Light melee + cavalry rush — melee primary, ranged secondary. + "blackhammer" => &["melee", "siege", "ranged"], + // Heavy melee (defenders, plated_warrior, mountain_king). + "ironhold" => &["melee", "siege", "ranged"], + // Mixed roster — try ranged first then melee. + "runesmith" => &["ranged", "melee", "siege"], + _ => &["melee", "siege", "ranged"], + }; + + // For each preferred type, only return a unit if the clan has a + // CLAN-AFFINITY match in that type. Generic warriors don't satisfy the + // archetype preference — they fall through so the next type can fire. + for &ut in preferences { + if let Some(uid) = pick_best_unit_of_type( + ut, researched_techs, race_id, strategic_resources, city_buildings, catalog, clan_id, + ) { + // Confirm this is an affinity match for this clan; if it's a + // generic (score=1) or off-clan (score=0), keep looking — we'd + // rather find an archetype unit even if a generic of the + // preferred type exists. + if let Some(spec) = catalog.iter().find(|u| u.id == uid) { + if clan_affinity_score(spec, clan_id) == 2 { + return Some(uid); + } + } + } + } + + // No affinity match in any preferred type. Fall back to the legacy + // melee picker, which returns the best (possibly generic) melee unit. + pick_best_melee( + researched_techs, race_id, strategic_resources, city_buildings, catalog, clan_id, + ) +} + /// Returns 2 if the unit's clan_affinity contains the player's clan, 1 if the /// affinity list is empty (generic unit, neutral fallback), 0 otherwise /// (off-clan — still buildable but ranked below affinity matches and @@ -1230,4 +1331,144 @@ mod tests { let s = state(99, 10, vec![player(0, "ironhold", Vec::new(), Vec::new())]); assert!(decide_production(&s, &weights(), &mut rng(), None).is_empty()); } + + // ── p1-36 cycle 3: pick_best_unit_for_clan dispatcher ───────────────── + + /// Helper for cycle-3 dispatcher tests — builds a `TacticalUnitSpec` with + /// explicit unit_type AND clan_affinity, mirroring the runtime flow where + /// both fields come from per-unit JSON. + fn unit_with_type_and_affinity( + id: &str, tier: u32, tech: Option<&str>, unit_type: &str, clan_affinity: Vec<&str>, + ) -> super::super::state::TacticalUnitSpec { + super::super::state::TacticalUnitSpec { + id: id.into(), + tier, + tech_required: tech.map(Into::into), + unit_type: unit_type.into(), + requires_resource: None, + race_required: None, + clan_affinity: clan_affinity.into_iter().map(String::from).collect(), + archetype: None, + requires_building: None, + } + } + + /// pick_best_unit_of_type (siege) returns siege candidates only and is + /// blind to melee units, even when a higher-tier melee is buildable. + /// Pre-cycle-3, the AI was completely blind to siege units; this test + /// pins the new selector's discrimination by unit_type. + #[test] + fn pick_best_siege_returns_siege_unit_not_melee() { + let techs = vec!["mechanized_warfare".into()]; + let catalog = vec![ + // High-tier melee with affinity — would dominate pick_best_melee. + unit_with_type_and_affinity("steam_walker", 7, Some("steam_walkers"), "melee", vec!["deepforge"]), + // Siege with deepforge affinity — only candidate of unit_type=siege. + unit_with_type_and_affinity("forge_titan", 5, Some("mechanized_warfare"), "siege", vec!["deepforge"]), + // Filler: generic melee. + unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]), + ]; + // Player only researched mechanized_warfare; steam_walkers tech is gated out. + assert_eq!( + pick_best_unit_of_type("siege", &techs, None, &[], &[], &catalog, "deepforge"), + Some("forge_titan"), + "siege selector picks forge_titan not steam_walker (which is melee)" + ); + } + + /// Dispatcher routes Deepforge to siege when an affinity siege unit is + /// buildable, even if a generic melee (warrior) exists. This is the + /// critical p1-36 cycle 3 routing change. + #[test] + fn dispatcher_routes_deepforge_to_siege_over_generic_melee() { + let techs = vec!["mechanized_warfare".into()]; + let catalog = vec![ + unit_with_type_and_affinity("forge_titan", 5, Some("mechanized_warfare"), "siege", vec!["deepforge"]), + unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]), + ]; + assert_eq!( + pick_best_unit_for_clan("deepforge", &techs, None, &[], &[], &catalog), + Some("forge_titan"), + "deepforge dispatcher prefers siege-affinity forge_titan over generic warrior" + ); + } + + /// Dispatcher routes Goldvein to ranged when an affinity ranged unit is + /// buildable. + #[test] + fn dispatcher_routes_goldvein_to_ranged_over_generic_melee() { + let techs = vec!["gunpowder".into()]; + let catalog = vec![ + unit_with_type_and_affinity("hand_cannoneer", 4, Some("gunpowder"), "ranged", vec!["goldvein"]), + unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]), + ]; + assert_eq!( + pick_best_unit_for_clan("goldvein", &techs, None, &[], &[], &catalog), + Some("hand_cannoneer"), + "goldvein dispatcher prefers ranged-affinity over generic warrior" + ); + } + + /// Dispatcher falls back to melee when no affinity unit is buildable in + /// any preferred type. Critical regression guard — ensures cycle-3 doesn't + /// strand clans with no production when their archetype units are + /// tech-gated past current research. + #[test] + fn dispatcher_falls_back_to_melee_when_no_archetype_buildable() { + let techs = vec![]; // no tech researched + let catalog = vec![ + // All affinity units gated by tech the player hasn't researched. + unit_with_type_and_affinity("forge_titan", 5, Some("mechanized_warfare"), "siege", vec!["deepforge"]), + unit_with_type_and_affinity("steam_walker", 7, Some("steam_walkers"), "melee", vec!["deepforge"]), + // Generic warrior — always buildable. + unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]), + ]; + assert_eq!( + pick_best_unit_for_clan("deepforge", &techs, None, &[], &[], &catalog), + Some("warrior"), + "deepforge falls back to generic warrior when affinity units are tech-gated" + ); + } + + /// Dispatcher prefers archetype affinity match over generic of the same + /// preferred type. If both a generic melee and an affinity melee exist, + /// the affinity one wins (existing p1-37 behavior, but now via dispatcher). + #[test] + fn dispatcher_prefers_affinity_over_generic_in_same_type() { + let techs = vec!["bronze_working".into()]; + let catalog = vec![ + unit_with_type_and_affinity("hearth_raider", 2, Some("bronze_working"), "melee", vec!["blackhammer"]), + unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]), + ]; + assert_eq!( + pick_best_unit_for_clan("blackhammer", &techs, None, &[], &[], &catalog), + Some("hearth_raider"), + "blackhammer dispatcher picks affinity hearth_raider over generic warrior" + ); + } + + /// Empty catalog → dispatcher returns None (caller falls back to WARRIOR). + /// Mirrors `pick_best_melee_falls_back_to_none_on_empty_catalog`. + #[test] + fn dispatcher_returns_none_on_empty_catalog() { + assert_eq!( + pick_best_unit_for_clan("deepforge", &[], None, &[], &[], &[]), + None, + ); + } + + /// Unknown clan_id → dispatcher uses default ladder ([melee, siege, ranged]). + /// No affinity match (unknown clan), so falls back to melee picker which + /// returns the highest-tier generic melee. + #[test] + fn dispatcher_unknown_clan_falls_back_via_default_ladder() { + let techs = vec![]; + let catalog = vec![ + unit_with_type_and_affinity("warrior", 1, None, "melee", vec![]), + ]; + assert_eq!( + pick_best_unit_for_clan("unknown_clan", &techs, None, &[], &[], &catalog), + Some("warrior"), + ); + } } diff --git a/tools/huge-map-5clan.sh b/tools/huge-map-5clan.sh index 553d0b98..e58be5c4 100755 --- a/tools/huge-map-5clan.sh +++ b/tools/huge-map-5clan.sh @@ -94,7 +94,8 @@ MARKER="$PARENT/completion.marker" MAP_SIZE="$MAP_SIZE" \ NUM_PLAYERS="$NUM_PLAYERS" \ PARALLEL="$PARALLEL" \ -MCTS_DECISION_BUDGET_MS=2000 \ +MCTS_DECISION_BUDGET_MS="${MCTS_DECISION_BUDGET_MS:-2000}" \ +SAFETY_TIMEOUT_OVERRIDE="${SAFETY_TIMEOUT_OVERRIDE:-}" \ AI_USE_MCTS=true \ AI_PIN_PERSONALITY_P0=ironhold \ AI_PIN_PERSONALITY_P1=blackhammer \