1use canwu_api::{
8 ArmyId, Canwu, CanwuError, Command, CommandEnvelope, CommandRecord, CommandRequest,
9 CommandRequestId, DemoIds, EntityRef, Issuer, SimDuration, SimEvent, SimTime, TerritoryId,
10 TransitState, WorldSnapshot,
11};
12use std::{
13 fmt::{Display, Formatter},
14 time::Duration,
15};
16
17const SIMULATION_QUANTUM: SimDuration = SimDuration::minutes(1);
18const SIMULATION_MINUTE_NANOS: u128 = 60_000_000_000;
19
20const BASE_SIM_SECONDS_PER_WALL_SECOND: u128 = 60;
26
27const SIXTY_FPS_ISH: FrameProfile = FrameProfile {
28 name: "60 FPS-ish",
29 frame_millis: &[16, 17],
30};
31const THIRTY_FPS_ISH: FrameProfile = FrameProfile {
32 name: "30 FPS-ish",
33 frame_millis: &[33, 34],
34};
35
36const SPEED_SCHEDULE: [SpeedPhase; 5] = [
41 SpeedPhase::new(GameSpeed::X1, 500),
42 SpeedPhase::new(GameSpeed::X1, 500),
43 SpeedPhase::new(GameSpeed::X5, 53_900),
44 SpeedPhase::new(GameSpeed::Paused, 1_000),
45 SpeedPhase::new(GameSpeed::X20, 40_575),
46];
47
48#[derive(Clone, Copy, Debug)]
49enum GameSpeed {
50 Paused,
51 X1,
52 X5,
53 X20,
54}
55
56impl GameSpeed {
57 const fn multiplier(self) -> u128 {
58 match self {
59 Self::Paused => 0,
60 Self::X1 => 1,
61 Self::X5 => 5,
62 Self::X20 => 20,
63 }
64 }
65
66 const fn label(self) -> &'static str {
67 match self {
68 Self::Paused => "Paused",
69 Self::X1 => "1x",
70 Self::X5 => "5x",
71 Self::X20 => "20x",
72 }
73 }
74}
75
76#[derive(Clone, Copy)]
77struct SpeedPhase {
78 speed: GameSpeed,
79 wall_millis: u64,
80}
81
82impl SpeedPhase {
83 const fn new(speed: GameSpeed, wall_millis: u64) -> Self {
84 Self { speed, wall_millis }
85 }
86}
87
88#[derive(Clone, Copy)]
89struct FrameProfile {
90 name: &'static str,
91 frame_millis: &'static [u64],
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95struct PresentationTime {
96 canonical: SimTime,
97 sub_minute: Duration,
98}
99
100impl Display for PresentationTime {
101 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
102 write!(
103 formatter,
104 "{} + {:.3}m",
105 self.canonical,
106 self.sub_minute.as_secs_f64() / 60.0,
107 )
108 }
109}
110
111struct PresentationState {
114 authoritative_location: TerritoryId,
115 last_authoritative_transit: Option<TransitState>,
116 visual_progress: Option<f64>,
117}
118
119impl PresentationState {
120 fn from_world(world: &WorldSnapshot, army: ArmyId) -> Self {
121 let army = world.army(army).expect("the demo army must exist");
122 Self {
123 authoritative_location: army.location,
124 last_authoritative_transit: army.transit.clone(),
125 visual_progress: None,
126 }
127 }
128
129 fn refresh_from_authority(&mut self, world: &WorldSnapshot, army: ArmyId) {
130 let army = world.army(army).expect("the demo army must exist");
131 self.authoritative_location = army.location;
132 if let Some(transit) = &army.transit {
133 self.last_authoritative_transit = Some(transit.clone());
134 }
135 }
136
137 fn render(&mut self, presentation_time: PresentationTime) -> Option<f64> {
138 self.visual_progress = self.last_authoritative_transit.as_ref().map(|transit| {
139 if presentation_time.canonical < transit.departed_at {
140 return 0.0;
141 }
142 let elapsed = std_duration(presentation_time.canonical - transit.departed_at)
143 .checked_add(presentation_time.sub_minute)
144 .expect("presentation elapsed time must remain representable");
145 let travel = std_duration(transit.arrives_at - transit.departed_at);
146 (elapsed.as_secs_f64() / travel.as_secs_f64()).clamp(0.0, 1.0)
147 });
148 self.visual_progress
149 }
150
151 fn describe(&self) -> String {
152 match (&self.last_authoritative_transit, self.visual_progress) {
153 (Some(transit), Some(progress)) => format!(
154 "army visual progress={:.1}% ({} -> {}, authoritative_location={})",
155 progress * 100.0,
156 transit.from,
157 transit.to,
158 self.authoritative_location,
159 ),
160 _ => format!("army stationary at {}", self.authoritative_location),
161 }
162 }
163}
164
165struct GameHost {
168 authority: Canwu,
169 ids: DemoIds,
170 presentation: PresentationState,
171 accumulated_sim_nanos: u128,
172 wall_elapsed: Duration,
173 frame: u64,
174 reported_events: usize,
175 next_progress_milestone: u8,
176 canonical_boundaries: usize,
177 verbose: bool,
178}
179
180impl GameHost {
181 fn new(verbose: bool) -> Result<Self, CanwuError> {
182 let authority = Canwu::demo(35)?;
183 let ids = Canwu::demo_ids();
184 let presentation = PresentationState::from_world(&authority.world(), ids.army);
185 Ok(Self {
186 authority,
187 ids,
188 presentation,
189 accumulated_sim_nanos: 0,
190 wall_elapsed: Duration::ZERO,
191 frame: 0,
192 reported_events: 0,
193 next_progress_milestone: 25,
194 canonical_boundaries: 0,
195 verbose,
196 })
197 }
198
199 fn run_phase(&mut self, phase: SpeedPhase, profile: FrameProfile) -> Result<(), CanwuError> {
200 let mut remaining_millis = phase.wall_millis;
201 let mut pattern_index = 0;
202 let mut first_frame = true;
203
204 while remaining_millis > 0 {
205 let frame_millis = profile.frame_millis[pattern_index % profile.frame_millis.len()]
206 .min(remaining_millis);
207 remaining_millis -= frame_millis;
208 self.render_frame(
209 Duration::from_millis(frame_millis),
210 phase.speed,
211 first_frame || remaining_millis == 0,
212 )?;
213 first_frame = false;
214 pattern_index += 1;
215 }
216 Ok(())
217 }
218
219 fn render_frame(
220 &mut self,
221 wall_dt: Duration,
222 speed: GameSpeed,
223 phase_edge: bool,
224 ) -> Result<(), CanwuError> {
225 self.frame += 1;
226 self.wall_elapsed += wall_dt;
227
228 let converted_wall_nanos = wall_dt.as_nanos() * BASE_SIM_SECONDS_PER_WALL_SECOND;
229 self.accumulated_sim_nanos += converted_wall_nanos * speed.multiplier();
230
231 let mut authority_changed = false;
232 while self.accumulated_sim_nanos >= SIMULATION_MINUTE_NANOS {
233 let receipts = self.authority.advance_canonical(SIMULATION_QUANTUM)?;
234 self.canonical_boundaries += receipts.len();
235 authority_changed |= !receipts.is_empty();
236 self.accumulated_sim_nanos -= SIMULATION_MINUTE_NANOS;
237 }
238
239 if authority_changed {
240 self.refresh_presentation_from_authority();
241 }
242
243 let progress = self.presentation.render(self.presentation_time());
244 let crossed_milestone = self.crossed_progress_milestone(progress);
245 let has_new_events = self.reported_events < self.authority.events().len();
246
247 if self.verbose && (phase_edge || crossed_milestone || has_new_events) {
248 self.print_frame(speed);
249 }
250 self.report_new_events();
251 Ok(())
252 }
253
254 fn submit_player_command(&mut self) -> Result<(), CanwuError> {
258 let captured_at = self.presentation_time();
259 let due_at = if self.accumulated_sim_nanos == 0 {
260 self.authority.time()
261 } else {
262 self.authority
263 .time()
264 .checked_add(SIMULATION_QUANTUM)
265 .expect("the scripted command time must remain representable")
266 };
267 let envelope = CommandEnvelope::new(
268 Issuer::Actor(self.ids.commander),
269 Command::OrderMovement {
270 subject: EntityRef::Army(self.ids.army),
271 destination: self.ids.eastern_territory,
272 cargo: Vec::new(),
273 },
274 )
275 .at_time(due_at);
276 let request = CommandRequest::new(
277 CommandRequestId::new(1),
278 self.authority.revision(),
279 envelope,
280 );
281 let ingress = self.authority.enqueue_command(due_at, 0, request)?;
282
283 if self.verbose {
284 println!(
285 "INPUT: queued commander move as {:?}, captured_at={captured_at}, quantized_due_at={due_at}",
286 ingress.ingress_id,
287 );
288 }
289
290 let receipts = self.authority.advance_canonical(SimDuration::ZERO)?;
294 self.canonical_boundaries += receipts.len();
295 self.refresh_presentation_from_authority();
296 self.presentation.render(self.presentation_time());
297 if self.verbose {
298 self.print_frame(GameSpeed::X1);
299 }
300 self.report_new_events();
301 Ok(())
302 }
303
304 fn refresh_presentation_from_authority(&mut self) {
305 self.presentation
306 .refresh_from_authority(&self.authority.world(), self.ids.army);
307 }
308
309 fn presentation_time(&self) -> PresentationTime {
310 PresentationTime {
311 canonical: self.authority.time(),
312 sub_minute: Duration::from_nanos(
313 u64::try_from(self.accumulated_sim_nanos)
314 .expect("the sub-minute accumulator must fit a Duration"),
315 ),
316 }
317 }
318
319 fn crossed_progress_milestone(&mut self, progress: Option<f64>) -> bool {
320 let Some(progress) = progress else {
321 return false;
322 };
323 let mut crossed = false;
324 while self.next_progress_milestone <= 100
325 && progress * 100.0 + f64::EPSILON >= f64::from(self.next_progress_milestone)
326 {
327 crossed = true;
328 self.next_progress_milestone += 25;
329 }
330 crossed
331 }
332
333 fn print_frame(&self, speed: GameSpeed) {
334 println!(
335 "frame={:>5} speed={:<6} wall={:>7.3}s visual_time={} canwu_time={} {}",
336 self.frame,
337 speed.label(),
338 self.wall_elapsed.as_secs_f64(),
339 self.presentation_time(),
340 self.authority.time(),
341 self.presentation.describe(),
342 );
343 }
344
345 fn report_new_events(&mut self) {
346 let events = self.authority.events();
347 if self.verbose {
348 for event in &events[self.reported_events..] {
349 println!(
350 "EVENT: at={} type={} {}",
351 event.timestamp,
352 event.kind.qualified_event_type(),
353 event.summary,
354 );
355 }
356 }
357 self.reported_events = events.len();
358 }
359
360 fn outcome(self, profile: FrameProfile) -> Result<RunOutcome, CanwuError> {
361 Ok(RunOutcome {
362 profile: profile.name,
363 frames: self.frame,
364 time: self.authority.time(),
365 world: self.authority.world(),
366 events: self.authority.events().to_vec(),
367 commands: self.authority.commands().to_vec(),
368 authoritative_state_hash: self.authority.authoritative_state_hash()?,
369 checkpoint_hash: self.authority.checkpoint_hash().to_owned(),
370 canonical_boundaries: self.canonical_boundaries,
371 })
372 }
373}
374
375struct RunOutcome {
376 profile: &'static str,
377 frames: u64,
378 time: SimTime,
379 world: WorldSnapshot,
380 events: Vec<SimEvent>,
381 commands: Vec<CommandRecord>,
382 authoritative_state_hash: String,
383 checkpoint_hash: String,
384 canonical_boundaries: usize,
385}
386
387fn run(profile: FrameProfile, verbose: bool) -> Result<RunOutcome, CanwuError> {
388 let mut host = GameHost::new(verbose)?;
389 if verbose {
390 println!("\n{} render loop", profile.name);
391 }
392
393 host.run_phase(SPEED_SCHEDULE[0], profile)?;
394 host.submit_player_command()?;
395 host.run_phase(SPEED_SCHEDULE[1], profile)?;
396 host.run_phase(SPEED_SCHEDULE[2], profile)?;
397
398 let time_before_pause = host.authority.time();
399 let remainder_before_pause = host.accumulated_sim_nanos;
400 let presentation_before_pause = host.presentation_time();
401 let frames_before_pause = host.frame;
402 assert!(
403 remainder_before_pause > 0,
404 "the pause fixture must preserve a fractional simulation minute"
405 );
406 host.run_phase(SPEED_SCHEDULE[3], profile)?;
407 assert!(
408 host.frame > frames_before_pause,
409 "pause must keep rendering"
410 );
411 assert_eq!(
412 host.authority.time(),
413 time_before_pause,
414 "pause must not advance Canwu"
415 );
416 assert_eq!(
417 host.accumulated_sim_nanos, remainder_before_pause,
418 "pause must not add desired simulation time"
419 );
420 assert_eq!(
421 host.presentation_time(),
422 presentation_before_pause,
423 "pause must freeze presentation time"
424 );
425
426 host.run_phase(SPEED_SCHEDULE[4], profile)?;
427 assert_eq!(
428 host.accumulated_sim_nanos, 0,
429 "the scripted schedule should end on a simulation-minute boundary"
430 );
431 host.outcome(profile)
432}
433
434fn std_duration(duration: SimDuration) -> Duration {
435 let minutes = u64::try_from(duration.as_minutes())
436 .expect("presentation interpolation requires a non-negative duration");
437 Duration::from_secs(
438 minutes
439 .checked_mul(60)
440 .expect("presentation duration must remain representable"),
441 )
442}
443
444fn assert_fps_independent(left: &RunOutcome, right: &RunOutcome) {
445 assert_ne!(
446 left.frames, right.frames,
447 "the render segmentations must differ"
448 );
449 assert_eq!(left.time, right.time, "Canwu time changed with render FPS");
450 assert_eq!(
451 left.world, right.world,
452 "world state changed with render FPS"
453 );
454 assert_eq!(left.events, right.events, "events changed with render FPS");
455 assert_eq!(
456 left.commands, right.commands,
457 "command ordering changed with render FPS"
458 );
459 assert_eq!(
460 left.authoritative_state_hash, right.authoritative_state_hash,
461 "authoritative state hash changed with render FPS"
462 );
463 assert_eq!(
464 left.checkpoint_hash, right.checkpoint_hash,
465 "checkpoint hash changed with render FPS"
466 );
467 assert_eq!(
468 left.canonical_boundaries, right.canonical_boundaries,
469 "canonical boundary count changed with render FPS"
470 );
471}
472
473fn assert_expected_outcome(outcome: &RunOutcome) {
474 let ids = Canwu::demo_ids();
475 let command_at = SimTime::from_minutes(1);
476 let arrival_at = SimTime::from_minutes(1_081);
477 let final_time = SimTime::from_minutes(1_082);
478
479 assert_eq!(outcome.time, final_time, "the scripted final time changed");
480 let army = outcome
481 .world
482 .army(ids.army)
483 .expect("the demo army must remain present");
484 assert_eq!(
485 army.location, ids.eastern_territory,
486 "the scheduled arrival must execute"
487 );
488 assert!(
489 army.transit.is_none(),
490 "the army must no longer be in authoritative transit"
491 );
492
493 assert_eq!(
494 outcome.commands.len(),
495 1,
496 "the move command must be accepted"
497 );
498 let command = &outcome.commands[0];
499 assert_eq!(command.accepted_at, command_at);
500 assert_eq!(command.envelope.expected_time, Some(command_at));
501 assert!(matches!(
502 &command.envelope.command,
503 Command::OrderMovement {
504 subject: EntityRef::Army(army),
505 destination,
506 cargo,
507 } if *army == ids.army
508 && *destination == ids.eastern_territory
509 && cargo.is_empty()
510 ));
511
512 let event_timeline = outcome
513 .events
514 .iter()
515 .map(|event| (event.kind.qualified_event_type(), event.timestamp))
516 .collect::<Vec<_>>();
517 assert_eq!(
518 event_timeline,
519 vec![
520 ("move_ordered".to_owned(), command_at),
521 ("army_arrived".to_owned(), arrival_at),
522 ("knowledge_updated".to_owned(), arrival_at),
523 ("report_dispatched".to_owned(), arrival_at),
524 ],
525 "the expected command and scheduled-arrival event timeline changed"
526 );
527 assert_eq!(
528 outcome.canonical_boundaries, 2,
529 "the command and arrival should settle at two canonical boundaries"
530 );
531}
532
533fn short_hash(hash: &str) -> &str {
534 hash.get(..12).unwrap_or(hash)
535}
536
537fn main() -> Result<(), CanwuError> {
538 println!(
539 "1x host policy: {BASE_SIM_SECONDS_PER_WALL_SECOND} simulation seconds per wall second"
540 );
541 println!("Frames are predefined; this example never sleeps or reads the real clock.");
542
543 let sixty = run(SIXTY_FPS_ISH, true)?;
544 let thirty = run(THIRTY_FPS_ISH, false)?;
545 assert_expected_outcome(&sixty);
546 assert_expected_outcome(&thirty);
547 assert_fps_independent(&sixty, &thirty);
548
549 println!(
550 "\nFPS INVARIANT: {} frames={} and {} frames={} -> time={}, events={}, boundaries={}, state_hash={}..., checkpoint={}...",
551 sixty.profile,
552 sixty.frames,
553 thirty.profile,
554 thirty.frames,
555 sixty.time,
556 sixty.events.len(),
557 sixty.canonical_boundaries,
558 short_hash(&sixty.authoritative_state_hash),
559 short_hash(&sixty.checkpoint_hash),
560 );
561 Ok(())
562}