Add opt-in phone gameplay recording and JSON export

This commit is contained in:
wuwi committed 2026-09-07 10:19:38 +02:00
1 parent ede1f48152
commit fe64242090
8 files changed
+394 -12

No files matched your search

+41
View File
@@ -4922,6 +4922,47 @@ This is stronger measured opposition, not a measured human win rate: league 5 st
Oliver's level-26 phone verdict. Oliver requested merging to main and pushing for his iPhone Oliver's level-26 phone verdict. Oliver requested merging to main and pushing for his iPhone
test; this release carries the cache-busting entry version v350. test; this release carries the cache-busting entry version v350.
## Session log: THE PHONE PLAYTEST RECORDER (2026-09-07, v351)
Oliver: difficulty is better, but title defences still come easily — log real phone play so we
can explain his wins per game before tuning again. The old 50-entry `G.missLog` is tennis-only
and cannot do that. `src/recorder.js` adds opt-in, local-only diagnostics for solo cup, Climb and
training. No video, server endpoint, automatic upload, package or change to difficulty.
Use **STATS → RECORD PLAYTEST**, play, then **STATS → STOP RECORDING → EXPORT RECORDING**.
`?record=1` also arms/resumes it. A small REC below the quit chip marks active capture; normal
reloads restore the saved file paused. Export uses the phone's file-share sheet when supported,
otherwise a JSON download. Cancellation/export never deletes data. Replacement is a separate,
explicitly labelled button. The native iPhone share sheet still needs Oliver's phone verdict.
- Events identify run/segment, opponent encounter, roster position, cup round, point number and
the pre-verdict game/point score. Run starts capture build, viewport, court, league, player level
and actual TUNE values. No player name, account id, sync key or other credentials are exported.
- Both serves and returns capture quality, aim, resulting velocity, positions and habit/read
state; player swing attempts and actual probabilistic opponent whiffs get their own events.
- `resolveInterlude` accepts an optional diagnostic reason. Its existing decided/ending guards
gate the recorder too. Reasons distinguish out, double fault, double bounce, dead roller,
passed player, body destruction and the two early-unreachable adjudication paths. Record
BEFORE scoring mutates the state, including a point that kills the player.
- Every point includes up to 90 live update frames of player/opponent/ball movement (roughly
1.5 s at 60 Hz, deliberately not the inaccurate old 20 Hz closest-approach sampler). Timestamps
use update time; this is evidence for analysis, not a deterministic replay. An ace is derived
from the shot sequence and verdict, not guessed at the logging call site.
- Completed points and run boundaries checkpoint to `splatter-playtest-v1` in localStorage.
Visibility/pagehide also save; backgrounded/resumed play gets explicitly marked segments.
Event data caps at 1.5 million JSON characters, then stops with REC FULL instead of silently
discarding the beginning. Storage errors preserve in-memory export and report the limitation.
Validation: 138 tests pass, default seed plus 1, 42, 1234 and 20260907. A 180 s Level-26 cup
simulation has identical gameplay state AND RNG state with recording on/off (reset P.dustAcc in
the comparison fixture: resetState's Object.assign leaves that cosmetic accumulator behind).
Tests cover actual shots/scoring, guarded duplicate verdicts, fatal losses, early player wins,
quota failure, size limits, reload recovery, exclusions, file sharing/cancel/download fallback.
Browser checked: opt-in, REC, run exit, Stats export controls, reload paused with export retained,
download-request feedback and no console errors. The in-app browser did not expose a download
completion event; the exporter branches are tested with platform stubs, real Safari export is
the next phone check. New file is under src/, already covered by Docker's COPY.
# OPEN — leftovers, in rough priority order # OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks. Nothing here is broken; these are known gaps and unverified risks.
+19 -1
View File
@@ -657,6 +657,15 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
padding-bottom: max(10px, env(safe-area-inset-bottom)); padding-bottom: max(10px, env(safe-area-inset-bottom));
border-top: 2px solid var(--line); background: var(--solid); } border-top: 2px solid var(--line); background: var(--solid); }
.foot .btn { margin-bottom: 0; } .foot .btn { margin-bottom: 0; }
#recLamp { position: fixed; right: 9px; top: max(52px, calc(env(safe-area-inset-top) + 44px));
z-index: 5; color: #ffd8ca; background: #59251f; padding: 4px 6px;
font: 10px Silkscreen, monospace; pointer-events: none; }
#recLamp[hidden], body:has(#intro:not([hidden])) #recLamp { display: none; }
#recorder { border-top: 1px solid var(--line); padding-top: 10px; margin-top: 14px; }
#recorder p { font: 11px/1.5 Silkscreen, monospace; color: var(--dim); }
#recorder .btn { min-height: 44px; }
#recorder .btn:disabled { opacity: 0.4; }
#recNew[hidden] { display: none; }
/* Gore gets a front door; the records become quiet navigation, with full touch targets. */ /* Gore gets a front door; the records become quiet navigation, with full touch targets. */
#mGore { display: flex; align-items: center; justify-content: center; gap: 12px; #mGore { display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 9px 12px; border-color: #804338; background: #281b19; color: #f1d6bf; } padding: 9px 12px; border-color: #804338; background: #281b19; color: #f1d6bf; }
@@ -1033,6 +1042,14 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
your career now, in four numbers that always mean the same thing, plus the two things your career now, in four numbers that always mean the same thing, plus the two things
the screen is actually good at: the bodies you have collected, and the rack you fired. --> the screen is actually good at: the bodies you have collected, and the rack you fired. -->
<div class="figs pair" id="stFigs"></div> <div class="figs pair" id="stFigs"></div>
<section id="recorder">
<h3>PLAYTEST RECORDING</h3>
<p>Record league, Climb and training points to investigate difficulty. Export here after playing.</p>
<p id="recStatus" role="status"></p>
<button class="btn sec" id="recToggle">RECORD PLAYTEST</button>
<button class="btn sec" id="recExport" disabled>EXPORT RECORDING</button>
<button class="btn sec" id="recNew" hidden>REPLACE WITH NEW RECORDING</button>
</section>
<h3>BY OPPONENT<span>ALL TIME</span></h3> <h3>BY OPPONENT<span>ALL TIME</span></h3>
<div id="stZ"></div> <div id="stZ"></div>
<h3>BY WEAPON</h3> <h3>BY WEAPON</h3>
@@ -1089,6 +1106,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=350"></script> <span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=351"></script>
</body> </body>
</html> </html>
+6 -1
View File
@@ -15,6 +15,7 @@ import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.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 { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js';
import { startDrillStream } from './drill.js'; import { startDrillStream } from './drill.js';
import { recordEvent } from './recorder.js';
import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon
// the court is mopped up (or was already clear): play advances to whatever the won game earned — // the court is mopped up (or was already clear): play advances to whatever the won game earned —
@@ -422,6 +423,7 @@ export function startMatch(cup = false, draw = null, train = 0) {
rules: ['ONE GAME PER BODY — HOW FAR CAN YOU GET?', rules: ['ONE GAME PER BODY — HOW FAR CAN YOU GET?',
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] }); { lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
} }
recordEvent('run_start');
matchOpponent(); matchOpponent();
matchReady(); matchReady();
zombieDuck(true); zombieDuck(true);
@@ -692,11 +694,13 @@ function matchOpponent() {
// matchPoint divides the same way, so the wound stages spread across the whole round instead of // matchPoint divides the same way, so the wound stages spread across the whole round instead of
// being spent in game one — the body enters game two already missing what game one cost it. // being spent in game one — the body enters game two already missing what game one cost it.
zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? cup.rounds[cup.round].need : 1); zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? cup.rounds[cup.round].need : 1);
recordEvent('opponent', { traits: { ...zb.foe } });
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
} }
export function endMatch() { export function endMatch() {
if (!G.match) return; if (!G.match) return;
recordEvent('run_end', { outcome: 'ended' });
G.match = null; G.interlude = false; G.matchFall = null; // a latch left armed outlives its mode G.match = null; G.interlude = false; G.matchFall = null; // a latch left armed outlives its mode
zombies.length = 0; shots.length = 0; zombies.length = 0; shots.length = 0;
zombieDuck(false); zombieDuck(false);
@@ -982,7 +986,7 @@ function matchGameLost() {
G.phase = 'over'; G.overT = 1.8; G.phase = 'over'; G.overT = 1.8;
} }
export function resolveInterlude(side) { export function resolveInterlude(side, reason = 'unspecified') {
if (!G.interlude) return; if (!G.interlude) return;
// THE MATCH HOOK: a settled point feeds the game score instead of ending the interlude — the // THE MATCH HOOK: a settled point feeds the game score instead of ending the interlude — the
// interlude is permanent while a match runs (it is what keeps the serving tennis-like and the // interlude is permanent while a match runs (it is what keeps the serving tennis-like and the
@@ -992,6 +996,7 @@ export function resolveInterlude(side) {
// for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here. // for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here.
// G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs. // G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs.
if (G.match.decided && !G.matchEarly) { ball.active = false; return; } if (G.match.decided && !G.matchEarly) { ball.active = false; return; }
if (!G.match.ending) recordEvent('point', { winner: side, reason });
// netplay (death match): this phone judged the live flight — the verdict crosses the wire // netplay (death match): this phone judged the live flight — the verdict crosses the wire
// before it is applied, exactly as pointTo does for classic. netJudging() is false while a // before it is applied, exactly as pointTo does for classic. netJudging() is false while a
// remote verdict is being applied, which is what stops the echo. // remote verdict is being applied, which is what stops the echo.
+22 -9
View File
@@ -9,6 +9,7 @@ import { W } from './render.js'; // runtime-only cycle (render imports gameplay)
import { updateDrill } from './drill.js'; import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.js'; import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { award, beginRunXp, prof, bonus, levelAt } from './profile.js'; import { award, beginRunXp, prof, bonus, levelAt } from './profile.js';
import { recordEvent, recordFrame } from './recorder.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 { 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, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
@@ -320,6 +321,8 @@ function doHit(ch, quality, aim) {
ball.kills = 0; // fresh shot: multi-kill streak restarts ball.kills = 0; // fresh shot: multi-kill streak restarts
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them
ball.chain = 0; // ...and the slice dies with the shot that earned it ball.chain = 0; // ...and the slice dies with the shot that earned it
recordEvent('shot', { kind: 'return', side: ch.side, quality, aim: aim ? { ...aim } : null,
target: [tx, tz] });
ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact
// (the landing ring that used to be stamped here confused testers — the umpire calls the ball, // (the landing ring that used to be stamped here confused testers — the umpire calls the ball,
// the court stays clean) // the court stays clean)
@@ -571,6 +574,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
} }
// (spendSlot already hands you back plain balls when the last one goes) // (spendSlot already hands you back plain balls when the last one goes)
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded serve strike, per side G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded serve strike, per side
recordEvent('shot', { kind: 'serve', side: sv.side, quality, perfect, aim: aim ? { ...aim } : null });
if (sv.side === 'P') strikeKick(); if (sv.side === 'P') strikeKick();
if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit
sv.pose = 'serve'; sv.poseT = 0; sv.pose = 'serve'; sv.poseT = 0;
@@ -1028,6 +1032,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 // 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. // back themselves, so nothing here has to know the shape of any of it.
export function restartRun() { export function restartRun() {
recordEvent('run_end', { outcome: G.done?.won ? 'won' : G.mode === 'eaten' ? 'lost' : 'abandoned' });
netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried
resetZombies(); resetInput(); resetState(); setupPoint(); resetZombies(); resetInput(); resetState(); setupPoint();
beginRun(); // ...and the stats bag, which is the only state that does not live on G beginRun(); // ...and the stats bag, which is the only state that does not live on G
@@ -1114,6 +1119,7 @@ export function startGore() {
} }
export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve
recordEvent('fault', { serveNumber: G.serveNum });
if (G.serveNum === 1) { if (G.serveNum === 1) {
if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double
// fault below goes out through pointTo as the point it is // fault below goes out through pointTo as the point it is
@@ -1132,7 +1138,7 @@ export function fault() { // exported for net.js: the receiver's fault call re-a
// the ladder) and a raw pointTo there would bank a number and skip all of it — the same seam the // the ladder) and a raw pointTo there would bank a number and skip all of it — the same seam the
// interlude branch of onBounce is written around. Nothing reached this line from an interlude // interlude branch of onBounce is written around. Nothing reached this line from an interlude
// before, because those modes had no fault in them at all. // before, because those modes had no fault in them at all.
else if (G.interlude) { voice('double_fault'); resolveInterlude(other(score.server)); } else if (G.interlude) { voice('double_fault'); resolveInterlude(other(score.server), 'double_fault'); }
else pointTo(other(score.server), 'double_fault'); else pointTo(other(score.server), 'double_fault');
} }
@@ -1256,7 +1262,7 @@ function onBounce() {
// serve stays legal in the match, which is the concession that mode was tuned around. // serve stays legal in the match, which is the concession that mode was tuned around.
if (!inCourt(ball.x, ball.z) || !okSide) { if (!inCourt(ball.x, ball.z) || !okSide) {
endFlight(); endFlight();
if (G.wasServe) fault(); else { resolveInterlude(other(ball.lastHit)); voice('out'); } if (G.wasServe) fault(); else { resolveInterlude(other(ball.lastHit), 'out'); voice('out'); }
} }
// THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know // THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know
// this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just // this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just
@@ -1279,10 +1285,10 @@ function onBounce() {
&& matchFallWillPlay() && matchOutOfReach()) { && matchFallWillPlay() && matchOutOfReach()) {
G.match.decided = true; // scope: the REMAINING ball events of this rally G.match.decided = true; // scope: the REMAINING ball events of this rally
G.matchEarly = true; // startFall leaves the ball rolling; the swallow exempts us G.matchEarly = true; // startFall leaves the ball rolling; the swallow exempts us
resolveInterlude('P'); resolveInterlude('P', 'unreachable_after_bounce');
G.matchEarly = false; G.matchEarly = false;
} }
} else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit); } } else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit, 'double_bounce'); }
return; return;
} }
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
@@ -1552,7 +1558,10 @@ function racketAI(rz, dt) {
// the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this // the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this
// body had actually learned. The champion's near-zero flub is exactly what this opens. // body had actually learned. The champion's near-zero flub is exactly what this opens.
+ (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0); + (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0);
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder) if (Math.random() < flub) {
recordEvent('opponent_whiff', { chance: flub, chaseSpeed: rzChaseSpeed(rz), reach: TUNE.aiReach * wnd.reach });
rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
}
else { else {
const qCap = G.match && !G.net ? (rz.foe?.qMax ?? TUNE.racketQMax) const qCap = G.match && !G.net ? (rz.foe?.qMax ?? TUNE.racketQMax)
: TUNE.racketQMax + TUNE.duelQBonus * duelSkill(); : TUNE.racketQMax + TUNE.duelQBonus * duelSkill();
@@ -1742,7 +1751,10 @@ export function keyPressed(code) {
hitServe(P, q, sway || hard ? { sx: sway, hard } : null); hitServe(P, q, sway || hard ? { sx: sway, hard } : null);
return; return;
} }
if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; } if (G.phase === 'rally' && P.swingT < 0) {
recordEvent('swing', { window: TUNE.swingWindow * winLevel(), assist: assistK() });
P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true;
}
} }
function expirePose(ch, dt) { function expirePose(ch, dt) {
@@ -1813,6 +1825,7 @@ function mopRound(b, dt) {
// ---------------------------------------------------------------- update ---- // ---------------------------------------------------------------- update ----
export function update(dt) { export function update(dt) {
recordFrame(dt);
if (G.hitstop > 0) { G.hitstop -= dt; return; } if (G.hitstop > 0) { G.hitstop -= dt; return; }
// THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the // THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the
// crowd is still clapping it down — and the only thing running is the clock the summary and its // crowd is still clapping it down — and the only thing running is the clock the summary and its
@@ -2175,7 +2188,7 @@ export function update(dt) {
if (pred && inCourt(pred.x, pred.z) && matchOutOfReachFlight(arz, pred)) { if (pred && inCourt(pred.x, pred.z) && matchOutOfReachFlight(arz, pred)) {
G.match.decided = true; // same latch discipline as the bounce-1 hook below G.match.decided = true; // same latch discipline as the bounce-1 hook below
G.matchEarly = true; G.matchEarly = true;
resolveInterlude('P'); resolveInterlude('P', 'unreachable_in_flight');
G.matchEarly = false; G.matchEarly = false;
} }
} }
@@ -2200,7 +2213,7 @@ export function update(dt) {
if (ball.scored) { /* already called: this IS the natural halt it was left rolling for */ } if (ball.scored) { /* already called: this IS the natural halt it was left rolling for */ }
else if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter else if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter
if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel
else resolveInterlude(ball.lastHit); else resolveInterlude(ball.lastHit, 'dead_roller');
} else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming } else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming
spentBall(); sendBoy(); spentBall(); sendBoy();
if (Math.random() < 0.12) voice('new_balls', 0.3); if (Math.random() < 0.12) voice('new_balls', 0.3);
@@ -2210,7 +2223,7 @@ export function update(dt) {
// a racket zombie's return that gets past you (crosses your baseline) is a point for THEM // a racket zombie's return that gets past you (crosses your baseline) is a point for THEM
if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) { if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
ball.active = false; ball.active = false;
if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel if (G.interlude) resolveInterlude('O', 'passed_player'); // sudden death: its passing shot ends the duel
else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way
} }
// the far backstop: a LIVE ball doesn't leave the field, it rebounds off the fence and comes // the far backstop: a LIVE ball doesn't leave the field, it rebounds off the fence and comes
+49
View File
@@ -15,6 +15,7 @@ import { cv } from './dom.js';
import { haptic } from './haptics.js'; import { haptic } from './haptics.js';
import * as stats from './stats.js'; import * as stats from './stats.js';
import * as profile from './profile.js'; import * as profile from './profile.js';
import * as recorder from './recorder.js';
import './input.js'; import './input.js';
let last = performance.now(); let last = performance.now();
@@ -89,6 +90,7 @@ let pickedTour = remembered('tour') || TOURS[0].id;
// menus share, so its offset outlives the stage that earned it unless it is reset here — the one // menus share, so its offset outlives the stage that earned it unless it is reset here — the one
// place every stage change goes through. // place every stage change goes through.
const go = stage => { const go = stage => {
if (stage === 'stats') refreshRecorder();
if (stage === 'setup') syncSetup(); if (stage === 'setup') syncSetup();
if (stage === 'train') buildTrain(); if (stage === 'train') buildTrain();
intro.dataset.stage = stage; intro.dataset.stage = stage;
@@ -792,6 +794,53 @@ function start(modeId = pickedMode) {
const openStats = () => { const openStats = () => {
stats.drawStats('all', SPRV); go('stats'); stats.drawStats('all', SPRV); go('stats');
}; };
// A phone can record without a console. Opt in from Stats or ?record=1; export is local/share
// sheet only. Keep this off the normal splash and give active recordings one small live marker.
const recordingMeta = () => ({
build: document.querySelector('script[src*="main.js"]').getAttribute('src'),
viewport: [innerWidth, innerHeight], pixelRatio: devicePixelRatio,
touch: matchMedia('(pointer: coarse)').matches,
});
function refreshRecorder() {
const r = recorder.recordingInfo();
document.getElementById('recStatus').textContent = r.warning || (r.full
? 'Recording full. Export it, then start a new recording.'
: (r.active ? 'Recording' : r.exists ? 'Paused' : 'Off') + ' · ' + r.points + ' points saved on this phone.');
document.getElementById('recToggle').textContent = r.active ? 'STOP RECORDING' : r.exists ? 'RESUME RECORDING' : 'RECORD PLAYTEST';
document.getElementById('recToggle').disabled = r.full;
document.getElementById('recExport').disabled = !r.events;
document.getElementById('recNew').hidden = !r.exists || r.active;
const lamp = document.getElementById('recLamp');
lamp.hidden = !r.active && !r.full;
lamp.textContent = r.full ? 'REC FULL' : 'REC';
}
recorder.onRecorderChange(refreshRecorder);
document.getElementById('recToggle').addEventListener('click', () => {
const r = recorder.recordingInfo();
if (!r.exists) recorder.beginRecording(recordingMeta());
else recorder.setRecording(!r.active, recordingMeta());
});
document.getElementById('recNew').addEventListener('click', () => recorder.beginRecording(recordingMeta()));
document.getElementById('recExport').addEventListener('click', async () => {
try {
const result = await recorder.exportRecording();
if (result === 'download') document.getElementById('recStatus').textContent = 'Download requested. Your recording is also kept on this phone.';
} catch {
document.getElementById('recStatus').textContent = 'Export could not open. Your recording is kept; please try again.';
}
});
if (new URLSearchParams(location.search).get('record') === '1') {
if (recorder.recordingInfo().exists) recorder.setRecording(true, recordingMeta());
else recorder.beginRecording(recordingMeta());
}
refreshRecorder();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
recorder.recordEvent('run_end', { outcome: 'backgrounded' }); recorder.saveRecording();
}
});
addEventListener('pagehide', () => recorder.saveRecording());
// entering a question screen rebuilds it, so the LAST tag always names the run actually played — // entering a question screen rebuilds it, so the LAST tag always names the run actually played —
// and the FIRST visit ever detours through the identity screen (the leagues): the club wants a // and the FIRST visit ever detours through the identity screen (the leagues): the club wants a
// name before it deals a match. ENTER THE CLUB lands on the mode screen, so the detour costs the // name before it deals a match. ENTER THE CLUB lands on the mode screen, so the detour costs the
+126
View File
@@ -0,0 +1,126 @@
// Opt-in, local-only match diagnostics. Imports are read only inside functions (the existing
// state/zombies/gameplay cycles); recording never consumes randomness or changes game state.
import { G, P, ball, score, court } from './state.js';
import { zombies } from './zombies.js';
import { TUNE, matchFoe } from './config.js';
import { prof, levelAt } from './profile.js';
const KEY = 'splatter-playtest-v1', LIMIT = 1500000;
let session = null, active = false, openRun = false, tail = [], warning = '', changed = () => {}, environment = {};
try {
const raw = localStorage.getItem(KEY);
if (raw && raw.length < LIMIT + 30000) {
const saved = JSON.parse(raw);
if (saved.schema === 1 && Array.isArray(saved.events)
&& ['bytes', 'runs', 'encounter', 'points', 'elapsed'].every(k => Number.isFinite(saved[k]) && saved[k] >= 0)) session = saved;
}
} catch { warning = 'Phone storage is unavailable. Export before closing this tab.'; }
const round = n => Number.isFinite(n) ? Math.round(n * 1000) / 1000 : null;
const position = c => c ? [round(c.x), round(c.z)] : null;
const opponent = () => zombies.find(z => z.variant === 'r');
const eligible = () => !!G.match && !G.net;
function snapshot() {
const z = opponent(), m = G.match;
return {
phase: G.phase, pts: [...score.pts], games: [...score.games], server: score.server,
serve: G.serveNum, hp: G.hp, player: position(P), swing: round(P.swingT),
opponent: z ? { pos: position(z), hp: z.hp, hpMax: z.hpMax,
velocity: [round(z.vx), round(z.vz)], cooldown: round(z.swingCool) } : null,
ball: { pos: [round(ball.x), round(ball.y), round(ball.z)],
velocity: [round(ball.vx), round(ball.vy), round(ball.vz)], gravity: round(ball.g),
quality: round(ball.pow), hitter: ball.lastHit, bounces: ball.bounces, serve: G.wasServe },
read: m ? { lane: m.lastLane, streak: m.laneRun, expected: !!m.expected, surprised: !!m.surprised } : null,
};
}
export function onRecorderChange(fn) { changed = fn; }
export function recordingInfo() {
return { active, exists: !!session, full: !!session?.full, points: session?.points || 0,
events: session?.events.length || 0, warning };
}
export function beginRecording(meta = {}) {
environment = meta;
session = { schema: 1, started: new Date().toISOString(), meta, runs: 0, encounter: 0,
points: 0, elapsed: 0, bytes: 0, events: [], full: false,
tailColumns: ['seconds', 'playerX', 'playerZ', 'swingSeconds', 'opponentX', 'opponentZ',
'ballX', 'ballY', 'ballZ', 'lastHitter', 'bounces'],
notes: 'Positions in metres. Point snapshots precede scoring. Tail is up to 90 live update frames, not video or a deterministic replay.' };
active = true; openRun = false; tail = [];
saveRecording(); changed();
}
export function setRecording(value, meta = environment) {
if (!session || (value && session.full)) return;
// Preserve an interrupted rally on pause. Resume begins a separately labelled segment.
if (!value) recordEvent('run_end', { outcome: 'paused' });
saveRecording(); active = value; environment = meta; openRun = false; tail = []; changed();
}
export function saveRecording() {
if (!session) return;
if (openRun && tail.length) session.pending = { run: session.runs, point: session.points + 1,
state: snapshot(), tail: tail.slice() };
try { localStorage.setItem(KEY, JSON.stringify(session)); warning = ''; }
catch { warning = 'Phone storage is full or unavailable. Export before closing this tab.'; }
}
export function recordingJSON() {
saveRecording();
return JSON.stringify(session);
}
export async function exportRecording() {
const filename = 'splatter-playtest-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
const file = new File([recordingJSON()], filename, { type: 'application/json' });
if (navigator.canShare?.({ files: [file] })) {
try { await navigator.share({ files: [file], title: 'Splatter Tennis playtest' }); return 'shared'; }
catch (e) { if (e.name === 'AbortError') return 'cancelled'; }
}
const a = document.createElement('a'), url = URL.createObjectURL(file);
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
return 'download';
}
export function recordEvent(type, detail = {}) {
if (!active || !eligible()) return;
if (type === 'run_start') {
if (openRun) recordEvent('run_end', { outcome: 'restarted' });
if (!active) return;
session.runs++; openRun = true; tail = [];
detail = { ...detail, environment: { ...environment }, mode: G.match.cup ? 'cup' : G.match.train ? 'training' : 'climb',
league: G.league, level: levelAt(prof.xp), court: court.id, tuning: { ...TUNE } };
} else if (!openRun) {
if (type === 'run_end') return;
recordEvent('run_start', { partial: true });
}
if (!active) return;
if (type === 'opponent') session.encounter++;
const event = { type, t: round(session.elapsed), run: session.runs, encounter: session.encounter,
point: session.points + 1, rung: G.match.opp, round: G.match.cup?.round ?? null,
foe: matchFoe(G.match.opp).short, ...detail, state: snapshot() };
if (type === 'point' || type === 'run_end') event.tail = tail.slice();
const bytes = JSON.stringify(event).length;
if (session.bytes + bytes > LIMIT) {
session.full = true; active = false; saveRecording(); changed(); return;
}
session.bytes += bytes;
session.events.push(event);
if (type === 'point') { session.points++; tail = []; delete session.pending; }
if (type === 'run_end') { openRun = false; tail = []; delete session.pending; }
// Only write at calm boundaries, never per frame or per shot.
if (type === 'point' || type === 'run_end' || type === 'run_start') saveRecording();
}
export function recordFrame(dt) {
if (!active || !eligible()) return;
session.elapsed += dt;
if (G.done || G.mode === 'eaten') {
recordEvent('run_end', { outcome: G.done?.won ? 'won' : 'lost' }); return;
}
if (G.modal || G.hitstop > 0 || G.phase !== 'rally' || !ball.active || G.match.decided) return;
if (!openRun) recordEvent('run_start', { partial: true });
if (!active) return;
const z = opponent();
tail.push([round(session.elapsed), ...position(P), round(P.swingT),
...(position(z) || [null, null]), round(ball.x), round(ball.y), round(ball.z), ball.lastHit, ball.bounces]);
if (tail.length > 90) tail.shift();
}
+1 -1
View File
@@ -535,7 +535,7 @@ function killZombie(i, quiet) {
if (!G.horde && !G.clearing) splatPoint('P', quiet); if (!G.horde && !G.clearing) splatPoint('P', quiet);
return; return;
} }
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts if (G.interlude && zb.variant === 'r') resolveInterlude('P', 'body_destroyed'); // regicide ends the duel your way — any weapon counts
if (zb.variant === 'B') endBossFight(); // the chapter is over if (zb.variant === 'B') endBossFight(); // the chapter is over
// A BURNT BODY DOES NOT BURST. Fire kills by cooking, so there is nothing left to spray — it goes // A BURNT BODY DOES NOT BURST. Fire kills by cooking, so there is nothing left to spray — it goes
// to dark grey ash instead of blood: same decal, same mound, same particles, all recoloured and a // to dark grey ash instead of blood: same decal, same mound, same particles, all recoloured and a
+130
View File
@@ -2583,5 +2583,135 @@ test('THE PEER GIFT: cup and its training rep ease from fifteen levels down to p
G.net = null; G.match = null; prof.prof.xp = 0; G.net = null; G.match = null; prof.prof.xp = 0;
}); });
const recorder = await import('./src/recorder.js');
const { fault: recordFault } = await import('./src/gameplay.js');
test('PLAYTEST: recording the level-26 cup changes neither gameplay nor its random stream', () => {
const play = enabled => {
recorder.setRecording(false);
_seed = 77123;
freshSplatter(); prof.prof.lg = 5; prof.prof.xp = prof.xpFor(26);
P.dustAcc = 0; // cosmetic distance accumulator survives resetState's Object.assign
if (enabled) recorder.beginRecording({ build: 'test' });
_startMatch(true);
for (let i = 0; i < 180 * 60 && G.mode !== 'eaten' && !G.done; i++) {
G.modal = null; G.modalQ.length = 0;
if (G.phase === 'pretoss' && !(G.interlude?.introT > 0)) {
if (i % 40 === 0) keyPressed('Space');
} else if (G.phase === 'toss') {
if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space');
} else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (d < 2.9 && d > 2.2) keyPressed('Space');
}
update(DT);
}
return JSON.stringify({ seed: _seed, mode: G.mode, hp: G.hp, score, player: [P.x, P.z],
ball: [ball.x, ball.y, ball.z, ball.lastHit], opponent: G.match.opp });
};
assert.equal(play(true), play(false), 'passive instrumentation leaves the real simulation identical');
const saved = JSON.parse(recorder.recordingJSON());
assert.equal(saved.events[0].level, 26);
assert.equal(saved.events[0].mode, 'cup');
assert.ok(saved.events.some(e => e.type === 'shot' && e.side === 'P' && e.kind === 'serve'));
assert.ok(saved.events.some(e => e.type === 'shot' && e.side === 'O'), 'real opponent returns are captured');
const points = saved.events.filter(e => e.type === 'point');
assert.ok(points.length > 0, 'the long baseline rallies reached a scored point');
assert.ok(points.every(e => e.reason !== 'unspecified' && e.foe && e.state.games && e.run));
assert.ok(points.some(e => e.tail.length > 10), 'the point carries live movement frames');
assert.ok(points.every(e => e.tail.length <= 90), 'trajectory storage is bounded');
});
test('PLAYTEST: faults, decided points and fatal losses retain their exact scoring context', () => {
recorder.beginRecording({ build: 'test' });
startMatch(); G.interlude.introT = 0;
recordFault(); recordFault();
let data = JSON.parse(recorder.recordingJSON());
assert.equal(data.events.filter(e => e.type === 'fault').length, 2);
assert.deepEqual(data.events.filter(e => e.type === 'point').map(e => [e.winner, e.reason]), [['O', 'double_fault']]);
G.match.decided = true; G.matchEarly = false;
resolveInterlude('P', 'double_bounce');
assert.equal(recorder.recordingInfo().points, 1, 'a spent flight cannot record a second verdict');
G.matchEarly = true;
resolveInterlude('P', 'unreachable_in_flight');
G.matchEarly = false; G.match.decided = false; G.hp = 1;
resolveInterlude('O', 'passed_player'); update(DT);
data = JSON.parse(recorder.recordingJSON());
assert.equal(data.points, 3);
assert.deepEqual(data.events.filter(e => e.type === 'point').map(e => [e.winner, e.reason]),
[['O', 'double_fault'], ['P', 'unreachable_in_flight'], ['O', 'passed_player']]);
assert.equal(data.events.filter(e => e.type === 'point').at(-1).state.hp, 1, 'fatal point recorded before death mutates state');
assert.equal(data.events.at(-1).outcome, 'lost');
assert.ok(store.get('splatter-playtest-v1').includes('passed_player'), 'complete points persist on the phone');
recorder.setRecording(false);
});
test('PLAYTEST: storage failure and the recording limit cannot interfere with scoring', () => {
recorder.beginRecording(); startMatch();
const write = localStorage.setItem;
try {
localStorage.setItem = () => { throw Error('quota'); };
resolveInterlude('O', 'passed_player');
assert.equal(G.hp, TUNE.matchPlayerHp - 1, 'the game still scores when storage is unavailable');
assert.ok(recorder.recordingInfo().warning.includes('Export'));
assert.ok(recorder.recordingJSON().includes('passed_player'), 'in-memory export still works');
} finally { localStorage.setItem = write; }
recorder.recordEvent('test_limit', { payload: 'x'.repeat(1500001) });
assert.ok(recorder.recordingInfo().full && !recorder.recordingInfo().active);
const previous = recorder.recordingInfo().events;
resolveInterlude('O', 'passed_player');
assert.equal(recorder.recordingInfo().events, previous, 'a full recording stops without changing the game');
assert.ok(recorder.recordingJSON().length < 1530000, 'oversized data never enters the export');
recorder.beginRecording(); recorder.setRecording(false);
});
test('PLAYTEST: Gore and netplay are excluded', () => {
recorder.beginRecording();
const before = recorder.recordingInfo().events;
recorder.recordEvent('shot');
assert.equal(recorder.recordingInfo().events, before, 'no match, no recorded play');
startMatch(); G.net = { on: true };
const matchEvents = recorder.recordingInfo().events;
recorder.recordEvent('shot'); recorder.recordFrame(DT);
assert.equal(recorder.recordingInfo().events, matchEvents, 'no network player data');
G.net = null;
recorder.setRecording(false);
});
const recoveredRecorder = await import('./src/recorder.js?reload-check');
test('PLAYTEST: reloading restores saved events without silently starting recording', () => {
assert.ok(recoveredRecorder.recordingInfo().events > 0);
assert.equal(recoveredRecorder.recordingInfo().active, false);
assert.equal(recoveredRecorder.recordingInfo().events, recorder.recordingInfo().events);
});
// File sharing is asynchronous; exercise the actual exporter with browser APIs stubbed, just
// like the game's platform above. Cancellation keeps the data; an unsupported share downloads.
{
const create = document.createElement, share = navigator.share, canShare = navigator.canShare;
let file, downloads = 0, anchor;
try {
navigator.canShare = ({ files }) => { file = files[0]; return true; };
navigator.share = async () => {};
assert.equal(await recorder.exportRecording(), 'shared');
assert.ok(file.name.endsWith('.json'));
assert.equal(JSON.parse(await file.text()).schema, 1);
navigator.share = async () => { throw Object.assign(Error('cancelled'), { name: 'AbortError' }); };
assert.equal(await recorder.exportRecording(), 'cancelled');
document.createElement = () => (anchor = { click() { downloads++; }, remove() {} });
navigator.share = async () => { throw Error('share unavailable'); };
assert.equal(await recorder.exportRecording(), 'download');
assert.equal(downloads, 1);
assert.ok(anchor.download.endsWith('.json') && anchor.href.startsWith('blob:'));
navigator.canShare = () => false;
assert.equal(await recorder.exportRecording(), 'download');
assert.equal(downloads, 2);
assert.ok(recorder.recordingInfo().events > 0, 'export never deletes the saved recording');
} finally {
document.createElement = create; navigator.share = share; navigator.canShare = canShare;
}
passed++; console.log(' ok - PLAYTEST: file sharing, cancellation and download fallback preserve the export');
}
console.log(`\n${passed} tests passed`); console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report