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:
1 parent
108ed695eb
commit
f302354bb2
11 files changed
+493
-21
No files matched your search
+46
-3
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 561 KiB |
+1
-1
@@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=251"></script>
|
||||
<script type="module" src="src/main.js?v=252"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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())})
|
||||
|
||||
+31
-7
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
+26
-5
@@ -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);
|
||||
|
||||
+39
-3
@@ -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);
|
||||
|
||||
|
||||
+146
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'); });
|
||||
|
||||
Reference in new issue
Block a user