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}