diff --git a/.project/designs/app/src/pages/BuildingTrees.tsx b/.project/designs/app/src/pages/BuildingTrees.tsx index 79a7d7b4..a017bd46 100644 --- a/.project/designs/app/src/pages/BuildingTrees.tsx +++ b/.project/designs/app/src/pages/BuildingTrees.tsx @@ -155,16 +155,22 @@ const SectionLabel = styled.div` // ── unit tree (organised by class) ────────────────────────────────────────── +type UnitRole = "melee" | "ranged" | "siege" | "support" | "civilian" | "summoned"; +type Faction = "dwarf" | "wild" | "freepeople" | "summoned"; +type Domain = "land" | "naval" | "air" | "amphibious"; + interface UnitMeta { id: string; cost: number; tier: number; - unitType: string; + unitType: UnitRole; techRequired: string | null; - domain: string; + domain: Domain; + faction: Faction; hp: number; attack: number; defense: number; + range: number; } interface RawUnit { @@ -174,33 +180,60 @@ interface RawUnit { unit_type?: string; tech_required?: string | null; domain?: string; + faction?: string; hp?: number; attack?: number; defense?: number; + range?: number; } +// Vite glob includes only data files; *.schema.json defines structure, not units. const unitResourceModules = import.meta.glob( "../../../../../public/resources/units/*.json", - { eager: true } -) as Record; + { eager: true, import: "default" } +) as Record; + +const ROLE_ORDER: UnitRole[] = ["melee", "ranged", "siege", "support", "civilian", "summoned"]; +const FACTION_ORDER: Faction[] = ["dwarf", "wild", "freepeople", "summoned"]; + +function isRole(v: string | undefined): v is UnitRole { + return v !== undefined && (ROLE_ORDER as readonly string[]).includes(v); +} +function isFaction(v: string | undefined): v is Faction { + return v !== undefined && (FACTION_ORDER as readonly string[]).includes(v); +} +function isDomain(v: string | undefined): v is Domain { + return v === "land" || v === "naval" || v === "air" || v === "amphibious"; +} const ALL_UNITS_META: UnitMeta[] = (() => { const out: UnitMeta[] = []; - for (const mod of Object.values(unitResourceModules)) { - const raw = mod.default; + for (const [path, raw] of Object.entries(unitResourceModules)) { + if (path.endsWith(".schema.json")) continue; const items = Array.isArray(raw) ? raw : [raw]; for (const it of items) { if (!it.id) continue; + if (!isRole(it.unit_type)) { + console.warn(`[trees] unit ${it.id} has invalid unit_type=${it.unit_type}`); + continue; + } + if (!isDomain(it.domain)) { + console.warn(`[trees] unit ${it.id} has invalid domain=${it.domain}`); + continue; + } + const faction: Faction = isFaction(it.faction) ? it.faction : "dwarf"; out.push({ id: it.id, cost: it.cost ?? 0, tier: it.tier ?? 0, - unitType: it.unit_type ?? "?", + unitType: it.unit_type, techRequired: it.tech_required ?? null, - domain: it.domain ?? "land", + domain: it.domain, + faction, hp: it.hp ?? 0, attack: it.attack ?? 0, defense: it.defense ?? 0, + range: it.range ?? 0, }); } } @@ -294,39 +327,68 @@ function BuildingsTab() { ); } +const ROLE_ICON: Record = { + melee: "⚔", + ranged: "🏹", + siege: "💥", + support: "🛡", + civilian: "👤", + summoned: "✨", +}; + function UnitsTab() { - const byClass = useMemo(() => { - const out: Record = {}; + // Group by role (the primary user-facing axis), then split each card by faction/domain. + const byRole = useMemo(() => { + const out = new Map(); for (const u of ALL_UNITS_META) { - const k = `${u.unitType} · ${u.domain}`; - (out[k] ??= []).push(u); + const list = out.get(u.unitType) ?? []; + list.push(u); + out.set(u.unitType, list); } return out; }, []); + return ( - {Object.entries(byClass) - .sort() - .map(([k, list]) => ( - + {ROLE_ORDER.filter(r => byRole.has(r)).map(role => { + const list = byRole.get(role)!; + // Sub-group within a role: faction × domain + const subgroups = new Map(); + for (const u of list) { + const k = `${u.faction} · ${u.domain}`; + const arr = subgroups.get(k) ?? []; + arr.push(u); + subgroups.set(k, arr); + } + return ( +

- {k} · {list.length} + {ROLE_ICON[role]} {role} · {list.length}

- {list.map(u => ( - - - {u.id} - - t{u.tier} · {u.cost}p · {u.hp}hp / {u.attack}atk / {u.defense}def - - {u.techRequired && ( - tech: {u.techRequired} - )} - - - ))} + {[...subgroups.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, units]) => ( +
+ {k} · {units.length} + {units.map(u => ( + + + {u.id} + + t{u.tier} · {u.cost}p · {u.hp}hp / {u.attack}atk / {u.defense}def + {u.range > 0 ? ` · rng ${u.range}` : ""} + + {u.techRequired && ( + tech: {u.techRequired} + )} + + + ))} +
+ ))}
- ))} + ); + })}
); } diff --git a/public/games/age-of-dwarves/data/unit_actions.json b/public/games/age-of-dwarves/data/unit_actions.json index 6843ad88..5d72c619 100644 --- a/public/games/age-of-dwarves/data/unit_actions.json +++ b/public/games/age-of-dwarves/data/unit_actions.json @@ -1,8 +1,11 @@ { "by_unit_type": { - "military": ["move", "attack", "fortify", "skip"], + "melee": ["move", "attack", "fortify", "skip"], + "ranged": ["move", "attack", "fortify", "skip"], + "siege": ["move", "attack", "fortify", "skip"], + "support": ["move", "skip"], "civilian": ["move", "skip"], - "support": ["move", "skip"] + "summoned": ["move", "attack", "skip"] }, "by_keyword": { "ranged": ["ranged_attack"], diff --git a/public/resources/ecology/fauna/lair_combat_modes.json b/public/resources/ecology/fauna/lair_combat_modes.json index e1601b5d..1cd34e6d 100644 --- a/public/resources/ecology/fauna/lair_combat_modes.json +++ b/public/resources/ecology/fauna/lair_combat_modes.json @@ -2,7 +2,11 @@ "id": "lair_combat_modes", "name": "Lair Combat Modes", "description": "Defines the three combat approaches for engaging fauna lairs: assault, siege, and raid. Each mode has different risk, reward, and time profiles.", - "tags": ["combat", "wilds", "ecology"], + "tags": [ + "combat", + "wilds", + "ecology" + ], "combat_modes": [ { "id": "assault", @@ -14,7 +18,11 @@ "risk": "high", "requirements": { "unit_in_lair_tile": true, - "min_unit_type": "military" + "allowed_unit_types": [ + "melee", + "ranged", + "siege" + ] }, "effects_on_success": { "lair_cleared": true, @@ -37,7 +45,11 @@ "requirements": { "unit_in_lair_tile": false, "unit_adjacent_to_lair": true, - "min_unit_type": "military" + "allowed_unit_types": [ + "melee", + "ranged", + "siege" + ] }, "effects_on_success": { "lair_cleared": true, @@ -63,8 +75,14 @@ "risk": "high", "requirements": { "unit_in_lair_tile": true, - "min_unit_type": "military", - "unit_flags_required": ["fast"] + "unit_flags_required": [ + "fast" + ], + "allowed_unit_types": [ + "melee", + "ranged", + "siege" + ] }, "effects_on_success": { "lair_cleared": false, @@ -126,36 +144,67 @@ }, "xp_diminishing_returns": { "description": "Prevents XP farming by reducing rewards for repeated same-tier fights.", - "fight_xp_decay": [1.0, 0.5, 0.25, 0.1], + "fight_xp_decay": [ + 1.0, + 0.5, + 0.25, + 0.1 + ], "reset_condition": "Fight a creature of higher tier than previous maximum" }, "loot_tiers": [ { "id": "common", "name": "Common", - "ecology_tier_range": [1, 4], - "examples": ["pelts", "bones", "meat", "timber"], + "ecology_tier_range": [ + 1, + 4 + ], + "examples": [ + "pelts", + "bones", + "meat", + "timber" + ], "use": "City production, food" }, { "id": "uncommon", "name": "Uncommon", - "ecology_tier_range": [4, 6], - "examples": ["venom_glands", "giant_silk", "wyvern_scale"], + "ecology_tier_range": [ + 4, + 6 + ], + "examples": [ + "venom_glands", + "giant_silk", + "wyvern_scale" + ], "use": "Special buildings, unit upgrades" }, { "id": "rare", "name": "Rare", - "ecology_tier_range": [6, 8], - "examples": ["ironbark_staff", "stormwing_quill"], + "ecology_tier_range": [ + 6, + 8 + ], + "examples": [ + "ironbark_staff", + "stormwing_quill" + ], "use": "Permanent unit promotions" }, { "id": "legendary", "name": "Legendary", - "ecology_tier_range": [8, 10], - "examples": ["species_unique_artifact"], + "ecology_tier_range": [ + 8, + 10 + ], + "examples": [ + "species_unique_artifact" + ], "use": "City-wide bonuses, wonder enablers" } ], diff --git a/src/game/engine/scenes/combat/promotion_picker.gd b/src/game/engine/scenes/combat/promotion_picker.gd index 6f77ce36..e0e5ec04 100644 --- a/src/game/engine/scenes/combat/promotion_picker.gd +++ b/src/game/engine/scenes/combat/promotion_picker.gd @@ -152,7 +152,7 @@ func _get_available_promotions(unit: RefCounted) -> Array[Dictionary]: var next_level: int = unit.veteran_level + 1 var unit_data: Dictionary = unit.get_data() var unit_flags: Array = unit_data.get("flags", []) - var unit_combat_type: String = unit_data.get("unit_type", "military") + var unit_combat_type: String = unit_data.get("unit_type", "melee") for tree_key: String in trees: var tree: Dictionary = trees[tree_key] diff --git a/src/game/engine/scenes/hud/unit_panel.gd b/src/game/engine/scenes/hud/unit_panel.gd index 708313db..c5dfd411 100644 --- a/src/game/engine/scenes/hud/unit_panel.gd +++ b/src/game/engine/scenes/hud/unit_panel.gd @@ -533,7 +533,7 @@ func _get_movement_total(unit: RefCounted) -> int: func _get_unit_type_str(unit: RefCounted) -> String: if unit is UnitScript: return (unit as UnitScript).unit_type - return str(unit.get("unit_type") if "unit_type" in unit else "military") + return str(unit.get("unit_type") if "unit_type" in unit else "melee") func _get_keywords_str(unit: RefCounted) -> String: diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index dfc64034..4948097c 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -1667,7 +1667,7 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_founder func _best_melee_for_player(player: RefCounted, city: Variant) -> String: # Strongest buildable melee unit per p0-39 rules: - # - unit_type == "military" AND not in the ranged-specialist set + # - unit_type == "melee" (post p1-44 taxonomy: melee/ranged/siege/support/civilian/summoned) # - tech_required satisfied (player.has_tech) if set # - requires_resource satisfied (BuildableHelper) if set # - race_required matches player.race_id if set @@ -1680,22 +1680,12 @@ func _best_melee_for_player(player: RefCounted, city: Variant) -> String: # that way for forward-compatibility. var best_id: String = "warrior" var best_rank: int = 0 - var ranged_keywords: Array[String] = [ - "archer", "ranger", "arbalest", "crossbow", "flying", "catapult", "ballista", - ] for u: Dictionary in DataLoader.get_all_units(): var uid: String = str(u.get("id", "")) if uid.is_empty(): continue var unit_type: String = str(u.get("unit_type", "")) - if unit_type != "military": - continue - var is_ranged: bool = false - for kw: String in ranged_keywords: - if uid.contains(kw): - is_ranged = true - break - if is_ranged: + if unit_type != "melee": continue var tech_req: String = str(u.get("tech_required", "")) if not tech_req.is_empty() and tech_req != "" and not player.has_tech(tech_req): diff --git a/src/game/engine/src/entities/auto_play.gd b/src/game/engine/src/entities/auto_play.gd index 64c1701a..2ebb22f5 100644 --- a/src/game/engine/src/entities/auto_play.gd +++ b/src/game/engine/src/entities/auto_play.gd @@ -1675,7 +1675,7 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_founder func _best_melee_for_player(player: RefCounted, city: Variant) -> String: # Strongest buildable melee unit per p0-39 rules: - # - unit_type == "military" AND not in the ranged-specialist set + # - unit_type == "melee" (post p1-44 taxonomy: melee/ranged/siege/support/civilian/summoned) # - tech_required satisfied (player.has_tech) if set # - requires_resource satisfied (BuildableHelper) if set # - race_required matches player.race_id if set @@ -1688,22 +1688,12 @@ func _best_melee_for_player(player: RefCounted, city: Variant) -> String: # that way for forward-compatibility. var best_id: String = "warrior" var best_rank: int = 0 - var ranged_keywords: Array[String] = [ - "archer", "ranger", "arbalest", "crossbow", "flying", "catapult", "ballista", - ] for u: Dictionary in DataLoader.get_all_units(): var uid: String = str(u.get("id", "")) if uid.is_empty(): continue var unit_type: String = str(u.get("unit_type", "")) - if unit_type != "military": - continue - var is_ranged: bool = false - for kw: String in ranged_keywords: - if uid.contains(kw): - is_ranged = true - break - if is_ranged: + if unit_type != "melee": continue var tech_req: String = str(u.get("tech_required", "")) if not tech_req.is_empty() and tech_req != "" and not player.has_tech(tech_req): diff --git a/src/game/engine/src/entities/unit.gd b/src/game/engine/src/entities/unit.gd index 905e0d3d..75e4ca15 100644 --- a/src/game/engine/src/entities/unit.gd +++ b/src/game/engine/src/entities/unit.gd @@ -198,10 +198,10 @@ func is_flying() -> bool: func is_military() -> bool: + # Post p1-44 taxonomy: unit_type ∈ {melee, ranged, siege, support, civilian, summoned}. + # Only the three combat roles count as military for ZOC, attack, and target selection. var combat_type: String = get_combat_type() - if combat_type.is_empty(): - return false - return combat_type != "founder" and combat_type != "worker" and combat_type != "civilian" + return combat_type == "melee" or combat_type == "ranged" or combat_type == "siege" func is_civilian() -> bool: diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd b/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd index 04c0cea0..20e71b29 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd @@ -342,7 +342,9 @@ static func build_formations_for_player(p: RefCounted) -> Array: continue var uid_str: String = str(u.get("unit_id") if "unit_id" in u else "") var udata: Dictionary = DataLoader.get_unit(uid_str) - if udata.get("unit_type", "") != "military": + # Combat units only — exclude support/civilian/summoned non-fighters. + var ut: String = str(udata.get("unit_type", "")) + if ut != "melee" and ut != "ranged" and ut != "siege": continue alive_units.append(u) var visited: Array[bool] = [] diff --git a/src/game/engine/tests/unit/entities/test_unit_actions.gd b/src/game/engine/tests/unit/entities/test_unit_actions.gd index f2329998..de4b7cc1 100644 --- a/src/game/engine/tests/unit/entities/test_unit_actions.gd +++ b/src/game/engine/tests/unit/entities/test_unit_actions.gd @@ -105,9 +105,11 @@ func test_unit_actions_has_by_keyword() -> void: func test_unit_actions_military_includes_move_and_attack() -> void: var ua: Dictionary = DataLoader.get_unit_actions() - var military: Array = ua.get("by_unit_type", {}).get("military", []) - assert_true("move" in military, "military units can move") - assert_true("attack" in military, "military units can attack") + var by_type: Dictionary = ua.get("by_unit_type", {}) + for role: String in ["melee", "ranged", "siege"]: + var actions: Array = by_type.get(role, []) + assert_true("move" in actions, "%s units can move" % role) + assert_true("attack" in actions, "%s units can attack" % role) func test_unit_actions_founder_keyword_includes_found_city() -> void: diff --git a/src/game/engine/tests/unit/test_keyword_handler.gd b/src/game/engine/tests/unit/test_keyword_handler.gd index 95d0f6d2..27197642 100644 --- a/src/game/engine/tests/unit/test_keyword_handler.gd +++ b/src/game/engine/tests/unit/test_keyword_handler.gd @@ -116,10 +116,10 @@ func test_poison_persists_with_turns_remaining() -> void: func test_zoc_entry_cost_from_adjacent_enemy() -> void: var mover: UnitScript = _make_unit(0, Vector2i(3, 3), 5, 2, 30) mover.unit_id = "spearmen" - mover.unit_type = "military" + mover.unit_type = "melee" var enemy: UnitScript = _make_unit(1, Vector2i(4, 3), 5, 2, 30) enemy.unit_id = "spearmen" - enemy.unit_type = "military" + enemy.unit_type = "melee" assert_true(enemy.is_military(), "spearmen must classify as military") @@ -144,7 +144,7 @@ func test_flying_unit_bypasses_zoc_blocked() -> void: flyer.domain = "air" var enemy: UnitScript = _make_unit(1, Vector2i(4, 3), 5, 2, 30) enemy.unit_id = "spearmen" - enemy.unit_type = "military" + enemy.unit_type = "melee" assert_true(flyer.is_flying(), "wild_wyvern must classify as flying (air domain)") diff --git a/src/simulator/api-gdext/src/action.rs b/src/simulator/api-gdext/src/action.rs index 3e345776..ee4389eb 100644 --- a/src/simulator/api-gdext/src/action.rs +++ b/src/simulator/api-gdext/src/action.rs @@ -27,7 +27,7 @@ impl GdUnitActions { /// Return the legal actions for a unit described by its capability parameters. /// /// Parameters match the fields of `UnitCapability`: - /// - `unit_type`: `"military"`, `"support"`, `"civilian"`, etc. + /// - `unit_type`: `"melee"`, `"ranged"`, `"siege"`, `"support"`, `"civilian"`, `"summoned"` /// - `keywords`: space-separated keyword string, e.g. `"ranged"` or `"worker founder"` /// - `has_movement`: true when movement_remaining > 0 /// - `is_fortified`: true when the unit is currently fortified diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs index ab2c32b0..7265c319 100644 --- a/src/simulator/crates/mc-ai/src/tactical/movement.rs +++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs @@ -145,7 +145,7 @@ pub(crate) fn decide_movement( /// Returns `Fortify` if available, else `None` (unit idles). fn non_motion_macro(unit: &TacticalUnit) -> Option { let cap = UnitCapability { - unit_type: "military".into(), + unit_type: "melee".into(), keywords: vec![], has_movement: unit.moves_left > 0, is_fortified: unit.fortified, diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs index e216f75d..30e02ab2 100644 --- a/src/simulator/crates/mc-ai/src/tactical/production.rs +++ b/src/simulator/crates/mc-ai/src/tactical/production.rs @@ -343,11 +343,11 @@ fn pick_for_city( /// strategic resources. /// /// Filter rules (all AND'd): -/// 1. `spec.unit_type == "military"` — only combat units (not workers/scouts/founders). -/// 2. Ranged / aerial specialists (`"archer"`, `"ranger"`, `"flying"` in id) excluded. -/// 3. `spec.tech_required` is `None` OR present in `researched_techs`. -/// 4. `spec.race_required` is `None` OR matches `race_id`. -/// 5. `spec.requires_resource` is `None` OR present in `strategic_resources`. +/// 1. `spec.unit_type == "melee"` — taxonomy carries the role directly post +/// p1-44 reclassification (was `"military"` + id-substring heuristic before). +/// 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`. /// /// Rule 4 + 5 prevent the AI from queueing units the engine's strategic-gate /// check will reject (e.g. cavalry → iron_ore). Without them the AI cycles @@ -365,13 +365,9 @@ fn pick_best_melee<'a>( catalog: &'a [super::state::TacticalUnitSpec], clan_id: &str, ) -> Option<&'a str> { - let is_ranged_specialty = |id: &str| -> bool { - id.contains("archer") || id.contains("ranger") || id.contains("flying") - }; catalog .iter() - .filter(|u| u.unit_type == "military") - .filter(|u| !is_ranged_specialty(&u.id)) + .filter(|u| u.unit_type == "melee") .filter(|u| match &u.tech_required { None => true, Some(tech) => researched_techs.iter().any(|t| t == tech), @@ -611,7 +607,7 @@ mod tests { id: id.into(), tier, tech_required: tech.map(Into::into), - unit_type: "military".into(), + unit_type: "melee".into(), requires_resource: None, race_required: None, clan_affinity: clan_affinity.into_iter().map(String::from).collect(), @@ -628,8 +624,8 @@ mod tests { #[test] fn pick_best_melee_selects_tier_1_when_no_higher_tech_researched() { let catalog = [ - unit_spec("warrior", 1, None, "military"), - unit_spec("pikeman", 2, Some("bronze_working"), "military"), + unit_spec("warrior", 1, None, "melee"), + unit_spec("pikeman", 2, Some("bronze_working"), "melee"), ]; let techs = vec!["mining".to_string()]; assert_eq!(pick_best_melee(&techs, None, &[], &catalog, "ironhold"), Some("warrior")); @@ -638,9 +634,9 @@ mod tests { #[test] fn pick_best_melee_climbs_tier_when_tech_available() { let catalog = [ - unit_spec("warrior", 1, None, "military"), - unit_spec("pikeman", 2, Some("bronze_working"), "military"), - unit_spec("cavalry", 3, Some("steelworking"), "military"), + unit_spec("warrior", 1, None, "melee"), + unit_spec("pikeman", 2, Some("bronze_working"), "melee"), + unit_spec("cavalry", 3, Some("steelworking"), "melee"), ]; let techs = vec!["bronze_working".to_string()]; assert_eq!(pick_best_melee(&techs, None, &[], &catalog, "ironhold"), Some("pikeman")); @@ -651,7 +647,7 @@ mod tests { #[test] fn pick_best_melee_excludes_non_military_unit_types() { let catalog = [ - unit_spec("warrior", 1, None, "military"), + unit_spec("warrior", 1, None, "melee"), unit_spec("worker", 1, None, "worker"), unit_spec("founder", 1, None, "founder"), ]; @@ -661,9 +657,9 @@ mod tests { #[test] fn pick_best_melee_excludes_ranged_specialists() { let catalog = [ - unit_spec("warrior", 1, None, "military"), - unit_spec("archer", 2, Some("bronze_working"), "military"), - unit_spec("pikeman", 2, Some("bronze_working"), "military"), + unit_spec("warrior", 1, None, "melee"), + unit_spec("archer", 2, Some("bronze_working"), "ranged"), + unit_spec("pikeman", 2, Some("bronze_working"), "melee"), ]; let techs = vec!["bronze_working".to_string()]; assert_eq!( @@ -680,9 +676,9 @@ mod tests { // pikeman (no resource requirement) instead of queueing cavalry // every turn and hitting the engine's strategic_gate_rejected path. let catalog = [ - unit_spec_full("warrior", 1, None, "military", None, None), - unit_spec_full("pikeman", 2, Some("bronze_working"), "military", None, None), - unit_spec_full("cavalry", 3, Some("steelworking"), "military", Some("iron_ore"), None), + unit_spec_full("warrior", 1, None, "melee", None, None), + unit_spec_full("pikeman", 2, Some("bronze_working"), "melee", None, None), + unit_spec_full("cavalry", 3, Some("steelworking"), "melee", Some("iron_ore"), None), ]; let techs = vec!["bronze_working".to_string(), "steelworking".to_string()]; assert_eq!( @@ -756,8 +752,8 @@ mod tests { // Berserker requires dwarf race. A human-race player with dwarf_heritage // researched (via diplomacy/trade/etc.) must NOT get berserker queued. let catalog = [ - unit_spec_full("warrior", 1, None, "military", None, None), - unit_spec_full("berserker", 2, Some("dwarf_heritage"), "military", None, Some("dwarf")), + unit_spec_full("warrior", 1, None, "melee", None, None), + unit_spec_full("berserker", 2, Some("dwarf_heritage"), "melee", None, Some("dwarf")), ]; let techs = vec!["dwarf_heritage".to_string()]; assert_eq!(pick_best_melee(&techs, Some("human"), &[], &catalog, "ironhold"), Some("warrior")); @@ -772,8 +768,8 @@ mod tests { // should get pikeman on every military-slot branch (threat preempt, // early mil floor, steady mil, offensive push). let catalog = vec![ - unit_spec("warrior", 1, None, "military"), - unit_spec("pikeman", 2, Some("bronze_working"), "military"), + unit_spec("warrior", 1, None, "melee"), + unit_spec("pikeman", 2, Some("bronze_working"), "melee"), ]; let mut p = player(0, "ironhold", Vec::new(), vec![city(10, (0, 0), 1, &[], &[], true)]); p.researched_techs = vec!["bronze_working".into()]; @@ -810,9 +806,9 @@ mod tests { // every turn and rejected by strategic_gate because iron_ore missing. // Now pick_best_melee must filter cavalry out and fall back to pikeman. let catalog = vec![ - unit_spec_full("warrior", 1, None, "military", None, None), - unit_spec_full("pikeman", 2, Some("bronze_working"), "military", None, None), - unit_spec_full("cavalry", 3, Some("steelworking"), "military", Some("iron_ore"), None), + unit_spec_full("warrior", 1, None, "melee", None, None), + unit_spec_full("pikeman", 2, Some("bronze_working"), "melee", None, None), + unit_spec_full("cavalry", 3, Some("steelworking"), "melee", Some("iron_ore"), None), ]; let mut p = player(0, "ironhold", Vec::new(), vec![city(10, (0, 0), 1, &[], &[], true)]); p.researched_techs = vec!["bronze_working".into(), "steelworking".into()]; diff --git a/src/simulator/crates/mc-core/src/action.rs b/src/simulator/crates/mc-core/src/action.rs index ebd88895..b04bddca 100644 --- a/src/simulator/crates/mc-core/src/action.rs +++ b/src/simulator/crates/mc-core/src/action.rs @@ -196,7 +196,8 @@ impl ActionAvailability { /// the GDExtension bridge which has access to the loaded game data. #[derive(Clone, Debug)] pub struct UnitCapability { - /// Unit-type classification: `"military"`, `"support"`, `"civilian"`, etc. + /// Unit-type classification (post p1-44 taxonomy): + /// `"melee" | "ranged" | "siege" | "support" | "civilian" | "summoned"`. pub unit_type: String, /// Keywords granted by the unit's JSON `keywords` array. /// Examples: `["ranged"]`, `["founder"]`, `["worker"]`. @@ -361,7 +362,7 @@ mod tests { fn military_cap(has_movement: bool, is_fortified: bool, keywords: Vec<&str>) -> UnitCapability { UnitCapability { - unit_type: "military".into(), + unit_type: "melee".into(), keywords: keywords.into_iter().map(String::from).collect(), has_movement, is_fortified,