diff --git a/assets/images/originals/ice-shop-01.png b/assets/images/originals/ice-shop-01.png new file mode 100644 index 0000000..aad07d9 Binary files /dev/null and b/assets/images/originals/ice-shop-01.png differ diff --git a/assets/images/originals/ice-surface-03.png b/assets/images/originals/ice-surface-03.png new file mode 100644 index 0000000..367f6d9 Binary files /dev/null and b/assets/images/originals/ice-surface-03.png differ diff --git a/assets/speech/oac/intro-05.mp3 b/assets/speech/oac/intro-05.mp3 new file mode 100644 index 0000000..e65d292 Binary files /dev/null and b/assets/speech/oac/intro-05.mp3 differ diff --git a/assets/speech/oac/intro-06.mp3 b/assets/speech/oac/intro-06.mp3 new file mode 100644 index 0000000..241e6c7 Binary files /dev/null and b/assets/speech/oac/intro-06.mp3 differ diff --git a/assets/speech/oac/intro-07.mp3 b/assets/speech/oac/intro-07.mp3 new file mode 100644 index 0000000..b824f68 Binary files /dev/null and b/assets/speech/oac/intro-07.mp3 differ diff --git a/assets/speech/oac/intro-08.mp3 b/assets/speech/oac/intro-08.mp3 new file mode 100644 index 0000000..7003d47 Binary files /dev/null and b/assets/speech/oac/intro-08.mp3 differ diff --git a/assets/speech/oac/intro-09.mp3 b/assets/speech/oac/intro-09.mp3 new file mode 100644 index 0000000..57d29fd Binary files /dev/null and b/assets/speech/oac/intro-09.mp3 differ diff --git a/assets/speech/oac/intro-10.mp3 b/assets/speech/oac/intro-10.mp3 new file mode 100644 index 0000000..3a80741 Binary files /dev/null and b/assets/speech/oac/intro-10.mp3 differ diff --git a/assets/videos/ice-surface-01.mp4 b/assets/videos/ice-surface-01.mp4 index 4d22fd1..0e7b363 100644 Binary files a/assets/videos/ice-surface-01.mp4 and b/assets/videos/ice-surface-01.mp4 differ diff --git a/assets/videos/ice-surface-02.mp4 b/assets/videos/ice-surface-02.mp4 new file mode 100644 index 0000000..0757502 Binary files /dev/null and b/assets/videos/ice-surface-02.mp4 differ diff --git a/assets/videos/ice-surface-03.mp4 b/assets/videos/ice-surface-03.mp4 new file mode 100644 index 0000000..773d84f Binary files /dev/null and b/assets/videos/ice-surface-03.mp4 differ diff --git a/data/comms.json b/data/comms.json index fde1055..3ed0661 100644 --- a/data/comms.json +++ b/data/comms.json @@ -1,5 +1,5 @@ { - "$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary: 'newGame' is live (the fresh-run welcome); the reservedTriggers list below is the proposed event set to discuss and assign comms to (researchComplete, researchNew, questNew, questComplete, discovery, landing, jump, buildComplete) — none of them fire yet. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean.", + "$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back. 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open). Reserved for future comms: researchNew, questNew, questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean.", "enabled": true, "audio": { "$comment": "The comm's voice plays at its OWN volume (volume) — the manager's master is left untouched (in v4 `sound.setVolume` IS the master, so ducking it would quiet the voice too). While the voice talks, the other playing sounds (the game music) are ducked PER-SOUND by musicDuck (a fraction of each one's own volume) and restored exactly when the voice ends.", @@ -25,10 +25,114 @@ "audio": "assets/speech/oac/intro-01.mp3", "once": true, "delayMs": 1400 + }, + { + "$comment": "Welcome beat two (the tether region). Queued right behind oac-intro on newGame: the hub's FIFO plays it the moment the welcome is acknowledged — each 'ack' holds until its own ACKNOWLEDGE press, so the order is guaranteed.", + "id": "oac-tether", + "from": "oac", + "type": "ack", + "trigger": "newGame", + "text": "First things first. We are tethered to your home planet, this limits how far you can go and what you can explore. We can build more tethers on planets and space stations, but we're going to need to find another planet or space station before we can do that.", + "audio": "assets/speech/oac/intro-02.mp3", + "once": true + }, + { + "$comment": "Welcome beat three (the first instruction): scan the tether region. The player clicks SCAN on the deck after acknowledging this — oac-upgrade (firstScan) is the reaction to that click.", + "id": "oac-scan", + "from": "oac", + "type": "ack", + "trigger": "newGame", + "text": "Let's begin by scanning. Scanning allows us to find other objects inside of our tether region. Lets see what we can scan right now. Click Acknowledge on this comms window, then click Scan on the bottom bar to scan your tether region.", + "audio": "assets/speech/oac/intro-03.mp3", + "once": true + }, + { + "$comment": "The scan reaction: fires when the player clicks SCAN on the deck (GameScene.startScan → comms.trigger('firstScan')) and plays 5 s after that click (delayMs) — the sweep is well underway by then; the busy() contract holds it until the scene is free. once per run.", + "id": "oac-upgrade", + "from": "oac", + "type": "ack", + "trigger": "firstScan", + "text": "Good. Now you will see several indicators around your ship that point in the direction of where other objects are within your tether region. Hmm... I don't see any planets or space stations. We're going to need to upgrade our tether. Click Acknowledge, then click research below, and research a Tether Level 2 upgrade.", + "audio": "assets/speech/oac/intro-04.mp3", + "once": true, + "delayMs": 5000 + }, + { + "$comment": "The Tether Level 2 reaction (trigger 'tetherResearch' — GameScene.beginResearch, the moment the player commits to the tether_l2 node). Queued before oac-fly-mine: each ack holds until its own ACKNOWLEDGE press, so the pair plays back-to-back. The busy() contract holds it until the research console closes (comms never play over a full-screen window).", + "id": "oac-minerals", + "from": "oac", + "type": "ack", + "trigger": "tetherResearch", + "text": "Excellent, that's going to help a lot. We will upgrade the tether on your home planet itself, but we're going to need to mine 200 minerals to afford to do so.", + "audio": "assets/speech/oac/intro-05.mp3", + "once": true + }, + { + "$comment": "Chained right after oac-minerals (same trigger, config order): the fly-to / continuous-fly instructions and the go signal to hunt asteroids.", + "id": "oac-fly-mine", + "from": "oac", + "type": "ack", + "trigger": "tetherResearch", + "text": "Let's press scan again to find those nearby objects. Then click in the direction of one of the gray indicators and you'll fly your ship to wherever you click. If you want to continuously fly in a direction, hold shift when you click. Now lets go find some asteroids to mine!", + "audio": "assets/speech/oac/intro-06.mp3", + "once": true + }, + { + "$comment": "The first rock field (trigger 'firstCluster' — GameScene.celebrateDiscovery, the run's first asteroid-cluster discovery; the scene flag fires the event once, the comm's once-ledger dedupes across saves). delayMs beats the discovery chime (1.5 s, sfx.discovery) so it lands before OAC speaks.", + "id": "oac-first-cluster", + "from": "oac", + "type": "ack", + "trigger": "firstCluster", + "text": "Good work. You found your first asteroid cluster. Let's mine these until we reach 200 minerals. Just click on whichever asteroid you want to mine and your mining laser will do the rest. Keep mining until you reach 200 minerals or more.", + "audio": "assets/speech/oac/intro-07.mp3", + "once": true, + "delayMs": 1500 + }, + { + "$comment": "Every research completion (trigger 'researchComplete' — GameScene._completeResearch, the moment any project's clock runs out). AUDIO ONLY: no box, no pause — OAC's short sign-off rides under the action (the voice ducks whatever else is playing, per the audio section). once:false — it re-arms on EVERY completion (the FIFO serializes them, the busy() contract holds it while the research console is still open). delayMs beats the completion chime (1.5 s, sfx.discovery) so it lands before the voice.", + "id": "oac-research-complete", + "from": "oac", + "type": "audio", + "trigger": "researchComplete", + "audio": "assets/speech/oac/research-complete.mp3", + "once": false, + "delayMs": 1500 + }, + { + "$comment": "The 200-mineral threshold (trigger 'minerals200' — GameScene's ore event when the run's lifetime mining first crosses 200 — the 'Mine 200 Minerals' quest line and the tether-l2 build cost, data/builds.json). OAC confirms the build can be afforded and sends the player home to build it. The 1.5 s beat lets the HUD/quest beat land before the voice.", + "id": "oac-tether2", + "from": "oac", + "type": "ack", + "trigger": "minerals200", + "text": "Looks like we have enough minerals to afford that level 2 tether. To build it return to your home planet. You can auto pilot to it by clicking it's name in that little green box. When you land click the build menu and build the level 2 tether. Then launch back into space and we'll see if we can find another planet or space station.", + "audio": "assets/speech/oac/intro-08.mp3", + "once": true, + "delayMs": 1500 + }, + { + "$comment": "The launch-back beat (trigger 'tetherL2Launch' — SurfaceScene.finishTakeoff, the take-off that puts the player back in space; armed only when the run's build record shows the home world holding a level-2 tether — BuildState.isBuilt, which outlives the surface stay and saves with the run). OAC clears the player to scan again: planets green, stations purple.", + "id": "oac-relaunch", + "from": "oac", + "type": "ack", + "trigger": "tetherL2Launch", + "text": "OK we should be all set. Click Scan again and let's see what we can find. Planets show up as green and space stations show up as purple on our indicator.", + "audio": "assets/speech/oac/intro-09.mp3", + "once": true, + "delayMs": 1500 + }, + { + "$comment": "Chained right after oac-relaunch is acknowledged (same trigger, config order, delayMs 0 — the FIFO starts it the moment the previous is ACKed). OAC's send-off: the jump-gate primer, then good luck.", + "id": "oac-explore", + "from": "oac", + "type": "ack", + "trigger": "tetherL2Launch", + "text": "This should be everything you need to get around and explore. Once you discover all the objects in a solar system, you can research that systems jump gates and then use them to jump into other solar systems. I'll let you do some exploring for a while on your own now... Good luck.", + "audio": "assets/speech/oac/intro-10.mp3", + "once": true, + "delayMs": 0 } ], "reservedTriggers": [ - "researchComplete", "researchNew", "questNew", "questComplete", diff --git a/data/landing.json b/data/landing.json index 388fd3c..70bea08 100644 --- a/data/landing.json +++ b/data/landing.json @@ -64,8 +64,8 @@ { "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" }, { "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }, { "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" }, - { "land": "ice-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "ice-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" }, - { "land": "ice-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" } + { "land": "ice-land-02.mp4", "surface": "ice-surface-02.mp4", "takeoff": "ice-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" }, + { "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" } ], "stationVideos": [ diff --git a/dev/comms-intro.mjs b/dev/comms-intro.mjs index f190b0f..96ffafd 100644 --- a/dev/comms-intro.mjs +++ b/dev/comms-intro.mjs @@ -1,21 +1,52 @@ /** - * Dev-only: the COMMS intro self-check — boots the game straight into - * the GameScene (a FRESH run, so the 'newGame' trigger arms OAC's - * welcome) and paints a plain-text report answering "did the ack comm - * actually happen?": + * 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. Did the comm ARM (trigger → queue) and PLAY (box + voice)? - * 2. Is the pause contract live (comms.paused, the ship parked)? - * 3. Is the clip muted + rolling (the video's element state)? - * 4. Does ACKNOWLEDGE actually end it (button press → finish → - * the box gone, the action free, the music volume restored)? + * 1. oac-intro (ack) — full contract: box + muted looping clip + voice + * (full volume, music ducked per-sound) + the + * action paused; ACKNOWLEDGE ends it and the duck + * restores. + * 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 "document.getElementById('report').textContent.includes('SELF-CHECK')" + * out.png "window.__commsResult && JSON.stringify(window.__commsResult)" */ import Phaser from '../js/vendor/phaser.js'; import { config } from '../js/config/Config.js'; @@ -65,90 +96,185 @@ setTimeout(async () => { 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; - // 1) The welcome armed + played (the hub's deferred load may take a - // moment — the clip + the speech come in on demand). - const live = await until(() => (s.comms.active ? s : null)); - d.push(`arm+play: active=${s.comms.active} paused=${s.comms.paused} queued=${s.comms.queued}`); - if (!live) { setReport(['FAIL — the newGame comm never came up', ...d]); window.__commsResult = { ok: false, details: d }; return; } + // --- 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; + }; - // The clip may take a beat to start (v4's autoplay retry — the - // browser unlocks muted autoplay on its own schedule); a real - // player has the whole voice to see it, so give it 4 s here. + // ---- 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 snd = s.sound; 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 : '—'} t=${el && Number.isFinite(el.currentTime) ? el.currentTime.toFixed(2) + 's' : '—'}`); - d.push(`voice: key=${voiceKey ?? '—'} playing=${voiceKey ? snd.isPlaying(voiceKey) : '—'} ducked=${s.comms._ducked}`); - d.push(`ship: paused-contract=${s.ship.body.velocity.length().toFixed(1)} state=${s.ship.state}`); + 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. (The - // old bug: `sound.setVolume` IS the master in v4, so the voice was - // being quieted along with the music — that is why the comms - // sounded quiet.) Asserted on currentConfig.volume — the LOGICAL - // volume; the gain-node read lags in a suspended (headless) context. + // 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 duckOk = - 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 - - const ok = - s.comms.active === true && + const introOk = + s.comms.current?.id === 'oac-intro' && s.comms.paused === true && // ack: the action holds - !!box && - !!box.button && - !!clip && - !!el && + !!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 && // …and it's rolling - voiceKey !== null && + el.paused === false && + voiceKey === 'comms_au_intro-01' && snd.isPlaying(voiceKey) === true && // the speech is talking - duckOk && + 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 ✘'}`); - // 2) ACKNOWLEDGE — press the box's button (its own pointerdown, the - // save-panel's modal idiom) and the comm must end: box gone, - // action free, the voice stopped. - const before = { paused: s.comms.paused, ducked: s.comms._ducked }; + // Press ACKNOWLEDGE; the duck must RESTORE (the music's own volume). box.button.panel.emit('pointerdown'); await until(() => (s.comms.active === false ? s : null), 5000); - d.push(`ack: active=${s.comms.active} paused=${before.paused} → ${s.comms.paused} ducked=${before.ducked} → ${s.comms._ducked} voice=${voiceKey ? snd.isPlaying(voiceKey) : '—'}`); - // The duck must RESTORE: the music goes back to its own volume. const msAfter = ms ? (snd.getAll(ms.key) ?? [])[0] : null; - d.push(`duck-restore: music.volume=${msAfter ? cfgVol(msAfter) : '—'} (expect ${musicVol})`); const duckRestoreOk = !ms || (msAfter !== null && cfgVol(msAfter) !== null && Math.abs(cfgVol(msAfter) - musicVol) < 0.001); - // The comm must NOT replay: give the hub several frames to (wrongly) - // re-arm the same entry, then assert it stays finished. + 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_intro-02'); + await sleep(300); + const scanOk = await ackStep('oac-scan', 'comms_au_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)`); + + // ---- 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_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_intro-05'); + await sleep(300); + const flyMineOk = await ackStep('oac-fly-mine', 'comms_au_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_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_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_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_intro-09'); // the 1.5 s beat + await sleep(300); + const exploreOk = await ackStep('oac-explore', 'comms_au_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; - d.push(`no-replay: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`); - const ackOk = s.comms.active === false && s.comms.paused === false && s.comms._ducked === false && duckRestoreOk && (voiceKey ? snd.isPlaying(voiceKey) === false : true) && noReplay; + 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)`); - // 3) The ledger: the welcome is PLAYED (a reload of this run would - // not re-hear it). - d.push(`ledger: played=${JSON.stringify(Array.from(s.registry.get('commsPlayed') ?? []))}`); - const playedOk = s.registry.get('commsPlayed')?.has('oac-intro') === true; - - // 4) The played filter: re-triggering arms nothing. - const armed = s.comms.trigger('newGame'); - d.push(`re-arm: armed=${armed} (expect 0 — the ledger holds)`); - const rearmOk = armed === 0; - - const allOk = ok && ackOk && playedOk && rearmOk; + const allOk = introOk && duckRestoreOk && tetherOk && scanOk && quiet && 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]); diff --git a/dev/comms-upgrade-hold.html b/dev/comms-upgrade-hold.html new file mode 100644 index 0000000..c3c2b3a --- /dev/null +++ b/dev/comms-upgrade-hold.html @@ -0,0 +1,19 @@ + + +
+ +