Two phones, one match: the shot is the message, the receiver is the judge

VS A FRIEND on the mode screen (host a code, join a code), a ~110-line
RFC6455 relay in server.py that holds no game state, and src/net.js: each
phone plays itself near-side and the opponent's court is this one rotated
half a turn about the net. The phone a ball flies toward makes every call
on it; the striker's copy is picture. Six tests pin the rule from both
seats. No reconnect yet — a dropped radio is a card and a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 17:36:02 +02:00
1 parent 108ed695eb
commit f302354bb2
11 files changed
+493 -21

No files matched your search

+89 -1
View File
@@ -84,7 +84,8 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, O, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN } = await import('./src/beats.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court
const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; };
@@ -1166,6 +1167,93 @@ test('classic wears the match feel, and the difficulty table is a ladder', () =>
assert.equal(m.serve, 1.0, 'MEDIUM leaves the serve alone');
});
// ---- NETPLAY (src/net.js + beats.startNetVs). The shot is the sync unit and THE RECEIVER OF A
// FLIGHT IS ITS ONLY JUDGE — these pin both halves of that rule, with netTest.sent standing in
// for the socket so the wire traffic is a plain array the assertions can read.
function freshNet(side) {
restartRun();
G.classic = true; // netplay rides the classic tennis machinery
assert.ok(startNetVs(), 'net vs takes a clean court');
netTest.sent = [];
netMsg({ t: 'start', side }); // paired: 'A' hosts and serves first
}
test('netplay: the vs match owns the court both ways', () => {
assert.ok(_startMatch(), 'setup: the match takes the court');
assert.ok(!startNetVs(), 'netplay refuses while the match owns it');
endMatch();
assert.ok(startNetVs(), 'and takes it once the match is gone');
assert.ok(!_startMatch(), 'the match refuses while netplay owns the court');
assert.ok(!startDrill(), 'the drill refuses too');
startDuel();
assert.equal(G.interlude, false, 'and no duel can begin');
endNetVs();
assert.equal(G.net, null, 'endNetVs hands the court back');
});
test('netplay: the mirror is a half-turn about the net and its own inverse', () => {
const s = { x: 1.3, y: 2.2, z: 24.3, vx: -3.2, vy: 4.1, vz: -28 };
const m = mirror(s);
assert.ok(s.z > COURT.netZ && m.z < COURT.netZ, 'a near-baseline strike lands on the far half');
assert.ok(m.vz > 0, 'travelling away becomes travelling toward');
const rt = mirror(m);
for (const k of ['x', 'y', 'z', 'vx', 'vy', 'vz'])
assert.ok(Math.abs(rt[k] - s[k]) < 1e-9, `two mirrors are the identity (${k})`);
});
test('netplay: a remote serve arrives mirrored and arms the receiver as its judge', () => {
freshNet('B'); // they host, they serve
assert.equal(score.server, 'O', 'the host serves first on both scoreboards');
// their serve event, in THEIR coordinates: struck from behind their near baseline
netMsg({ t: 'shot', x: 1.3, y: 2.6, z: 24.3, vx: -2, vy: 2, vz: -30, g: 12, pow: 0.8, serve: true });
assert.ok(ball.active && ball.lastHit === 'O', 'their ball, live on this court');
assert.ok(ball.z < COURT.netZ, 'launched from the far half');
assert.ok(ball.vz > 0, 'and travelling toward the receiver');
assert.equal(G.phase, 'rally');
assert.ok(G.wasServe, 'the receiver\'s own service-box machinery is armed');
});
test('netplay: the receiver judges the flight — an untouched winner scores and goes over the wire', () => {
freshNet('B');
// a legal rally ball from their side (their frame): lands mid-court, never swung at
netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false });
for (let i = 0; i < 900 && ball.active; i++) update(DT);
assert.ok(!ball.active, 'the rally resolved locally — this phone was the judge');
assert.deepEqual(score.pts, [0, 1], 'their untouched winner is their point');
const pt = netTest.sent.find(m => m.t === 'pt');
assert.ok(pt && pt.win === 'you', 'and the verdict went to their phone, winner relative to sender');
});
test('netplay: an outbound ball is picture — this phone never judges its own shot', () => {
freshNet('A');
G.phase = 'rally';
ball.active = true; ball.collected = false; ball.bounces = 0;
ball.x = 0; ball.y = 1.5; ball.z = 20; ball.vx = 8; ball.vy = 3; ball.vz = -16; ball.g = 9.2;
ball.lastHit = 'P'; G.wasServe = false; // a rally ball flying well OUT — 8 m/s of sideways
const pts = JSON.stringify(score.pts);
for (let i = 0; i < 240; i++) update(DT);
assert.equal(JSON.stringify(score.pts), pts, 'no local verdict on an outbound flight');
assert.ok(!netTest.sent.some(m => m.t === 'pt' || m.t === 'fault'), 'and none was sent');
netMsg({ t: 'pt', win: 'me', reason: 'out' }); // their call arrives instead
assert.deepEqual(score.pts, [0, 1], 'their call, this scoreboard');
assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back');
});
test('netplay: a serve fault is the receiver\'s call, and re-arms the server\'s second serve', () => {
freshNet('B'); // they serve — long, out of the box
netMsg({ t: 'shot', x: 0.5, y: 2.6, z: 24.3, vx: 0, vy: 1, vz: -35, g: 9.2, pow: 1, serve: true });
for (let i = 0; i < 300 && ball.active; i++) update(DT);
assert.equal(G.serveNum, 2, 'the receiver\'s copy of the point advances to second serve');
assert.ok(netTest.sent.some(m => m.t === 'fault'), 'the fault went to their phone');
assert.deepEqual(score.pts, [0, 0], 'a first fault is not a point');
freshNet('A'); // ...and on the server phone the same message lands
G.serveNum = 1; G.phase = 'rally'; ball.active = true; ball.lastHit = 'P'; G.wasServe = true;
netMsg({ t: 'fault' });
assert.equal(G.serveNum, 2, 'their call re-armed the second serve');
assert.ok(!ball.active, 'the dead serve is off the court');
assert.ok(!netTest.sent.some(m => m.t === 'fault'), 'applying a fault does not echo one back');
});
// stats.js ships its own fixture-based check — the period filter, the mode split and the name
// normalisation that has to agree with server.py. It was exported and never called by anything.
test("stats.js's own check", () => { assert.equal(statsCheck(), 'ok'); });