libs/game/coedit (zero deps, so merge logic is testable without a socket or
a VM). Not a CRDT: transactions are host-serialized into an append-only
generation history, and a conflict is answered by handing the author the new
base so THEY re-derive their intent.
- Transaction carries the author's whole intended file, not a patch: the
diff against its declared base is derived host-side, so a stale or
malformed patch can never be applied — and an AI writes whole files anyway
- diff3 over lines with LCS anchors. Merge::Conflict deliberately carries NO
merged text: a half-merged game file that still parses is worse than an
honest rejection. Conflict -> Rebase{new base + per-generation summary of
what landed underneath}. An edit already present in the tip is refused as
NoChange rather than appended as an empty generation
- Leases are advisory as designed: a test asserts a submit SUCCEEDS while
another author holds the lease. They shape who chooses to edit; they never
gate the log. TTL expiry means a crashed author cannot lock a region
- Wire: coedit is reliable-channel ONLY — a test signs a valid submission,
sends it by datagram, and asserts it is ignored, so no datagram can
rewrite the game. Every response is addressed, never broadcast
- Arcade bridge routes in exactly one place; local agent and remote authors
share the queue and the rules, and a test asserts the local agent gets
rebased identically when it loses a race. Remote players map to
AuthorId(player+1) so a client holding player id 0 cannot impersonate the
host's agent
- 200-round deterministic fuzz: 4 authors submit against deliberately stale
bases, rebase, resubmit — asserting linear append-only numbering, no
generation claiming a base from the future, and that replaying accepted
diffs from generation 0 reproduces the head exactly
Two real bugs found building it: validate used `?` on the base lookup, so an
unknown base returned "no refusal" instead of UnknownBase; and
MAX_COEDIT_SOURCE (512 KiB) exceeded MAX_FRAME_BYTES (256 KiB), so the host
would have accepted a source it could never hand back inside a Rebase,
stranding the next author on an answer that never arrives. Both now 192 KiB.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
477 lines
16 KiB
Rust
477 lines
16 KiB
Rust
//! Full sessions — host plus N clients — driven inside one process over real
|
|
//! loopback sockets against a virtual clock.
|
|
|
|
use makepad_game_net::endpoint::{HostConfig, PEER_TIMEOUT};
|
|
use makepad_game_net::*;
|
|
use makepad_micro_serde::SerBin;
|
|
use std::net::{IpAddr, Ipv4Addr, UdpSocket};
|
|
|
|
const SECRET: &[u8] = b"arcade-lobby-secret";
|
|
|
|
fn host() -> Host {
|
|
let mut config = HostConfig::new("test-room", SECRET);
|
|
config.bind_ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
|
Host::bind(config).expect("bind host")
|
|
}
|
|
|
|
fn join(host: &mut Host, id: u64, name: &str, clock: &mut f64) -> Client {
|
|
let mut client = Client::connect(
|
|
id,
|
|
name,
|
|
host.tcp_addr(),
|
|
host.udp_addr(),
|
|
SECRET,
|
|
*clock,
|
|
)
|
|
.expect("connect");
|
|
settle(host, std::slice::from_mut(&mut client), clock);
|
|
client
|
|
}
|
|
|
|
/// Advances the virtual clock a frame at a time, pumping everything, until the
|
|
/// sockets go quiet.
|
|
fn settle(host: &mut Host, clients: &mut [Client], clock: &mut f64) -> (Vec<HostEvent>, Vec<ClientEvent>) {
|
|
let mut host_events = Vec::new();
|
|
let mut client_events = Vec::new();
|
|
for _ in 0..40 {
|
|
*clock += 1.0 / 60.0;
|
|
host_events.extend(host.pump(*clock));
|
|
for client in clients.iter_mut() {
|
|
client_events.extend(client.pump(*clock));
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
}
|
|
(host_events, client_events)
|
|
}
|
|
|
|
fn entity(id: u64, x: f32) -> EntityState {
|
|
EntityState {
|
|
id,
|
|
seq: 0,
|
|
pos: [x, 0.0, 0.0],
|
|
vel: [0.0, 0.0, 0.0],
|
|
yaw: 0.0,
|
|
flags: 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn join_leave_and_rejoin_resets_sequence_state() {
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
|
|
let mut client = join(&mut host, 1001, "kid", &mut clock);
|
|
assert_eq!(host.player_count(), 1);
|
|
assert_eq!(client.player, Some(PlayerId(1001)));
|
|
assert_eq!(host.player_name(PlayerId(1001)), Some("kid"));
|
|
|
|
// Inputs at increasing ticks are delivered.
|
|
for tick in 1..=5 {
|
|
client.send_input(InputFrame {
|
|
tick,
|
|
axis_x: 1.0,
|
|
..Default::default()
|
|
});
|
|
}
|
|
let (events, _) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
let inputs = events
|
|
.iter()
|
|
.filter(|e| matches!(e, HostEvent::Input { .. }))
|
|
.count();
|
|
assert_eq!(inputs, 5, "all five inputs applied");
|
|
|
|
client.leave();
|
|
let (events, _) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
assert!(events.iter().any(|e| matches!(e, HostEvent::Left { .. })));
|
|
assert_eq!(host.player_count(), 0);
|
|
|
|
// The same player id returns with its tick counter restarted. Without the
|
|
// rejoin reset the stale-tick window would swallow every input.
|
|
let mut client = join(&mut host, 1001, "kid", &mut clock);
|
|
assert_eq!(host.player_count(), 1);
|
|
client.send_input(InputFrame {
|
|
tick: 1,
|
|
axis_x: -1.0,
|
|
..Default::default()
|
|
});
|
|
let (events, _) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
assert!(
|
|
events.iter().any(|e| matches!(e, HostEvent::Input { .. })),
|
|
"input after rejoin must not be treated as stale"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn client_reconstructs_world_from_snapshot_and_state() {
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
|
|
// A session already in progress.
|
|
let world: Vec<EntityState> = (0..8).map(|i| entity(i, i as f32)).collect();
|
|
host.set_snapshot(42, world.clone());
|
|
|
|
let mut client = join(&mut host, 7, "latecomer", &mut clock);
|
|
assert_eq!(client.entities.len(), 8, "snapshot reconstructs the world");
|
|
assert_eq!(client.last_tick, 42);
|
|
|
|
// Then live state moves them.
|
|
let moved: Vec<EntityState> = (0..8).map(|i| entity(i, 100.0 + i as f32)).collect();
|
|
host.broadcast_state(43, &moved);
|
|
settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
|
|
for i in 0..8 {
|
|
assert_eq!(client.entities[&i].pos[0], 100.0 + i as f32);
|
|
}
|
|
|
|
// Reliable events mutate membership.
|
|
host.broadcast_event(44, GameEvent::Spawn { id: 99, kind: 1, state: entity(99, 5.0) });
|
|
host.broadcast_event(45, GameEvent::Remove { id: 0 });
|
|
settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
assert!(client.entities.contains_key(&99), "spawn applied");
|
|
assert!(!client.entities.contains_key(&0), "removal applied");
|
|
}
|
|
|
|
#[test]
|
|
fn six_clients_at_sixty_hz_with_two_hundred_entities() {
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
let mut clients: Vec<Client> = (0..6)
|
|
.map(|i| {
|
|
let mut c = Client::connect(
|
|
2000 + i,
|
|
&format!("p{i}"),
|
|
host.tcp_addr(),
|
|
host.udp_addr(),
|
|
SECRET,
|
|
clock,
|
|
)
|
|
.expect("connect");
|
|
for _ in 0..8 {
|
|
clock += 1.0 / 60.0;
|
|
host.pump(clock);
|
|
c.pump(clock);
|
|
}
|
|
c
|
|
})
|
|
.collect();
|
|
settle(&mut host, &mut clients, &mut clock);
|
|
assert_eq!(host.player_count(), 6, "six players joined");
|
|
|
|
// Every client must send an input once so the host pins its datagram
|
|
// address and starts replicating to it.
|
|
for client in clients.iter_mut() {
|
|
client.send_input(InputFrame { tick: 1, ..Default::default() });
|
|
}
|
|
settle(&mut host, &mut clients, &mut clock);
|
|
|
|
let mut world: Vec<EntityState> = (0..200).map(|i| entity(i, i as f32)).collect();
|
|
let ticks = 60;
|
|
let start = std::time::Instant::now();
|
|
for tick in 0..ticks {
|
|
clock += 1.0 / 60.0;
|
|
for (i, e) in world.iter_mut().enumerate() {
|
|
e.pos[0] = (tick as f32) * 0.1 + i as f32;
|
|
}
|
|
host.broadcast_state(tick as u64 + 2, &world);
|
|
for client in clients.iter_mut() {
|
|
client.send_input(InputFrame { tick: tick as u64 + 2, ..Default::default() });
|
|
}
|
|
host.pump(clock);
|
|
for client in clients.iter_mut() {
|
|
client.pump(clock);
|
|
}
|
|
}
|
|
let elapsed = start.elapsed();
|
|
settle(&mut host, &mut clients, &mut clock);
|
|
|
|
let per_tick_datagrams = host.stats.datagrams_out as f64 / ticks as f64;
|
|
let seconds = ticks as f64 / 60.0;
|
|
println!(
|
|
"6x60Hz x200 entities: {} datagrams out ({:.1}/tick, {:.0} pps), \
|
|
{:.2} MB out ({:.2} MB/s), wall {:?} for {} simulated ticks",
|
|
host.stats.datagrams_out,
|
|
per_tick_datagrams,
|
|
per_tick_datagrams * 60.0,
|
|
host.stats.bytes_out as f64 / 1e6,
|
|
host.stats.bytes_out as f64 / 1e6 / seconds,
|
|
elapsed,
|
|
ticks
|
|
);
|
|
|
|
// Entities-per-datagram must stay in the batching sweet spot: one datagram
|
|
// per client per batch, ~17 batches for 200 entities.
|
|
assert!(
|
|
per_tick_datagrams <= 6.0 * 20.0,
|
|
"batching regressed: {per_tick_datagrams} datagrams/tick"
|
|
);
|
|
|
|
// Loopback drops under burst; the point is that clients track the world.
|
|
for client in &clients {
|
|
assert!(
|
|
client.entities.len() >= 190,
|
|
"client saw only {} of 200 entities",
|
|
client.entities.len()
|
|
);
|
|
}
|
|
let received: u64 = clients.iter().map(|c| c.stats.datagrams_in).sum();
|
|
assert!(received > 0, "clients received state");
|
|
}
|
|
|
|
#[test]
|
|
fn stale_and_duplicate_state_is_ignored_per_entity() {
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
let mut client = join(&mut host, 3001, "p", &mut clock);
|
|
client.send_input(InputFrame { tick: 1, ..Default::default() });
|
|
settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
|
|
host.broadcast_state(1, &[entity(1, 10.0), entity(2, 20.0)]);
|
|
settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
assert_eq!(client.entities[&1].pos[0], 10.0);
|
|
|
|
host.broadcast_state(2, &[entity(1, 11.0), entity(2, 21.0)]);
|
|
settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
assert_eq!(client.entities[&1].pos[0], 11.0);
|
|
|
|
// Replay tick 1's datagram after tick 2 has landed — exactly what UDP
|
|
// reordering looks like. It is correctly signed and genuinely from the
|
|
// host, so only per-entity sequencing can reject it.
|
|
let replay = UdpSocket::bind(("127.0.0.1", 0)).unwrap();
|
|
let stale = HostToClient::StateBatch {
|
|
tick: 1,
|
|
entities: vec![
|
|
EntityState { id: 1, seq: 1, pos: [999.0, 0.0, 0.0], vel: [0.0; 3], yaw: 0.0, flags: 0 },
|
|
// A fresh entity in the same datagram must still be applied: the
|
|
// whole batch is not discarded, only the stale members.
|
|
EntityState { id: 3, seq: 1, pos: [30.0, 0.0, 0.0], vel: [0.0; 3], yaw: 0.0, flags: 0 },
|
|
],
|
|
};
|
|
let key = LobbyKey::new(SECRET);
|
|
let host_id = HostConfig::new("test-room", SECRET).host_id.0;
|
|
let datagram = Envelope::seal(host_id, &stale.serialize_bin(), &key);
|
|
replay
|
|
.send_to(&datagram, client.udp_addr().unwrap())
|
|
.unwrap();
|
|
|
|
let before = client.stats.stale_dropped;
|
|
settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
assert_eq!(client.entities[&1].pos[0], 11.0, "stale entity ignored");
|
|
assert!(client.stats.stale_dropped > before, "staleness was counted");
|
|
assert_eq!(
|
|
client.entities[&3].pos[0], 30.0,
|
|
"fresh entity in the same datagram still applied"
|
|
);
|
|
|
|
// A duplicate of the current sequence is also a no-op.
|
|
let dup = client.entities[&2];
|
|
let msg = HostToClient::StateBatch { tick: 2, entities: vec![EntityState { pos: [-5.0, 0.0, 0.0], ..dup }] };
|
|
let datagram = Envelope::seal(host_id, &msg.serialize_bin(), &key);
|
|
replay.send_to(&datagram, client.udp_addr().unwrap()).unwrap();
|
|
settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
assert_ne!(client.entities[&2].pos[0], -5.0, "duplicate seq ignored");
|
|
}
|
|
|
|
#[test]
|
|
fn peer_timeout_drops_a_silent_client() {
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
let mut client = join(&mut host, 4001, "quiet", &mut clock);
|
|
assert_eq!(host.player_count(), 1);
|
|
|
|
// Jump past the timeout without the client speaking.
|
|
clock += PEER_TIMEOUT + 1.0;
|
|
let events = host.pump(clock);
|
|
assert!(events
|
|
.iter()
|
|
.any(|e| matches!(e, HostEvent::Left { reason: LeaveReason::Timeout, .. })));
|
|
assert_eq!(host.player_count(), 0);
|
|
let _ = client.pump(clock);
|
|
}
|
|
|
|
#[test]
|
|
fn lobby_is_capped_at_max_players() {
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
let mut clients = Vec::new();
|
|
for i in 0..(MAX_PLAYERS + 3) {
|
|
if let Ok(mut c) = Client::connect(
|
|
9000 + i as u64,
|
|
"p",
|
|
host.tcp_addr(),
|
|
host.udp_addr(),
|
|
SECRET,
|
|
clock,
|
|
) {
|
|
for _ in 0..6 {
|
|
clock += 1.0 / 60.0;
|
|
host.pump(clock);
|
|
c.pump(clock);
|
|
}
|
|
clients.push(c);
|
|
}
|
|
}
|
|
settle(&mut host, &mut clients, &mut clock);
|
|
assert_eq!(host.player_count(), MAX_PLAYERS, "cap enforced");
|
|
assert!(host.stats.rejected_full > 0, "over-cap joins were rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn a_client_authoring_request_reaches_the_host() {
|
|
// The keyless-client path: a device with no API key types a request and
|
|
// the host — which owns the agent — receives it verbatim.
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
let mut client = join(&mut host, 7, "tablet", &mut clock);
|
|
|
|
client.send_intent(Intent::Authoring {
|
|
text: "make a racing game with boats".to_string(),
|
|
});
|
|
let (events, _) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
|
|
let authoring: Vec<_> = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
HostEvent::Intent { player, intent: Intent::Authoring { text } } => {
|
|
Some((*player, text.clone()))
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(authoring.len(), 1, "expected exactly one authoring intent");
|
|
assert_eq!(authoring[0].1, "make a racing game with boats");
|
|
}
|
|
|
|
#[test]
|
|
fn a_remote_claude_submits_an_edit_and_is_answered_over_the_wire() {
|
|
// The multi-Claude path: a client's agent asks for the base, submits an
|
|
// edit against it, and the host answers that client alone.
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
let mut client = join(&mut host, 11, "laptop", &mut clock);
|
|
|
|
client.send_coedit(CoeditRequest::GetBase);
|
|
let (events, _) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
let asked = events.iter().any(|e| {
|
|
matches!(
|
|
e,
|
|
HostEvent::Coedit {
|
|
req: CoeditRequest::GetBase,
|
|
..
|
|
}
|
|
)
|
|
});
|
|
assert!(asked, "the host must see the base request");
|
|
|
|
// The host answers with the source it holds.
|
|
host.send_coedit(
|
|
PlayerId(11),
|
|
CoeditResponse::Base {
|
|
generation: 0,
|
|
source: "cars {\n count: 4\n}\n".to_string(),
|
|
},
|
|
);
|
|
let (_, client_events) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
let base = client_events.iter().find_map(|e| match e {
|
|
ClientEvent::Coedit {
|
|
res: CoeditResponse::Base { generation, source },
|
|
} => Some((*generation, source.clone())),
|
|
_ => None,
|
|
});
|
|
assert_eq!(base, Some((0, "cars {\n count: 4\n}\n".to_string())));
|
|
|
|
client.send_coedit(CoeditRequest::Submit {
|
|
intent: "eight cars".to_string(),
|
|
base_generation: 0,
|
|
source: "cars {\n count: 8\n}\n".to_string(),
|
|
});
|
|
let (events, _) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
let submitted = events.iter().find_map(|e| match e {
|
|
HostEvent::Coedit {
|
|
player,
|
|
req:
|
|
CoeditRequest::Submit {
|
|
intent,
|
|
base_generation,
|
|
source,
|
|
},
|
|
} => Some((*player, intent.clone(), *base_generation, source.clone())),
|
|
_ => None,
|
|
});
|
|
let (player, intent, base_generation, source) =
|
|
submitted.expect("the submission must arrive intact");
|
|
assert_eq!(player, PlayerId(11));
|
|
assert_eq!(intent, "eight cars");
|
|
assert_eq!(base_generation, 0);
|
|
assert_eq!(source, "cars {\n count: 8\n}\n");
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_edit_is_refused_without_reaching_the_intent_log() {
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
let mut client = join(&mut host, 12, "laptop", &mut clock);
|
|
|
|
// Empty intent and an over-cap source: both must be refused at the edge.
|
|
client.send_coedit(CoeditRequest::Submit {
|
|
intent: " ".to_string(),
|
|
base_generation: 0,
|
|
source: "x\n".to_string(),
|
|
});
|
|
client.send_coedit(CoeditRequest::Submit {
|
|
intent: "huge".to_string(),
|
|
base_generation: 0,
|
|
source: "x".repeat(MAX_COEDIT_SOURCE + 1),
|
|
});
|
|
let (events, client_events) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
|
|
assert!(
|
|
!events
|
|
.iter()
|
|
.any(|e| matches!(e, HostEvent::Coedit { .. })),
|
|
"malformed submissions must not surface as host events"
|
|
);
|
|
let refusals = client_events
|
|
.iter()
|
|
.filter(|e| {
|
|
matches!(
|
|
e,
|
|
ClientEvent::Coedit {
|
|
res: CoeditResponse::Refused {
|
|
reason: CoeditRefusal::Malformed
|
|
}
|
|
}
|
|
)
|
|
})
|
|
.count();
|
|
assert_eq!(refusals, 2, "each malformed submission is answered");
|
|
}
|
|
|
|
#[test]
|
|
fn source_cannot_be_rewritten_by_a_datagram() {
|
|
// Coedit is reliable-channel only: accepting it over UDP would let one
|
|
// spoofed datagram rewrite the game.
|
|
let mut clock = 0.0;
|
|
let mut host = host();
|
|
let mut client = join(&mut host, 13, "laptop", &mut clock);
|
|
|
|
let payload = ClientToHost::Coedit {
|
|
req: CoeditRequest::Submit {
|
|
intent: "sneaky".to_string(),
|
|
base_generation: 0,
|
|
source: "cars {\n count: 999\n}\n".to_string(),
|
|
},
|
|
}
|
|
.serialize_bin();
|
|
// Correctly signed, from the real player — only the channel is wrong.
|
|
let datagram = Envelope::seal(13, &payload, &LobbyKey::new(SECRET));
|
|
let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
|
sock.send_to(&datagram, host.udp_addr()).unwrap();
|
|
|
|
let (events, _) = settle(&mut host, std::slice::from_mut(&mut client), &mut clock);
|
|
assert!(
|
|
!events
|
|
.iter()
|
|
.any(|e| matches!(e, HostEvent::Coedit { .. })),
|
|
"a datagram must never carry an edit into the intent log"
|
|
);
|
|
}
|