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}