feat(@projects): ✨ add civics buildings and great works ui components
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8e7014e3da
commit
c229733cf5
4 changed files with 342 additions and 9 deletions
|
|
@ -6,6 +6,7 @@ status: partial
|
|||
scope: game1
|
||||
owner: simulator-infra
|
||||
updated_at: 2026-05-14
|
||||
ui_lane_status: done
|
||||
evidence:
|
||||
- "src/simulator/crates/mc-city/src/great_works.rs:113 — GreatWorkOccupation per-city occupation ledger (assign / evict_building / remove_work / occupancy_by_building_type) + typed GreatWorkAssignError; 8 new tests + 3 pre-existing = 11/11 green"
|
||||
- "src/simulator/crates/mc-city/src/great_person.rs:1 — GreatPersonAction tagged enum (compose_great_work / wonder_hurry / free_tech / trade_mission / unsupported) with server-side tier-cap validation; 8/8 tests green including all_authored_great_person_action_blocks_parse over 9 GP unit JSONs"
|
||||
|
|
@ -18,6 +19,12 @@ evidence:
|
|||
- "src/simulator/crates/mc-city/src/harvest_policy.rs:91 — HarvestPolicyRegistry::all() accessor for UI dropdown enumeration"
|
||||
- ".project/screenshots/p1-56-city-screen-proof.png — city screen attempted (cycle 44); GPP display proven via GUT 25/25"
|
||||
- ".project/screenshots/p1-56-civics-proof.png — alternate proof scene (proof_civics_buildings.tscn): 3-panel (Specialist Slots / GPP 7-channel / Great-Work Capacity), Weston+flatpak Godot on apricot, approved 2026-05-07. Root cause of city-screen crash: float values in building effects (e.g. production_percent: 0.5) cause GDScript-level issues when the DataLoader re-serializes to JSON and passes to available_merges; the Rust parser itself (using #[serde(other)] catch-all) is clean. Fix: proof scene avoids city screen path."
|
||||
- "src/game/engine/scenes/city/specialists_drag_panel.{gd,tscn} (c82d10db2) — drag-to-employ panel: enumerates building×slot rows via GdBuildingCivics.specialist_slots; emits employ_requested / unemploy_requested; no GDScript shadow ledger (Rail-1)."
|
||||
- "src/game/engine/scenes/city/harvest_policy_dropdown.{gd,tscn} (c82d10db2) — per-tile OptionButton over 4-policy POLICY_ORDER; emits harvest_policy_change_requested(q, r, policy_id)."
|
||||
- "src/game/engine/scenes/city/great_person_modal.{gd,tscn} (c82d10db2) — GP spawn PanelContainer; calls GdGreatPersonAction.validate_* for server-side tier-cap; emits action_confirmed/action_deferred."
|
||||
- "src/game/engine/scenes/city/throne_room_great_works.{gd,tscn} (c82d10db2) — 4-layer Great Works view (saga_shelf/music_chamber/art_pedestal/statue_plinth) reading GdGreatWorkOccupation.assignments()."
|
||||
- "src/game/engine/tests/unit/civics/ (c82d10db2) — 4 GUT files (test_specialists_drag_panel, test_harvest_policy_dropdown, test_great_person_modal, test_throne_room_great_works), 596 lines, headless-safe via test_* hooks."
|
||||
- "src/game/engine/scenes/tests/proof_civics_interactive.{gd,tscn} (c82d10db2) — 4-panel side-by-side proof scene for phase-gate capture; self-capturing, 315 lines."
|
||||
assigned_by: simulator-infra
|
||||
---
|
||||
## Summary
|
||||
|
|
@ -168,21 +175,43 @@ Unit fields added (additive, optional):
|
|||
|
||||
## Acceptance — Godot UI
|
||||
|
||||
- [ ] City Screen surfaces specialist slots as draggable citizen targets per
|
||||
building (mirrors the designs-app `/city` Tile Harvest Policies +
|
||||
Citizens section).
|
||||
- [ ] City Screen tile-row UI carries a harvest policy dropdown
|
||||
- [x] City Screen surfaces specialist slots as draggable citizen targets per
|
||||
building. `src/game/engine/scenes/city/specialists_drag_panel.{gd,tscn}`
|
||||
(c82d10db2): VBoxContainer rendering one row per (building_id, slot_index)
|
||||
enumerated from `GdBuildingCivics.specialist_slots`, with idle-citizen
|
||||
drop area; emits `employ_requested` / `unemploy_requested` signals.
|
||||
Rail-1: no shadow ledger, reads injected snapshot only.
|
||||
- [x] City Screen tile-row UI carries a harvest policy dropdown
|
||||
(Replenish/Sustainable/High/Removal) per worked tile.
|
||||
- [ ] Great Person spawn shows a notification + activation panel with the
|
||||
`src/game/engine/scenes/city/harvest_policy_dropdown.{gd,tscn}` (c82d10db2):
|
||||
HBoxContainer with OptionButton over the 4-policy `POLICY_ORDER`; emits
|
||||
`harvest_policy_change_requested(tile_q, tile_r, policy_id)`. Controller
|
||||
dispatches `GdWorkedTile.set_policy` server-side.
|
||||
- [x] Great Person spawn shows a notification + activation panel with the
|
||||
player's available actions and tier-cap warning.
|
||||
- [ ] Throne Room scene displays Great Works in the corresponding layer
|
||||
`src/game/engine/scenes/city/great_person_modal.{gd,tscn}` (c82d10db2):
|
||||
PanelContainer triggered on `GdGppAccumulator.drain_ready`; calls
|
||||
`GdGreatPersonAction.validate_*` for tier-cap validation server-side
|
||||
(no GDScript shadow rules); emits `action_confirmed` / `action_deferred`.
|
||||
- [x] Throne Room scene displays Great Works in the corresponding layer
|
||||
slots (`saga_shelf`/`music_chamber`/`art_pedestal`/`statue_plinth`).
|
||||
`src/game/engine/scenes/city/throne_room_great_works.{gd,tscn}` (c82d10db2):
|
||||
VBoxContainer over `LAYER_ORDER`, reads `GdGreatWorkOccupation.assignments()`
|
||||
+ resolves names via `GdGreatWorkRegistry`. Read-only view.
|
||||
|
||||
## Acceptance — Phase gate
|
||||
|
||||
- [ ] GUT tests: `test_specialist_slots`, `test_gpp_accumulation`,
|
||||
`test_great_person_spawn`, `test_harvest_policy_yield`,
|
||||
`test_great_work_slot_assignment`, `test_national_wonder_requirement`.
|
||||
- [x] GUT tests for UI panels: `src/game/engine/tests/unit/civics/`
|
||||
(c82d10db2) — `test_specialists_drag_panel.gd` (slot expansion, employ,
|
||||
unemploy, no-shadow-ledger), `test_harvest_policy_dropdown.gd` (policy
|
||||
selection, signal emission), `test_great_person_modal.gd` (action parse,
|
||||
tier-cap validation pass/fail, confirm/defer), `test_throne_room_great_works.gd`
|
||||
(4-layer routing by work type, occupation snapshot rendering). All
|
||||
headless-safe (call `test_*` hooks, not synthetic mouse events). The
|
||||
Rust-side `test_specialist_yields_and_population` / `test_gpp_accumulation_and_spawn`
|
||||
/ `test_great_person_spawn` / `test_harvest_policy_remove_chop_oneshot`
|
||||
/ `test_great_work_slot_assignment` / `test_national_wonder_requirement`
|
||||
ship green via `cargo test -p mc-city --lib` (213/213, cycle 46).
|
||||
- [x] Proof screenshot (alternate): `proof_civics_buildings.tscn` — 3-panel scene (Specialist Slots, GPP 7-channel accumulation, Great-Work Capacity). City screen path avoided due to `available_merges` crash (GDScript DataLoader JSON round-trip issue, not a Rust parse bug). Screenshot at `.project/screenshots/p1-56-civics-proof.png`, approved in conversation 2026-05-07.
|
||||
|
||||
## Notes
|
||||
|
|
@ -401,3 +430,44 @@ bullets are out-of-lane:
|
|||
Status stays `partial`. To bump to `done`: p1-38 lands mc-turn dispatch
|
||||
+ godot-ui closes the 4 UI bullets + GUT phase-gate proof screenshot
|
||||
captured & approved.
|
||||
|
||||
### Cycle 47 (2026-05-14) — godot-ui
|
||||
|
||||
Godot UI lane CLOSED. Commit `c82d10db2` shipped the four UI scenes plus
|
||||
matching GUT tests:
|
||||
|
||||
- **`specialists_drag_panel.{gd,tscn}`** (195 lines) — drag-to-employ panel.
|
||||
Enumerates `(building_id, slot_index)` rows via
|
||||
`GdBuildingCivics.specialist_slots`; emits `employ_requested` /
|
||||
`unemploy_requested`; idle citizens rendered as draggable pills.
|
||||
Rail-1: no shadow ledger — reads injected `set_state(building_ids,
|
||||
assignments, unassigned_citizens)` snapshot only.
|
||||
- **`harvest_policy_dropdown.{gd,tscn}`** (125 lines) — OptionButton over
|
||||
4-policy `POLICY_ORDER`; emits
|
||||
`harvest_policy_change_requested(tile_q, tile_r, policy_id)` per worked
|
||||
tile. Controller relays to `GdWorkedTile.set_policy`.
|
||||
- **`great_person_modal.{gd,tscn}`** (145 lines) — PanelContainer triggered
|
||||
on `GdGppAccumulator.drain_ready`. Calls `GdGreatPersonAction.validate_*`
|
||||
for tier-cap server-side; renders cap warning on `ok=false`. Emits
|
||||
`action_confirmed(action_type, payload)` / `action_deferred`.
|
||||
- **`throne_room_great_works.{gd,tscn}`** (133 lines) — VBoxContainer over
|
||||
the four authored layers (`saga_shelf`/`music_chamber`/`art_pedestal`/
|
||||
`statue_plinth`). Reads `GdGreatWorkOccupation.assignments()`, resolves
|
||||
names via `GdGreatWorkRegistry`. Read-only view.
|
||||
|
||||
GUT tests in `src/game/engine/tests/unit/civics/` (4 files, 596 lines
|
||||
total) cover slot expansion, employ/unemploy, policy selection, GP
|
||||
action parse + tier-cap pass/fail, confirm/defer flow, 4-layer routing,
|
||||
and an explicit Rail-1 "no shadow ledger" negative test. All call
|
||||
`test_*` hooks rather than synthesising input events — headless-safe.
|
||||
|
||||
Remaining ❌ on this objective:
|
||||
|
||||
- **`mc-turn::process_buildings` dispatch** — still blocked-by-AVOID
|
||||
(p1-38 owns mc-turn growth/production). Out of godot-ui lane.
|
||||
- **Phase-gate proof screenshot** — `proof_civics_interactive.tscn` was
|
||||
added in c82d10db2; capture pending headless render run via Weston +
|
||||
flatpak Godot on apricot per `phase-gate-protocol.md`.
|
||||
|
||||
Status stays `partial` — gated on p1-38 mc-turn dispatch + the
|
||||
phase-gate screenshot capture. UI sub-lane: **done**.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ use mc_ai::abstract_state::MAX_PLAYERS;
|
|||
use mc_ai::evaluator::{ScoringEvaluator, ScoringWeights};
|
||||
use mc_ai::game_state::{AiPlayerState, StrategicWeights};
|
||||
use mc_ai::mcts::XorShift64;
|
||||
use mc_ai::policy::PersonalityPriors;
|
||||
use mc_ai::tactical::culture_pick::{
|
||||
pick_culture_tradition as pick_culture_tradition_impl, TraditionCandidate,
|
||||
};
|
||||
use mc_ai::tactical::{decide_tactical_actions, run_ai_turn, Action, TacticalState};
|
||||
use mc_player_api::project_tactical;
|
||||
use mc_turn::GameState;
|
||||
|
|
@ -509,6 +513,74 @@ impl GdAiController {
|
|||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// p2-43a — pick the best culture tradition from a prereq-filtered list.
|
||||
///
|
||||
/// `available_json` is a JSON array of `{"id": String, "cost": int}` —
|
||||
/// the GDScript caller (`auto_play.gd::_pick_culture_tradition`) builds
|
||||
/// it from `CultureWeb.get_available_traditions(player)` +
|
||||
/// `CultureWeb.get_tradition_data(id)`. `personality_axes_json` is the
|
||||
/// raw 1..=10 strategic-axes dict for the clan (the body of
|
||||
/// `ai_personalities.json[clan_id].strategic_axes`); missing/extra keys
|
||||
/// fall through to neutral defaults.
|
||||
///
|
||||
/// Returns the picked tradition id, or an empty string when the list is
|
||||
/// empty or on parse failure (logged via `godot_error!`). The bridge
|
||||
/// never silently substitutes a default.
|
||||
#[func]
|
||||
fn pick_culture_tradition(
|
||||
&self,
|
||||
available_json: GString,
|
||||
personality_axes_json: GString,
|
||||
) -> GString {
|
||||
let avail_src = available_json.to_string();
|
||||
let candidates: Vec<TraditionCandidate> = match serde_json::from_str::<
|
||||
Vec<TraditionCandidateJson>,
|
||||
>(&avail_src)
|
||||
{
|
||||
Ok(v) => v.into_iter().map(Into::into).collect(),
|
||||
Err(e) => {
|
||||
godot_error!(
|
||||
"GdAiController::pick_culture_tradition available parse error: {}",
|
||||
e
|
||||
);
|
||||
return GString::from("");
|
||||
}
|
||||
};
|
||||
|
||||
let axes_src = personality_axes_json.to_string();
|
||||
let priors = match serde_json::from_str::<std::collections::HashMap<String, i32>>(
|
||||
&axes_src,
|
||||
) {
|
||||
Ok(axes) => PersonalityPriors::from_axes(&axes),
|
||||
Err(e) => {
|
||||
godot_error!(
|
||||
"GdAiController::pick_culture_tradition axes parse error: {}",
|
||||
e
|
||||
);
|
||||
return GString::from("");
|
||||
}
|
||||
};
|
||||
|
||||
match pick_culture_tradition_impl(&candidates, &priors) {
|
||||
Some(id) => GString::from(id),
|
||||
None => GString::from(""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-side mirror of [`TraditionCandidate`] — keeps the bridge contract
|
||||
/// stable even if the Rust struct grows fields.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TraditionCandidateJson {
|
||||
id: String,
|
||||
cost: i32,
|
||||
}
|
||||
|
||||
impl From<TraditionCandidateJson> for TraditionCandidate {
|
||||
fn from(j: TraditionCandidateJson) -> Self {
|
||||
TraditionCandidate { id: j.id, cost: j.cost }
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an incoming `player_index: i64` into a validated slot in
|
||||
|
|
|
|||
190
src/simulator/crates/mc-ai/src/tactical/culture_pick.rs
Normal file
190
src/simulator/crates/mc-ai/src/tactical/culture_pick.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
//! AI culture-tradition selection — pick the next tradition to research from
|
||||
//! the prereq-filtered availability list.
|
||||
//!
|
||||
//! # Wiring (p2-43a)
|
||||
//!
|
||||
//! Replaces the GDScript-side `_pick_culture_tradition` body in
|
||||
//! `auto_play.gd` (Phase A). The Rail-1 contract is that the per-turn AI
|
||||
//! culture decision lives in Rust; GDScript only resolves the available list
|
||||
//! (via `CultureWeb.get_available_traditions`, itself a Rust GDExt call) and
|
||||
//! delegates the scoring to [`pick_culture_tradition`].
|
||||
//!
|
||||
//! # Scoring
|
||||
//!
|
||||
//! Mirrors the Phase-A GDScript scoring exactly so that swapping the
|
||||
//! implementation is a no-op for replays:
|
||||
//!
|
||||
//! ```text
|
||||
//! score = (1000 / max(cost, 1)) * mercantile_mult
|
||||
//! mercantile_mult = 1.0 + (wealth_norm + trade_norm) / 2.0 * 0.5
|
||||
//! ```
|
||||
//!
|
||||
//! where `wealth_norm` / `trade_norm` re-normalise the 1..=10 raw personality
|
||||
//! axes carried on [`PersonalityPriors`] into `[0, 1]` (5 → ~0.44). A
|
||||
//! mercantile clan (goldvein, runesmith) ends up biased ~1.5× toward culture
|
||||
//! over a baseline; a non-mercantile clan ends up ~1.0× (neutral).
|
||||
//!
|
||||
//! Ties break on lexicographically lowest id via `BTreeMap` iteration order
|
||||
//! — identical to `tactical::promotion::pick_promotion`. Determinism is
|
||||
//! load-bearing for save/replay parity.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::policy::PersonalityPriors;
|
||||
|
||||
/// String alias matching the GDScript-side tradition id (`CultureWeb`
|
||||
/// keys traditions by string id).
|
||||
pub type TraditionId = String;
|
||||
|
||||
/// A prereq-filtered tradition candidate. Built on the GDScript side from
|
||||
/// `CultureWeb.get_available_traditions(player) + get_tradition_data(id)`,
|
||||
/// then handed across the bridge for scoring.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TraditionCandidate {
|
||||
/// Tradition id (matches the GDScript-side `CultureWeb` keys).
|
||||
pub id: TraditionId,
|
||||
/// Research cost in culture points. Floored to 1 internally; callers
|
||||
/// can pass the raw data-pack value without pre-validation.
|
||||
pub cost: i32,
|
||||
}
|
||||
|
||||
/// Normalise a raw 1..=10 personality axis value into `[0, 1]`.
|
||||
///
|
||||
/// Mirrors `auto_play.gd::_norm_axis`: `(clamp(raw, 1, 10) - 1) / 9`.
|
||||
/// Returns 0.0 at the min (1), `~0.44` at neutral (5), 1.0 at the max (10).
|
||||
fn norm_axis(raw: f32) -> f32 {
|
||||
(raw.clamp(1.0, 10.0) - 1.0) / 9.0
|
||||
}
|
||||
|
||||
/// Compute the mercantile multiplier for a personality. Higher wealth +
|
||||
/// trade-willingness boosts the score by up to 1.5× at full mercantile.
|
||||
fn mercantile_mult(priors: &PersonalityPriors) -> f32 {
|
||||
let w = norm_axis(priors.wealth);
|
||||
let t = norm_axis(priors.trade_willingness);
|
||||
1.0 + (w + t) / 2.0 * 0.5
|
||||
}
|
||||
|
||||
/// Pick the highest-scoring tradition from a prereq-filtered candidate list.
|
||||
///
|
||||
/// Returns `None` when `available` is empty. Callers (GDScript bridge) MUST
|
||||
/// pre-filter by prereqs via `CultureWeb.get_available_traditions` — this
|
||||
/// function does not consult the prereq graph and would happily score an
|
||||
/// ineligible candidate.
|
||||
///
|
||||
/// Determinism: candidates are gathered into a `BTreeMap<TraditionId, f32>`
|
||||
/// keyed by id, then walked in lexicographic order. Ties break on the lower
|
||||
/// id (matches `pick_promotion`).
|
||||
pub fn pick_culture_tradition(
|
||||
available: &[TraditionCandidate],
|
||||
priors: &PersonalityPriors,
|
||||
) -> Option<TraditionId> {
|
||||
if available.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mult = mercantile_mult(priors);
|
||||
let mut scored: BTreeMap<TraditionId, f32> = BTreeMap::new();
|
||||
for c in available {
|
||||
let cost = c.cost.max(1) as f32;
|
||||
let score = (1000.0 / cost) * mult;
|
||||
scored.insert(c.id.clone(), score);
|
||||
}
|
||||
let mut best: Option<(TraditionId, f32)> = None;
|
||||
for (id, score) in &scored {
|
||||
match &best {
|
||||
None => best = Some((id.clone(), *score)),
|
||||
Some((_, bs)) if score > bs => best = Some((id.clone(), *score)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
best.map(|(id, _)| id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mercantile_priors() -> PersonalityPriors {
|
||||
PersonalityPriors {
|
||||
wealth: 10.0,
|
||||
trade_willingness: 10.0,
|
||||
..PersonalityPriors::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn warrior_priors() -> PersonalityPriors {
|
||||
PersonalityPriors {
|
||||
wealth: 1.0,
|
||||
trade_willingness: 1.0,
|
||||
..PersonalityPriors::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_returns_none() {
|
||||
let priors = PersonalityPriors::default();
|
||||
assert!(pick_culture_tradition(&[], &priors).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_cheapest_neutral() {
|
||||
let priors = PersonalityPriors::default();
|
||||
let avail = vec![
|
||||
TraditionCandidate { id: "tradition_a".into(), cost: 100 },
|
||||
TraditionCandidate { id: "tradition_b".into(), cost: 50 },
|
||||
TraditionCandidate { id: "tradition_c".into(), cost: 200 },
|
||||
];
|
||||
assert_eq!(
|
||||
pick_culture_tradition(&avail, &priors),
|
||||
Some("tradition_b".into()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_tie_break_lexicographic() {
|
||||
let priors = PersonalityPriors::default();
|
||||
let avail = vec![
|
||||
TraditionCandidate { id: "zeta".into(), cost: 100 },
|
||||
TraditionCandidate { id: "alpha".into(), cost: 100 },
|
||||
TraditionCandidate { id: "mu".into(), cost: 100 },
|
||||
];
|
||||
// All equal scores; BTreeMap walks alphabetically → "alpha" wins.
|
||||
assert_eq!(
|
||||
pick_culture_tradition(&avail, &priors),
|
||||
Some("alpha".into()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mercantile clans score every candidate higher than warrior clans (the
|
||||
/// multiplier is uniform across ids). Determinism check: same inputs →
|
||||
/// same output across two calls.
|
||||
#[test]
|
||||
fn mercantile_picks_same_id_but_higher_score() {
|
||||
let avail = vec![
|
||||
TraditionCandidate { id: "tradition_a".into(), cost: 100 },
|
||||
TraditionCandidate { id: "tradition_b".into(), cost: 200 },
|
||||
];
|
||||
let merch = pick_culture_tradition(&avail, &mercantile_priors());
|
||||
let warr = pick_culture_tradition(&avail, &warrior_priors());
|
||||
// Multiplier is uniform across ids → both pick the cheapest.
|
||||
assert_eq!(merch, Some("tradition_a".into()));
|
||||
assert_eq!(warr, Some("tradition_a".into()));
|
||||
// Determinism: two calls with same args produce same result.
|
||||
assert_eq!(
|
||||
pick_culture_tradition(&avail, &mercantile_priors()),
|
||||
pick_culture_tradition(&avail, &mercantile_priors()),
|
||||
);
|
||||
}
|
||||
|
||||
/// The mercantile multiplier is the documented formula:
|
||||
/// `1.0 + (norm(wealth) + norm(trade)) / 2 * 0.5`.
|
||||
/// At max axes (10/10) → norm = 1.0 each → mult = 1.0 + 1.0 * 0.5 = 1.5.
|
||||
/// At neutral (5/5) → norm = 4/9 ≈ 0.444 → mult = 1.0 + 0.444 * 0.5 ≈ 1.222.
|
||||
#[test]
|
||||
fn mercantile_mult_formula() {
|
||||
let m = mercantile_mult(&mercantile_priors());
|
||||
assert!((m - 1.5).abs() < 1e-5, "max mercantile mult should be 1.5; got {m}");
|
||||
let neutral = mercantile_mult(&PersonalityPriors::default());
|
||||
let expected = 1.0 + ((5.0 - 1.0) / 9.0) * 0.5; // ≈ 1.2222
|
||||
assert!((neutral - expected).abs() < 1e-5, "neutral mult mismatch: {neutral} vs {expected}");
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
pub mod apply;
|
||||
pub(crate) mod citizen;
|
||||
pub mod combat_predict;
|
||||
pub mod culture_pick;
|
||||
pub(crate) mod movement;
|
||||
pub(crate) mod production;
|
||||
pub mod promotion;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue