feat(units): add structured unit roles, factions, and domains

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 10:52:09 -04:00
parent cdaaefb280
commit c1f66151f0
15 changed files with 212 additions and 117 deletions

View file

@ -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>
);
}

View file

@ -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"],

View file

@ -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"
}
],

View file

@ -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]

View file

@ -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:

View file

@ -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):

View file

@ -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):

View file

@ -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:

View file

@ -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] = []

View file

@ -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:

View file

@ -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)")

View file

@ -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

View file

@ -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,

View file

@ -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()];

View file

@ -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,