feat(units): ✨ add structured unit roles, factions, and domains
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
cdaaefb280
commit
c1f66151f0
15 changed files with 212 additions and 117 deletions
|
|
@ -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<string, { default: RawUnit[] | RawUnit }>;
|
||||
{ eager: true, import: "default" }
|
||||
) as Record<string, RawUnit[] | RawUnit>;
|
||||
|
||||
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<UnitRole, string> = {
|
||||
melee: "⚔",
|
||||
ranged: "🏹",
|
||||
siege: "💥",
|
||||
support: "🛡",
|
||||
civilian: "👤",
|
||||
summoned: "✨",
|
||||
};
|
||||
|
||||
function UnitsTab() {
|
||||
const byClass = useMemo(() => {
|
||||
const out: Record<string, UnitMeta[]> = {};
|
||||
// Group by role (the primary user-facing axis), then split each card by faction/domain.
|
||||
const byRole = useMemo(() => {
|
||||
const out = new Map<UnitRole, UnitMeta[]>();
|
||||
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 (
|
||||
<Grid>
|
||||
{Object.entries(byClass)
|
||||
.sort()
|
||||
.map(([k, list]) => (
|
||||
<CategoryCard key={k}>
|
||||
{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<string, UnitMeta[]>();
|
||||
for (const u of list) {
|
||||
const k = `${u.faction} · ${u.domain}`;
|
||||
const arr = subgroups.get(k) ?? [];
|
||||
arr.push(u);
|
||||
subgroups.set(k, arr);
|
||||
}
|
||||
return (
|
||||
<CategoryCard key={role}>
|
||||
<h2>
|
||||
{k} · {list.length}
|
||||
{ROLE_ICON[role]} {role} · {list.length}
|
||||
</h2>
|
||||
{list.map(u => (
|
||||
<Row key={u.id} $depth={0}>
|
||||
<BuildingHeader>
|
||||
<span className="id">{u.id}</span>
|
||||
<span className="meta">
|
||||
t{u.tier} · {u.cost}p · {u.hp}hp / {u.attack}atk / {u.defense}def
|
||||
</span>
|
||||
{u.techRequired && (
|
||||
<span className="tech">tech: {u.techRequired}</span>
|
||||
)}
|
||||
</BuildingHeader>
|
||||
</Row>
|
||||
))}
|
||||
{[...subgroups.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, units]) => (
|
||||
<div key={k}>
|
||||
<SectionLabel>{k} · {units.length}</SectionLabel>
|
||||
{units.map(u => (
|
||||
<Row key={u.id} $depth={0}>
|
||||
<BuildingHeader>
|
||||
<span className="id">{u.id}</span>
|
||||
<span className="meta">
|
||||
t{u.tier} · {u.cost}p · {u.hp}hp / {u.attack}atk / {u.defense}def
|
||||
{u.range > 0 ? ` · rng ${u.range}` : ""}
|
||||
</span>
|
||||
{u.techRequired && (
|
||||
<span className="tech">tech: {u.techRequired}</span>
|
||||
)}
|
||||
</BuildingHeader>
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</CategoryCard>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 != "<null>" and not player.has_tech(tech_req):
|
||||
|
|
|
|||
|
|
@ -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 != "<null>" and not player.has_tech(tech_req):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ pub(crate) fn decide_movement(
|
|||
/// Returns `Fortify` if available, else `None` (unit idles).
|
||||
fn non_motion_macro(unit: &TacticalUnit) -> Option<Action> {
|
||||
let cap = UnitCapability {
|
||||
unit_type: "military".into(),
|
||||
unit_type: "melee".into(),
|
||||
keywords: vec![],
|
||||
has_movement: unit.moves_left > 0,
|
||||
is_fortified: unit.fortified,
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue