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.register(TypeSchema {
391        type_name: "person".to_owned(),
392        description: "Historical actor with roles and a location".to_owned(),
393        fields: vec![
394            field("id", "PersonId", "Stable person identifier"),
395            field("name", "String", "Display name"),
396            field("government", "GovernmentId", "Government membership"),
397            field("current_location", "TerritoryId", "Current territory"),
398            field("roles", "Vec<String>", "Offices and authorities"),
399        ],
400    });
401    schema.register(TypeSchema {
402        type_name: "army".to_owned(),
403        description: "Mobile military organization".to_owned(),
404        fields: vec![
405            field("id", "ArmyId", "Stable army identifier"),
406            field("commander", "PersonId", "Commanding person"),
407            field("location", "TerritoryId", "Ground-truth territory"),
408            field("strength", "u32", "Ground-truth personnel strength"),
409            FieldSchema {
410                name: "morale".to_owned(),
411                value_type: "u16".to_owned(),
412                description: "Morale from 0 through 100".to_owned(),
413                reference_type: None,
414                writable_via_debug_command: true,
415            },
416            field("transit", "Option<TransitState>", "Pending movement"),
417        ],
418    });
419    schema.register(TypeSchema {
420        type_name: "territory".to_owned(),
421        description: "Administrative and geographic unit".to_owned(),
422        fields: vec![
423            field("id", "TerritoryId", "Stable territory identifier"),
424            field("controller", "GovernmentId", "Controlling government"),
425            field("position", "MapPoint", "Abstract visualization point"),
426        ],
427    });
428    schema.register(TypeSchema {
429        type_name: "route".to_owned(),
430        description: "Travel connection between territories".to_owned(),
431        fields: vec![
432            field("from", "TerritoryId", "First route endpoint"),
433            field("to", "TerritoryId", "Second route endpoint"),
434            field("travel_minutes", "i64", "Deterministic travel duration"),
435            field("terrain", "String", "Terrain classification"),
436        ],
437    });
438    schema.register(TypeSchema {
439        type_name: "event".to_owned(),
440        description: "Inspectable state-change or information event".to_owned(),
441        fields: vec![field("timestamp", "SimTime", "Simulation occurrence time")],
442    });
443    schema
444}
445
446#[must_use]
447pub fn demo_scenario() -> (Scenario, DemoIds) {
448    let ids = DemoIds {
449        commander: PersonId::new(1),
450        observer: PersonId::new(2),
451        government: GovernmentId::new(1),
452        army: ArmyId::new(1),
453        western_territory: TerritoryId::new(1),
454        central_territory: TerritoryId::new(2),
455        eastern_territory: TerritoryId::new(3),
456    };
457    let world = WorldSnapshot {
458        people: vec![
459            Person {
460                id: ids.commander,
461                name: "General Shen".to_owned(),
462                government: ids.government,
463                current_location: ids.central_territory,
464                roles: vec!["army_commander".to_owned()],
465                transit: None,
466            },
467            Person {
468                id: ids.observer,
469                name: "Minister Luo".to_owned(),
470                government: ids.government,
471                current_location: ids.western_territory,
472                roles: vec!["civil_minister".to_owned()],
473                transit: None,
474            },
475        ],
476        governments: vec![Government {
477            id: ids.government,
478            name: "State of Yun".to_owned(),
479            capital: ids.central_territory,
480        }],
481        territories: vec![
482            Territory {
483                id: ids.western_territory,
484                name: "Westford".to_owned(),
485                controller: ids.government,
486                position: MapPoint { x: 80.0, y: 180.0 },
487            },
488            Territory {
489                id: ids.central_territory,
490                name: "Yun Capital".to_owned(),
491                controller: ids.government,
492                position: MapPoint { x: 240.0, y: 120.0 },
493            },
494            Territory {
495                id: ids.eastern_territory,
496                name: "Eastwatch".to_owned(),
497                controller: ids.government,
498                position: MapPoint { x: 420.0, y: 210.0 },
499            },
500        ],
501        routes: vec![
502            Route {
503                id: RouteId::new(1),
504                name: "Western Post Road".to_owned(),
505                from: ids.western_territory,
506                to: ids.central_territory,
507                travel_minutes: SimDuration::hours(12).as_minutes(),
508                terrain: "road".to_owned(),
509            },
510            Route {
511                id: RouteId::new(2),
512                name: "Eastern River Road".to_owned(),
513                from: ids.central_territory,
514                to: ids.eastern_territory,
515                travel_minutes: SimDuration::hours(18).as_minutes(),
516                terrain: "river_road".to_owned(),
517            },
518        ],
519        armies: vec![Army {
520            id: ids.army,
521            name: "First Field Army".to_owned(),
522            government: ids.government,
523            commander: ids.commander,
524            location: ids.central_territory,
525            strength: 8_000,
526            morale: 72,
527            transit: None,
528        }],
529        letters: Vec::new(),
530    };
531    let initial_time = SimTime::EPOCH;
532    let mut knowledge = KnowledgeSnapshot::default();
533    knowledge.actors.insert(
534        ids.commander,
535        ActorKnowledge {
536            actor: ids.commander,
537            armies: BTreeMap::from([(
538                ids.army,
539                ArmyKnowledge {
540                    army: ids.army,
541                    known_name: Some("First Field Army".to_owned()),
542                    known_location: Some(ids.central_territory),
543                    estimated_strength: EstimateRange {
544                        minimum: 8_000,
545                        maximum: 8_000,
546                    },
547                    observed_at: initial_time,
548                    learned_at: initial_time,
549                    confidence_per_mille: 1000,
550                    source: KnowledgeSource::CommandResponsibility,
551                },
552            )]),
553        },
554    );
555    knowledge.actors.insert(
556        ids.observer,
557        ActorKnowledge {
558            actor: ids.observer,
559            armies: BTreeMap::from([(
560                ids.army,
561                ArmyKnowledge {
562                    army: ids.army,
563                    known_name: Some("First Field Army".to_owned()),
564                    known_location: Some(ids.central_territory),
565                    estimated_strength: EstimateRange {
566                        minimum: 7_000,
567                        maximum: 9_000,
568                    },
569                    observed_at: initial_time,
570                    learned_at: initial_time,
571                    confidence_per_mille: 700,
572                    source: KnowledgeSource::ScenarioRecord,
573                },
574            )]),
575        },
576    );
577    (
578        Scenario {
579            start_time: initial_time,
580            entities: vec![
581                EntityRef::Army(ids.army),
582                EntityRef::Government(ids.government),
583                EntityRef::Person(ids.commander),
584                EntityRef::Person(ids.observer),
585                EntityRef::Route(RouteId::new(1)),
586                EntityRef::Route(RouteId::new(2)),
587                EntityRef::Territory(ids.western_territory),
588                EntityRef::Territory(ids.central_territory),
589                EntityRef::Territory(ids.eastern_territory),
590            ],
591            world,
592            knowledge,
593            domain_records: Vec::new(),
594        },
595        ids,
596    )
597}