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

+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