1use canwu_api::{
8 ArmyId, Canwu, CanwuError, Command, CommandEnvelope, CommandRecord, CommandRequest,
9 CommandRequestId, DemoIds, Issuer, SimDuration, SimEvent, SimTime, TerritoryId, TransitState,
10 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::MoveArmy {
270 army: self.ids.army,
271 destination: self.ids.eastern_territory,
272 },
273 )
274 .at_time(due_at);
275 let request = CommandRequest::new(
276 CommandRequestId::new(1),
277 self.authority.revision(),
278 envelope,
279 );
280 let ingress = self.authority.enqueue_command(due_at, 0, request)?;
281
282 if self.verbose {
283 println!(
284 "INPUT: queued commander move as {:?}, captured_at={captured_at}, quantized_due_at={due_at}",
285 ingress.ingress_id,
286 );
287 }
288
289 let receipts = self.authority.advance_canonical(SimDuration::ZERO)?;
293 self.canonical_boundaries += receipts.len();
294 self.refresh_presentation_from_authority();
295 self.presentation.render(self.presentation_time());
296 if self.verbose {
297 self.print_frame(GameSpeed::X1);
298 }
299 self.report_new_events();
300 Ok(())
301 }
302
303 fn refresh_presentation_from_authority(&mut self) {
304 self.presentation
305 .refresh_from_authority(&self.authority.world(), self.ids.army);
306 }
307
308 fn presentation_time(&self) -> PresentationTime {
309 PresentationTime {
310 canonical: self.authority.time(),
311 sub_minute: Duration::from_nanos(
312 u64::try_from(self.accumulated_sim_nanos)
313 .expect("the sub-minute accumulator must fit a Duration"),
314 ),
315 }
316 }
317
318 fn crossed_progress_milestone(&mut self, progress: Option<f64>) -> bool {
319 let Some(progress) = progress else {
320 return false;
321 };
322 let mut crossed = false;
323 while self.next_progress_milestone <= 100
324 && progress * 100.0 + f64::EPSILON >= f64::from(self.next_progress_milestone)
325 {
326 crossed = true;
327 self.next_progress_milestone += 25;
328 }
329 crossed
330 }
331
332 fn print_frame(&self, speed: GameSpeed) {
333 println!(
334 "frame={:>5} speed={:<6} wall={:>7.3}s visual_time={} canwu_time={} {}",
335 self.frame,
336 speed.label(),
337 self.wall_elapsed.as_secs_f64(),
338 self.presentation_time(),
339 self.authority.time(),
340 self.presentation.describe(),
341 );
342 }
343
344 fn report_new_events(&mut self) {
345 let events = self.authority.events();
346 if self.verbose {
347 for event in &events[self.reported_events..] {
348 println!(
349 "EVENT: at={} type={} {}",
350 event.timestamp,
351 event.kind.qualified_event_type(),
352 event.summary,
353 );
354 }
355 }
356 self.reported_events = events.len();
357 }
358
359 fn outcome(self, profile: FrameProfile) -> Result<RunOutcome, CanwuError> {
360 Ok(RunOutcome {
361 profile: profile.name,
362 frames: self.frame,
363 time: self.authority.time(),
364 world: self.authority.world(),
365 events: self.authority.events().to_vec(),
366 commands: self.authority.commands().to_vec(),
367 authoritative_state_hash: self.authority.authoritative_state_hash()?,
368 checkpoint_hash: self.authority.checkpoint_hash().to_owned(),
369 canonical_boundaries: self.canonical_boundaries,
370 })
371 }
372}
373
374struct RunOutcome {
375 profile: &'static str,
376 frames: u64,
377 time: SimTime,
378 world: WorldSnapshot,
379 events: Vec<SimEvent>,
380 commands: Vec<CommandRecord>,
381 authoritative_state_hash: String,
382 checkpoint_hash: String,
383 canonical_boundaries: usize,
384}
385
386fn run(profile: FrameProfile, verbose: bool) -> Result<RunOutcome, CanwuError> {
387 let mut host = GameHost::new(verbose)?;
388 if verbose {
389 println!("\n{} render loop", profile.name);
390 }
391
392 host.run_phase(SPEED_SCHEDULE[0], profile)?;
393 host.submit_player_command()?;
394 host.run_phase(SPEED_SCHEDULE[1], profile)?;
395 host.run_phase(SPEED_SCHEDULE[2], profile)?;
396
397 let time_before_pause = host.authority.time();
398 let remainder_before_pause = host.accumulated_sim_nanos;
399 let presentation_before_pause = host.presentation_time();
400 let frames_before_pause = host.frame;
401 assert!(
402 remainder_before_pause > 0,
403 "the pause fixture must preserve a fractional simulation minute"
404 );
405 host.run_phase(SPEED_SCHEDULE[3], profile)?;
406 assert!(
407 host.frame > frames_before_pause,
408 "pause must keep rendering"
409 );
410 assert_eq!(
411 host.authority.time(),
412 time_before_pause,
413 "pause must not advance Canwu"
414 );
415 assert_eq!(
416 host.accumulated_sim_nanos, remainder_before_pause,
417 "pause must not add desired simulation time"
418 );
419 assert_eq!(
420 host.presentation_time(),
421 presentation_before_pause,
422 "pause must freeze presentation time"
423 );
424
425 host.run_phase(SPEED_SCHEDULE[4], profile)?;
426 assert_eq!(
427 host.accumulated_sim_nanos, 0,
428 "the scripted schedule should end on a simulation-minute boundary"
429 );
430 host.outcome(profile)
431}
432
433fn std_duration(duration: SimDuration) -> Duration {
434 let minutes = u64::try_from(duration.as_minutes())
435 .expect("presentation interpolation requires a non-negative duration");
436 Duration::from_secs(
437 minutes
438 .checked_mul(60)
439 .expect("presentation duration must remain representable"),
440 )
441}
442
443fn assert_fps_independent(left: &RunOutcome, right: &RunOutcome) {
444 assert_ne!(
445 left.frames, right.frames,
446 "the render segmentations must differ"
447 );
448 assert_eq!(left.time, right.time, "Canwu time changed with render FPS");
449 assert_eq!(
450 left.world, right.world,
451 "world state changed with render FPS"
452 );
453 assert_eq!(left.events, right.events, "events changed with render FPS");
454 assert_eq!(
455 left.commands, right.commands,
456 "command ordering changed with render FPS"
457 );
458 assert_eq!(
459 left.authoritative_state_hash, right.authoritative_state_hash,
460 "authoritative state hash changed with render FPS"
461 );
462 assert_eq!(
463 left.checkpoint_hash, right.checkpoint_hash,
464 "checkpoint hash changed with render FPS"
465 );
466 assert_eq!(
467 left.canonical_boundaries, right.canonical_boundaries,
468 "canonical boundary count changed with render FPS"
469 );
470}
471
472fn assert_expected_outcome(outcome: &RunOutcome) {
473 let ids = Canwu::demo_ids();
474 let command_at = SimTime::from_minutes(1);
475 let arrival_at = SimTime::from_minutes(1_081);
476 let final_time = SimTime::from_minutes(1_082);
477
478 assert_eq!(outcome.time, final_time, "the scripted final time changed");
479 let army = outcome
480 .world
481 .army(ids.army)
482 .expect("the demo army must remain present");
483 assert_eq!(
484 army.location, ids.eastern_territory,
485 "the scheduled arrival must execute"
486 );
487 assert!(
488 army.transit.is_none(),
489 "the army must no longer be in authoritative transit"
490 );
491
492 assert_eq!(
493 outcome.commands.len(),
494 1,
495 "the move command must be accepted"
496 );
497 let command = &outcome.commands[0];
498 assert_eq!(command.accepted_at, command_at);
499 assert_eq!(command.envelope.expected_time, Some(command_at));
500 assert!(matches!(
501 &command.envelope.command,
502 Command::MoveArmy { army, destination }
503 if *army == ids.army && *destination == ids.eastern_territory
504 ));
505
506 let event_timeline = outcome
507 .events
508 .iter()
509 .map(|event| (event.kind.qualified_event_type(), event.timestamp))
510 .collect::<Vec<_>>();
511 assert_eq!(
512 event_timeline,
513 vec![
514 ("move_ordered".to_owned(), command_at),
515 ("army_arrived".to_owned(), arrival_at),
516 ("knowledge_updated".to_owned(), arrival_at),
517 ("report_dispatched".to_owned(), arrival_at),
518 ],
519 "the expected command and scheduled-arrival event timeline changed"
520 );
521 assert_eq!(
522 outcome.canonical_boundaries, 2,
523 "the command and arrival should settle at two canonical boundaries"
524 );
525}
526
527fn short_hash(hash: &str) -> &str {
528 hash.get(..12).unwrap_or(hash)
529}
530
531fn main() -> Result<(), CanwuError> {
532 println!(
533 "1x host policy: {BASE_SIM_SECONDS_PER_WALL_SECOND} simulation seconds per wall second"
534 );
535 println!("Frames are predefined; this example never sleeps or reads the real clock.");
536
537 let sixty = run(SIXTY_FPS_ISH, true)?;
538 let thirty = run(THIRTY_FPS_ISH, false)?;
539 assert_expected_outcome(&sixty);
540 assert_expected_outcome(&thirty);
541 assert_fps_independent(&sixty, &thirty);
542
543 println!(
544 "\nFPS INVARIANT: {} frames={} and {} frames={} -> time={}, events={}, boundaries={}, state_hash={}..., checkpoint={}...",
545 sixty.profile,
546 sixty.frames,
547 thirty.profile,
548 thirty.frames,
549 sixty.time,
550 sixty.events.len(),
551 sixty.canonical_boundaries,
552 short_hash(&sixty.authoritative_state_hash),
553 short_hash(&sixty.checkpoint_hash),
554 );
555 Ok(())
556}