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
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
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));
border-top: 2px solid var(--line); background: var(--solid); }
.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. */
#mGore { display: flex; align-items: center; justify-content: center; gap: 12px;
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
the screen is actually good at: the bodies you have collected, and the rack you fired. -->
<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>
<div id="stZ"></div>
<h3>BY WEAPON</h3>
@@ -1089,6 +1106,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</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>
</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 { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.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
// 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?',
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
}
recordEvent('run_start');
matchOpponent();
matchReady();
zombieDuck(true);
@@ -692,11 +694,13 @@ function matchOpponent() {
// 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.
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
}
export function endMatch() {
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
zombies.length = 0; shots.length = 0;
zombieDuck(false);
@@ -982,7 +986,7 @@ function matchGameLost() {
G.phase = 'over'; G.overT = 1.8;
}
export function resolveInterlude(side) {
export function resolveInterlude(side, reason = 'unspecified') {
if (!G.interlude) return;
// 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
@@ -992,6 +996,7 @@ export function resolveInterlude(side) {
// 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.
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
// 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.
+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 { endRun, beginRun, MODE_CLASSIC } from './stats.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 { 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
@@ -320,6 +321,8 @@ function doHit(ch, quality, aim) {
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.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
// (the landing ring that used to be stamped here confused testers — the umpire calls the ball,
// 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)
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') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit
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
// back themselves, so nothing here has to know the shape of any of it.
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
resetZombies(); resetInput(); resetState(); setupPoint();
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
recordEvent('fault', { serveNumber: G.serveNum });
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
@@ -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
// 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.
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');
}
@@ -1256,7 +1262,7 @@ function onBounce() {
// serve stays legal in the match, which is the concession that mode was tuned around.
if (!inCourt(ball.x, ball.z) || !okSide) {
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
// 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()) {
G.match.decided = true; // scope: the REMAINING ball events of this rally
G.matchEarly = true; // startFall leaves the ball rolling; the swallow exempts us
resolveInterlude('P');
resolveInterlude('P', 'unreachable_after_bounce');
G.matchEarly = false;
}
} else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit); }
} else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit, 'double_bounce'); }
return;
}
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
// 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);
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 {
const qCap = G.match && !G.net ? (rz.foe?.qMax ?? TUNE.racketQMax)
: TUNE.racketQMax + TUNE.duelQBonus * duelSkill();
@@ -1742,7 +1751,10 @@ export function keyPressed(code) {
hitServe(P, q, sway || hard ? { sx: sway, hard } : null);
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) {
@@ -1813,6 +1825,7 @@ function mopRound(b, dt) {
// ---------------------------------------------------------------- update ----
export function update(dt) {
recordFrame(dt);
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
// 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)) {
G.match.decided = true; // same latch discipline as the bounce-1 hook below
G.matchEarly = true;
resolveInterlude('P');
resolveInterlude('P', 'unreachable_in_flight');
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 */ }
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
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
spentBall(); sendBoy();
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
if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
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
}
// 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 * as stats from './stats.js';
import * as profile from './profile.js';
import * as recorder from './recorder.js';
import './input.js';
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
// place every stage change goes through.
const go = stage => {
if (stage === 'stats') refreshRecorder();
if (stage === 'setup') syncSetup();
if (stage === 'train') buildTrain();
intro.dataset.stage = stage;
@@ -792,6 +794,53 @@ function start(modeId = pickedMode) {
const openStats = () => {
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 —
// 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
+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);
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
// 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
+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;
});
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`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report