diff --git a/core/tests/host_guest.rs b/core/tests/host_guest.rs new file mode 100644 index 0000000..64c8e47 --- /dev/null +++ b/core/tests/host_guest.rs @@ -0,0 +1,296 @@ +//! Host and guest driven by hand, message by message. These tests produce +//! what the in-memory network of `session.rs` never does, because it lets +//! every guest run each tick before the next one arrives: a guest that lags +//! behind, a tick that gets lost, and joins that wait at the limits. + +use grovewright_core::command::Command; +use grovewright_core::guest::{Arrival, Guest}; +use grovewright_core::host::{Host, Peer, MAX_ACTIONS_PER_TICK}; +use grovewright_core::map::Pos; +use grovewright_core::mapgen::outcrop_origin; +use grovewright_core::player::{PlayerId, PlayerKey}; +use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS}; +use grovewright_core::world::{Event, World}; + +const HOST_KEY: PlayerKey = [100; 16]; + +/// A host whose own player is in a new world. +fn started_host() -> Host { + let mut host = Host::new(World::new(5)); + host.join_local(HOST_KEY, "Host"); + host.step(); + host +} + +/// Connects a guest with a key made of `key` as `peer` and hands its +/// greeting to the host. +fn greet(host: &mut Host, peer: Peer, key: u8) -> Guest { + host.connect(peer); + let mut guest = Guest::new([key; 16], "Guest"); + for bytes in guest.take_outbox() { + host.receive(peer, &bytes); + } + guest +} + +/// Takes the host's whole outbox and keeps what is for `peer`. +fn messages_for(host: &mut Host, peer: Peer) -> Vec> { + host.take_outbox() + .into_iter() + .filter(|(to, _)| *to == peer) + .map(|(_, bytes)| bytes) + .collect() +} + +/// The host's answer to a greeting from `peer`: `None` for its world, or +/// the reason it turned the guest away. +fn answer_to(host: &mut Host, peer: Peer) -> Option { + let messages = messages_for(host, peer); + match ToGuest::decode(&messages[0]) { + Ok(ToGuest::World { .. }) => None, + Ok(ToGuest::Refused(refusal)) => Some(refusal), + other => panic!("unexpected answer {other:?}"), + } +} + +/// A host and the guest Ada at peer 2, in the same state. +fn host_with_ada() -> (Host, Guest) { + let mut host = started_host(); + let mut ada = greet(&mut host, 2, 1); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + host.step(); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + while ada.step().is_some() {} + assert!(ada.local_player().is_some()); + assert_eq!( + ada.world().map(World::state_hash), + Some(host.world().state_hash()) + ); + (host, ada) +} + +/// A world that knows players with keys made of 1 to `count`, all offline. +fn world_with_offline_players(count: u8) -> World { + let mut world = World::new(5); + for key in 1..=count { + world.step(&[Command::Join { + key: [key; 16], + name: "Visitor".to_owned(), + }]); + world.step(&[Command::Leave { + player: PlayerId(key - 1), + }]); + } + world +} + +#[test] +fn the_hosts_own_queued_join_counts_for_the_online_limit() { + let mut host = Host::new(World::new(5)); + host.join_local(HOST_KEY, "Host"); + // No tick has run: the host's join is still queued. + let mut answers = Vec::new(); + for key in 1..=4 { + let peer = Peer::from(key) + 1; + greet(&mut host, peer, key); + answers.push(answer_to(&mut host, peer)); + } + assert_eq!(answers, [None, None, None, Some(Refusal::WorldFull)]); +} + +#[test] +fn queued_new_keys_count_for_the_limit_of_known_players_but_known_ones_do_not() { + let mut host = Host::new(world_with_offline_players(15)); + greet(&mut host, 2, 3); + assert_eq!(answer_to(&mut host, 2), None, "a returning player"); + greet(&mut host, 3, 200); + assert_eq!( + answer_to(&mut host, 3), + None, + "a new player fits as the 16th" + ); + greet(&mut host, 4, 201); + assert_eq!( + answer_to(&mut host, 4), + Some(Refusal::TooManyPlayers), + "a second new player does not" + ); + + let events = host.step(); + assert!(!events + .iter() + .any(|event| matches!(event, Event::JoinRefused { .. }))); + assert_eq!(host.world().players().len(), 16); +} + +#[test] +fn a_queued_join_of_a_player_who_is_online_already_counts_once() { + let mut world = World::new(5); + let joins = [HOST_KEY, [1; 16], [2; 16]].map(|key| Command::Join { + key, + name: "Online".to_owned(), + }); + world.step(&joins); + let mut host = Host::new(world); + // The host's player is online already; its queued join changes nothing. + host.join_local(HOST_KEY, "Host"); + greet(&mut host, 2, 3); + assert_eq!(answer_to(&mut host, 2), None, "the fourth player fits"); +} + +#[test] +fn the_count_of_a_guests_actions_starts_again_in_every_tick() { + let (mut host, mut ada) = host_with_ada(); + for tick in 0..3 { + for x in 0..10 { + ada.act(Action::Dig { + pos: Pos::new(x, 1), + }); + } + for bytes in ada.take_outbox() { + host.receive(2, &bytes); + } + host.step(); + let counts: Vec = messages_for(&mut host, 2) + .iter() + .filter_map(|bytes| match ToGuest::decode(bytes) { + Ok(ToGuest::Tick { commands, .. }) => Some(commands.len()), + _ => None, + }) + .collect(); + assert_eq!(counts, [MAX_ACTIONS_PER_TICK as usize], "tick {tick}"); + } +} + +#[test] +fn a_tick_carries_the_number_of_the_tick_before_it_runs() { + let (mut host, mut ada) = host_with_ada(); + let before = host.world().tick(); + host.step(); + let messages = messages_for(&mut host, 2); + let Ok(ToGuest::Tick { tick, .. }) = ToGuest::decode(&messages[0]) else { + panic!("not a tick"); + }; + assert_eq!(tick, before); + for bytes in messages { + ada.receive(&bytes).unwrap(); + } + ada.step().unwrap(); + assert_eq!(ada.world().map(World::tick), Some(before + 1)); +} + +#[test] +fn a_lagging_guest_that_diverges_runs_nothing_until_the_world_comes_and_drops_older_ticks() { + let (mut host, mut ada) = host_with_ada(); + + // The host's player hits the rock, but Ada's copy of that tick loses the + // command, as if her machine had run it differently. + host.act_local(Action::Dig { + pos: outcrop_origin(host.world().map()), + }); + host.step(); + for bytes in messages_for(&mut host, 2) { + let bytes = match ToGuest::decode(&bytes) { + Ok(ToGuest::Tick { tick, check, .. }) => ToGuest::Tick { + tick, + commands: vec![], + check, + } + .encode(), + _ => bytes, + }; + ada.receive(&bytes).unwrap(); + } + while ada.step().is_some() {} + assert!(!ada.has_diverged()); + + // Thirty more ticks arrive before Ada runs any of them. + for _ in 0..30 { + host.step(); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + } + assert!(ada.pending_ticks() >= 30); + + // She catches up and trips over the next checksum. + while ada.step().is_some() && !ada.has_diverged() {} + assert!(ada.has_diverged()); + let waiting = ada.pending_ticks(); + assert!(waiting > 0, "newer ticks are still waiting"); + assert!(ada.step().is_none(), "no tick runs after a divergence"); + assert_eq!(ada.pending_ticks(), waiting, "and none is used up"); + + // Her report names her tick after the step that showed the divergence. + let reports = ada.take_outbox(); + assert_eq!(reports.len(), 1); + let Ok(ToHost::Diverged { tick, hash }) = ToHost::decode(&reports[0]) else { + panic!("not a divergence report"); + }; + assert_eq!(Some(tick), ada.world().map(World::tick)); + assert_eq!(tick % CHECK_INTERVAL_TICKS, 0); + host.receive(2, &reports[0]); + let divergences = host.take_divergences(); + assert_eq!(divergences.len(), 1); + assert_eq!((divergences[0].tick, divergences[0].hash), (tick, hash)); + + // The host has gone on meanwhile. Its world comes first, then later ticks. + let world_tick = host.world().tick(); + let answer = messages_for(&mut host, 2); + assert_eq!(answer.len(), 1); + host.step(); + host.step(); + let later = messages_for(&mut host, 2); + assert_eq!(later.len(), 2); + + assert_eq!( + ada.receive(&answer[0]), + Ok(Arrival::World { replaced: true }) + ); + assert_eq!(ada.pending_ticks(), 0, "the older ticks are dropped"); + assert_eq!(ada.world().map(World::tick), Some(world_tick)); + assert!(!ada.has_diverged()); + for bytes in later { + ada.receive(&bytes).unwrap(); + } + while ada.step().is_some() {} + assert_eq!( + ada.world().map(World::state_hash), + Some(host.world().state_hash()) + ); + assert!(ada.take_diverged_recording().is_some()); +} + +#[test] +fn a_missing_tick_makes_the_guest_ask_for_the_world() { + let (mut host, mut ada) = host_with_ada(); + host.step(); + // This tick never arrives. + messages_for(&mut host, 2); + host.step(); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + assert!(ada.step().is_none()); + assert!(ada.has_diverged()); + let reports = ada.take_outbox(); + assert_eq!(reports.len(), 1); + + host.receive(2, &reports[0]); + for bytes in messages_for(&mut host, 2) { + assert_eq!(ada.receive(&bytes), Ok(Arrival::World { replaced: true })); + } + host.step(); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + while ada.step().is_some() {} + assert_eq!( + ada.world().map(World::state_hash), + Some(host.world().state_hash()) + ); +}