Skip to main content

canwu_sim/runtime/
scenario.rs

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