Add full OAC tutorial comms chain and ice-surface video assets

- Wire 10 new comm beats (oac-tether, oac-scan, oac-upgrade, oac-minerals,
  oac-fly-mine, oac-first-cluster, oac-research-complete, oac-tether2,
  oac-relaunch, oac-explore) into data/comms.json with their triggers,
  speech clips, and FIFO ordering
- Add trigger arming in GameScene (firstScan, tetherResearch, firstCluster,
  minerals200, researchComplete) and SurfaceScene (tetherL2Launch)
- Make CommsHub.trigger idempotent so a double-click can't queue the same
  comm twice
- Fix landing.json to use ice-surface-02/03.mp4 instead of rocky variants;
  add the two new ice-surface videos and an ice-shop image
- Add speech clips intro-05 through intro-10 and research-complete
- Expand dev/comms-intro.mjs into a full 10-step walkthrough self-check,
  add dev/comms-upgrade-hold page for screenshotting the longest comm, and
  update dev/comms.test.mjs to cover the new roster
This commit is contained in:
Brian Fertig 2026-09-09 11:28:22 -06:00
parent aaa953d654
commit 881a94a1b6
20 changed files with 618 additions and 81 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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",

View File

@ -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": [

View File

@ -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]);

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev: the comms intro self-check</title>
<!-- This page lives in /dev, but the game's relative asset paths are
rooted at the project root — resolve them against it. -->
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/comms-upgrade-hold.mjs"></script>
</body>
</html>

View File

@ -0,0 +1,66 @@
/**
* Dev-only: HOLD oac-upgrade (the longest tutorial text) open for a clean
* screenshot the shipped roster queues it behind the welcome chain on
* firstScan, so this page overrides the config at boot: the same comm,
* armed on newGame with no delay (dev-only; the shipped config is untouched).
* node dev/cdp-shot.mjs "http://127.0.0.1:3000/dev/comms-upgrade-hold.html" out.png \
* "window.__commsHold" 60000 "document.getElementById('report')?.remove()"
*/
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();
data.comms = {
...(data.comms ?? {}),
comms: [{ ...data.comms.comms.find((c) => c.id === 'oac-upgrade'), trigger: 'newGame', delayMs: 0 }],
};
config.init(data);
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;margin:0;padding:8px 12px;font:12px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.9);border:1px solid #1b3a5a;max-width:50%;';
document.body.appendChild(report);
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);
}
};
setTimeout(async () => {
const s = () => game.scene.getScene('GameScene');
const ready = await until(() => {
const sc = s();
return sc && sc.ship && sc.comms && sc.comms.active && sc.comms.box?.video ? sc : null;
});
if (!ready) { report.textContent = 'FAIL — comm never came up'; window.__commsHold = { ok: false }; return; }
await until(() => (s().comms.box._decFinished === true), 8000);
await sleep(700);
const sc = s();
const box = sc.comms.box;
const el = box.video?.video;
window.__commsHold = {
ok: true,
comm: sc.comms.current?.id,
box: { x: Math.round(box.x), y: Math.round(box.y), w: box.W, h: box.H },
clip: el ? { paused: el.paused, muted: el.muted, t: el.currentTime?.toFixed(1) } : null,
voice: sc.comms._voiceKey ? sc.sound.isPlaying(sc.comms._voiceKey) : null,
};
report.textContent = 'COMMS HOLD: ' + (sc.comms.current?.id ?? '?') + ' box ' + box.W + '×' + box.H +
' @ ' + Math.round(box.x) + ',' + Math.round(box.y) +
' · clip ' + (el ? (el.paused ? 'PAUSED' : 'rolling') : '—') +
' · voice ' + (sc.comms._voiceKey ? 'talking' : 'done');
}, 600);

View File

