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 \