diff --git a/PROJECT.md b/PROJECT.md index 72b3179..7c8c18c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -20,8 +20,8 @@ anything, so if you add a `return` for an awkward deal, check the body still run the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way. -Last updated 2026-08-11 (the coaching drill lands as a back-room prototype; the horde's breather restored, the boss walk-on overlay; beats.js -extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER — +Last updated 2026-08-14 (the live 1v1 socket lands: VS A FRIEND on the mode screen, the relay in +server.py, the receiver-is-judge netcode in src/net.js — phone verdict pending). **Start with "How to work on this", "OPEN — leftovers" and "LATER — shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback should be handled, the second is everything known to be outstanding in the game, the third is the demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed @@ -411,6 +411,46 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: MULTIPLAYER — the live 1v1 socket (2026-08-14). PHONE VERDICT PENDING + +Oliver: *"alright multiplayer"* — and, offered the recorded ghost-match step one, he picked the +LIVE SOCKET instead, plus *"a new button with generated art in the main menu — the possibility to +start or join a game"*. Built to the architecture LATER item 7 recorded; verified end to end in two +browser panes through the real relay (pair → serve → mirrored flight → verdict → both scoreboards +agree on the same point from opposite perspectives). 73 tests. + +- **It is CLASSIC TENNIS between two phones** — `G.classic` with the O opponent driven by the other + phone instead of the AI. The mode poster is **VS A FRIEND** (`mode_vs.png`, generated in the + zombie poster's night-court style); its second step is two chips, HOST — GET A ROOM CODE / JOIN + WITH A CODE, and either chip IS the start — a room needs a socket, not a setup screen. `#net` + hosts and `#net-ABCD` joins from a URL. The room code lands in a modal over the frozen court + (an empty court waiting for a stranger is the calmest court there is). +- **The relay is ~110 lines of stdlib in server.py** (RFC6455 handshake + framing, `/ws`). It + holds NO game state: mints a 4-letter code (no I/L/O/U/0/1 — codes are read aloud), pairs exactly + two sockets, forwards text frames verbatim. The host serves first on both scoreboards. 75 s + socket timeout against vanished radios; the client heartbeats every 20 s. +- **The sync unit is THE SHOT** (`src/net.js`): each phone simulates its own court with itself as + P; a strike sends ~9 numbers and `mirror()` (x → -x, z → COURT.L - z) rotates them half a turn. + The 10 Hz `pos` stream drives the O sprite and carries no authority. +- **THE ONE RULE: the phone the ball is flying toward is the only judge of that flight** — line + call, service box, double bounce, its own swing. The striker's outbound ball is picture + (gameplay's tennis bounce block returns early on `G.net && lastHit === 'P'`) and the verdict + arrives as a message: `shot` (returned), `fault` (re-serve), `pt` (point, winner relative to + sender). This is what stops two sims a latency apart double-scoring a rally — the striker's copy + would call a double bounce before the defender's swing had happened. An `applying` latch stops a + received verdict echoing back out. Six tests pin the rule from both seats. +- **startNetVs/endNetVs live in beats.js** under the ownership contract, and `G.net` joined every + other start*'s refusal list. Serving is barred while unpaired or dead (`keyPressed`), O's auto + serve and rally AI are gated off, `restartRun` closes the socket. +- **Honest limits, all deliberate (ponytail)**: NO reconnect-and-resume — a dropped radio is a + CONNECTION LOST card and a reload (the recorded plan calls reconnection "the bulk of the real + work"; a prototype must not start there). prompt() is the join UI. The scoreboard still says + CPU. A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the + flight swallows it). Runs bank as classic runs. The wound-decay hook (losing IS zombification) + is the recorded next step once the feel survives a two-phone playtest. +- **Test on staging with two phones** — the relay deploys with the ordinary push, nothing to + provision. Coolify's proxy passes WebSocket upgrades as-is. + ## Session log: the far court, without spending the lens (2026-08-13) Oliver: the far-side opponent is barely visible in portrait, and the lens (camZ/camF, already at its @@ -2880,7 +2920,10 @@ never an interstitial. An interstitial after death would land exactly in the "on is not), and canvas perf at `RES` 4 — the third place that risk lands, after OPEN 1 and item 2 above. `matchMedia('(pointer: coarse)')` does still work there, so the mobile branches are fine. -7. **MULTIPLAYER: 1v1 zombie tennis (decided worth pursuing, 2026-08-12).** Tennis is one of the +7. **MULTIPLAYER: 1v1 zombie tennis (decided worth pursuing, 2026-08-12). THE LIVE-SOCKET + PROTOTYPE IS BUILT (2026-08-14, staging — see the session log) — Oliver chose the socket over + the ghost-match step one. Still open from this item: the ghost match as a solo mode, the + wound-decay hook below, reconnect-and-resume.** Tennis is one of the friendliest genres in existence for netcode, and this codebase is unusually ready for it — record the decision and the architecture so neither is re-derived: - **The sync unit is THE SHOT, not world state.** One ball, one legal hitter at a time diff --git a/assets/mode_vs.png b/assets/mode_vs.png new file mode 100644 index 0000000..75f30b9 Binary files /dev/null and b/assets/mode_vs.png differ diff --git a/index.html b/index.html index 82de44c..453f9c0 100644 --- a/index.html +++ b/index.html @@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/server.py b/server.py index 4394890..07b15a2 100644 --- a/server.py +++ b/server.py @@ -14,7 +14,7 @@ board is a wall for writing your name on, and the client is public JS that could anything regardless. What IS enforced is that nothing here can be made to crash, hang or grow without bound by a hostile request — see clean() and the caps below. """ -import json, os, re, threading, time +import base64, hashlib, json, os, random, re, threading, time from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer HERE = os.path.dirname(os.path.abspath(__file__)) @@ -73,6 +73,48 @@ def rank_of(rows, name): return next((i + 1 for i, r in enumerate(rows) if r.get('n') == name), len(rows)) +# ---- the multiplayer relay: RFC6455 on the standard library, because that is all a dumb relay +# needs. The netcode's sync unit is THE SHOT (see PROJECT.md, LATER item 7), so this server holds +# no game state at all: it mints a 4-letter room code, pairs exactly two sockets, and forwards +# text frames verbatim between them. The clients judge everything; a hostile client can only cheat +# its friend, which is the agreed trust model for friend codes. Caps mirror the score API's stance: +# nothing here may crash, hang or grow without bound on a hostile request. +WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' +WS_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789' # read aloud between phones: no I/L/O/U/0/1 +WS_MAX = 2048 # a shot event is ~120 bytes; anything larger is not one +ROOMS = {} # code -> [host_conn] while waiting; deleted on pairing +ws_lock = threading.Lock() + + +def ws_send(conn, text): + data = text if isinstance(text, bytes) else text.encode() + head = bytes([0x81, len(data)]) if len(data) < 126 else bytes([0x81, 126]) + len(data).to_bytes(2, 'big') + try: + with conn['lock']: # relay thread and own thread share this socket + conn['sock'].sendall(head + data) + except OSError: + pass # a dead peer is handled by its own recv loop + + +def ws_read(rf): + """One frame -> (opcode, payload) or (None, b'') on EOF/oversize. Client frames are masked.""" + h = rf.read(2) + if len(h) < 2: + return None, b'' + op, n = h[0] & 0x0f, h[1] & 0x7f + if n == 126: + n = int.from_bytes(rf.read(2), 'big') + elif n == 127: + n = int.from_bytes(rf.read(8), 'big') + if n > WS_MAX: + return None, b'' + mask = rf.read(4) if h[1] & 0x80 else None + data = rf.read(n) + if mask: + data = bytes(b ^ mask[i & 3] for i, b in enumerate(data)) + return op, data + + class Handler(SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=HERE, **kw) @@ -90,8 +132,76 @@ class Handler(SimpleHTTPRequestHandler): self.end_headers() self.wfile.write(body) + # ---- the relay endpoint ---- + def handle_ws(self): + key = self.headers.get('Sec-WebSocket-Key') + if not key or (self.headers.get('Upgrade') or '').lower() != 'websocket': + return self.send_error(400) + accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode() + self.connection.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n' + b'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept.encode() + b'\r\n\r\n') + self.close_connection = True + # a phone that vanishes without FIN must not hold a thread and a room forever — the client + # heartbeats every 20 s, so 75 s of silence is a dead radio, not a slow one + self.connection.settimeout(75) + me = {'sock': self.connection, 'lock': threading.Lock(), 'peer': None, 'room': None} + want = re.sub(r'[^A-Z0-9]', '', (self.path.split('room=')[1] if 'room=' in self.path else '').upper())[:8] + with ws_lock: + if want: # joining a friend + room = ROOMS.pop(want, None) + if not room: + ws_send(me, '{"t":"gone"}') + return + host = room[0] + me['peer'] = host + host['peer'] = me # the code is single-use: popped above, never re-joinable + else: # hosting: mint a code nobody is holding + code = ''.join(random.choice(WS_ALPHABET) for _ in range(4)) + while code in ROOMS: + code = ''.join(random.choice(WS_ALPHABET) for _ in range(4)) + ROOMS[code] = [me] + me['room'] = code + if len(ROOMS) > 200: # a lobby is two people; 200 stale rooms is a flood + ROOMS.pop(next(iter(ROOMS))) + if me['peer']: # paired: the host serves first, on both scoreboards + ws_send(me['peer'], '{"t":"start","side":"A"}') + ws_send(me, '{"t":"start","side":"B"}') + else: + ws_send(me, json.dumps({'t': 'room', 'code': me['room']})) + try: + while True: + try: + op, data = ws_read(self.rfile) + except OSError: # timeout or reset: same as hanging up + break + if op is None or op == 8: + break + if op == 9: # protocol ping -> pong, payload echoed + try: + with me['lock']: + me['sock'].sendall(bytes([0x8A, len(data)]) + data) + except OSError: + pass + continue + if op != 1 or data == b'{"t":"hb"}': + continue # the heartbeat is for the socket, not the friend + if me['peer']: + ws_send(me['peer'], data) # the whole job: forward the frame verbatim + finally: + with ws_lock: + if me['room'] and ROOMS.get(me['room']) and ROOMS[me['room']][0] is me: + del ROOMS[me['room']] # nobody ever came + peer = me['peer'] + me['peer'] = None + if peer: + peer['peer'] = None + if peer: + ws_send(peer, '{"t":"bye"}') + def do_GET(self): path = self.path.split('?')[0] + if path == '/ws': + return self.handle_ws() if path == '/api/scores': with lock: return self.reply(200, {'scores': public(load())}) diff --git a/src/beats.js b/src/beats.js index ed29e3d..8a96ce3 100644 --- a/src/beats.js +++ b/src/beats.js @@ -14,6 +14,7 @@ import { voice, zombieDuck, aww } from './audio.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js'; import { startDrillStream } from './drill.js'; +import { netClose } from './net.js'; // the court is mopped up (or was already clear): play advances to whatever the won game earned — // a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde. @@ -64,7 +65,7 @@ function startChangeover() { export function startDuel() { // Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while // another control mode owns the court. Each of these has ended a run when they overlapped. - if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return; + if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return; if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel G.weapon = 0; // tennis rules for the duel: plain balls, no tins @@ -87,7 +88,7 @@ export function startDuel() { // It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary // instant death. G.bossFight holds the chapter number while it runs. export function startBossFight(chapter) { - if (G.bossFight || G.drill || G.match || G.bench) return; + if (G.bossFight || G.drill || G.match || G.bench || G.net) return; G.bossFight = chapter; zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage G.horde = null; G.mgT = 0; endBurst(); G.clearing = false; @@ -136,7 +137,7 @@ export function endBossFight() { // was walking around a moment ago is still walking around inside a drill round. The drill's own // clock and rules live in drill.js; only the transition is here, where the rule can be enforced. export function startDrill() { - if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench) return false; + if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench || G.net) return false; G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null }; G.changeover = false; G.clearing = false; G.afterClear = null; // ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending @@ -171,7 +172,7 @@ export function endDrill() { const BENCH_HP = 12; // a deep pool so a wound stage is set by FRACTION, cleanly export function startBench() { - if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false; + if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; G.bench = { i: 0, stage: 0, fam: 0, dir: 1 }; zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // ...and DISARM the ordinary schedule, exactly as the drill does: a pending horde or an owed @@ -342,7 +343,7 @@ export function watchdog(dt) { // it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which // lands in matchGameWon via the empty-court check). export function startMatch(cup = false) { - if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false; + if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; G.match = { opp: 1, ending: false }; // THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks // on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream @@ -827,12 +828,35 @@ export function resolveInterlude(side) { } } +// ---- NETPLAY: 1v1 classic tennis against another phone. The wire, the protocol and the judging +// rule all live in src/net.js; ONLY the court ownership lives here, where the contract can be +// enforced — same shape as every mode above. It runs in G.mode 'tennis' with G.classic set (the +// caller does that via start('classic')), so the whole splatter schedule is structurally inert; +// the disarm lines below are the same belt the drill and the bench wear anyway. +export function startNetVs() { + if (G.net || G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false; + G.net = { ready: false, dead: false, side: null, code: null, opp: null }; + G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false; + G.changeover = false; G.clearing = false; G.afterClear = null; + G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null; + zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; + ball.active = false; setupPoint(); + return true; +} + +export function endNetVs() { + if (!G.net) return; + netClose(); + G.net = null; + ball.active = false; setupPoint(); +} + // THE BREATHER BEFORE THE WAVE. The horde used to begin on the same frame as its own announcement, // so the flash was already gone by the time the crowd was on you — "the horde is not announced // anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock // counts it down under the flash), and startHorde still owns the transition itself. export function warnHorde() { - if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same court-ownership contract as the rest + if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same court-ownership contract as the rest G.hordeWarn = TUNE.hordeWarnT; G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT }; ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT; @@ -843,7 +867,7 @@ export function warnHorde() { // Returns false when another mode owns the court — the caller re-arms the latch rather than // silently eating the wave the set paid for. export function startHorde() { - if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same contract as startDuel: one mode owns the court + if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same contract as startDuel: one mode owns the court G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse // the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell diff --git a/src/config.js b/src/config.js index e45ca38..a6d0ec4 100644 --- a/src/config.js +++ b/src/config.js @@ -655,6 +655,9 @@ export const MENU = [ { mode: 'classic', diffs: true }, { name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] }, { mode: 'gore' }, + // NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN, + // and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js) + { name: 'VS A FRIEND', sub: 'LIVE 1V1 · REAL TENNIS · TWO PHONES', art: 'mode_vs', net: true }, ]; // CLASSIC's difficulty. The first cut scaled only flub/quality-cap/speed and Oliver felt NOTHING — diff --git a/src/gameplay.js b/src/gameplay.js index 565a6f0..fe1332d 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -9,6 +9,7 @@ import { updateDrill } from './drill.js'; import { endRun, beginRun, MODE_CLASSIC } from './stats.js'; import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing +import { netSendShot, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/showModal // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. @@ -227,6 +228,7 @@ function doHit(ch, quality, aim) { if (w) popText(ball.x, ball.y, ball.z, w); } ch.pose = 'swing'; ch.poseT = 0; + if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit } // ----------------------------------------------------------------- serve ---- @@ -460,6 +462,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { // bounce. Praising at release meant the HUD spoke while the ball was still mid-air. ball.praise = praiseWord(); } + if (G.net && sv.side === 'P') netSendShot(); // netplay: a serve is a shot event like any other } // ---------------------------------------------------------------- scoring ---- @@ -539,7 +542,11 @@ export function advancePoint(side) { // shared scoring math; returns whether a g return false; } -function pointTo(side, reason) { +export function pointTo(side, reason) { // exported for net.js: a remote verdict lands here + // netplay: this phone judged the live flight — the verdict goes over the wire before it is + // applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE + // verdict is being applied, which is what stops the two phones echoing a point back and forth. + if (netJudging()) netSendPoint(side, reason); if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it const n = G.rallyNear; logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`, @@ -859,6 +866,7 @@ const xLimit = () => Math.max(xVisible(), COURT.W / 2); // bag has a pristine copy (state.resetState) and the two modules with timers of their own put them // back themselves, so nothing here has to know the shape of any of it. export function restartRun() { + netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried resetZombies(); resetInput(); resetState(); setupPoint(); beginRun(); // ...and the stats bag, which is the only state that does not live on G } @@ -917,8 +925,12 @@ function transition(dt) { }); } -function fault() { - if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); } +export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve + if (G.serveNum === 1) { + if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double + // fault below goes out through pointTo as the point it is + G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); + } else pointTo(other(score.server), 'double_fault'); } @@ -1031,6 +1043,11 @@ function onBounce() { return; } if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns + // NETPLAY: THE RECEIVING PHONE IS THE ONLY JUDGE OF A FLIGHT. Our outbound ball is picture, not + // evidence — the two sims are ~a latency apart, and this one would call a double bounce before + // the other one's player had swung. Every call on this ball (box, line, second bounce) arrives + // as a message instead; the ball rolls on harmlessly until it does. (See net.js.) + if (G.net && ball.lastHit === 'P') return; if (ball.bounces === 1) { const wasServe = G.wasServe; G.wasServe = false; if (wasServe) { @@ -1365,6 +1382,7 @@ export function keyPressed(code) { if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves if (G.changeover) return; // the breather is mandatory — the melee swish above still works if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel + if (G.net && (!G.net.ready || G.net.dead)) return; // netplay: no serving into an empty room // splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } // splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from @@ -1693,6 +1711,7 @@ export function update(dt) { if (G.meleeT > 0) G.meleeT -= dt; if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out watchdog(dt); + if (G.net) netTick(dt); // netplay: the 10 Hz position stream out, the opponent's body in // !G.horde: a horde mows the court down to an EMPTY FRAME between spawns, and a clear latched // before the wave began (a game won during the set card) then advanced the beat mid-horde — // which is how a sudden-death duel once started INSIDE a horde. The clear waits for endHorde. @@ -1717,7 +1736,8 @@ export function update(dt) { // own baseline and arms aiServeT; this is the one condition that was keeping it switched off. // The intro beat holds it: a body still walking on must not open with a serve. const oServes = (G.mode === 'tennis' || G.match) && score.server === 'O' - && !(G.interlude && G.interlude.introT > 0); + && !(G.interlude && G.interlude.introT > 0) + && !G.net; // netplay: the far side serves on the OTHER phone — the serve arrives as a shot event if (oServes && G.phase === 'pretoss') { G.aiServeT -= dt; if (G.aiServeT <= 0) tossBall(O); @@ -1728,7 +1748,8 @@ export function update(dt) { if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; } } } - if (G.mode === 'tennis' && G.phase === 'rally') { + if (G.mode === 'tennis' && G.phase === 'rally' && !G.net) { // netplay: O is a real person on + // another phone — netTick drives the body G.aiReactT += dt; const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= (G.diff ? G.diff.reaction : TUNE.aiReaction); diff --git a/src/main.js b/src/main.js index 8d56a2a..dc50e04 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,8 @@ import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js'; import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; -import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall } from './beats.js'; +import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js'; +import { netConnect } from './net.js'; import { fitViewport, render, proj, RES, SPRV, LOAD, SPR } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; @@ -99,10 +100,35 @@ const banner = (card, cls, last) => { const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} }; // TAP ADVANCES. Each screen's tap both answers and moves on — the wizard's whole promise is one // decision per screen, and a NEXT button under a single row of choices is a second tap for nothing. -function buildSub() { +// NETPLAY's second step: two chips, and either one IS the start — a room needs a socket, not a +// setup screen (court and tour ride whatever was last picked). HOST shows the code to share in a +// modal over the frozen court; JOIN asks for a friend's code. +// ponytail: prompt() is the join UI — replace with a drawn code entry if the mode ships. +function netChip(label, join) { + const b = document.createElement('button'); + b.className = 'chip'; + b.textContent = label; + b.addEventListener('click', () => { + let code = null; + if (join) { + code = (prompt('ROOM CODE FROM YOUR OPPONENT') || '').trim().toUpperCase(); + if (!code) return; + } + start('classic'); + if (startNetVs()) netConnect(code); + }); + return b; +} + +function buildSub(top) { modeSub.innerHTML = ''; - const t = topOf(pickedMode); + const t = top || topOf(pickedMode); document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || ''); + if (t.net) { + modeSub.appendChild(netChip('HOST — GET A ROOM CODE', false)); + modeSub.appendChild(netChip('JOIN WITH A CODE', true)); + return; + } if (t.diffs) for (const d of DIFFS) { const b = document.createElement('button'); b.className = 'chip'; @@ -131,6 +157,9 @@ function buildTops() { for (const t of MENU) { const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t); b.addEventListener('click', () => { + // the netplay poster deals HOST/JOIN and never touches pickedMode — it is not a run mode + // the stats screen or the summary could name, and either chip starts the game itself + if (t.net) { buildSub(t); go('modesub'); return; } // a group remembers which child you were on; entering it fresh lands on its first card pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0])); buildSub(); @@ -349,6 +378,8 @@ document.getElementById('scMatch').addEventListener('click', () => { start(null); intoSplatter(); startMatch(); }); +// (netplay has no back-room button: the VS A FRIEND poster on the mode screen IS the door, and +// #net / #net-CODE below covers sending a phone straight in from a URL) // THE SPRITE BENCH — the art test. Same "drop into a real run first" trick as its neighbours, so the // body is drawn by the real renderer at the real size, on the real court, under the real palette. // Judging a walk cycle anywhere else is judging a different picture. @@ -552,6 +583,11 @@ if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter(); if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700); if (location.hash === '#cup') setTimeout(() => { start(null); intoSplatter(); startMatch(true); }, 700); if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700); +// #net hosts a room, #net-ABCD joins one — the netplay door from a URL (slice(5) skips '#net-') +if (location.hash.startsWith('#net')) setTimeout(() => { + start('classic'); + if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null); +}, 700); requestAnimationFrame(frame); diff --git a/src/net.js b/src/net.js new file mode 100644 index 0000000..6d70842 --- /dev/null +++ b/src/net.js @@ -0,0 +1,146 @@ +// NETPLAY — 1v1 classic tennis between two phones, over the dumb relay in server.py. +// (gameplay <-> net is a runtime-only import cycle, same pattern as input <-> gameplay: the +// imported bindings are only ever touched inside functions, never during module evaluation.) +// +// THE SYNC UNIT IS THE SHOT, NOT WORLD STATE (PROJECT.md, LATER item 7). Each phone runs the whole +// sim with ITSELF as P; the opponent's court is this one rotated half a turn about the net centre +// (mirror() below). A struck ball is ~9 numbers; each ~1 s flight swallows the latency. +// +// WHO JUDGES WHAT — the one rule that keeps two skewed sims from double-scoring a rally: +// THE PHONE THE BALL IS FLYING TOWARD IS THE ONLY JUDGE OF THAT FLIGHT. Your swing window is +// judged on your phone (lag never eats your tap), and so are the line call, the service box and +// the double bounce of everything that comes AT you. The striker's phone renders its outbound +// ball but judges nothing (gameplay's bounce block returns early on it) and waits for one of +// three verdicts: 'shot' (returned — snap the ball to the mirrored strike), 'fault' (re-serve), +// 'pt' (the point, winner relative to the SENDER). The 10 Hz 'pos' stream is pure picture — it +// drives the O sprite and carries no authority at all. +// +// ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded +// plan calls reconnection "the bulk of the real work" and a prototype must not start there. +import { COURT, UI } from './config.js'; +import { G, P, O, ball, score, setupPoint } from './state.js'; +import { pointTo, fault, showModal } from './gameplay.js'; + +let ws = null, hb = 0, posT = 0; +let applying = false; // a remote verdict being applied must not echo back out +export const netTest = { sent: null }; // test seam: an array here catches outgoing messages + +const r3 = v => Math.round(v * 1000) / 1000; + +// the half-turn about the net centre: x -> -x, z -> COURT.L - z, velocities with them +export const mirror = m => ({ ...m, x: -m.x, z: COURT.L - m.z, vx: -m.vx, vz: -m.vz }); + +function send(m) { + if (netTest.sent) { netTest.sent.push(m); return; } + if (ws && ws.readyState === 1) ws.send(JSON.stringify(m)); +} + +// true while THIS phone is the judge of the live flight and the verdict is its own (not an echo) +export const netJudging = () => !!(G.net && G.net.ready) && !applying; +export const netSendPoint = (side, reason) => send({ t: 'pt', win: side === 'P' ? 'me' : 'you', reason: reason || null }); +export const netSendFault = () => send({ t: 'fault' }); + +// called by gameplay the moment a local strike has finished writing the ball — the ball IS the message +export function netSendShot() { + if (!G.net || !G.net.ready) return; + send({ t: 'shot', x: r3(ball.x), y: r3(ball.y), z: r3(ball.z), + vx: r3(ball.vx), vy: r3(ball.vy), vz: r3(ball.vz), + g: r3(ball.g), pow: r3(ball.pow || 0), serve: !!G.wasServe }); +} + +// the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here +export function netApplyShot(m) { + const s = mirror(m); + ball.active = true; ball.collected = false; ball.bounces = 0; + ball.x = s.x; ball.y = m.y; ball.z = s.z; + ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz; + ball.g = m.g; ball.pow = m.pow; + ball.lastHit = 'O'; + ball.bomb = false; ball.flame = false; ball.wid = 'ball'; ball.chain = 0; ball.praise = null; ball.kills = 0; + G.wasServe = !!m.serve; // arms the receiver's own service-box call + G.phase = 'rally'; G.swung = false; G.rallyNear = null; + O.x = s.x; O.z = s.z; // the strike happened where the ball says it did + O.pose = m.serve ? 'serve' : 'swing'; O.poseT = 0; +} + +function netApplyPoint(m) { + applying = true; // their verdict — apply it without judging or echoing + ball.active = false; + pointTo(m.win === 'me' ? 'O' : 'P', m.reason); + applying = false; +} + +function netApplyFault() { + applying = true; + ball.active = false; + fault(); // we are the server here: second serve arms exactly as ever + applying = false; +} + +function netStart(side) { + G.net.ready = true; G.net.side = side; + G.modal = null; G.modalQ.length = 0; // the waiting card has done its job + score.server = side === 'A' ? 'P' : 'O'; // the host serves first, on both scoreboards + score.pts = [0, 0]; + G.serveNum = 1; + setupPoint(); + G.flash = { text: side === 'A' ? 'AN OPPONENT — YOUR SERVE' : 'AN OPPONENT — THEIR SERVE', t: 2.4 }; +} + +function netDead(text) { + if (!G.net || G.net.dead) return; + G.net.ready = false; G.net.dead = true; + ball.active = false; + G.modal = null; G.modalQ.length = 0; + G.card = { title: 'CONNECTION LOST', sub1: text, color: UI.bad, t: 30 }; +} + +export function netMsg(m) { + if (!G.net) return; + if (m.t === 'room') { + G.net.code = m.code; + // a modal is legal here: an empty court waiting for a stranger is the calmest court there is + showModal({ title: m.code, color: UI.pick, + body: ['THE ROOM CODE — TELL YOUR OPPONENT', 'WAITING FOR THEM TO JOIN'] }); + } + else if (m.t === 'start') netStart(m.side); + else if (m.t === 'gone') netDead('NO SUCH ROOM — RELOAD AND HOST ONE'); + else if (m.t === 'bye') netDead('YOUR OPPONENT LEFT — RELOAD TO PLAY AGAIN'); + else if (m.t === 'shot') netApplyShot(m); + else if (m.t === 'pt') netApplyPoint(m); + else if (m.t === 'fault') netApplyFault(); + else if (m.t === 'pos') G.net.opp = m; +} + +// ticked from update() while G.net: the position stream out, the O sprite in +export function netTick(dt) { + const n = G.net; + if (!n || !n.ready) return; + if ((posT += dt) >= 0.1) { + posT = 0; + send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0 }); + } + const o = n.opp; + if (o) { + const tx = -o.x, tz = COURT.L - o.z; + const k = Math.min(1, dt * 9); // a short lerp: smooths the 10 Hz stream, adds no lag worth feeling + if (o.m && Math.abs(tx - O.x) > 0.2) O.facing = tx > O.x ? 1 : -1; + O.x += (tx - O.x) * k; O.z += (tz - O.z) * k; + if (O.pose !== 'swing' && O.pose !== 'serve') O.pose = o.m ? 'run' : 'idle'; + if (o.m) O.runT += dt; + } +} + +export function netConnect(code) { + const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + + '/ws' + (code ? '?room=' + encodeURIComponent(code) : ''); + ws = new WebSocket(url); + ws.onmessage = e => { let m; try { m = JSON.parse(e.data); } catch { return; } netMsg(m); }; + ws.onclose = () => netDead('THE LINE DROPPED — RELOAD TO PLAY AGAIN'); + hb = setInterval(() => send({ t: 'hb' }), 20000); // keeps phone radios and proxies from idling us out +} + +export function netClose() { + if (hb) { clearInterval(hb); hb = 0; } + if (ws) { try { ws.onclose = null; ws.close(); } catch { /* already gone */ } ws = null; } +} diff --git a/src/state.js b/src/state.js index 68282bb..989cfdb 100644 --- a/src/state.js +++ b/src/state.js @@ -53,6 +53,7 @@ export const G = { idleT: 0, boy: null, // ball boy sprinting across: {x, z, dir, runT} aiServeT: 0, + net: null, // netplay (src/net.js): { ready, dead, side, code, opp } while a 1v1 runs aiSwingCool: 0, aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction wasServe: false, // the ball in flight is a serve (first bounce must hit the box) diff --git a/test.js b/test.js index 88bec3b..8e11040 100644 --- a/test.js +++ b/test.js @@ -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'); });