Skip to main content

canwu_sim/runtime/
scenario.rs

1use super::{
2    ActorKnowledge, Army, ArmyId, ArmyKnowledge, CanwuError, DomainRecord, ErrorCode,
3    EstimateRange, FieldSchema, Government, GovernmentId, KnowledgeSnapshot, KnowledgeSource,
4    LetterStatus, MapPoint, Person, PersonId, Route, RouteId, SchemaRegistry, SimDuration, SimTime,
5    Territory, TerritoryId, TypeSchema, WorldSnapshot, core_world_entity_exists, invalid_snapshot,
6    records,
7};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::Display;
11
12#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
13pub struct DemoIds {
14    pub commander: PersonId,
15    pub observer: PersonId,
16    pub government: GovernmentId,
17    pub army: ArmyId,
18    pub western_territory: TerritoryId,
19    pub central_territory: TerritoryId,
20    pub eastern_territory: TerritoryId,
21}
22
23#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
24pub struct Scenario {
25    pub start_time: SimTime,
26    pub world: WorldSnapshot,
27    pub knowledge: KnowledgeSnapshot,
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub domain_records: Vec<DomainRecord>,
30}
31
32pub(super) fn require_plugin_aware_initial_records(scenario: &Scenario) -> Result<(), CanwuError> {
33    if scenario.domain_records.is_empty() {
34        return Ok(());
35    }
36    Err(CanwuError::new(
37        ErrorCode::PluginNotActive,
38        "scenarios with initial domain records require a plugin-aware constructor",
39    ))
40}
41
42pub(super) fn canonicalize_scenario(scenario: &mut Scenario) {
43    scenario.world.people.sort_by_key(|value| value.id);
44    scenario.world.governments.sort_by_key(|value| value.id);
45    scenario.world.territories.sort_by_key(|value| value.id);
46    scenario.world.routes.sort_by_key(|value| value.id);
47    scenario.world.armies.sort_by_key(|value| value.id);
48    scenario.world.letters.sort_by_key(|value| value.id);
49    scenario
50        .domain_records
51        .sort_by(|left, right| left.reference.cmp(&right.reference));
52}
53
54pub(super) fn validate_scenario(scenario: &Scenario) -> Result<(), CanwuError> {
55    if !scenario.knowledge.records.is_empty() {
56        return Err(CanwuError::new(
57            ErrorCode::InvalidKnowledgeRecord,
58            "scenario authors cannot preselect generic knowledge IDs, times, or origins",
59        ));
60    }
61    validate_scenario_state(scenario)
62}
63
64pub(super) fn validate_scenario_state(scenario: &Scenario) -> Result<(), CanwuError> {
65    validate_unique_ids(&scenario.world.people, |value| value.id, "person")?;
66    validate_unique_ids(&scenario.world.governments, |value| value.id, "government")?;
67    validate_unique_ids(&scenario.world.territories, |value| value.id, "territory")?;
68    validate_unique_ids(&scenario.world.routes, |value| value.id, "route")?;
69    validate_unique_ids(&scenario.world.armies, |value| value.id, "army")?;
70    validate_unique_ids(&scenario.world.letters, |value| value.id, "letter")?;
71
72    for person in &scenario.world.people {
73        if scenario.world.government(person.government).is_none()
74            || scenario.world.territory(person.current_location).is_none()
75        {
76            return Err(CanwuError::new(
77                ErrorCode::InvalidSnapshot,
78                format!(
79                    "person {} references a missing government or location",
80                    person.id
81                ),
82            ));
83        }
84        if let Some(transit) = &person.transit
85            && (scenario.world.territory(transit.from).is_none()
86                || scenario.world.territory(transit.to).is_none()
87                || transit.arrives_at <= transit.departed_at
88                || transit.departed_at > scenario.start_time
89                || person.current_location != transit.from)
90        {
91            return Err(CanwuError::new(
92                ErrorCode::InvalidSnapshot,
93                format!("person {} has invalid transit state", person.id),
94            ));
95        }
96    }
97    for government in &scenario.world.governments {
98        if scenario.world.territory(government.capital).is_none() {
99            return Err(CanwuError::new(
100                ErrorCode::InvalidSnapshot,
101                format!("government {} references a missing capital", government.id),
102            ));
103        }
104    }
105    for territory in &scenario.world.territories {
106        if scenario.world.government(territory.controller).is_none()
107            || !territory.position.x.is_finite()
108            || !territory.position.y.is_finite()
109        {
110            return Err(CanwuError::new(
111                ErrorCode::InvalidSnapshot,
112                format!(
113                    "territory {} has a missing controller or non-finite position",
114                    territory.id
115                ),
116            ));
117        }
118    }
119    for army in &scenario.world.armies {
120        if scenario.world.person(army.commander).is_none()
121            || scenario.world.government(army.government).is_none()
122        {
123            return Err(CanwuError::new(
124                ErrorCode::InvalidSnapshot,
125                format!(
126                    "army {} references a missing commander or government",
127                    army.id
128                ),
129            ));
130        }
131        if scenario.world.territory(army.location).is_none() {
132            return Err(CanwuError::new(
133                ErrorCode::InvalidSnapshot,
134                format!("army {} references a missing location", army.id),
135            ));
136        }
137        if let Some(transit) = &army.transit
138            && (scenario.world.territory(transit.from).is_none()
139                || scenario.world.territory(transit.to).is_none()
140                || transit.arrives_at < transit.departed_at
141                || transit.departed_at > scenario.start_time
142                || army.location != transit.from)
143        {
144            return Err(CanwuError::new(
145                ErrorCode::InvalidSnapshot,
146                format!("army {} has invalid transit state", army.id),
147            ));
148        }
149    }
150    for route in &scenario.world.routes {
151        if scenario.world.territory(route.from).is_none()
152            || scenario.world.territory(route.to).is_none()
153            || route.travel_minutes <= 0
154        {
155            return Err(CanwuError::new(
156                ErrorCode::InvalidSnapshot,
157                format!("route {} has invalid endpoints or travel time", route.id),
158            ));
159        }
160    }
161    for letter in &scenario.world.letters {
162        let custody_valid = match letter.status {
163            LetterStatus::HeldByPerson | LetterStatus::InTransit => {
164                letter.carrier.is_some()
165                    && letter.location.is_none()
166                    && letter.delivered_at.is_none()
167            }
168            LetterStatus::HeldAtLocation => {
169                letter.carrier.is_none()
170                    && letter.location.is_some()
171                    && letter.delivered_at.is_none()
172            }
173            LetterStatus::Delivered => {
174                letter.carrier.is_none()
175                    && letter.location.is_some()
176                    && letter.delivered_at.is_some()
177            }
178        };
179        if letter.body.len() > 65_536
180            || scenario.world.person(letter.sender).is_none()
181            || scenario.world.person(letter.recipient).is_none()
182            || letter
183                .location
184                .is_some_and(|location| scenario.world.territory(location).is_none())
185            || !custody_valid
186        {
187            return Err(CanwuError::new(
188                ErrorCode::InvalidSnapshot,
189                format!("letter {} has invalid custody or payload state", letter.id),
190            ));
191        }
192        if let Some(carrier) = letter.carrier
193            && scenario.world.person(carrier).is_none()
194        {
195            return Err(CanwuError::new(
196                ErrorCode::InvalidSnapshot,
197                format!("letter {} references a missing carrier", letter.id),
198            ));
199        }
200    }
201    records::validate_initial_records(&scenario.domain_records, scenario.start_time, &|entity| {
202        core_world_entity_exists(&scenario.world, entity)
203    })?;
204    for (actor_id, actor) in &scenario.knowledge.actors {
205        if actor.actor != *actor_id || scenario.world.person(*actor_id).is_none() {
206            return Err(CanwuError::new(
207                ErrorCode::InvalidSnapshot,
208                format!("knowledge actor {actor_id} is inconsistent or missing"),
209            ));
210        }
211        for (army_id, record) in &actor.armies {
212            if record.army != *army_id
213                || scenario.world.army(*army_id).is_none()
214                || record
215                    .known_location
216                    .is_some_and(|location| scenario.world.territory(location).is_none())
217                || record.estimated_strength.minimum > record.estimated_strength.maximum
218                || record.confidence_per_mille > 1000
219                || record.observed_at > record.learned_at
220                || record.observed_at > scenario.start_time
221                || record.learned_at > scenario.start_time
222            {
223                return Err(CanwuError::new(
224                    ErrorCode::InvalidSnapshot,
225                    format!("knowledge record for actor {actor_id} and army {army_id} is invalid"),
226                ));
227            }
228        }
229    }
230    Ok(())
231}
232
233pub(super) fn validate_unique_ids<T, I, F>(
234    values: &[T],
235    mut id_of: F,
236    label: &str,
237) -> Result<(), CanwuError>
238where
239    I: Copy + Default + Display + Ord,
240    F: FnMut(&T) -> I,
241{
242    let mut ids = BTreeSet::new();
243    for value in values {
244        let id = id_of(value);
245        if id == I::default() {
246            return Err(CanwuError::new(
247                ErrorCode::InvalidSnapshot,
248                format!("{label} IDs must be nonzero"),
249            ));
250        }
251        if !ids.insert(id) {
252            return Err(CanwuError::new(
253                ErrorCode::InvalidSnapshot,
254                format!("duplicate {label} ID {id}"),
255            ));
256        }
257    }
258    Ok(())
259}
260
261pub(super) fn validate_strict_id_order<T, I, F>(
262    values: &[T],
263    mut id_of: F,
264    label: &str,
265) -> Result<(), CanwuError>
266where
267    I: Copy + Ord,
268    F: FnMut(&T) -> I,
269{
270    if values
271        .windows(2)
272        .any(|pair| id_of(&pair[0]) >= id_of(&pair[1]))
273    {
274        return invalid_snapshot(format!("snapshot {label} are not in canonical ID order"));
275    }
276    Ok(())
277}
278
279fn field(name: &str, value_type: &str, description: &str) -> FieldSchema {
280    FieldSchema {
281        name: name.to_owned(),
282        value_type: value_type.to_owned(),
283        description: description.to_owned(),
284        reference_type: None,
285        writable_via_debug_command: false,
286    }
287}
288
289pub(super) fn base_schema() -> SchemaRegistry {
290    let mut schema = SchemaRegistry::default();
291    schema.register(TypeSchema {
292        type_name: "person".to_owned(),
293        description: "Historical actor with roles and a location".to_owned(),
294        fields: vec![
295            field("id", "PersonId", "Stable person identifier"),
296            field("name", "String", "Display name"),
297            field("government", "GovernmentId", "Government membership"),
298            field("current_location", "TerritoryId", "Current territory"),
299            field("roles", "Vec<String>", "Offices and authorities"),
300        ],
301    });
302    schema.register(TypeSchema {
303        type_name: "army".to_owned(),
304        description: "Mobile military organization".to_owned(),
305        fields: vec![
306            field("id", "ArmyId", "Stable army identifier"),
307            field("commander", "PersonId", "Commanding person"),
308            field("location", "TerritoryId", "Ground-truth territory"),
309            field("strength", "u32", "Ground-truth personnel strength"),
310            FieldSchema {
311                name: "morale".to_owned(),
312                value_type: "u16".to_owned(),
313                description: "Morale from 0 through 100".to_owned(),
314                reference_type: None,
315                writable_via_debug_command: true,
316            },
317            field("transit", "Option<TransitState>", "Pending movement"),
318        ],
319    });
320    schema.register(TypeSchema {
321        type_name: "territory".to_owned(),
322        description: "Administrative and geographic unit".to_owned(),
323        fields: vec![
324            field("id", "TerritoryId", "Stable territory identifier"),
325            field("controller", "GovernmentId", "Controlling government"),
326            field("position", "MapPoint", "Abstract visualization point"),
327        ],
328    });
329    schema.register(TypeSchema {
330        type_name: "route".to_owned(),
331        description: "Travel connection between territories".to_owned(),
332        fields: vec![
333            field("from", "TerritoryId", "First route endpoint"),
334            field("to", "TerritoryId", "Second route endpoint"),
335            field("travel_minutes", "i64", "Deterministic travel duration"),
336            field("terrain", "String", "Terrain classification"),
337        ],
338    });
339    schema.register(TypeSchema {
340        type_name: "event".to_owned(),
341        description: "Inspectable state-change or information event".to_owned(),
342        fields: vec![field("timestamp", "SimTime", "Simulation occurrence time")],
343    });
344    schema
345}
346
347#[must_use]
348pub fn demo_scenario() -> (Scenario, DemoIds) {
349    let ids = DemoIds {
350        commander: PersonId::new(1),
351        observer: PersonId::new(2),
352        government: GovernmentId::new(1),
353        army: ArmyId::new(1),
354        western_territory: TerritoryId::new(1),
355        central_territory: TerritoryId::new(2),
356        eastern_territory: TerritoryId::new(3),
357    };
358    let world = WorldSnapshot {
359        people: vec![
360            Person {
361                id: ids.commander,
362                name: "General Shen".to_owned(),
363                government: ids.government,
364                current_location: ids.central_territory,
365                roles: vec!["army_commander".to_owned()],
366                transit: None,
367            },
368            Person {
369                id: ids.observer,
370                name: "Minister Luo".to_owned(),
371                government: ids.government,
372                current_location: ids.western_territory,
373                roles: vec!["civil_minister".to_owned()],
374                transit: None,
375            },
376        ],
377        governments: vec![Government {
378            id: ids.government,
379            name: "State of Yun".to_owned(),
380            capital: ids.central_territory,
381        }],
382        territories: vec![
383            Territory {
384                id: ids.western_territory,
385                name: "Westford".to_owned(),
386                controller: ids.government,
387                position: MapPoint { x: 80.0, y: 180.0 },
388            },
389            Territory {
390                id: ids.central_territory,
391                name: "Yun Capital".to_owned(),
392                controller: ids.government,
393                position: MapPoint { x: 240.0, y: 120.0 },
394            },
395            Territory {
396                id: ids.eastern_territory,
397                name: "Eastwatch".to_owned(),
398                controller: ids.government,
399                position: MapPoint { x: 420.0, y: 210.0 },
400            },
401        ],
402        routes: vec![
403            Route {
404                id: RouteId::new(1),
405                name: "Western Post Road".to_owned(),
406                from: ids.western_territory,
407                to: ids.central_territory,
408                travel_minutes: SimDuration::hours(12).as_minutes(),
409                terrain: "road".to_owned(),
410            },
411            Route {
412                id: RouteId::new(2),
413                name: "Eastern River Road".to_owned(),
414                from: ids.central_territory,
415                to: ids.eastern_territory,
416                travel_minutes: SimDuration::hours(18).as_minutes(),
417                terrain: "river_road".to_owned(),
418            },
419        ],
420        armies: vec![Army {
421            id: ids.army,
422            name: "First Field Army".to_owned(),
423            government: ids.government,
424            commander: ids.commander,
425            location: ids.central_territory,
426            strength: 8_000,
427            morale: 72,
428            transit: None,
429        }],
430        letters: Vec::new(),
431    };
432    let initial_time = SimTime::EPOCH;
433    let mut knowledge = KnowledgeSnapshot::default();
434    knowledge.actors.insert(
435        ids.commander,
436        ActorKnowledge {
437            actor: ids.commander,
438            armies: BTreeMap::from([(
439                ids.army,
440                ArmyKnowledge {
441                    army: ids.army,
442                    known_name: Some("First Field Army".to_owned()),
443                    known_location: Some(ids.central_territory),
444                    estimated_strength: EstimateRange {
445                        minimum: 8_000,
446                        maximum: 8_000,
447                    },
448                    observed_at: initial_time,
449                    learned_at: initial_time,
450                    confidence_per_mille: 1000,
451                    source: KnowledgeSource::CommandResponsibility,
452                },
453            )]),
454        },
455    );
456    knowledge.actors.insert(
457        ids.observer,
458        ActorKnowledge {
459            actor: ids.observer,
460            armies: BTreeMap::from([(
461                ids.army,
462                ArmyKnowledge {
463                    army: ids.army,
464                    known_name: Some("First Field Army".to_owned()),
465                    known_location: Some(ids.central_territory),
466                    estimated_strength: EstimateRange {
467                        minimum: 7_000,
468                        maximum: 9_000,
469                    },
470                    observed_at: initial_time,
471                    learned_at: initial_time,
472                    confidence_per_mille: 700,
473                    source: KnowledgeSource::ScenarioRecord,
474                },
475            )]),
476        },
477    );
478    (
479        Scenario {
480            start_time: initial_time,
481            world,
482            knowledge,
483            domain_records: Vec::new(),
484        },
485        ids,
486    )
487}