orbit/dev/comms-intro.mjs

324 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 OACs 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);