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}