324 lines
20 KiB
JavaScript
324 lines
20 KiB
JavaScript
/**
|
||
* Dev-only: the COMMS tutorial self-check — boots the game straight into
|
||
* the GameScene (a FRESH run, so the 'newGame' trigger arms OAC's welcome
|
||
* CHAIN) and walks the whole sequence exactly as the player lives it:
|
||
*
|
||
* 1. oac-intro (ack) — full contract: box + muted looping clip + voice
|
||
* (full volume, music ducked per-sound) + the
|
||
* action paused; the bottom deck is on STANDBY
|
||
* (dim, hover/press dead — the pause contract's
|
||
* face) and the ACKNOWLEDGE button pulses (the
|
||
* primary-action cue); ACKNOWLEDGE ends it and
|
||
* the duck + the deck restore.
|
||
* 2. oac-tether (ack) — chains right after (the FIFO queue), voice is
|
||
* intro-02.
|
||
* 3. oac-scan (ack) — chains right after that, voice is intro-03.
|
||
* 4. QUIET — nothing else arms until the player acts (firstScan not
|
||
* triggered yet).
|
||
* 5. the player clicks SCAN on the deck (the real deckAction path);
|
||
* 5 s later oac-upgrade (ack, voice intro-04) must come up; it
|
||
* ACKNOWLEDGEs clean.
|
||
* 6. the player opens the research console and commits to the Tether
|
||
* Level 2 research (beginResearch): the pair oac-minerals →
|
||
* oac-fly-mine arms (queued) but must HOLD while the console is
|
||
* open (the busy() contract) and play back-to-back once it closes
|
||
* (voices intro-05, intro-06). The project's clock is then
|
||
* fast-forwarded to its natural 60 s completion: oac-research-
|
||
* complete (AUDIO-ONLY, once:false) must play with NO box and NO
|
||
* pause, and re-arm on the next trigger.
|
||
* 7. the player finds the first asteroid cluster (the ship brought
|
||
* within discovery range of one): oac-first-cluster (ack, voice
|
||
* intro-07, 1.5 s after the chime) must come up and ACKNOWLEDGE
|
||
* clean.
|
||
* 8. the player mines their first 200 minerals (the real onOre path):
|
||
* oac-tether2 (ack, voice intro-08, the build-it-home brief) must
|
||
* come up and ACKNOWLEDGE clean.
|
||
* 9. the player builds the level-2 tether on home (the real beginBuild
|
||
* path — research gate, tether gate, the 200-mineral cost — with the
|
||
* build clock fast-forwarded to its natural 20 s completion), then
|
||
* launches back into space (SurfaceScene.finishTakeoff's arm):
|
||
* oac-relaunch (ack, voice intro-09) must come up, ACKNOWLEDGE,
|
||
* and oac-explore (ack, voice intro-10) must chain IMMEDIATELY
|
||
* after — OAC's send-off.
|
||
* 10. the ledger holds all eleven (the ten `once` comms + the sign-off
|
||
* it heard); re-arming any `once` trigger arms nothing (and the
|
||
* sign-off re-arms, as it should).
|
||
*
|
||
* node dev/slow-server.mjs 8080 # or any static server on the repo
|
||
* → open http://127.0.0.1:8080/dev/comms-intro.html
|
||
*
|
||
* Headless screenshot:
|
||
* node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/comms-intro.html" \
|
||
* out.png "window.__commsResult && JSON.stringify(window.__commsResult)"
|
||
*/
|
||
import Phaser from '../js/vendor/phaser.js';
|
||
import { config } from '../js/config/Config.js';
|
||
import { ConfigLoader } from '../js/config/ConfigLoader.js';
|
||
import { createGameConfig } from '../js/config/GameConfig.js';
|
||
import { GameScene } from '../js/scenes/GameScene.js';
|
||
|
||
const data = await ConfigLoader.load();
|
||
config.init(data);
|
||
|
||
const errors = [];
|
||
const origErr = console.error.bind(console);
|
||
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
|
||
if (typeof window !== 'undefined') {
|
||
window.addEventListener('error', (e) => errors.push(String(e.message)));
|
||
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
|
||
}
|
||
|
||
const gameConfig = createGameConfig();
|
||
gameConfig.scene = [GameScene];
|
||
const game = new Phaser.Game(gameConfig);
|
||
window.game = game;
|
||
|
||
const report = document.createElement('pre');
|
||
report.id = 'report';
|
||
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:72%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
|
||
document.body.appendChild(report);
|
||
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
|
||
setReport('COMMS SELF-CHECK RUNNING… (waiting for OAC’s welcome)');
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
const until = async (fn, ms = 30000) => {
|
||
const t0 = Date.now();
|
||
for (;;) {
|
||
const v = fn();
|
||
if (v) return v;
|
||
if (Date.now() - t0 > ms) return null;
|
||
await sleep(150);
|
||
}
|
||
};
|
||
|
||
const scene = () => game.scene.getScene('GameScene');
|
||
|
||
setTimeout(async () => {
|
||
const d = [];
|
||
try {
|
||
const s = scene();
|
||
const ready = await until(() => (s && s.ship && s.comms ? s : null));
|
||
if (!ready) { setReport(['FAIL — scene never became ready']); window.__commsResult = { ok: false }; return; }
|
||
const snd = s.sound;
|
||
|
||
// --- a walk helper: the comm `id` must be in play (box + voice);
|
||
// --- ACKNOWLEDGE it and let it finish. `vk` is the expected voice key.
|
||
const ackStep = async (id, vk, extra = null) => {
|
||
const up = await until(() => (s.comms.active && s.comms.current?.id === id ? s : null), 20000);
|
||
if (!up) { d.push(`${id}: NEVER CAME UP (active=${s.comms.active} current=${s.comms.current?.id ?? '—'})`); return false; }
|
||
const box = s.comms.box;
|
||
const el = box?.video?.video;
|
||
if (el) await until(() => el.paused === false, 4000); // v4's autoplay retry
|
||
const voiceKey = s.comms._voiceKey;
|
||
const voicePlaying = voiceKey ? snd.isPlaying(voiceKey) === true : false;
|
||
d.push(`${id}: paused=${s.comms.paused} box=${!!box} ackBtn=${!!box?.button} clip=${el ? `muted=${el.muted} loop=${el.loop}` : '—'} voice=${voiceKey ?? '—'} playing=${voicePlaying}`);
|
||
const okStep = s.comms.paused === true && !!box && !!box.button && vk === voiceKey && voicePlaying;
|
||
d.push(`${id}: ${okStep ? 'contract ✔' : 'contract ✘ (expect paused, box+button, voice ' + vk + ' playing)'}`);
|
||
// Press ACKNOWLEDGE (the button's own pointerdown) and let it finish.
|
||
box.button.panel.emit('pointerdown');
|
||
await until(() => (s.comms.active === false ? s : null), 5000);
|
||
if (extra) extra(id);
|
||
return okStep && s.comms.active === false && s.comms.paused === false;
|
||
};
|
||
|
||
// ---- 1) the welcome — the FULL contract (box, clip, voice, duck) --
|
||
const up1 = await until(() => (s.comms.active ? s : null), 20000);
|
||
if (!up1) { setReport(['FAIL — oac-intro never came up']); window.__commsResult = { ok: false, details: d }; return; }
|
||
const box = s.comms.box;
|
||
const clip = box?.video;
|
||
const el = clip?.video;
|
||
if (el) await until(() => el.paused === false, 4000);
|
||
const voiceKey = s.comms._voiceKey;
|
||
d.push(`box: ${box ? `${Math.round(box.x)},${Math.round(box.y)} ${box.W}×${box.H}` : '—'}`);
|
||
d.push(`clip: present=${!!clip} muted=${el ? el.muted : '—'} paused=${el ? el.paused : '—'} loop=${el ? el.loop : '—'}`);
|
||
d.push(`text: "${String(box?.text?.text ?? '').slice(0, 60)}…"`);
|
||
|
||
// Per-sound ducking (the volume contract): the VOICE's own sound
|
||
// stays at FULL volume (1.0); the music's own sound drops by
|
||
// musicDuck (0.6 → 0.18); the manager's MASTER is untouched. Asserted
|
||
// on currentConfig.volume — the LOGICAL volume; the gain-node read
|
||
// lags in a suspended (headless) context.
|
||
const allPlaying = typeof snd.getAllPlaying === 'function' ? snd.getAllPlaying() : [];
|
||
const vs = allPlaying.find((x) => x.key === voiceKey);
|
||
const ms = allPlaying.find((x) => typeof x.key === 'string' && x.key.startsWith('music_'));
|
||
const musicVol = config.get('music.volume', 0.6);
|
||
const cfgVol = (x) => (x && x.currentConfig && typeof x.currentConfig.volume === 'number' ? x.currentConfig.volume : null);
|
||
d.push(`duck: voice.volume=${cfgVol(vs)} (expect 1.0) music.volume=${cfgVol(ms)} (expect ${(musicVol * 0.3).toFixed(2)}) master=${snd.masterVolumeNode && snd.masterVolumeNode.gain.value} (expect 1)`);
|
||
const introOk =
|
||
s.comms.current?.id === 'oac-intro' &&
|
||
s.comms.paused === true && // ack: the action holds
|
||
!!box && !!box.button && !!clip && !!el &&
|
||
el.muted === true && // the video is silent (the voice is the speech)
|
||
el.loop === true && // the ack clip loops until acknowledged
|
||
el.paused === false &&
|
||
voiceKey === 'comms_au_oac-intro-01' &&
|
||
snd.isPlaying(voiceKey) === true && // the speech is talking
|
||
s.comms._ducked === true && // the music duck was issued
|
||
cfgVol(vs) === 1.0 && // the voice is FULL volume
|
||
(cfgVol(ms) !== null && Math.abs(cfgVol(ms) - musicVol * 0.3) < 0.001) && // music ducked per-sound
|
||
s.ship.body.velocity.length() < 1; // the ship is parked
|
||
d.push(`oac-intro: ${introOk ? 'contract ✔' : 'contract ✘'}`);
|
||
|
||
// The pause contract's FACE: the deck is on STANDBY (dim + inert —
|
||
// no hover, no press, the slots' input disabled) and the
|
||
// ACKNOWLEDGE button is pulsing (the primary-action cue).
|
||
const ab = s.actionBar;
|
||
const liveSlots = ab ? ab.slots.filter((sl) => sl.live) : [];
|
||
const deckStandby = ab && ab.enabled === false && liveSlots.every((sl) => sl.panel?.input?.enabled === false);
|
||
d.push(`deck standby: enabled=${ab?.enabled} (expect false) slotsInert=${liveSlots.every((sl) => sl.panel?.input?.enabled === false)} (expect true)`);
|
||
const liveSlot = liveSlots.find((sl) => sl.id === 'scan') ?? liveSlots[0];
|
||
let deckInertOk = true;
|
||
if (liveSlot) {
|
||
liveSlot.panel.emit('pointerover'); // direct emit — the guards must still swallow it
|
||
const hoverDead = liveSlot.hoverOn === false;
|
||
let pressed = false;
|
||
const origAction = ab.onAction;
|
||
ab.onAction = () => {
|
||
pressed = true;
|
||
};
|
||
liveSlot.panel.emit('pointerdown');
|
||
ab.onAction = origAction;
|
||
deckInertOk = hoverDead && !pressed;
|
||
d.push(`deck inert: hoverDead=${hoverDead} (expect true) pressSwallowed=${!pressed} (expect true)`);
|
||
}
|
||
const pulseUp = !!(box?._ringA && box?._ringB && box?._halo);
|
||
const pa1 = box?._ringA?.alpha;
|
||
await sleep(260);
|
||
const pa2 = box?._ringA?.alpha;
|
||
const pulseMoving = pulseUp && typeof pa1 === 'number' && typeof pa2 === 'number' && Math.abs(pa2 - pa1) > 0.001;
|
||
d.push(`ack pulse: live=${pulseUp} (expect true) ringA.alpha ${pa1?.toFixed?.(3)} → ${pa2?.toFixed?.(3)} (expect moving)`);
|
||
|
||
// Press ACKNOWLEDGE; the duck must RESTORE. (The deck re-arms too —
|
||
// but oac-tether starts the moment this one ends, so it stands by
|
||
// again: the re-arm is asserted at step 4, when the comms are idle.)
|
||
box.button.panel.emit('pointerdown');
|
||
await until(() => (s.comms.active === false ? s : null), 5000);
|
||
const msAfter = ms ? (snd.getAll(ms.key) ?? [])[0] : null;
|
||
const duckRestoreOk = !ms || (msAfter !== null && cfgVol(msAfter) !== null && Math.abs(cfgVol(msAfter) - musicVol) < 0.001);
|
||
d.push(`duck-restore: music.volume=${msAfter ? cfgVol(msAfter) : '—'} (expect ${musicVol})`);
|
||
await sleep(300); // > the endedWithin(200) race window before the next step
|
||
|
||
// ---- 2) + 3) the chain — tether, then the scan brief --------------
|
||
const tetherOk = await ackStep('oac-tether', 'comms_au_oac-intro-02');
|
||
await sleep(300);
|
||
const scanOk = await ackStep('oac-scan', 'comms_au_oac-intro-03');
|
||
|
||
// ---- 4) QUIET — nothing else arms until the player acts -----------
|
||
await sleep(700);
|
||
const quiet = s.comms.active === false && s.comms.queue.length === 0;
|
||
d.push(`quiet after chain: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`);
|
||
// The comms are idle now — the deck must be LIVE again (re-armed the
|
||
// frame the last hold ended: hover + press back, slots interactive).
|
||
const deckArmed = ab && ab.enabled === true && liveSlots.every((sl) => sl.panel?.input?.enabled === true);
|
||
d.push(`deck re-armed: enabled=${ab?.enabled} (expect true) slotsLive=${liveSlots.every((sl) => sl.panel?.input?.enabled === true)} (expect true)`);
|
||
|
||
// ---- 5) the player clicks SCAN on the deck — the real path --------
|
||
s.deckAction('scan'); // the comm guard must let it through (the scene is free)
|
||
d.push(`scan: ${s.scanPulse.busy ? 'sweep is out (busy)' : 'NO SWEEP (busy=false — the comm guard swallowed it?)'}`);
|
||
const upgradeOk = await ackStep('oac-upgrade', 'comms_au_oac-intro-04'); // the 5 s beat + the sweep settle
|
||
await sleep(400); // > the endedWithin(200) race window before the next input
|
||
|
||
// ---- 6) Tether Level 2 research — the pair + the busy() hold ------
|
||
s.deckAction('research'); // the deck's console path
|
||
const winUp = await until(() => (s.researchWindow?.isOpen === true ? s : null), 5000);
|
||
if (!winUp) { setReport(['FAIL — the research console never opened', ...d]); window.__commsResult = { ok: false, details: d }; return; }
|
||
s.beginResearch('exploration', 'tether_l2'); // the console's RESEARCH button path
|
||
await sleep(600); // frames for the hub to (wrongly) pump while the console owns the screen
|
||
const proj = s.researchState.getActive();
|
||
d.push(`research: in-flight=${proj?.id ?? '—'} (expect tether_l2) queued=${s.comms.queue.length} (expect 2) active=${s.comms.active} (expect false — the console owns the screen)`);
|
||
const holdOk = !!proj && proj.id === 'tether_l2' && s.comms.queue.length === 2 && s.comms.active === false;
|
||
d.push(`busy-hold: ${holdOk ? 'contract ✔' : 'contract ✘'}`);
|
||
s.researchWindow.close(); // the player steps away from the console
|
||
await until(() => (s.researchWindow?.isOpen === false), 5000);
|
||
const mineralsOk = await ackStep('oac-minerals', 'comms_au_oac-intro-05');
|
||
await sleep(300);
|
||
const flyMineOk = await ackStep('oac-fly-mine', 'comms_au_oac-intro-06');
|
||
await sleep(400);
|
||
|
||
// ---- 6b) the project completes — the AUDIO-ONLY sign-off ----------
|
||
// Fast-forward the in-flight clock to its natural expiry (a real
|
||
// player gets this 60 s into the project; the tick is gated on the
|
||
// comm pause, so it lands on a live run exactly as in play).
|
||
const projDone = s.researchState.getActive();
|
||
if (projDone) projDone.startedAt = s.time.now - projDone.durationMs - 1000; // the clock expires on the next tick
|
||
const rcKey = 'comms_au_oac-research-complete';
|
||
const rcUp = await until(() => (s.comms.active && s.comms.current?.id === 'oac-research-complete' ? s : null), 12000);
|
||
const rcOk = !!rcUp && s.comms.box === null && s.comms.paused === false && s.comms._voiceKey === rcKey && s.sound.isPlaying(rcKey);
|
||
d.push(`research-complete: active=${s.comms.active} box=${String(s.comms.box)} (expect null — audio only) paused=${s.comms.paused} (expect false — no pause) voice=${s.comms._voiceKey ?? '—'} playing=${s.sound.isPlaying(rcKey)}`);
|
||
d.push(`audio-only contract: ${rcOk ? '✔' : '✘'}`);
|
||
if (s.sound) s.sound.stopByKey(rcKey); // headless: the AudioContext never advances — simulate the voice ending
|
||
await until(() => s.comms.active === false, 5000);
|
||
// once:false — EVERY completion signs off (a second trigger re-arms)
|
||
const rcArmed = s.comms.trigger('researchComplete');
|
||
const rc2Up = await until(() => (s.comms.active && s.comms.current?.id === 'oac-research-complete' ? s : null), 12000);
|
||
d.push(`once:false: re-arm=${rcArmed} (expect 1) replay=${!!rc2Up} (expect true)`);
|
||
if (rc2Up && s.sound) s.sound.stopByKey(rcKey);
|
||
await until(() => s.comms.active === false, 5000);
|
||
await sleep(300);
|
||
|
||
// ---- 7) the first rock field — discovery range, then the comm -----
|
||
const cluster = s.asteroidClusters[0];
|
||
if (!cluster) { setReport(['FAIL — the run has no asteroid clusters to find', ...d]); window.__commsResult = { ok: false, details: d }; return; }
|
||
s.ship.stop(); // park the ship, then bring it within discovery range
|
||
s.ship.x = cluster.x;
|
||
s.ship.y = cluster.y + cluster.bound + 100; // 100px off the rim (< the 540px discovery distance)
|
||
d.push(`discover: ship at ${Math.round(s.ship.x)},${Math.round(s.ship.y)} — ${cluster.discoveryId} is ${Math.round(Math.hypot(s.ship.x - cluster.x, s.ship.y - cluster.y) - cluster.bound)}px off its rim`);
|
||
const clusterOk = await ackStep('oac-first-cluster', 'comms_au_oac-intro-07'); // the 1.5 s chime beat
|
||
|
||
// ---- 8) the first 200 minerals — the build-it-home beat ------------
|
||
// The real ore event (Mining.js loads the hold, then the scene's
|
||
// onOre: the ledger, the HUD, the quest — and the comm's arm).
|
||
const added = s.ship.addMinerals(200); // the beam's 'take' (capped to the hold)
|
||
s.mining.onOre?.(added, s.ship?.minerals ?? 0);
|
||
d.push(`mine: hold=${s.ship.minerals} (expect >= 200) totalMined=${s.totalMined}`);
|
||
const tether2Ok = await ackStep('oac-tether2', 'comms_au_oac-intro-08'); // the 1.5 s beat
|
||
await sleep(400);
|
||
|
||
// ---- 9) the level-2 tether + the launch-back pair ------------------
|
||
// The REAL build path (research gate, the home's level-1 tether gate,
|
||
// the 200-mineral cost), then the build clock fast-forwarded to its
|
||
// natural 20 s completion (a real player waits it out on the surface).
|
||
const buildRes = s.beginBuild(s.homeWorldName, 'tether-l2', s.game.loop.now);
|
||
const b = s.buildState.getActive();
|
||
if (b) b.startedAt = s.game.loop.now - b.durationMs - 1000; // the clock expires on the next tick
|
||
const built = await until(() => (s.buildState.isBuilt(s.homeWorldName, 'tether-l2') ? s : null), 8000);
|
||
const buildOk = buildRes?.ok === true && !!built && (s.tetherLevelFor(s.homeWorldName) ?? 0) === 2;
|
||
d.push(`build: res=${JSON.stringify(buildRes)} built=${!!built} tetherLevel=${s.tetherLevelFor(s.homeWorldName)} (expect 2)`);
|
||
d.push(`tether-l2 on home: ${buildOk ? '✔' : '✘'}`);
|
||
// The take-off (SurfaceScene.finishTakeoff's arm — the same witness +
|
||
// the same trigger) puts the player back in space:
|
||
if (s.homeWorldName && s.buildState.isBuilt(s.homeWorldName, 'tether-l2')) {
|
||
s.comms.trigger('tetherL2Launch');
|
||
}
|
||
const relaunchOk = await ackStep('oac-relaunch', 'comms_au_oac-intro-09'); // the 1.5 s beat
|
||
await sleep(300);
|
||
const exploreOk = await ackStep('oac-explore', 'comms_au_oac-intro-10'); // IMMEDIATE after the ACK (delayMs 0)
|
||
await sleep(400);
|
||
|
||
// ---- 10) the ledger holds what the run heard -----------------------
|
||
// The ten `once` comms (the ledger is what bars their next trigger)
|
||
// plus the once:false sign-off (recorded as heard; the model ignores
|
||
// the ledger for it, so it re-arms on every completion — proven in 6b)
|
||
await sleep(700); // > the race window; also the hub's no-replay window
|
||
const noReplay = s.comms.active === false && s.comms.queue.length === 0;
|
||
const ledger = s.registry.get('commsPlayed');
|
||
d.push(`ledger: played=${JSON.stringify(ledger ? Array.from(ledger) : [])}`);
|
||
const playedOk = !!ledger && ['oac-intro', 'oac-tether', 'oac-scan', 'oac-upgrade', 'oac-minerals', 'oac-fly-mine', 'oac-research-complete', 'oac-first-cluster', 'oac-tether2', 'oac-relaunch', 'oac-explore'].every((id) => ledger.has(id));
|
||
const armed2 = s.comms.trigger('newGame');
|
||
const armed3 = s.comms.trigger('firstScan');
|
||
const armed4 = s.comms.trigger('tetherResearch');
|
||
const armed5 = s.comms.trigger('firstCluster');
|
||
const armed6 = s.comms.trigger('minerals200');
|
||
const armed7 = s.comms.trigger('tetherL2Launch');
|
||
d.push(`re-arm: newGame=${armed2} firstScan=${armed3} tetherResearch=${armed4} firstCluster=${armed5} minerals200=${armed6} tetherL2Launch=${armed7} (expect 0 0 0 0 0 0 — the ledger holds)`);
|
||
|
||
const allOk = introOk && duckRestoreOk && deckStandby && deckInertOk && pulseMoving && tetherOk && scanOk && quiet && deckArmed && upgradeOk && holdOk && mineralsOk && flyMineOk && rcOk && rcArmed === 1 && rc2Up && clusterOk && tether2Ok && buildOk && relaunchOk && exploreOk && noReplay && playedOk && armed2 === 0 && armed3 === 0 && armed4 === 0 && armed5 === 0 && armed6 === 0 && armed7 === 0;
|
||
const errLine = errors.length ? ['console errors:', ...errors.slice(0, 4)] : [];
|
||
window.__commsResult = { ok: allOk, details: [...d, ...errLine] };
|
||
setReport(['COMMS SELF-CHECK ' + (allOk ? 'PASS ✔' : 'FAIL ✘'), '', ...d, ...errLine]);
|
||
} catch (err) {
|
||
window.__commsResult = { ok: false, details: ['EXCEPTION: ' + String(err?.stack ?? err)] };
|
||
setReport(['COMMS SELF-CHECK FAIL ✘', String(err?.stack ?? err)]);
|
||
}
|
||
}, 600);
|