@ -5,8 +5,12 @@
*
* The pure rules of data/comms.json (js/comms/CommsModel.js) the comm
* roster's contract:
* - the shipped config is VALID (enabled, OAC registered, the
* oac-intro comm wired: ack + newGame + the real assets);
* - the shipped config is VALID (enabled, OAC registered, the tutorial
* chain wired: oac-intro oac-tether oac-scan on newGame, oac-upgrade
* on firstScan, oac-minerals oac-fly-mine on tetherResearch, and
* oac-first-cluster on firstCluster, oac-tether2 on minerals200,
* oac-relaunch oac-explore on tetherL2Launch, and
* oac-research-complete (audio-only, once:false) on researchComplete);
* - the reserved triggers are RESERVED (no comm uses them yet);
* - normalizeComm drops what can't be shown (bad type / no trigger /
* no text for a box / no speech for an audio comm / unknown sender),
@ -64,12 +68,12 @@ const oac = charEntry(cfg, 'oac');
check('OAC is registered (label + role + clip)', !!oac && oac.label === 'O.A.C.' && !!oac.role && oac.video === 'assets/videos/characters/oac.mp4');
const roster = listComms(cfg);
check('the roster holds exactly the shipped comms', roster.length === 1);
const intro = roster[0];
check('the roster holds exactly the shipped comms', roster.length === 11);
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
const intro = byId['oac-intro'];
check(
'oac-intro: the fresh-run welcome (ack, newGame, OAC, once)',
!!intro &&
intro.id === 'oac-intro' &&
intro.from === 'oac' &&
intro.type === 'ack' &&
intro.trigger === 'newGame' &&
@ -79,9 +83,135 @@ check('oac-intro: the welcome text (the brief, verbatim)', intro?.text.includes(
check('oac-intro: the parked speech clip', intro?.audio === 'assets/speech/oac/intro-01.mp3');
check('oac-intro: its delay beats the opening toast', (intro?.delayMs ?? 0) >= 1000);
// The welcome chain — beats two + three (the hub's FIFO plays them in
// this order, each ack holding until its own ACKNOWLEDGE press).
check(
'oac-tether: welcome beat two (ack, newGame, intro-02)',
!!byId['oac-tether'] &&
byId['oac-tether'].type === 'ack' &&
byId['oac-tether'].trigger === 'newGame' &&
byId['oac-tether'].audio === 'assets/speech/oac/intro-02.mp3' &&
byId['oac-tether'].once === true,
);
check('oac-tether: the tether text (the brief, verbatim)', byId['oac-tether']?.text.includes('tethered to your home planet') && byId['oac-tether']?.text.includes('build more tethers'));
check(
'oac-scan: welcome beat three (ack, newGame, intro-03)',
!!byId['oac-scan'] &&
byId['oac-scan'].type === 'ack' &&
byId['oac-scan'].trigger === 'newGame' &&
byId['oac-scan'].audio === 'assets/speech/oac/intro-03.mp3' &&
byId['oac-scan'].once === true,
);
check('oac-scan: the scan instruction (the brief, verbatim)', byId['oac-scan']?.text.includes('begin by scanning') && byId['oac-scan']?.text.includes('click Scan on the bottom bar'));
// The scan reaction — the fourth beat, bound to the player's first SCAN press.
check(
'oac-upgrade: the scan reaction (ack, firstScan, intro-04, once)',
!!byId['oac-upgrade'] &&
byId['oac-upgrade'].type === 'ack' &&
byId['oac-upgrade'].trigger === 'firstScan' &&
byId['oac-upgrade'].audio === 'assets/speech/oac/intro-04.mp3' &&
byId['oac-upgrade'].once === true,
);
check('oac-upgrade: the 5 s beat after the scan click', byId['oac-upgrade']?.delayMs === 5000);
check('oac-upgrade: the upgrade instruction (the brief, verbatim)', byId['oac-upgrade']?.text.includes("I don't see any planets or space stations") && byId['oac-upgrade']?.text.includes('Tether Level 2 upgrade'));
// The tether-research reaction — the pair bound to 'tetherResearch'
// (OAC the moment the player commits to Tether Level 2 research).
check(
'oac-minerals: the tether-research reaction (ack, tetherResearch, intro-05, once)',
!!byId['oac-minerals'] &&
byId['oac-minerals'].type === 'ack' &&
byId['oac-minerals'].trigger === 'tetherResearch' &&
byId['oac-minerals'].audio === 'assets/speech/oac/intro-05.mp3' &&
byId['oac-minerals'].once === true,
);
check('oac-minerals: the 200-minerals brief (the brief, verbatim)', byId['oac-minerals']?.text.includes('upgrade the tether on your home planet itself') && byId['oac-minerals']?.text.includes('mine 200 minerals'));
check(
'oac-fly-mine: chained after oac-minerals (ack, tetherResearch, intro-06, once)',
!!byId['oac-fly-mine'] &&
byId['oac-fly-mine'].type === 'ack' &&
byId['oac-fly-mine'].trigger === 'tetherResearch' &&
byId['oac-fly-mine'].audio === 'assets/speech/oac/intro-06.mp3' &&
byId['oac-fly-mine'].once === true,
);
check('oac-fly-mine: the fly-to + continuous-fly brief (the brief, verbatim)', byId['oac-fly-mine']?.text.includes('hold shift when you click') && byId['oac-fly-mine']?.text.includes('find some asteroids to mine'));
check(
'oac-first-cluster: the first rock field (ack, firstCluster, intro-07, once)',
!!byId['oac-first-cluster'] &&
byId['oac-first-cluster'].type === 'ack' &&
byId['oac-first-cluster'].trigger === 'firstCluster' &&
byId['oac-first-cluster'].audio === 'assets/speech/oac/intro-07.mp3' &&
byId['oac-first-cluster'].once === true,
);
check('oac-first-cluster: the chime beat (delayMs > 0, lands after the discovery SFX)', (byId['oac-first-cluster']?.delayMs ?? 0) > 0);
check('oac-first-cluster: the mining brief (the brief, verbatim)', byId['oac-first-cluster']?.text.includes('first asteroid cluster') && byId['oac-first-cluster']?.text.includes('Keep mining until you reach 200 minerals or more'));
// The research-complete sign-off — the AUDIO-ONLY repeat comm (once:false).
check(
'oac-research-complete: the sign-off (audio-only, researchComplete, once:false)',
!!byId['oac-research-complete'] &&
byId['oac-research-complete'].from === 'oac' &&
byId['oac-research-complete'].type === 'audio' &&
byId['oac-research-complete'].trigger === 'researchComplete' &&
byId['oac-research-complete'].once === false,
);
check('oac-research-complete: speaks research-complete.mp3', byId['oac-research-complete']?.audio === 'assets/speech/oac/research-complete.mp3');
check('oac-research-complete: beats the completion chime (delayMs > 0)', (byId['oac-research-complete']?.delayMs ?? 0) > 0);
// The 200-mineral threshold — OAC sends the player home to build.
check(
'oac-tether2: the 200-mineral beat (ack, minerals200, once)',
!!byId['oac-tether2'] &&
byId['oac-tether2'].from === 'oac' &&
byId['oac-tether2'].type === 'ack' &&
byId['oac-tether2'].trigger === 'minerals200' &&
byId['oac-tether2'].once === true,
);
check('oac-tether2: the build-it-home brief (the brief, verbatim)', byId['oac-tether2']?.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.');
check('oac-tether2: speaks intro-08.mp3', byId['oac-tether2']?.audio === 'assets/speech/oac/intro-08.mp3');
check('oac-tether2: beats the HUD/quest beat (delayMs > 0)', (byId['oac-tether2']?.delayMs ?? 0) > 0);
// The launch-back pair — OAC clears the player to scan, then sends off.
check(
'oac-relaunch: the launch-back beat (ack, tetherL2Launch, once)',
!!byId['oac-relaunch'] &&
byId['oac-relaunch'].from === 'oac' &&
byId['oac-relaunch'].type === 'ack' &&
byId['oac-relaunch'].trigger === 'tetherL2Launch' &&
byId['oac-relaunch'].once === true,
);
check('oac-relaunch: the scan-again brief (the brief, verbatim)', byId['oac-relaunch']?.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.');
check('oac-relaunch: speaks intro-09.mp3', byId['oac-relaunch']?.audio === 'assets/speech/oac/intro-09.mp3');
check(
'oac-explore: chained after oac-relaunch (ack, tetherL2Launch, once, immediate)',
!!byId['oac-explore'] &&
byId['oac-explore'].from === 'oac' &&
byId['oac-explore'].type === 'ack' &&
byId['oac-explore'].trigger === 'tetherL2Launch' &&
byId['oac-explore'].once === true &&
(byId['oac-explore']?.delayMs ?? 0) === 0,
);
check('oac-explore: the send-off (the brief, verbatim)', byId['oac-explore']?.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.');
check('oac-explore: speaks intro-10.mp3', byId['oac-explore']?.audio === 'assets/speech/oac/intro-10.mp3');
check(
'tetherResearch arms the pair in config order (minerals → fly-mine)',
JSON.stringify(commsForTrigger(cfg, 'tetherResearch').map((c) => c.id)) === JSON.stringify(['oac-minerals', 'oac-fly-mine']),
);
check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluster').length === 1 && commsForTrigger(cfg, 'firstCluster')[0].id === 'oac-first-cluster');
check('researchComplete arms the sign-off', commsForTrigger(cfg, 'researchComplete').length === 1 && commsForTrigger(cfg, 'researchComplete')[0].id === 'oac-research-complete');
check('minerals200 arms the 200-mineral beat', commsForTrigger(cfg, 'minerals200').length === 1 && commsForTrigger(cfg, 'minerals200')[0].id === 'oac-tether2');
check('tetherL2Launch arms the launch-back pair in config order', JSON.stringify(commsForTrigger(cfg, 'tetherL2Launch').map((c) => c.id)) === JSON.stringify(['oac-relaunch', 'oac-explore']));
check(
'the chain arms in config order (intro → tether → scan)',
JSON.stringify(commsForTrigger(cfg, 'newGame').map((c) => c.id)) === JSON.stringify(['oac-intro', 'oac-tether', 'oac-scan']),
);
check(
'the reserved triggers are reserved (no comm uses them yet)',
['researchComplete', 'researchNew', 'questNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
['researchNew', 'questNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
(t) => !roster.some((c) => c.trigger === t),
),
);
@ -123,17 +253,27 @@ warnOn = true;
// unknown senders are dropped by the ROSTER (listComms), not normalize
const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] };
const ghostList = listComms(withGhost);
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 1 && ghostList[0].id === 'oac-intro');
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 11 && ghostList[0].id === 'oac-intro');
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
check('drop: duplicate ids (first wins)', dupList.length === 1 && dupList[0].id === 'oac-intro');
check('drop: duplicate ids (first wins)', dupList.length === 11 && dupList[0].id === 'oac-intro');
warnOn = true;
// ----------------------------------------------------------------------
// commsForTrigger — the due set (order + the played ledger)
// ----------------------------------------------------------------------
check('newGame arms the welcome on a fresh run', commsForTrigger(cfg, 'newGame').length === 1 && commsForTrigger(cfg, 'newGame')[0].id === 'oac-intro');
check('a played `once` comm does not re-arm', commsForTrigger(cfg, 'newGame', new Set(['oac-intro'])).length === 0);
check('newGame arms the whole welcome chain on a fresh run', commsForTrigger(cfg, 'newGame').length === 3 && commsForTrigger(cfg, 'newGame')[0].id === 'oac-intro');
check('a played `once` comm does not re-arm (the rest of the chain does)', JSON.stringify(commsForTrigger(cfg, 'newGame', new Set(['oac-intro'])).map((c) => c.id)) === JSON.stringify(['oac-tether', 'oac-scan']));
check('firstScan arms the scan reaction', commsForTrigger(cfg, 'firstScan').length === 1 && commsForTrigger(cfg, 'firstScan')[0].id === 'oac-upgrade');
check('a played oac-upgrade does not re-arm firstScan', commsForTrigger(cfg, 'firstScan', new Set(['oac-upgrade'])).length === 0);
check('tetherResearch arms the pair on a fresh run', commsForTrigger(cfg, 'tetherResearch').length === 2);
check('a played oac-minerals does not re-arm its half (the other still does)', JSON.stringify(commsForTrigger(cfg, 'tetherResearch', new Set(['oac-minerals'])).map((c) => c.id)) === JSON.stringify(['oac-fly-mine']));
check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluster').length === 1);
check('a played oac-first-cluster does not re-arm firstCluster', commsForTrigger(cfg, 'firstCluster', new Set(['oac-first-cluster'])).length === 0);
check('a played oac-research-complete RE-arms (once:false — every completion signs off)', commsForTrigger(cfg, 'researchComplete', new Set(['oac-research-complete'])).length === 1);
check('a played oac-tether2 does not re-arm minerals200', commsForTrigger(cfg, 'minerals200', new Set(['oac-tether2'])).length === 0);
check('a played half of the launch-back pair still arms the other', JSON.stringify(commsForTrigger(cfg, 'tetherL2Launch', new Set(['oac-relaunch'])).map((c) => c.id)) === JSON.stringify(['oac-explore']));
check('a fully played launch-back pair re-arms nothing', commsForTrigger(cfg, 'tetherL2Launch', new Set(['oac-relaunch', 'oac-explore'])).length === 0);
check('an unknown trigger arms nothing', commsForTrigger(cfg, 'launchParty').length === 0);
check('a blank trigger arms nothing', commsForTrigger(cfg, ' ').length === 0);

View File

@ -129,25 +129,34 @@ export class CommsHub {
// ------------------------------------------------------------------
/**
* Arm the comms due for an event ('newGame', 'researchComplete', ).
* Arm the comms due for an event ('newGame', 'firstScan', ).
* Each one queues with its delayMs and starts its deferred asset load
* immediately (while the event's toast is still up, the network is
* warm) by the time the scene is free they are in.
* IDEMPOTENT: a comm already in play or already queued is not armed
* twice (a double click on the button that fired the event must not
* double the comm the played ledger only knows once it has STARTED).
*
* @returns {number} how many comms armed
*/
trigger(name) {
if (this._down || !this.enabled) return 0;
const due = commsForTrigger(this.cfg, name, this.played);
for (const def of due) {
const inFlight = new Set();
if (this.current) inFlight.add(this.current.id);
for (const e of this.queue) inFlight.add(e.def.id);
let armed = 0;
for (const def of commsForTrigger(this.cfg, name, this.played)) {
if (inFlight.has(def.id)) continue; // already queued / in play
this.queue.push({
def,
dueAt: this.scene.time.now + def.delayMs,
assets: commAssets(this.cfg, def),
loadingDone: false,
});
inFlight.add(def.id);
armed++;
}
return due.length;
return armed;
}
/**

View File

@ -805,6 +805,19 @@ export class GameScene extends Phaser.Scene {
},
});
// The run's FIRST rock field (the 'firstCluster' comm beat — see
// celebrateDiscovery): a scene-local one-shot, so the event fires
// exactly once per scene life. The comm's own once-ledger is the
// authoritative dedupe across saves (a flag reset on load can never
// re-play a comm the ledger already holds).
this._firstClusterSeen = false;
// The run's FIRST crossing of the 200-mineral line (the 'minerals200'
// comm beat — see onOre): same one-shot shape; the threshold check
// also runs at boot (create, below) so a loaded run that already
// crossed it still gets the beat.
this._minerals200Armed = false;
// Hint (pinned just ABOVE the command deck, not under it)
this.hint = this.add
.text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), {
@ -834,6 +847,16 @@ export class GameScene extends Phaser.Scene {
// not the hold — the hold is cargo, this is the record).
this.totalMined = (this.totalMined ?? 0) + Math.max(0, Number(gained) || 0);
this._questRefresh();
// The comms beat (data/comms.json: minerals200 — oac-tether2):
// OAC confirms the moment the run's lifetime mining first crosses
// 200 — the 'Mine 200 Minerals' quest line (data/quests.json) and
// the tether-l2 build cost (data/builds.json). The scene flag fires
// the event once per scene life; the comm's once-ledger is the
// authoritative dedupe across saves.
if (!this._minerals200Armed && this.totalMined >= 200) {
this._minerals200Armed = true;
this.comms?.trigger('minerals200');
}
},
});
this.miningPopup = new MiningPopup(this, {
@ -1339,6 +1362,11 @@ export class GameScene extends Phaser.Scene {
// hub's delayMs beats the opening toast, and its deferred load pulls
// OAC's clip + speech on demand (the scene's preload stays lean).
if (this._freshRun) this.comms.trigger('newGame');
// A loaded run that ALREADY crossed the 200-mineral line (a save from
// before the 'minerals200' beat existed) arms it here too — on a fresh
// run totalMined is zero so this is a no-op; the hub's ledger /
// in-flight guards keep the beat a one-shot either way.
if ((this.totalMined ?? 0) >= 200) this.comms.trigger('minerals200');
}
/**
@ -2811,6 +2839,18 @@ export class GameScene extends Phaser.Scene {
durationMs: 2400,
});
this._questRefresh(); // a 'Discover another world' requirement may have just landed
// The comms beat (data/comms.json: firstCluster — oac-first-cluster):
// OAC celebrates the FIRST rock field the player finds. The scene
// flag fires the event exactly once; the comm's once-ledger is the
// authoritative dedupe across saves.
if (!this._firstClusterSeen) {
const isCluster = (this.asteroidClusters ?? []).some((c) => c.discoveryId === o.id);
if (isCluster) {
this._firstClusterSeen = true;
this.comms?.trigger('firstCluster');
}
}
}
showTargetMarker(x, y) {
@ -3639,6 +3679,15 @@ export class GameScene extends Phaser.Scene {
});
return;
}
// The comms reaction (data/comms.json: tetherResearch — oac-minerals
// → oac-fly-mine): OAC reacts the moment the player commits to the
// Tether Level 2 research. The node is the canonical 'tether_l2'
// (data/research/exploration.json — the opening quest's 'research'
// check references the same). Queued now; the busy() contract holds
// the pair until the research console closes.
if (catId === 'exploration' && id === 'tether_l2') {
this.comms?.trigger('tetherResearch');
}
const durMs = Math.max(0, Number(node.duration ?? 0) * 1000);
if (durMs <= 0) {
// Instant tech (duration 0) — applies the moment it's requested.
@ -3665,6 +3714,14 @@ export class GameScene extends Phaser.Scene {
const node = this._treeFor(catId)?.nodes?.[id];
if (node?.effects) this._applyResearchEffects(node.effects, node, id);
this._completeResearchFx(catId, id, node);
// The comms sign-off (data/comms.json: researchComplete —
// oac-research-complete, audio-only, once:false — it plays on EVERY
// completion). It arms now, whatever the console is doing: the
// busy() contract holds it while the research window is still open
// (and the FIFO behind any comm already playing), and it rides the
// action once the screen is free — the clock can't expire while an
// ACK comm pauses the scene, so it always lands on a live run.
this.comms?.trigger('researchComplete');
}
/** The tree for a category: the SYSTEM category's live per-system tree,
@ -4028,6 +4085,10 @@ export class GameScene extends Phaser.Scene {
return;
}
if (this.scanPulse.busy) return; // a sweep is already out
// The comms reaction (data/comms.json: firstScan — oac-upgrade): the
// comm's own delayMs is the beat after this click; the busy() contract
// below (scanPulse.busy) holds it until the sweep settles.
this.comms?.trigger('firstScan');
const ox = this.ship.x;
const oy = this.ship.y;
const tethers = this.tetherField.tethers.map((t) => ({ x: t.x, y: t.y, radius: t.radius }));

View File

@ -834,6 +834,18 @@ export class SurfaceScene extends Phaser.Scene {
this.setSurfaceMusic(false); // the takeoff clip ends — the hum ends with it
this.destroyVideo(this.takeoffVideo);
this.takeoffVideo = null;
// The comms beat (data/comms.json: tetherL2Launch — oac-relaunch →
// oac-explore): the player is launching back into space with the
// level-2 tether installed on the HOME world (the run's build record
// is the witness — BuildState outlives this stay and saves with the
// run). Armed now, while the GameScene is still paused: the hub only
// queues (due off the scene clock, which resumes with the wake), and
// the FIFO plays the pair once the flight world is back — the comm's
// once-ledger makes any later take-off a no-op.
const home = this.gameScene?.homeWorldName;
if (home && this.gameScene.buildState?.isBuilt(home, 'tether-l2')) {
this.gameScene.comms?.trigger('tetherL2Launch');
}
this.scene.stop('SurfaceScene');
this.scene.wake('GameScene');
}