diff --git a/.project/designs/design-gallery.html b/.project/designs/design-gallery.html
new file mode 100644
index 00000000..972700d7
--- /dev/null
+++ b/.project/designs/design-gallery.html
@@ -0,0 +1,635 @@
+
+
+
+
+
+Age of Dwarves — Design System Gallery
+
+
+
+
+
+
+Age of Dwarves
+UI Design System · Episode 1 · Magic Civilization Series
+
+
+
+
+
+
+
+
+
+
Accents & Semantic
+
Accents
+
+
Semantic
+
+
✓ Positive
+
★ Golden Age
+
✗ Negative
+
⚠ Warning
+
⚔ War
+
☮ Peace
+
⇌ Trade
+
+
+
+
+
+
Player Colors (Default Palette)
+
+
Blue
+
Red
+
Green
+
Yellow
+
Purple
+
Orange
+
Cyan
+
Magenta
+
Brown
+
Gray
+
Sage
+
Navy
+
+
+
+
+
+
Typography
+
+
24px / title
+
Ironhold — Capital of the Stoneguard
+
+
+
16px / md
+
Research Complete: Iron Smelting Unlocked
+
+
+
15px / base
+
Production: Barracks · 8 turns remaining
+
+
+
14px / sm
+
Population 6 · Food +3 · Culture +2 per turn
+
+
+
13px / xs
+
Turn 42 · Era: Iron Age · Seed 0xDEAD
+
+
+
+
+
+
Buttons
+
States
+
+
+
+
+
+
+
+
Variants
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Panels & Lists
+
+
+
+
Tile Information
+
+
⛰ Mountain Plains
+
Owner: Stoneguard Clan · Improved
+
+
+
+
+
+
Barracks120 ⚒
+
Forge ↗ 3 turns80 ⚒
+
Marketplace150 ⚒
+
Library130 ⚒
+
Bathhouse110 ⚒
+
+
+
+
+
+
+
+
HUD Elements
+
Resource Stats (Top Bar)
+
+
🪙+84
+
⚗+22
+
☺+7
+
🌿+12
+
⚔War
+
⚠Unhappy −3
+
+
+
Climate Gauges
+
+
+
Notifications
+
+
+
⚔
+
Combat Victory
Warrior defeated Emberfall Scout · +12 XP
+
+
+
⚗
+
Research Complete
Iron Smelting unlocked · Choose next research
+
+
+
★
+
Golden Age
The Stoneguard enters a Golden Age · +2 turns
+
+
+
+
Promotions
+
+
★ Strength I
+
★★ Flanking II
+
★ City Raider
+
★★★ Combat Master
+
+
+
+
+
+
+
+
+
+
diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs
index 5ad92580..ab2c32b0 100644
--- a/src/simulator/crates/mc-ai/src/tactical/movement.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs
@@ -261,24 +261,15 @@ enum UnitRole {
}
fn unit_role(unit: &TacticalUnit) -> UnitRole {
- // `can_found_city` is the data-driven founder flag populated from the
- // engine's per-unit JSON (via the bridge in task #3). PREFER this over
- // a kind-string allow-list: clan-themed founders like `"dwarf_tribe"`
- // don't literally contain "settler"/"founder" but DO have the flag.
- // Letting them fall through to Military would make movement.rs move
- // the settler every turn, which races settle.rs's FoundCity emission
- // and perpetually rejects dispatch (observed 2026-04-18 as the
- // "p1 never founds" regression in batch bvlljmfvk trace).
- if unit.can_found_city {
+ // Use the canonical predicate so clan-themed founders (e.g. "dwarf_tribe")
+ // are caught by the data-driven flag rather than a literal kind-string.
+ // Moving a settler every turn races settle.rs's FoundCity emission and
+ // causes the "p1 never founds" regression (batch bvlljmfvk, 2026-04-18).
+ if unit.is_founder() {
return UnitRole::Settler;
}
match unit.kind.as_str() {
- "settler" | "dwarf_founder" | "founder" => UnitRole::Settler,
"worker" | "engineer" => UnitRole::Worker,
- // Everything else in the Age of Dwarves data set has combat stats
- // — warriors, spearmen, pikemen, archers, crossbowmen, cavalry,
- // scouts. Match the GDScript heuristic `attack > 0 ||
- // ranged_attack > 0` here via kind-based classification.
_ => UnitRole::Military,
}
}
diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs
index 699d389a..8826869d 100644
--- a/src/simulator/crates/mc-ai/src/tactical/production.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/production.rs
@@ -22,7 +22,7 @@ use std::time::Instant;
use crate::evaluator::ScoringWeights;
use crate::mcts::XorShift64;
-use super::{Action, TacticalCity, TacticalPlayerState, TacticalState};
+use super::{Action, TacticalCity, TacticalPlayerState, TacticalState, TacticalUnit};
/// Gold floor required before the AI commits to a rush-buy assault unit.
///
@@ -285,7 +285,7 @@ fn pick_for_city(
// founders — mirrors `city_index == 0` gate.
let expansion_target = (axes.expansion as u32 / 3).clamp(1, 5);
if city_count < expansion_target && city.is_capital {
- let founder_count = player.units.iter().filter(|u| is_founder(&u.kind)).count() as u32;
+ let founder_count = player.units.iter().filter(|u| is_founder(u)).count() as u32;
if founder_count == 0 {
return ids::FOUNDER.into();
}
@@ -415,16 +415,16 @@ fn count_military_units(player: &TacticalPlayerState) -> u32 {
player
.units
.iter()
- .filter(|u| is_military(&u.kind))
+ .filter(|u| is_military(u))
.count() as u32
}
-fn is_military(kind: &str) -> bool {
- !matches!(kind, "founder" | "settler" | "worker")
+fn is_military(unit: &TacticalUnit) -> bool {
+ !unit.is_founder() && unit.kind != "worker"
}
-fn is_founder(kind: &str) -> bool {
- matches!(kind, "founder" | "settler")
+fn is_founder(unit: &TacticalUnit) -> bool {
+ unit.is_founder()
}
/// Max military count across all opponents. GDScript uses total enemy
diff --git a/src/simulator/crates/mc-ai/src/tactical/settle.rs b/src/simulator/crates/mc-ai/src/tactical/settle.rs
index 302d168f..e0dcf13b 100644
--- a/src/simulator/crates/mc-ai/src/tactical/settle.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/settle.rs
@@ -114,13 +114,8 @@ pub(crate) fn decide_settle(
actions
}
-/// A settler is any unit flagged `can_found_city` by the engine's data pack.
-/// Prefer the data-driven flag over string matching on `kind` — clan-themed
-/// founders like `"dwarf_tribe"` wouldn't match a hardcoded id set.
-/// Falls back to string match on legacy fixtures (where `can_found_city` is
-/// default-false) so existing tests continue to pass.
fn is_settler(unit: &TacticalUnit) -> bool {
- unit.can_found_city || matches!(unit.kind.as_str(), "settler" | "founder")
+ unit.is_founder()
}
/// Return the action for a single settler, or `None` if the settler cannot
diff --git a/src/simulator/crates/mc-ai/src/tactical/state.rs b/src/simulator/crates/mc-ai/src/tactical/state.rs
index 1028a6a4..1792603c 100644
--- a/src/simulator/crates/mc-ai/src/tactical/state.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/state.rs
@@ -161,6 +161,14 @@ pub struct TacticalUnit {
pub patrol_order: Option>,
}
+impl TacticalUnit {
+ /// True when this unit can found a city. Checks the data-driven flag first;
+ /// falls back to kind-string matching for test fixtures that omit the flag.
+ pub fn is_founder(&self) -> bool {
+ self.can_found_city || matches!(self.kind.as_str(), "settler" | "founder")
+ }
+}
+
/// Specification for a producible military unit — carries enough data for the
/// production layer to select tier-appropriate units as tech unlocks (p0-39).
///
diff --git a/src/simulator/crates/mc-ai/src/tactical/thresholds.rs b/src/simulator/crates/mc-ai/src/tactical/thresholds.rs
index b8b455a0..95979ac8 100644
--- a/src/simulator/crates/mc-ai/src/tactical/thresholds.rs
+++ b/src/simulator/crates/mc-ai/src/tactical/thresholds.rs
@@ -75,11 +75,15 @@ pub fn derived_defense(axes: &BTreeMap) -> i32 {
/// to capital-assault commitment. Aggressive clans commit earlier; cautious
/// clans wait for real superiority.
///
-/// Range: `axis=1` → 1.80 (very cautious), `axis=5` → 1.50 (post-p0-37+39
-/// tempo baseline), `axis=10` → 1.15 (rush-happy). Baseline raised 2026-04-18
-/// from 1.25 so games reach T250+ — tier-3+ tech chains need the runway.
+/// Range: `axis=1` → 2.5 (very cautious), `axis=5` → 2.0 (baseline),
+/// `axis=10` → 1.5 (rush-happy). Baseline raised 2026-04-26 from 1.50 to 2.0
+/// (and rush-happy floor from 1.15 → 1.5) per warcouncil cycle-3 finding:
+/// games still ended T48-T121 in 50% of seeds because even rush-happy
+/// blackhammer (agg=9) needed only ~1.2× superiority before committing,
+/// triggering rush-domination before tier-3 tech could research. Higher
+/// thresholds give losers economic + tech runway.
pub fn dominance_factor(axes: &BTreeMap) -> f32 {
- lerp_axis(axis(axes, "aggression"), 1.80, 1.50, 1.15)
+ lerp_axis(axis(axes, "aggression"), 2.5, 2.0, 1.5)
}
/// Hex radius within which a unit bypasses stray-unit chasing to march on
@@ -191,7 +195,9 @@ mod tests {
#[test]
fn dominance_factor_baseline_matches_historical() {
let a = axes(&[("aggression", 5)]);
- assert!((dominance_factor(&a) - 1.50).abs() < 1e-6);
+ // Baseline raised 2026-04-26 from 1.50 → 2.0 (warcouncil cycle-3
+ // rush-domination dampening). See dominance_factor() docs.
+ assert!((dominance_factor(&a) - 2.0).abs() < 1e-6);
}
#[test]
@@ -242,7 +248,7 @@ mod tests {
#[test]
fn empty_axes_match_historical_baseline() {
let empty: BTreeMap = BTreeMap::new();
- assert!((dominance_factor(&empty) - 1.50).abs() < 1e-6);
+ assert!((dominance_factor(&empty) - 2.0).abs() < 1e-6);
assert_eq!(capital_approach_hex(&empty), 16);
assert!((retreat_hp_fraction(&empty) - 0.40).abs() < 1e-6);
assert_eq!(defensive_chase_range(&empty), 12);
@@ -304,8 +310,9 @@ mod tests {
let insane_high = axes(&[("aggression", 999)]);
let df_low = dominance_factor(&insane_low);
let df_high = dominance_factor(&insane_high);
- assert!(df_low >= 1.14 && df_low <= 1.81);
- assert!(df_high >= 1.14 && df_high <= 1.81);
+ // Range bumped 2026-04-26 alongside dominance_factor lerp (1.5–2.5).
+ assert!(df_low >= 1.49 && df_low <= 2.51);
+ assert!(df_high >= 1.49 && df_high <= 2.51);
}
#[test]