Files
Grovewright/core/tests/host_guest.rs
Steven Wroblewski be22e9f358 Abschlusskorrekturen M1c: Schläge der Gäste, Figuren und Fragebogen
- Der Host hält einen Schlag eines Gastes, der während der Abklingzeit
  ankommt, bis zum ersten Takt danach. Übers Internet ging sonst etwa
  jeder fünfte Schlag eines Gastes verloren.
- ENet drosselt die Figuren nie mehr; sonst konnten die Figuren eines
  von zwei Gästen dauerhaft stehen bleiben. Figuren gehen geordnet, und
  eine stille Verbindung gilt nach höchstens acht Sekunden als verloren.
- Positionen, die keine Zahlen sind, und Figuren von Spielern, die
  nicht online sind, werden verworfen.
- Ein Host mit angehaltener Simulation sagt das in der Statuszeile.
- Neue Tests: Schlag während der Abklingzeit, Zielauswahl von NetLink,
  verworfene Figuren, Neuabgleich über die Brücke.
- Der Fragebogen sagt, was jeder Rechner braucht, und die Freigabe in
  der Firewall gehört zum Host. CLAUDE.md beschreibt das Halten der
  Schläge, die Drosselung und die Zeitüberschreitung und ergänzt die
  Vertrauensregel um die Schlüssel.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 01:22:36 +02:00

352 lines
11 KiB
Rust

//! 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, DIG_COOLDOWN_TICKS};
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<Vec<u8>> {
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<Refusal> {
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<usize> = 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())
);
}
/// Hands a pickaxe hit of `guest` at `pos` to the host as from `peer`.
fn dig(host: &mut Host, peer: Peer, guest: &mut Guest, pos: Pos) {
guest.act(Action::Dig { pos });
for bytes in guest.take_outbox() {
host.receive(peer, &bytes);
}
}
/// The tiles that pickaxe hits landed on, without breaking them.
fn hits(events: &[Event]) -> Vec<Pos> {
events
.iter()
.filter_map(|event| match event {
Event::TileHit { pos, .. } => Some(*pos),
_ => None,
})
.collect()
}
#[test]
fn a_guests_hit_that_comes_during_the_cooldown_waits_for_it() {
let (mut host, mut ada) = host_with_ada();
let rock = outcrop_origin(host.world().map());
let next = Pos::new(rock.x + 1, rock.y);
dig(&mut host, 2, &mut ada, rock);
let first = host.world().tick();
assert_eq!(hits(&host.step()), [rock], "the first hit lands at once");
// Two more hits come during the cooldown, as if the round trip had
// become shorter. The newer one waits for the end of the cooldown.
dig(&mut host, 2, &mut ada, rock);
dig(&mut host, 2, &mut ada, next);
let mut landed = Vec::new();
for _ in 0..DIG_COOLDOWN_TICKS {
let tick = host.world().tick();
for pos in hits(&host.step()) {
landed.push((tick, pos));
}
}
assert_eq!(landed, [(first + DIG_COOLDOWN_TICKS, next)]);
}
#[test]
fn a_waiting_hit_is_dropped_when_its_guest_leaves() {
let (mut host, mut ada) = host_with_ada();
let rock = outcrop_origin(host.world().map());
dig(&mut host, 2, &mut ada, rock);
host.step();
dig(&mut host, 2, &mut ada, rock);
host.disconnect(2);
for _ in 0..DIG_COOLDOWN_TICKS {
assert_eq!(hits(&host.step()), [], "nobody hits");
}
}