diff --git a/PROJECT.md b/PROJECT.md index ecf5830..89d85eb 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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. diff --git a/index.html b/index.html index fd785a3..7d26484 100644 --- a/index.html +++ b/index.html @@ -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. -->
+
+

PLAYTEST RECORDING

+

Record league, Climb and training points to investigate difficulty. Export here after playing.

+

+ + + +

BY OPPONENTALL TIME

BY WEAPON

@@ -1089,6 +1106,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + + diff --git a/src/beats.js b/src/beats.js index 86d168d..17f3059 100644 --- a/src/beats.js +++ b/src/beats.js @@ -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. diff --git a/src/gameplay.js b/src/gameplay.js index 2dd7b80..cca6ab4 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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 diff --git a/src/main.js b/src/main.js index 63ddaa5..9b3d9a6 100644 --- a/src/main.js +++ b/src/main.js @@ -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 diff --git a/src/recorder.js b/src/recorder.js new file mode 100644 index 0000000..277455c --- /dev/null +++ b/src/recorder.js @@ -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(); +} diff --git a/src/zombies.js b/src/zombies.js index a065ea9..85bf254 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -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 diff --git a/test.js b/test.js index 2e89525..d700e03 100644 --- a/test.js +++ b/test.js @@ -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