feat(@projects/@magic-civilization): ✨ add apex fauna combat & cognitive profiles
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5ed3fedcff
commit
438f2b8b4e
11 changed files with 737 additions and 43 deletions
|
|
@ -139,3 +139,34 @@ Total grazing = Σ(individual_rate × population × social_multiplier)
|
|||
| colony | 0.3 per member | Mostly underground, minimal surface impact |
|
||||
|
||||
A herd of 8 medium herbivores: 8 × 0.02 × 1.2 = 0.192 undergrowth/turn (38% of a 0.50 undergrowth forest — unsustainable, will crash).
|
||||
|
||||
## Apex / Boss Profile Schema (p1-58)
|
||||
|
||||
Tier-10 fauna (`ancient_red_dragon`, `ancient_white_dragon`, `ancient_green_dragon`)
|
||||
and apex tier-10 sentient flora (`ancient_sentinel_tree`, `mind_orchid_colony`,
|
||||
`bloodwood_grove`, `ascendant_world_root`) carry an extended profile block
|
||||
deserialised by `mc-ecology::Species`:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `combat_profile` | object | optional | `{ hp, strength, dexterity, constitution, attack_type?, range?, defense?, breath_weapon? }` |
|
||||
| `combat_profile.breath_weapon` | object | optional | `{ damage, area_radius, cooldown_turns }` |
|
||||
| `cognitive_profile` | object | optional | `{ intelligence (1–10), hostility, can_hold_grudge, grudge_memory_turns, pack_behavior? }` |
|
||||
| `cognitive_profile.pack_behavior` | object | optional | `{ pack_size_min, pack_size_max, coordinated_attack }` |
|
||||
| `terrain_affinity` | string[] | optional | terrain IDs (e.g. `["mountains", "volcano", "cliff"]`) — flat array; resolves against `TerrainKind` registry at use sites |
|
||||
| `loot_table` | object | optional | `{ legendary[], rare[], common[] }` — string IDs |
|
||||
| `devastation` | object | optional | `{ auto_defeat_tier_at_or_below, description }` — units at or below the tier threshold are destroyed without a roll |
|
||||
| `food_consumption_per_turn` | u32 | optional | raw biome food drained per turn (apex predators stake out territory) |
|
||||
|
||||
Grudge rule: `cognitive_profile.intelligence >= 3 → can_hold_grudge: true`,
|
||||
`grudge_memory_turns = intelligence × 10`. Enforced by
|
||||
`Species::can_hold_grudge()` and `Species::computed_grudge_memory()`.
|
||||
|
||||
True-dragon lineage (`true_dragon`) is exempt from the T7 real-species cap
|
||||
(see `species::tests::real_species_are_tier_7_or_below`); these creatures are
|
||||
fantasy boss fauna by definition.
|
||||
|
||||
Apex flora carry the same `combat_profile`/`cognitive_profile`/`loot_table`
|
||||
shape but are not yet placed by the procedural flora generator — those fields
|
||||
are validated for parse-correctness only at this milestone (see
|
||||
`mc-flora::generation::tests::all_authored_flora_species_deserialize`).
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
//! building schema (sprite, encyclopedia, etc.) lives in JSON and is consumed
|
||||
//! directly by GDScript via the DataLoader dictionary path.
|
||||
|
||||
use mc_core::{BuildingId, GppType, GreatWorkType, SpecialistId};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -30,6 +31,75 @@ pub struct AdjacencyRule {
|
|||
pub yield_bonus: AdjacencyYieldBonus,
|
||||
}
|
||||
|
||||
/// One entry of a building's `effects` array.
|
||||
///
|
||||
/// The simulator branches directly on the variants relevant to per-turn
|
||||
/// processing (Great Person Points, Great Work slot capacity). Every other
|
||||
/// authored effect type — and there are ~140 of them, see
|
||||
/// `public/games/age-of-dwarves/docs/BUILDING_SCHEMA.md` — is preserved
|
||||
/// losslessly via `Other` until the matching domain crate consumes it.
|
||||
///
|
||||
/// JSON shape: `{ "type": "<snake_case>", "value": <number> }`. Effects
|
||||
/// not yet wired into the simulator land in `Other` and their `value` is
|
||||
/// dropped — by design: domain crates opt in by promoting their effect
|
||||
/// type to a typed variant here.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum BuildingEffect {
|
||||
// ── Great Person Point yields (per-turn, accumulated by `mc-city::gpp`) ──
|
||||
GppWriting { value: i32 },
|
||||
GppMusic { value: i32 },
|
||||
GppArt { value: i32 },
|
||||
GppStatuary { value: i32 },
|
||||
GppScholarship { value: i32 },
|
||||
GppTrade { value: i32 },
|
||||
GppEngineering { value: i32 },
|
||||
|
||||
// ── Great Work slot capacity (per-building, occupied at GP activation) ──
|
||||
GreatWorkSlotsWriting { value: i32 },
|
||||
GreatWorkSlotsMusic { value: i32 },
|
||||
GreatWorkSlotsArt { value: i32 },
|
||||
GreatWorkSlotsStatuary { value: i32 },
|
||||
|
||||
/// Catch-all for the ~140 authored effect types whose simulator hook
|
||||
/// lives outside `mc-city`. The `value` field (and any siblings) are
|
||||
/// silently dropped — downstream crates (combat, magic, economy, ...)
|
||||
/// will opt in by adding their own typed variants in this enum.
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl BuildingEffect {
|
||||
/// If this effect is a Great Person Point yield, return its channel and
|
||||
/// per-turn value.
|
||||
#[must_use]
|
||||
pub fn as_gpp(&self) -> Option<(GppType, i32)> {
|
||||
match *self {
|
||||
BuildingEffect::GppWriting { value } => Some((GppType::Writing, value)),
|
||||
BuildingEffect::GppMusic { value } => Some((GppType::Music, value)),
|
||||
BuildingEffect::GppArt { value } => Some((GppType::Art, value)),
|
||||
BuildingEffect::GppStatuary { value } => Some((GppType::Statuary, value)),
|
||||
BuildingEffect::GppScholarship { value } => Some((GppType::Scholarship, value)),
|
||||
BuildingEffect::GppTrade { value } => Some((GppType::Trade, value)),
|
||||
BuildingEffect::GppEngineering { value } => Some((GppType::Engineering, value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// If this effect is a Great Work slot capacity, return its category and
|
||||
/// the number of slots provided.
|
||||
#[must_use]
|
||||
pub fn as_great_work_slots(&self) -> Option<(GreatWorkType, i32)> {
|
||||
match *self {
|
||||
BuildingEffect::GreatWorkSlotsWriting { value } => Some((GreatWorkType::Writing, value)),
|
||||
BuildingEffect::GreatWorkSlotsMusic { value } => Some((GreatWorkType::Music, value)),
|
||||
BuildingEffect::GreatWorkSlotsArt { value } => Some((GreatWorkType::Art, value)),
|
||||
BuildingEffect::GreatWorkSlotsStatuary { value } => Some((GreatWorkType::Statuary, value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserializes `wonder_type` from JSON, treating both `null` and absent as
|
||||
/// `WonderType::None`.
|
||||
fn de_wonder_type<'de, D: Deserializer<'de>>(d: D) -> Result<WonderType, D::Error> {
|
||||
|
|
@ -73,9 +143,10 @@ pub enum WonderType {
|
|||
|
||||
/// Simulation-relevant projection of a building JSON entry.
|
||||
///
|
||||
/// The full schema (name, cost, effects, sprite, encyclopedia) lives in JSON
|
||||
/// and is read by GDScript. This struct captures only the fields the Rust
|
||||
/// simulation needs to enforce tile-placement rules and wonder uniqueness.
|
||||
/// The full schema (name, cost, sprite, encyclopedia) lives in JSON and is
|
||||
/// read by GDScript. This struct captures only the fields the Rust
|
||||
/// simulation needs to enforce tile-placement, wonder uniqueness, specialist
|
||||
/// allocation, and per-turn yields.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BuildingDef {
|
||||
pub id: String,
|
||||
|
|
@ -103,14 +174,21 @@ pub struct BuildingDef {
|
|||
pub wonder_type: WonderType,
|
||||
|
||||
/// Specialist IDs that may be employed in this building.
|
||||
/// Each ID corresponds to an entry in `public/resources/specialists/specialists.json`.
|
||||
/// Each ID corresponds to an entry in
|
||||
/// `public/resources/specialists/specialists.json`.
|
||||
#[serde(default)]
|
||||
pub specialist_slots: Vec<String>,
|
||||
pub specialist_slots: Vec<SpecialistId>,
|
||||
|
||||
/// For national wonders: every owned city must contain all buildings in
|
||||
/// this list before the national wonder can be queued.
|
||||
#[serde(default)]
|
||||
pub requires_buildings_all_cities: Vec<String>,
|
||||
pub requires_buildings_all_cities: Vec<BuildingId>,
|
||||
|
||||
/// Authored effect entries. Includes per-turn GPP yields, Great Work slot
|
||||
/// capacities, and ~140 other effect types consumed by the corresponding
|
||||
/// domain crates. Unknown variants round-trip via `BuildingEffect::Other`.
|
||||
#[serde(default)]
|
||||
pub effects: Vec<BuildingEffect>,
|
||||
|
||||
/// Minimum episode number required to build or appear in the game.
|
||||
/// `None` means available from episode 1 (no gate).
|
||||
|
|
@ -155,6 +233,30 @@ impl BuildingDef {
|
|||
.iter()
|
||||
.all(|req| present.contains(req.as_str()))
|
||||
}
|
||||
|
||||
/// Sum the per-turn Great Person Points yielded by this building for the
|
||||
/// given channel.
|
||||
#[must_use]
|
||||
pub fn gpp_yield(&self, channel: GppType) -> i32 {
|
||||
self.effects
|
||||
.iter()
|
||||
.filter_map(BuildingEffect::as_gpp)
|
||||
.filter(|(c, _)| *c == channel)
|
||||
.map(|(_, v)| v)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Sum the Great Work slot capacity provided by this building for the
|
||||
/// given category.
|
||||
#[must_use]
|
||||
pub fn great_work_slots(&self, category: GreatWorkType) -> i32 {
|
||||
self.effects
|
||||
.iter()
|
||||
.filter_map(BuildingEffect::as_great_work_slots)
|
||||
.filter(|(c, _)| *c == category)
|
||||
.map(|(_, v)| v)
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry of building definitions, keyed by id.
|
||||
|
|
@ -207,6 +309,7 @@ mod tests {
|
|||
wonder_type: WonderType::None,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec![],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -220,6 +323,7 @@ mod tests {
|
|||
wonder_type: WonderType::None,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec![],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -234,6 +338,7 @@ mod tests {
|
|||
wonder_type: WonderType::None,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec![],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -249,6 +354,7 @@ mod tests {
|
|||
assert!(!def.placement_tile_required);
|
||||
assert!(def.placeable_on.is_empty());
|
||||
assert!(def.adjacency.is_empty());
|
||||
assert!(def.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -299,6 +405,7 @@ mod tests {
|
|||
wonder_type: WonderType::None,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec![],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
};
|
||||
let json = serde_json::to_string(&def).unwrap();
|
||||
|
|
@ -374,8 +481,11 @@ mod tests {
|
|||
let json = r#"{"id": "saga_chronicle", "wonder_type": "national", "requires_buildings_all_cities": ["saga_arena"], "specialist_slots": ["saga_writer"]}"#;
|
||||
let def: BuildingDef = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(def.wonder_type, WonderType::National);
|
||||
assert_eq!(def.requires_buildings_all_cities, vec!["saga_arena"]);
|
||||
assert_eq!(def.specialist_slots, vec!["saga_writer"]);
|
||||
assert_eq!(
|
||||
def.requires_buildings_all_cities,
|
||||
vec![BuildingId::new("saga_arena")]
|
||||
);
|
||||
assert_eq!(def.specialist_slots, vec![SpecialistId::new("saga_writer")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -410,7 +520,8 @@ mod tests {
|
|||
adjacency: vec![],
|
||||
wonder_type: WonderType::National,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec!["saga_arena".into()],
|
||||
requires_buildings_all_cities: vec![BuildingId::new("saga_arena")],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
};
|
||||
assert!(def.is_national_wonder_buildable(&["saga_arena", "forge", "library"]));
|
||||
|
|
@ -425,7 +536,8 @@ mod tests {
|
|||
adjacency: vec![],
|
||||
wonder_type: WonderType::National,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec!["saga_arena".into()],
|
||||
requires_buildings_all_cities: vec![BuildingId::new("saga_arena")],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
};
|
||||
assert!(!def.is_national_wonder_buildable(&["forge", "library"]));
|
||||
|
|
@ -441,6 +553,7 @@ mod tests {
|
|||
wonder_type: WonderType::World,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec![],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
};
|
||||
// World wonder — is_national_wonder_buildable always returns true
|
||||
|
|
@ -448,6 +561,99 @@ mod tests {
|
|||
assert!(def.is_national_wonder_buildable(&[]));
|
||||
}
|
||||
|
||||
// ── New typed effect-array fields (p1-56) ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_building_deserialises_new_fields() {
|
||||
// saga_arena.json: gpp_writing + great_work_slots_writing + specialist
|
||||
let saga_arena = r#"{
|
||||
"id": "saga_arena",
|
||||
"wonder_type": null,
|
||||
"specialist_slots": ["saga_writer"],
|
||||
"effects": [
|
||||
{ "type": "culture", "value": 3 },
|
||||
{ "type": "happiness", "value": 1 },
|
||||
{ "type": "great_work_slots_writing", "value": 1 },
|
||||
{ "type": "gpp_writing", "value": 1 }
|
||||
]
|
||||
}"#;
|
||||
let def: BuildingDef = serde_json::from_str(saga_arena).unwrap();
|
||||
assert_eq!(def.gpp_yield(GppType::Writing), 1);
|
||||
assert_eq!(def.gpp_yield(GppType::Music), 0);
|
||||
assert_eq!(def.great_work_slots(GreatWorkType::Writing), 1);
|
||||
assert_eq!(def.specialist_slots, vec![SpecialistId::new("saga_writer")]);
|
||||
|
||||
// saga_chronicle.json: national wonder, multi-effect
|
||||
let saga_chronicle = r#"{
|
||||
"id": "saga_chronicle",
|
||||
"wonder_type": "national",
|
||||
"requires_buildings_all_cities": ["saga_arena"],
|
||||
"specialist_slots": ["saga_writer"],
|
||||
"effects": [
|
||||
{ "type": "great_person_rate_percent", "value": 0.25 },
|
||||
{ "type": "culture", "value": 4 },
|
||||
{ "type": "great_work_slots_writing", "value": 2 },
|
||||
{ "type": "gpp_writing", "value": 2 }
|
||||
]
|
||||
}"#;
|
||||
let def: BuildingDef = serde_json::from_str(saga_chronicle).unwrap();
|
||||
assert_eq!(def.wonder_type, WonderType::National);
|
||||
assert_eq!(def.gpp_yield(GppType::Writing), 2);
|
||||
assert_eq!(def.great_work_slots(GreatWorkType::Writing), 2);
|
||||
// The unsupported effect (great_person_rate_percent + culture) lands
|
||||
// in the Other variant — counted but not branched on by mc-city yet.
|
||||
let other_count = def
|
||||
.effects
|
||||
.iter()
|
||||
.filter(|e| matches!(e, BuildingEffect::Other))
|
||||
.count();
|
||||
assert_eq!(other_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpp_yield_sums_across_multiple_entries() {
|
||||
let json = r#"{
|
||||
"id": "test",
|
||||
"effects": [
|
||||
{ "type": "gpp_writing", "value": 2 },
|
||||
{ "type": "gpp_writing", "value": 3 },
|
||||
{ "type": "gpp_music", "value": 1 }
|
||||
]
|
||||
}"#;
|
||||
let def: BuildingDef = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(def.gpp_yield(GppType::Writing), 5);
|
||||
assert_eq!(def.gpp_yield(GppType::Music), 1);
|
||||
assert_eq!(def.gpp_yield(GppType::Art), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn great_work_slots_sums_across_multiple_entries() {
|
||||
let json = r#"{
|
||||
"id": "test",
|
||||
"effects": [
|
||||
{ "type": "great_work_slots_statuary", "value": 1 },
|
||||
{ "type": "great_work_slots_statuary", "value": 2 }
|
||||
]
|
||||
}"#;
|
||||
let def: BuildingDef = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(def.great_work_slots(GreatWorkType::Statuary), 3);
|
||||
assert_eq!(def.great_work_slots(GreatWorkType::Writing), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_effect_types_round_trip_as_other() {
|
||||
let json = r#"{
|
||||
"id": "test",
|
||||
"effects": [
|
||||
{ "type": "this_effect_does_not_exist", "value": 99 },
|
||||
{ "type": "another_unknown", "value": "stringy" }
|
||||
]
|
||||
}"#;
|
||||
let def: BuildingDef = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(def.effects.len(), 2);
|
||||
assert!(def.effects.iter().all(|e| matches!(e, BuildingEffect::Other)));
|
||||
}
|
||||
|
||||
// ── All authored buildings deserialize cleanly ────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ mod tests {
|
|||
wonder_type: crate::building::WonderType::None,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec![],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -123,6 +124,7 @@ mod tests {
|
|||
wonder_type: crate::building::WonderType::None,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec![],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -139,6 +141,7 @@ mod tests {
|
|||
wonder_type: crate::building::WonderType::None,
|
||||
specialist_slots: vec![],
|
||||
requires_buildings_all_cities: vec![],
|
||||
effects: vec![],
|
||||
min_episode: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
131
src/simulator/crates/mc-core/src/gpp.rs
Normal file
131
src/simulator/crates/mc-core/src/gpp.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
//! Great Person Point types and Great Work types — closed sets the simulator
|
||||
//! branches on directly when accumulating per-turn GPP and assigning Great
|
||||
//! Works to building slots.
|
||||
//!
|
||||
//! These are closed enums (not newtype strings) because the simulator must
|
||||
//! match-exhaustively on them. New variants require a code + content change
|
||||
//! together.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-turn Great Person Point yield channel.
|
||||
///
|
||||
/// Authored on buildings as `gpp_<lowercase>` effect entries (e.g.
|
||||
/// `"gpp_writing"`, `"gpp_scholarship"`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GppType {
|
||||
Writing,
|
||||
Music,
|
||||
Art,
|
||||
Statuary,
|
||||
Scholarship,
|
||||
Trade,
|
||||
Engineering,
|
||||
}
|
||||
|
||||
impl GppType {
|
||||
pub const ALL: [GppType; 7] = [
|
||||
GppType::Writing,
|
||||
GppType::Music,
|
||||
GppType::Art,
|
||||
GppType::Statuary,
|
||||
GppType::Scholarship,
|
||||
GppType::Trade,
|
||||
GppType::Engineering,
|
||||
];
|
||||
|
||||
/// String key used in building effect entries (`gpp_writing` etc.).
|
||||
#[must_use]
|
||||
pub fn effect_key(self) -> &'static str {
|
||||
match self {
|
||||
GppType::Writing => "gpp_writing",
|
||||
GppType::Music => "gpp_music",
|
||||
GppType::Art => "gpp_art",
|
||||
GppType::Statuary => "gpp_statuary",
|
||||
GppType::Scholarship => "gpp_scholarship",
|
||||
GppType::Trade => "gpp_trade",
|
||||
GppType::Engineering => "gpp_engineering",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Great Work category. Each great work occupies one slot of the matching
|
||||
/// type in a building.
|
||||
///
|
||||
/// Authored on buildings as `great_work_slots_<lowercase>` effect entries.
|
||||
/// `Scholarship`, `Trade`, `Engineering` GPP types do NOT have a matching
|
||||
/// great work category — those archetypes produce other actions (free tech,
|
||||
/// production hurry, gold) instead of spawning a great work.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GreatWorkType {
|
||||
Writing,
|
||||
Music,
|
||||
Art,
|
||||
Statuary,
|
||||
}
|
||||
|
||||
impl GreatWorkType {
|
||||
pub const ALL: [GreatWorkType; 4] = [
|
||||
GreatWorkType::Writing,
|
||||
GreatWorkType::Music,
|
||||
GreatWorkType::Art,
|
||||
GreatWorkType::Statuary,
|
||||
];
|
||||
|
||||
/// String key used in building effect entries (`great_work_slots_writing` etc.).
|
||||
#[must_use]
|
||||
pub fn slot_effect_key(self) -> &'static str {
|
||||
match self {
|
||||
GreatWorkType::Writing => "great_work_slots_writing",
|
||||
GreatWorkType::Music => "great_work_slots_music",
|
||||
GreatWorkType::Art => "great_work_slots_art",
|
||||
GreatWorkType::Statuary => "great_work_slots_statuary",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gpp_type_serialises_snake_case() {
|
||||
let json = serde_json::to_string(&GppType::Scholarship).unwrap();
|
||||
assert_eq!(json, "\"scholarship\"");
|
||||
let back: GppType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, GppType::Scholarship);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn great_work_type_serialises_snake_case() {
|
||||
let json = serde_json::to_string(&GreatWorkType::Statuary).unwrap();
|
||||
assert_eq!(json, "\"statuary\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpp_effect_keys_match_authored_inventory() {
|
||||
// Sanity: keys match what `public/resources/buildings/*.json` uses.
|
||||
assert_eq!(GppType::Writing.effect_key(), "gpp_writing");
|
||||
assert_eq!(GppType::Engineering.effect_key(), "gpp_engineering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn great_work_slot_keys_match_authored_inventory() {
|
||||
assert_eq!(
|
||||
GreatWorkType::Writing.slot_effect_key(),
|
||||
"great_work_slots_writing"
|
||||
);
|
||||
assert_eq!(
|
||||
GreatWorkType::Statuary.slot_effect_key(),
|
||||
"great_work_slots_statuary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_arrays_cover_every_variant() {
|
||||
assert_eq!(GppType::ALL.len(), 7);
|
||||
assert_eq!(GreatWorkType::ALL.len(), 4);
|
||||
}
|
||||
}
|
||||
105
src/simulator/crates/mc-core/src/ids.rs
Normal file
105
src/simulator/crates/mc-core/src/ids.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//! Typed string newtypes for content identifiers authored in JSON game packs.
|
||||
//!
|
||||
//! These prevent silent string-mixing bugs (e.g. passing a `BuildingId` where
|
||||
//! a `SpecialistId` is expected) without coupling Rust to closed enums of
|
||||
//! authored content. JSON serialisation is transparent — each newtype
|
||||
//! round-trips as a bare string.
|
||||
//!
|
||||
//! For closed sets that the simulator branches on directly (`GppType`,
|
||||
//! `GreatWorkType`), see the sibling `gpp` and `great_works` modules.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! string_newtype {
|
||||
($name:ident, $doc:literal) => {
|
||||
#[doc = $doc]
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub String);
|
||||
|
||||
impl $name {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for $name {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for $name {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
string_newtype!(BuildingId, "Snake_case building identifier (e.g. `\"saga_arena\"`).");
|
||||
string_newtype!(SpecialistId, "Snake_case specialist class identifier (e.g. `\"saga_writer\"`).");
|
||||
string_newtype!(
|
||||
GreatPersonClass,
|
||||
"Snake_case great person archetype identifier (e.g. `\"great_writer\"`)."
|
||||
);
|
||||
string_newtype!(
|
||||
HarvestPolicyId,
|
||||
"Snake_case harvest policy identifier (e.g. `\"replenish\"`)."
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn building_id_round_trips_as_bare_string() {
|
||||
let id = BuildingId::new("saga_arena");
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
assert_eq!(json, "\"saga_arena\"");
|
||||
let back: BuildingId = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specialist_id_vec_round_trips() {
|
||||
let v = vec![SpecialistId::new("saga_writer"), SpecialistId::new("forge_chanter")];
|
||||
let json = serde_json::to_string(&v).unwrap();
|
||||
assert_eq!(json, "[\"saga_writer\",\"forge_chanter\"]");
|
||||
let back: Vec<SpecialistId> = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harvest_policy_id_displays_as_inner() {
|
||||
let id = HarvestPolicyId::new("replenish");
|
||||
assert_eq!(id.to_string(), "replenish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn great_person_class_from_str() {
|
||||
let id: GreatPersonClass = "great_writer".into();
|
||||
assert_eq!(id.as_str(), "great_writer");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,13 +5,19 @@ pub mod building_state;
|
|||
pub mod collectibles;
|
||||
pub mod formation;
|
||||
pub mod gd_compat;
|
||||
pub mod gpp;
|
||||
pub mod grid;
|
||||
pub mod ids;
|
||||
pub mod improvement;
|
||||
pub mod multi_turn_action;
|
||||
pub mod perf;
|
||||
pub mod player;
|
||||
pub mod resources;
|
||||
pub mod tech;
|
||||
pub mod wonder;
|
||||
|
||||
pub use gpp::{GppType, GreatWorkType};
|
||||
pub use ids::{BuildingId, GreatPersonClass, HarvestPolicyId, SpecialistId};
|
||||
pub use player::{HexCoord, PlayerPrologue};
|
||||
pub use tech::TechDomain;
|
||||
pub use wonder::WonderId;
|
||||
|
|
|
|||
110
src/simulator/crates/mc-core/src/tech.rs
Normal file
110
src/simulator/crates/mc-core/src/tech.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! Tech categorisation primitives shared across the workspace.
|
||||
//!
|
||||
//! `TechDomain` is the typed, 10-value enumeration used by every consumer
|
||||
//! (`mc-tech`, UI bridges, analysis surfaces) to group techs for display
|
||||
//! and player analysis. It is a metadata layer — the simulator's primary
|
||||
//! organisational axis remains `pillar` (string) per design.
|
||||
//!
|
||||
//! Variants mirror the canonical PascalCase strings authored in
|
||||
//! `public/resources/techs/*.json`. "All" is a UI sentinel only and is
|
||||
//! never authored on a tech, so it has no variant here.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One of the 10 canonical tech domain categories.
|
||||
///
|
||||
/// Authored as a PascalCase string in tech JSON (e.g. `"Agriculture"`).
|
||||
/// Serde round-trips PascalCase → variant by name.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum TechDomain {
|
||||
Military,
|
||||
Economy,
|
||||
Industry,
|
||||
Agriculture,
|
||||
Governance,
|
||||
Culture,
|
||||
Science,
|
||||
Exploration,
|
||||
Engineering,
|
||||
Medicine,
|
||||
}
|
||||
|
||||
impl TechDomain {
|
||||
/// All 10 variants in canonical authoring order. Stable for iteration
|
||||
/// (e.g. dashboard column order, BTreeMap seeding).
|
||||
pub const ALL: [TechDomain; 10] = [
|
||||
TechDomain::Military,
|
||||
TechDomain::Economy,
|
||||
TechDomain::Industry,
|
||||
TechDomain::Agriculture,
|
||||
TechDomain::Governance,
|
||||
TechDomain::Culture,
|
||||
TechDomain::Science,
|
||||
TechDomain::Exploration,
|
||||
TechDomain::Engineering,
|
||||
TechDomain::Medicine,
|
||||
];
|
||||
|
||||
/// PascalCase string form, matching the authored JSON value.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TechDomain::Military => "Military",
|
||||
TechDomain::Economy => "Economy",
|
||||
TechDomain::Industry => "Industry",
|
||||
TechDomain::Agriculture => "Agriculture",
|
||||
TechDomain::Governance => "Governance",
|
||||
TechDomain::Culture => "Culture",
|
||||
TechDomain::Science => "Science",
|
||||
TechDomain::Exploration => "Exploration",
|
||||
TechDomain::Engineering => "Engineering",
|
||||
TechDomain::Medicine => "Medicine",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_json_pascal_case() {
|
||||
for variant in TechDomain::ALL {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let back: TechDomain = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, back);
|
||||
// Serialised form is the PascalCase variant name in quotes.
|
||||
assert_eq!(json, format!("\"{}\"", variant.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_contains_each_variant_exactly_once() {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for v in TechDomain::ALL {
|
||||
assert!(seen.insert(v), "duplicate variant in ALL: {v:?}");
|
||||
}
|
||||
assert_eq!(seen.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialises_authored_json_strings() {
|
||||
for name in [
|
||||
"Military", "Economy", "Industry", "Agriculture", "Governance",
|
||||
"Culture", "Science", "Exploration", "Engineering", "Medicine",
|
||||
] {
|
||||
let quoted = format!("\"{name}\"");
|
||||
let parsed: TechDomain = serde_json::from_str("ed)
|
||||
.unwrap_or_else(|e| panic!("failed to parse {name}: {e}"));
|
||||
assert_eq!(parsed.as_str(), name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_domain_values() {
|
||||
let bad = serde_json::from_str::<TechDomain>("\"All\"");
|
||||
assert!(bad.is_err(), "'All' is a UI sentinel, must not deserialise");
|
||||
let bad = serde_json::from_str::<TechDomain>("\"agriculture\"");
|
||||
assert!(bad.is_err(), "lowercase variants must be rejected");
|
||||
}
|
||||
}
|
||||
|
|
@ -1128,6 +1128,12 @@ mod tests {
|
|||
if sp.lineage.starts_with("freefolk_") && sp.ecology_tier >= 8 {
|
||||
continue;
|
||||
}
|
||||
// True dragons (ancient_red/white/green) are tier-10 boss fauna authored
|
||||
// for p1-58 ecology cognition; they are fantasy creatures by definition
|
||||
// and exempt from the T7 cap.
|
||||
if sp.lineage == "true_dragon" {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
sp.ecology_tier <= 7,
|
||||
"real species {key} (lineage={}) should be T7 or below, got T{}",
|
||||
|
|
|
|||
|
|
@ -337,20 +337,36 @@ fn species_name(biome_id: &str, layer: FloraLayer, structure: Structure, seed: u
|
|||
// ─── Authored Species JSON Loader ─────────────────────────────────────────────
|
||||
|
||||
/// Raw deserialization shape matching the authored species JSON files.
|
||||
///
|
||||
/// Apex tier-10 flora (ancient_sentinel_tree, mind_orchid_colony, bloodwood_grove,
|
||||
/// ascendant_world_root) omit `biomes` and `traits` (they use `substrate_climate`
|
||||
/// + `terrain_affinity` and are not procedurally placed by the biome loader).
|
||||
/// All structural fields therefore default; the biome filter loop will skip any
|
||||
/// file with empty `biomes`, leaving apex flora as data-only entries that
|
||||
/// deserialise without panic but are not yet wired into the placement engine.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AuthoredSpeciesFile {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
traits: Vec<String>,
|
||||
#[serde(default)]
|
||||
biomes: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
quality_tier: i32,
|
||||
#[serde(default)]
|
||||
canopy_contribution: f32,
|
||||
#[serde(default)]
|
||||
undergrowth_contribution: f32,
|
||||
#[serde(default)]
|
||||
fungi_contribution: f32,
|
||||
#[serde(default)]
|
||||
growth_rate: f32,
|
||||
#[serde(default)]
|
||||
drought_tolerance: f32,
|
||||
#[serde(default)]
|
||||
fire_resistance: f32,
|
||||
}
|
||||
|
||||
|
|
@ -760,6 +776,56 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// p1-58: every authored flora species file must deserialize without panic,
|
||||
/// including apex tier-10 sentient flora (ancient_sentinel_tree, mind_orchid_colony,
|
||||
/// bloodwood_grove, ascendant_world_root) which omit `biomes`/`traits` and carry
|
||||
/// extended profile fields (combat_profile, cognitive_profile, terrain_affinity,
|
||||
/// loot_table). Profile fields are dropped at this layer (FloraSpecies does not
|
||||
/// yet hold them) but parsing must not fail.
|
||||
#[test]
|
||||
fn all_authored_flora_species_deserialize() {
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.expect("workspace root")
|
||||
.to_path_buf();
|
||||
let dir = workspace_root.join("public/resources/ecology/flora/species");
|
||||
let mut total = 0usize;
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
let mut apex_ok = 0usize;
|
||||
for entry in std::fs::read_dir(&dir).expect("flora species dir readable") {
|
||||
let entry = entry.expect("dir entry");
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
let text = std::fs::read_to_string(&path).expect("flora json readable");
|
||||
match serde_json::from_str::<AuthoredSpeciesFile>(&text) {
|
||||
Ok(raw) => {
|
||||
if raw.biomes.is_empty() {
|
||||
// Apex / structural flora — no biome filter; validates the
|
||||
// optional-field path on AuthoredSpeciesFile.
|
||||
apex_ok += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => failures.push(format!("{}: {}", path.display(), e)),
|
||||
}
|
||||
}
|
||||
assert!(total >= 149, "expected ≥149 flora species files, found {total}");
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} flora species failed to deserialize:\n{}",
|
||||
failures.len(),
|
||||
failures.join("\n")
|
||||
);
|
||||
// The four apex tier-10 sentient flora should be among the no-biomes set.
|
||||
assert!(
|
||||
apex_ok >= 4,
|
||||
"expected ≥4 apex flora (no `biomes` field) to deserialize, got {apex_ok}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_generated_traits_are_valid() {
|
||||
for biome in &[
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
//! The graph is immutable after construction; per-player mutation happens
|
||||
//! in [`super::PlayerTechState`].
|
||||
|
||||
use mc_core::TechDomain;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// Accept both integer and float JSON numbers as u32 (GDScript JSON.stringify emits floats).
|
||||
fn de_u32_from_num<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
|
||||
|
|
@ -65,12 +66,12 @@ pub struct TechDefinition {
|
|||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub pillar: String,
|
||||
/// One of 10 canonical domain categories for UI grouping and analysis:
|
||||
/// Military / Economy / Industry / Agriculture / Governance / Culture /
|
||||
/// Science / Exploration / Engineering / Medicine.
|
||||
/// Never "All" — that is a UI sentinel only, never authored on a tech.
|
||||
/// One of 10 canonical domain categories for UI grouping and analysis
|
||||
/// (see [`mc_core::TechDomain`]). Authored as a PascalCase string in
|
||||
/// every tech JSON; absent only in synthetic test fixtures.
|
||||
/// Never `"All"` — that is a UI sentinel only, never authored on a tech.
|
||||
#[serde(default)]
|
||||
pub domain: String,
|
||||
pub domain: Option<TechDomain>,
|
||||
#[serde(default)]
|
||||
pub school: Option<String>,
|
||||
#[serde(default = "default_era", deserialize_with = "de_u32_from_num")]
|
||||
|
|
@ -242,6 +243,39 @@ impl TechWeb {
|
|||
matched.iter().map(|t| t.id.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Returns tech IDs grouped by [`TechDomain`], deterministic iteration
|
||||
/// (BTreeMap ordering by `TechDomain` derived `Ord`). Tech IDs within
|
||||
/// each domain are sorted by `(tier, id)` for stable display.
|
||||
/// Techs whose `domain` is `None` are skipped — only authored data has
|
||||
/// a domain, and authoring is enforced by the schema test below.
|
||||
#[must_use]
|
||||
pub fn techs_by_domain_map(&self) -> BTreeMap<TechDomain, Vec<&str>> {
|
||||
let mut out: BTreeMap<TechDomain, Vec<&TechDefinition>> = BTreeMap::new();
|
||||
for tech in self.techs.values() {
|
||||
if let Some(domain) = tech.domain {
|
||||
out.entry(domain).or_default().push(tech);
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|(domain, mut defs)| {
|
||||
defs.sort_by(|a, b| a.tier.cmp(&b.tier).then_with(|| a.id.cmp(&b.id)));
|
||||
(domain, defs.into_iter().map(|d| d.id.as_str()).collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns tech IDs belonging to `domain`, sorted by `(tier, id)`.
|
||||
#[must_use]
|
||||
pub fn techs_by_domain(&self, domain: TechDomain) -> Vec<&str> {
|
||||
let mut matched: Vec<&TechDefinition> = self
|
||||
.techs
|
||||
.values()
|
||||
.filter(|t| t.domain == Some(domain))
|
||||
.collect();
|
||||
matched.sort_by(|a, b| a.tier.cmp(&b.tier).then_with(|| a.id.cmp(&b.id)));
|
||||
matched.iter().map(|t| t.id.as_str()).collect()
|
||||
}
|
||||
|
||||
/// All unique pillar names found in the tech data.
|
||||
pub fn pillars(&self) -> Vec<&str> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
|
@ -368,36 +402,35 @@ impl TechWeb {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The 10 canonical domain values. "All" is a UI sentinel — never authored.
|
||||
const VALID_DOMAINS: &[&str] = &[
|
||||
"Military",
|
||||
"Economy",
|
||||
"Industry",
|
||||
"Agriculture",
|
||||
"Governance",
|
||||
"Culture",
|
||||
"Science",
|
||||
"Exploration",
|
||||
"Engineering",
|
||||
"Medicine",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn domain_field_round_trips_via_json() {
|
||||
let json = r#"{"id": "smelting", "name": "Smelting", "domain": "Industry"}"#;
|
||||
let def: TechDefinition = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(def.domain, "Industry");
|
||||
assert_eq!(def.domain, Some(TechDomain::Industry));
|
||||
|
||||
// And the round-trip back to JSON preserves the PascalCase string.
|
||||
let reser = serde_json::to_value(&def).unwrap();
|
||||
assert_eq!(reser["domain"], serde_json::json!("Industry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_field_defaults_to_empty_string_when_absent() {
|
||||
fn domain_field_defaults_to_none_when_absent() {
|
||||
let json = r#"{"id": "smelting", "name": "Smelting"}"#;
|
||||
let def: TechDefinition = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(def.domain, "");
|
||||
assert_eq!(def.domain, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_authored_techs_have_valid_non_empty_domain() {
|
||||
fn invalid_domain_string_is_rejected() {
|
||||
let json = r#"{"id": "smelting", "name": "Smelting", "domain": "All"}"#;
|
||||
let err = serde_json::from_str::<TechDefinition>(json)
|
||||
.err()
|
||||
.expect("'All' must be rejected");
|
||||
assert!(err.to_string().contains("variant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_authored_techs_have_valid_domain() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
|
|
@ -425,16 +458,12 @@ mod tests {
|
|||
});
|
||||
for def in &defs {
|
||||
total += 1;
|
||||
if def.domain.is_empty() {
|
||||
failures.push(format!("{}: domain is empty", def.id));
|
||||
continue;
|
||||
}
|
||||
if !VALID_DOMAINS.contains(&def.domain.as_str()) {
|
||||
failures.push(format!(
|
||||
"{}: domain '{}' not in canonical 10-value enum",
|
||||
def.id, def.domain
|
||||
));
|
||||
if def.domain.is_none() {
|
||||
failures.push(format!("{}: domain is missing", def.id));
|
||||
}
|
||||
// If `domain` deserialised at all it is already a valid
|
||||
// `TechDomain` variant (serde rejects unknown strings),
|
||||
// so a separate enum-membership check is redundant.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ fn def(id: &str, requires: &[&str], cost: u32) -> TechDefinition {
|
|||
name: id.to_string(),
|
||||
description: String::new(),
|
||||
pillar: String::new(),
|
||||
domain: None,
|
||||
school: None,
|
||||
era: 1,
|
||||
tier: 1,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue