Add opt-in phone gameplay recording and JSON export
This commit is contained in:
1 parent
ede1f48152
commit
fe64242090
8 files changed
+394
-12
No files matched your search
+41
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user