A room of devices now plays one game: host simulates, clients send input and render replicated truth. - libs/game/sim/player.rs: Players roster on GameWorld, slot 0 is always this device. Player 0's input stays in the world's original held/pressed/ pad/cam_yaw fields and is mirrored into the roster, which is what keeps single-player numerics bit-identical (tape gate confirms). Ids are never reused, so a stale reference resolves to None, never to somebody else - The camera-movement knot resolved: world.player_move(p) rotates that player's axes by THEIR cam_yaw, carried in their input packet. Player 0's branch is the original expression character-for-character (the f32 cos widened to f64 kept deliberately — tidying it would move the numbers) - libs/game/session/replication.rs: Shared = pos/vel/size/kind/tag; Derived = facing/anim/scale/glow/blob shadows, recomputed client-side and costing zero wire bytes; Local = camera/audio/effects. Statics never enter the per-tick stream - Protocol (additive, version unchanged): EntityDesc + Descriptors message splits rare reliable construction data from volatile unreliable state — without it a joiner sees poses for entities it cannot build. EntityDesc carries pos because statics never appear in the state stream (the late-joiner test caught ground arriving at the origin) - Script: game.players/player_name/player_entity/player_input/bot/on_join/ on_leave; blocks gained owner: PlayerId so a car reads its own driver - Arcade: ARCADE_HOST=1 / ARCADE_JOIN=<addr>; clients skip world construction and don't simulate - Racing wire volume, 6 players x 60Hz x 200 entities: 2400 pps, 20.9 Mbit/s up (audit projected 74 Mbit for the XR stack). Asserted in racing_scenario_wire_volume_fits_a_living_room Two more not-a-playable-default bugs, same class as M1b's rng-at-zero: Entity::default() leaves gravity_scale 0 (weightless wheels) and ground without friction gives no traction — only the DSL path filled these in. 255 tests green; tape probe BYTE_IDENTICAL; xr/arcade/gamemaker build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
468 lines
16 KiB
Rust
468 lines
16 KiB
Rust
//! A room of devices playing one game, driven inside a single test process
|
|
//! over real loopback sockets against a virtual clock.
|
|
//!
|
|
//! This is the deliverable that proves M2 (game.md §Testing strategy): the
|
|
//! deterministic, Cx-free sim means a host, three clients and two bots all run
|
|
//! here with no window, no headset and no second machine.
|
|
|
|
use makepad_game_blocks::{Blocks, Car, CarConfig, ControlSource};
|
|
use makepad_game_net::endpoint::HostConfig;
|
|
use makepad_game_session::{replication, Session, SessionEvent};
|
|
use makepad_game_sim::{BodyKind, Entity, GameWorld, PlayerId, PlayerSource};
|
|
use makepad_math::*;
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
|
|
const SECRET: &[u8] = b"arcade-room-secret";
|
|
const DT: f64 = 1.0 / 60.0;
|
|
|
|
/// A device: its own world, blocks and session role.
|
|
struct Device {
|
|
world: GameWorld,
|
|
blocks: Blocks,
|
|
session: Session,
|
|
}
|
|
|
|
impl Device {
|
|
fn tick(&mut self, clock: f64) -> Vec<SessionEvent> {
|
|
self.session.tick(&mut self.world, &mut self.blocks, clock)
|
|
}
|
|
|
|
fn pos_of(&self, id: u64) -> Option<Vec3f> {
|
|
self.world.entity(id).map(|e| e.pos)
|
|
}
|
|
}
|
|
|
|
fn ground(world: &mut GameWorld) {
|
|
// A world built straight through the sim API never calls reset_content,
|
|
// which is what normally installs gravity — without this the cars hover
|
|
// and their suspension never finds the floor.
|
|
world.gravity = 30.0;
|
|
world.next_id += 1;
|
|
let id = world.next_id;
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Static,
|
|
pos: vec3f(0.0, -1.0, 0.0),
|
|
half: vec3f(200.0, 1.0, 200.0),
|
|
color: vec4f(0.3, 0.6, 0.3, 1.0),
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
collide: true,
|
|
// Traction lives on the surface: without friction the wheels spin and
|
|
// the car never accelerates.
|
|
density: 1.0,
|
|
friction: 0.6,
|
|
tag: "ground".into(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
fn spawn_car(world: &mut GameWorld, blocks: &mut Blocks, x: f32, owner: PlayerId) -> u64 {
|
|
world.next_id += 1;
|
|
let id = world.next_id;
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Rigid,
|
|
pos: vec3f(x, 1.0, 0.0),
|
|
half: vec3f(0.9, 0.4, 1.6),
|
|
color: vec4f(0.8, 0.2, 0.2, 1.0),
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
collide: true,
|
|
// Entity::default() leaves gravity_scale at 0 — the DSL fills it in
|
|
// for real games, so a world built straight through the sim API has to
|
|
// say so or the body floats and its wheels never find the road.
|
|
gravity_scale: 1.0,
|
|
density: 1.0,
|
|
friction: 0.7,
|
|
tag: "car".into(),
|
|
..Default::default()
|
|
});
|
|
let mut car = Car::new(id, CarConfig::default(), ControlSource::Player);
|
|
car.owner = owner;
|
|
blocks.cars.push(car);
|
|
id
|
|
}
|
|
|
|
fn host_device() -> (Device, std::net::SocketAddr, std::net::SocketAddr) {
|
|
let mut config = HostConfig::new("test-room", SECRET);
|
|
config.bind_ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
|
let session = Session::host_with(config).expect("bind host");
|
|
let (tcp, udp) = session.host_addrs().unwrap();
|
|
let mut world = GameWorld::new();
|
|
ground(&mut world);
|
|
(
|
|
Device {
|
|
world,
|
|
blocks: Blocks::new(),
|
|
session,
|
|
},
|
|
tcp,
|
|
udp,
|
|
)
|
|
}
|
|
|
|
fn join_device(
|
|
tcp: std::net::SocketAddr,
|
|
udp: std::net::SocketAddr,
|
|
id: u64,
|
|
name: &str,
|
|
clock: f64,
|
|
) -> Device {
|
|
let session = Session::join(id, name, tcp, udp, SECRET, clock).expect("join");
|
|
Device {
|
|
world: GameWorld::new(),
|
|
blocks: Blocks::new(),
|
|
session,
|
|
}
|
|
}
|
|
|
|
/// Hold the throttle on a device, exactly as a gamepad stick would. The
|
|
/// session transmits this itself each tick — a test that hand-crafted input
|
|
/// packets would race the real ones and lose on the host's staleness check.
|
|
fn hold_forward(device: &mut Device) {
|
|
device.world.pad.axis_z = -1.0;
|
|
}
|
|
|
|
/// Advance everyone one tick, sleeping a hair so loopback actually delivers.
|
|
fn step_room(host: &mut Device, clients: &mut [Device], clock: &mut f64) -> Vec<SessionEvent> {
|
|
*clock += DT;
|
|
let mut events = host.tick(*clock);
|
|
for client in clients.iter_mut() {
|
|
events.extend(client.tick(*clock));
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_micros(300));
|
|
events
|
|
}
|
|
|
|
fn run_room(host: &mut Device, clients: &mut [Device], clock: &mut f64, ticks: usize) -> Vec<SessionEvent> {
|
|
let mut events = Vec::new();
|
|
for _ in 0..ticks {
|
|
events.extend(step_room(host, clients, clock));
|
|
}
|
|
events
|
|
}
|
|
|
|
#[test]
|
|
fn three_clients_and_two_bots_converge_on_host_state() {
|
|
let mut clock = 0.0;
|
|
let (mut host, tcp, udp) = host_device();
|
|
|
|
let mut clients: Vec<Device> = (0..3)
|
|
.map(|i| {
|
|
let d = join_device(tcp, udp, 9000 + i, &format!("kid{i}"), clock);
|
|
clock += DT;
|
|
d
|
|
})
|
|
.collect();
|
|
|
|
// Let the joins land so the host knows every player before bodies exist.
|
|
run_room(&mut host, &mut clients, &mut clock, 30);
|
|
let remote_ids: Vec<PlayerId> = host.world.players.remotes().map(|p| p.id).collect();
|
|
assert_eq!(remote_ids.len(), 3, "three clients joined");
|
|
|
|
// Two bots: host-side players with no device at all.
|
|
let bot_a = host.world.players.add("bot-a", PlayerSource::Bot);
|
|
let bot_b = host.world.players.add("bot-b", PlayerSource::Bot);
|
|
assert_eq!(host.world.players.len(), 6, "local + 3 remote + 2 bots");
|
|
|
|
// One car each for the three clients and the two bots.
|
|
let mut cars = Vec::new();
|
|
for (i, player) in remote_ids.iter().chain([bot_a, bot_b].iter()).enumerate() {
|
|
let id = spawn_car(&mut host.world, &mut host.blocks, i as f32 * 4.0, *player);
|
|
host.world.players.get_mut(*player).unwrap().entity = id;
|
|
cars.push(id);
|
|
}
|
|
|
|
// Clients hold throttle; bots are driven host-side.
|
|
for client in clients.iter_mut() {
|
|
hold_forward(client);
|
|
}
|
|
|
|
run_room(&mut host, &mut clients, &mut clock, 600);
|
|
|
|
// Every client reconstructed every replicated entity.
|
|
for (i, client) in clients.iter().enumerate() {
|
|
for car in &cars {
|
|
let host_pos = host.pos_of(*car).expect("host has car");
|
|
let client_pos = client
|
|
.pos_of(*car)
|
|
.unwrap_or_else(|| panic!("client {i} is missing car {car}"));
|
|
let delta = (host_pos - client_pos).length();
|
|
assert!(
|
|
delta < 1.0,
|
|
"client {i} car {car} drifted {delta} ({client_pos:?} vs {host_pos:?})"
|
|
);
|
|
}
|
|
assert!(
|
|
client.world.entity(1).is_some(),
|
|
"client {i} should have the static ground too"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn client_input_moves_its_own_car_on_the_host() {
|
|
let mut clock = 0.0;
|
|
let (mut host, tcp, udp) = host_device();
|
|
let mut clients = vec![join_device(tcp, udp, 4242, "driver", clock)];
|
|
run_room(&mut host, &mut clients, &mut clock, 30);
|
|
|
|
let player = host.world.players.remotes().next().unwrap().id;
|
|
let car = spawn_car(&mut host.world, &mut host.blocks, 0.0, player);
|
|
host.world.players.get_mut(player).unwrap().entity = car;
|
|
run_room(&mut host, &mut clients, &mut clock, 30);
|
|
|
|
run_room(&mut host, &mut clients, &mut clock, 60);
|
|
let start = host.pos_of(car).unwrap();
|
|
hold_forward(&mut clients[0]);
|
|
run_room(&mut host, &mut clients, &mut clock, 240);
|
|
let end = host.pos_of(car).unwrap();
|
|
let travelled = (end - start).length();
|
|
assert!(
|
|
travelled > 2.0,
|
|
"held throttle should drive the car (moved {travelled})"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_late_joiner_reconstructs_the_running_world() {
|
|
let mut clock = 0.0;
|
|
let (mut host, tcp, udp) = host_device();
|
|
let mut clients = vec![join_device(tcp, udp, 1, "early", clock)];
|
|
run_room(&mut host, &mut clients, &mut clock, 20);
|
|
|
|
let player = host.world.players.remotes().next().unwrap().id;
|
|
let car = spawn_car(&mut host.world, &mut host.blocks, 0.0, player);
|
|
host.world.players.get_mut(player).unwrap().entity = car;
|
|
|
|
// Race is well underway before the fourth device shows up.
|
|
run_room(&mut host, &mut clients, &mut clock, 300);
|
|
assert!(host.world.tick >= 300);
|
|
|
|
clients.push(join_device(tcp, udp, 2, "late", clock));
|
|
run_room(&mut host, &mut clients, &mut clock, 60);
|
|
|
|
let late = clients.last().unwrap();
|
|
let host_ids: Vec<u64> = host.world.entities.iter().map(|e| e.id).collect();
|
|
let late_ids: Vec<u64> = late.world.entities.iter().map(|e| e.id).collect();
|
|
assert_eq!(late_ids, host_ids, "late joiner sees the same entity set");
|
|
for id in &host_ids {
|
|
let delta = (host.pos_of(*id).unwrap() - late.pos_of(*id).unwrap()).length();
|
|
assert!(delta < 1.0, "entity {id} off by {delta} for the late joiner");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn convergence_survives_loss_reorder_and_duplication() {
|
|
// The client's per-entity sequencing is what makes this hold: a stale or
|
|
// duplicated datagram can only be ignored for the entities it carries.
|
|
let mut clock = 0.0;
|
|
let (mut host, tcp, udp) = host_device();
|
|
let mut clients = vec![join_device(tcp, udp, 77, "lossy", clock)];
|
|
run_room(&mut host, &mut clients, &mut clock, 30);
|
|
|
|
let player = host.world.players.remotes().next().unwrap().id;
|
|
let car = spawn_car(&mut host.world, &mut host.blocks, 0.0, player);
|
|
host.world.players.get_mut(player).unwrap().entity = car;
|
|
|
|
// Deterministic pseudo-loss: skip pumping the client on a fixed pattern,
|
|
// which drops whole datagrams and reorders what it does see.
|
|
hold_forward(&mut clients[0]);
|
|
let mut seed = 0x1234_5678u32;
|
|
for tick in 0..600u64 {
|
|
clock += DT;
|
|
host.tick(clock);
|
|
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
|
let drop = (seed >> 16) % 5 == 0;
|
|
if !drop {
|
|
clients[0].tick(clock);
|
|
}
|
|
let _ = tick;
|
|
std::thread::sleep(std::time::Duration::from_micros(300));
|
|
}
|
|
// Let it settle with no loss.
|
|
run_room(&mut host, &mut clients, &mut clock, 60);
|
|
|
|
let delta = (host.pos_of(car).unwrap() - clients[0].pos_of(car).unwrap()).length();
|
|
assert!(delta < 1.0, "client converged despite loss (off by {delta})");
|
|
}
|
|
|
|
#[test]
|
|
fn derived_changes_cost_nothing_and_shared_changes_cost_bytes() {
|
|
// Tier discipline, asserted rather than documented: turning a model costs
|
|
// no wire traffic; moving it does.
|
|
let mut world = GameWorld::new();
|
|
ground(&mut world);
|
|
world.next_id += 1;
|
|
let id = world.next_id;
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Mover,
|
|
pos: vec3f(0.0, 1.0, 0.0),
|
|
half: vec3f(0.5, 0.5, 0.5),
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
auto_face: true,
|
|
turn_rate: 8.0,
|
|
..Default::default()
|
|
});
|
|
|
|
let baseline = replication::collect_states(&world);
|
|
|
|
// Derived: facing and visual scale change, wire payload does not.
|
|
world.entity_mut(id).unwrap().yaw = 1.5;
|
|
world.entity_mut(id).unwrap().scale = vec3f(2.0, 2.0, 2.0);
|
|
let after_derived = replication::collect_states(&world);
|
|
assert_eq!(
|
|
baseline, after_derived,
|
|
"Derived-tier changes must not alter a single replicated byte"
|
|
);
|
|
|
|
// Shared: position changes, wire payload changes with it.
|
|
world.entity_mut(id).unwrap().pos.x = 5.0;
|
|
let after_shared = replication::collect_states(&world);
|
|
assert_ne!(baseline, after_shared, "Shared-tier changes must replicate");
|
|
}
|
|
|
|
#[test]
|
|
fn rejoining_with_the_same_identity_keeps_the_player_slot() {
|
|
let mut clock = 0.0;
|
|
let (mut host, tcp, udp) = host_device();
|
|
let mut clients = vec![join_device(tcp, udp, 555, "returning", clock)];
|
|
run_room(&mut host, &mut clients, &mut clock, 30);
|
|
|
|
let first = host.world.players.remotes().next().unwrap().id;
|
|
assert_eq!(host.world.players.remotes().count(), 1);
|
|
|
|
// Drop the connection and come back under the same client id.
|
|
clients.clear();
|
|
run_room(&mut host, &mut clients, &mut clock, 30);
|
|
assert_eq!(
|
|
host.world.players.remotes().count(),
|
|
0,
|
|
"leaving frees the slot"
|
|
);
|
|
|
|
clients.push(join_device(tcp, udp, 555, "returning", clock));
|
|
let events = run_room(&mut host, &mut clients, &mut clock, 60);
|
|
|
|
let rejoined = host.world.players.remotes().next().expect("came back");
|
|
assert_eq!(
|
|
rejoined.id, first,
|
|
"the same client id maps back to the same sim player"
|
|
);
|
|
assert!(events
|
|
.iter()
|
|
.any(|e| matches!(e, SessionEvent::Joined { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn replayed_inputs_reproduce_the_same_world() {
|
|
// Host-side determinism: same start, same inputs, same world hash. This is
|
|
// what makes replays, desync detection and host migration possible later.
|
|
fn run(inputs: &[(f32, f32)]) -> u64 {
|
|
let mut world = GameWorld::new();
|
|
let mut blocks = Blocks::new();
|
|
ground(&mut world);
|
|
let player = world.players.add("bot", PlayerSource::Bot);
|
|
let car = spawn_car(&mut world, &mut blocks, 0.0, player);
|
|
world.players.get_mut(player).unwrap().entity = car;
|
|
|
|
let mut session = Session::Local;
|
|
for (i, (steer, throttle)) in inputs.iter().enumerate() {
|
|
blocks.player_inputs.insert(
|
|
player,
|
|
makepad_game_blocks::DriveInput {
|
|
steer: *steer,
|
|
throttle: *throttle,
|
|
..Default::default()
|
|
},
|
|
);
|
|
session.tick(&mut world, &mut blocks, i as f64 * DT);
|
|
}
|
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
|
let mut mix = |v: u64| {
|
|
h ^= v;
|
|
h = h.wrapping_mul(0x100_0000_01b3);
|
|
};
|
|
for e in &world.entities {
|
|
mix(e.id);
|
|
for v in [e.pos.x, e.pos.y, e.pos.z, e.vel.x, e.vel.y, e.vel.z] {
|
|
mix(v.to_bits() as u64);
|
|
}
|
|
}
|
|
mix(blocks.hash());
|
|
h
|
|
}
|
|
|
|
let inputs: Vec<(f32, f32)> = (0..600)
|
|
.map(|i| {
|
|
let t = i as f32 * 0.01;
|
|
(makepad_game_math::sin(t) * 0.6, 1.0)
|
|
})
|
|
.collect();
|
|
|
|
let a = run(&inputs);
|
|
let b = run(&inputs);
|
|
assert_eq!(a, b, "identical inputs must produce an identical world");
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
fn racing_scenario_wire_volume_fits_a_living_room() {
|
|
// Six players, 200 moving entities, one second of play. The audit put the
|
|
// XR stack at ~5100 pps / 74 Mbit for this shape, which is where Quest WiFi
|
|
// starts failing; this asserts the rebuilt path stays well under it.
|
|
let mut clock = 0.0;
|
|
let (mut host, tcp, udp) = host_device();
|
|
let mut clients: Vec<Device> = (0..5)
|
|
.map(|i| {
|
|
let d = join_device(tcp, udp, 7000 + i, &format!("racer{i}"), clock);
|
|
clock += DT;
|
|
d
|
|
})
|
|
.collect();
|
|
run_room(&mut host, &mut clients, &mut clock, 40);
|
|
assert_eq!(host.world.players.remotes().count(), 5);
|
|
|
|
// 200 moving props on top of the ground.
|
|
for i in 0..200 {
|
|
host.world.next_id += 1;
|
|
let id = host.world.next_id;
|
|
let angle = i as f32 * 0.031;
|
|
host.world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Mover,
|
|
pos: vec3f(angle.cos() * 30.0, 1.0, angle.sin() * 30.0),
|
|
vel: vec3f(1.0, 0.0, 1.0),
|
|
half: vec3f(0.5, 0.5, 0.5),
|
|
color: vec4f(0.5, 0.5, 0.9, 1.0),
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
gravity_scale: 1.0,
|
|
collide: true,
|
|
auto_face: true,
|
|
tag: "prop".into(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
// Let descriptors land before measuring the steady state.
|
|
run_room(&mut host, &mut clients, &mut clock, 30);
|
|
|
|
let before = host.session.host_mut().unwrap().stats;
|
|
run_room(&mut host, &mut clients, &mut clock, 60); // one second
|
|
let after = host.session.host_mut().unwrap().stats;
|
|
|
|
let datagrams = after.datagrams_out - before.datagrams_out;
|
|
let bytes = after.bytes_out - before.bytes_out;
|
|
let mbit = (bytes as f64 * 8.0) / 1_000_000.0;
|
|
eprintln!("6 players x 60Hz x 200 entities: {datagrams} pps, {bytes} B/s, {mbit:.1} Mbit/s up");
|
|
|
|
assert!(
|
|
datagrams < 5_000,
|
|
"packet rate {datagrams} pps approaches the consumer-AP ceiling the audit flagged"
|
|
);
|
|
assert!(mbit < 74.0, "{mbit} Mbit/s is the XR stack's projection, not an improvement");
|
|
}
|