/** * ScrambleDecode test (dev tool, run with Node — no browser): * * node dev/decode.test.mjs * * The shared "decode" effect (js/utils/Decode.js) — the type-out the * menu uses on the Galaxy Seed and the game uses on the system dossier * HUD. Checks the effect's contract: * - '' before the start time, the exact target once finished; * - the revealed prefix always matches the target's; * - unrevealed slots are drawn from the decode alphabet; * - the reveal never goes backwards over time; * - display length is stable through the window; * - reverse mode deconstructs (value → static → '') with the shrinking * prefix never growing back; * - decodeDur floors short lines, caps long ones, grows in between; * - the default window is the menu's 620 ms. */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const { ScrambleDecode, DECODE_CHARS, DECODE_DURATION, decodeDur } = await import( pathToFileURL(join(__dirname, '../js/utils/Decode.js')).href ); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; const VALUE = 'Kepler-9 · 17 planets'; const T0 = 1000; const dur = DECODE_DURATION; const dec = new ScrambleDecode(VALUE, T0, dur); check('empty before the start time', dec.display(T0 - 1) === ''); check('exact target after the window', dec.display(T0 + dur + 1) === VALUE); check('started()/finished() agree with display()', dec.started(T0 - 1) === false && dec.finished(T0 + dur) === true); check('empty target stays empty', new ScrambleDecode('', T0, dur).display(T0 + dur / 2) === ''); // Through the window: length stable, the guaranteed-revealed prefix exact // (per the documented curve), the rest from the decode alphabet, and the // guaranteed reveal monotonically non-decreasing. (Slots that scramble to // a coincidental match with the target are indistinguishable — and fine, // so the checks are keyed on the curve, not on the measured common prefix.) const forwardReveal = (t) => Math.floor(Math.max(0, Math.min(1, ((t - T0) / dur) * 1.15)) * VALUE.length); let lenOk = true; let prefixOk = true; let charsOk = true; let monotonic = true; let prev = 0; for (let t = T0; t <= T0 + dur; t += 16) { const s = dec.display(t); const r = forwardReveal(t); if (s.length !== VALUE.length) lenOk = false; for (let i = 0; i < r; i++) if (s[i] !== VALUE[i]) prefixOk = false; // guaranteed part is exact for (let i = r; i < s.length; i++) { if (s[i] !== VALUE[i] && DECODE_CHARS.indexOf(s[i]) === -1) charsOk = false; } if (r < prev) monotonic = false; prev = r; } check('display length stays constant through the window', lenOk); check('the guaranteed-revealed prefix is always exact', prefixOk); check('unrevealed slots come from the decode alphabet', charsOk); check('the guaranteed reveal never goes backwards', monotonic); // decodeDur: floored, capped, growing in between. check('decodeDur floors short lines', decodeDur(0) >= 420 && decodeDur(1) >= 420); check('decodeDur caps long lines', decodeDur(500) <= 880); check('decodeDur grows with length (in range)', decodeDur(4) < decodeDur(12) && decodeDur(12) < decodeDur(40)); // --- Reverse (deconstruct): the same reveal played backwards -------------- const rdec = new ScrambleDecode(VALUE, T0, dur, true); check('reverse holds the value before the start time', rdec.display(T0 - 1) === VALUE); check('reverse empties out after the window', rdec.display(T0 + dur + 1) === ''); check('reverse started()/finished() agree with display()', rdec.started(T0 - 1) === false && rdec.finished(T0 + dur) === true); const reverseReveal = (t) => Math.floor(Math.max(0, Math.min(1, (1 - (t - T0) / dur) * 1.15)) * VALUE.length); let rLenOk = true; let rPrefixOk = true; let rCharsOk = true; let rMonotonic = true; let rPrev = VALUE.length; for (let t = T0; t <= T0 + dur; t += 16) { const s = rdec.display(t); const r = reverseReveal(t); if (s.length !== VALUE.length) rLenOk = false; for (let i = 0; i < r; i++) if (s[i] !== VALUE[i]) rPrefixOk = false; // still-stable part is exact for (let i = r; i < s.length; i++) { if (s[i] !== VALUE[i] && DECODE_CHARS.indexOf(s[i]) === -1) rCharsOk = false; } if (r > rPrev) rMonotonic = false; rPrev = r; } check('reverse: display length stays constant through the window', rLenOk); check('reverse: the still-stable prefix is always exact', rPrefixOk); check('reverse: unrevealed slots come from the decode alphabet', rCharsOk); check('reverse: the stable prefix only ever shrinks', rMonotonic); // The default window is the menu's 620 ms (the canonical decode timing). check('default window is the menu’s 620 ms', new ScrambleDecode('x', 0).dur === 620); if (failures > 0) { console.error(`\n${failures} decode test(s) FAILED`); process.exit(1); } console.log('\ndecode: all checks passed');