Rename firstForeignPlanet trigger to firstWorld and make busy() type-awa
- The Trestle intro comm now fires on the first discovered world (the starter quest's own witness, `discoveredWorldCount`) instead of the first planet in a foreign system, so the comm and the quest can never drift; delayMs drops to 0 so it lands at the moment of discovery. - The scene's `busy()` contract is now comm-type-aware: audio-only comms (voice, no box, no pause) ride over in-progress actions like mining or a scan sweep, while ack/visual comms still wait; modal windows and a jump in flight block every type. - Add the `comms-mine` dev harness that walks the type-aware busy contract (free scene → real mining → audio comm mid-mining → jump) and update the trestle dev scripts/tests to the new trigger and witness. - Arm the pair on scene-create for loaded saves that already hold discovered worlds, and widen the cdp-shot ready dump. - Add ice-shop art/video assets.
This commit is contained in:
parent
8afe4c78c6
commit
b33aecade2
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
Binary file not shown.
|
|
@ -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 (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; 'firstForeignPlanet' — trestle-meetup → trestle-quest: the FIRST planet discovered in a system that is NOT the player's home (GameScene.celebrateDiscovery; the scene flag fires once, the comm's once-ledger dedupes across saves), 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.",
|
||||
"$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; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), 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.",
|
||||
|
|
@ -95,7 +95,7 @@
|
|||
"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.",
|
||||
"$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 is TYPE-AWARE: the sign-off rides OVER in-progress actions (mining live, the scan sweep out — voice with no box and no pause), but still holds while a MODAL owns the screen (the research console, any window/panel, a jump in flight). delayMs beats the completion chime (1.5 s, sfx.discovery) so it lands before the voice.",
|
||||
"id": "oac-research-complete",
|
||||
"from": "oac",
|
||||
"type": "audio",
|
||||
|
|
@ -138,22 +138,22 @@
|
|||
"delayMs": 0
|
||||
},
|
||||
{
|
||||
"$comment": "Trestle's introduction (trigger 'firstForeignPlanet' — GameScene.celebrateDiscovery, the FIRST planet the player discovers in a system that is NOT home: the scene flag fires the event once per scene life, the comm's once-ledger dedupes across saves). Trestle is out of system, sees the player has arrived, and sets up the meet-up. delayMs beats the discovery chime (1.5 s, sfx.discovery) so the SFX lands before he speaks.",
|
||||
"$comment": "Trestle's introduction (trigger 'firstWorld' — GameScene.celebrateDiscovery, the FIRST world the player discovers: the starter quest's own witness, discoveredWorldCount, the 'Discover another planet or space station' check — any planet or space station, the home world never counts, in any system, so the comm and the quest can never drift; the scene flag fires the event once per scene life, the comm's once-ledger dedupes across saves, and a scene-create arm covers loaded saves that already hold discovered worlds). delayMs 0 — it fires AT the moment of discovery: discovery happens 540 px out of the world's edge (data/game.json → discovery.distance), so the ack hold is up long before the player could be in a landing (landing is the comms-panel button — a two-step action), and the box's input gate blocks any landing click for the rest of the hold.",
|
||||
"id": "trestle-meetup",
|
||||
"from": "trestle",
|
||||
"type": "ack",
|
||||
"trigger": "firstForeignPlanet",
|
||||
"trigger": "firstWorld",
|
||||
"text": "Hey Kid. Not bad.. not bad at all.. You're getting the hang of this quicker than I thought. Listen, I'm just out of system, but I think it's time we meet up. Finish exploring this system so you can unlock the jump gates. I'll ping you once you're ready.",
|
||||
"audio": "assets/speech/trestle/intro-01.mp3",
|
||||
"once": true,
|
||||
"delayMs": 1500
|
||||
"delayMs": 0
|
||||
},
|
||||
{
|
||||
"$comment": "Chained right after trestle-meetup is acknowledged (same trigger, config order, delayMs 0 — the FIFO starts it the moment the previous is ACKed). Trestle points at the completed starter quest (data/quests.json → start_your_journey, the 200-mineral reward) and how to claim it.",
|
||||
"id": "trestle-quest",
|
||||
"from": "trestle",
|
||||
"type": "ack",
|
||||
"trigger": "firstForeignPlanet",
|
||||
"trigger": "firstWorld",
|
||||
"text": "Oh and by the way, you just completed your first quest. If you click on the quest in the upper left, or click the quests button below, you can complete the quest and claim 200 minerals I set aside just for you. See you soon kid!",
|
||||
"audio": "assets/speech/trestle/intro-02.mp3",
|
||||
"once": true,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ async function main() {
|
|||
const buf = Buffer.from(shot.data, 'base64');
|
||||
await (await import('node:fs/promises')).writeFile(out, buf);
|
||||
ws.close();
|
||||
console.log(`screenshot: ${out} (${buf.length} bytes)` + (ready ? ` · ready=${JSON.stringify(ready).slice(0, 400)}` : ''));
|
||||
console.log(`screenshot: ${out} (${buf.length} bytes)` + (ready ? ` · ready=${JSON.stringify(ready).slice(0, 8000)}` : ''));
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('cdp-shot failed:', err.message); process.exit(1); });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev: comms × mining (the type-aware busy contract)</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-mine.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* COMMS × MINING — the TYPE-AWARE busy() contract (2026-07, user ask:
|
||||
* "let audio-only comms play even if the player is currently mining").
|
||||
*
|
||||
* The scene's busy() (GameScene → CommsHub) is now comm-type-aware:
|
||||
*
|
||||
* screen-owned (modal window/panel up, or a jump in flight)
|
||||
* → NO comm of any type starts until it's clear;
|
||||
* action in progress (mining live, the scan sweep out)
|
||||
* → box comms (ack/visual — they pause the action or take the
|
||||
* screen) wait; an 'audio' comm (voice only — no box, no pause,
|
||||
* no input) rides right over the action.
|
||||
*
|
||||
* Walk:
|
||||
* 1) free scene → busy is false for every type;
|
||||
* 2) start REAL mining (the pop-up's own path: Mining.begin on a live
|
||||
* cluster member → the arm extends → the beam goes live)
|
||||
* → busy('audio') is FALSE, busy('ack')/busy('visual') stay TRUE;
|
||||
* 3) trigger oac-research-complete (the AUDIO comm) MID-MINING
|
||||
* → it starts (delayMs 1500 beat, then the pump), no box, no
|
||||
* pause, the voice plays, and the MINING KEEPS RUNNING (the
|
||||
* comm did not stop the action);
|
||||
* 4) the screen-owned branch still holds audio: a jump in flight
|
||||
* (_jumping) → busy('audio') is TRUE.
|
||||
*
|
||||
* Headless note: the AudioContext is suspended, so the 1.7 s clip never
|
||||
* reaches its natural end — the test stops the voice with stopByKey()
|
||||
* (exactly what the hub's isPlaying poll reads as "the voice ended");
|
||||
* the hub must then finish the comm on its own.
|
||||
*
|
||||
* node dev/cdp-shot.mjs "http://127.0.0.1:3000/dev/comms-mine.html" o.png \
|
||||
* "window.__commsMine" 90000
|
||||
*/
|
||||
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();
|
||||
|
||||
// Roster override: only the audio comm — the newGame welcome chain must
|
||||
// not pollute the walk (it is ack comms, which this test is NOT about).
|
||||
const roster = (data.comms?.comms ?? []).filter((c) => c.trigger === 'researchComplete');
|
||||
if (roster.length !== 1 || roster[0].id !== 'oac-research-complete') {
|
||||
throw new Error('roster override expected exactly oac-research-complete');
|
||||
}
|
||||
data.comms = { ...(data.comms ?? {}), comms: roster };
|
||||
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:55%;white-space:pre-wrap;';
|
||||
document.body.appendChild(report);
|
||||
const log = (m) => { report.textContent += m + '\n'; };
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 20000) => {
|
||||
const t0 = Date.now();
|
||||
for (;;) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
if (Date.now() - t0 > ms) return null;
|
||||
await sleep(120);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
const d = [];
|
||||
try {
|
||||
const s = () => game.scene.getScene('GameScene');
|
||||
const ready = await until(() => {
|
||||
const sc = s();
|
||||
return sc && sc.ship && sc.comms && (sc.asteroidClusters?.length ?? 0) > 0 ? sc : null;
|
||||
}, 30000);
|
||||
if (!ready) throw new Error('scene never ready (no cluster?)');
|
||||
const busy = (t) => s().comms.busy(t);
|
||||
|
||||
// ---- 1) free scene -------------------------------------------------
|
||||
const freeOk = busy('audio') === false && busy('ack') === false && busy('visual') === false;
|
||||
d.push(`free: busy audio=${busy('audio')} ack=${busy('ack')} visual=${busy('visual')} (expect false/false/false) ${freeOk ? '✔' : '✘'}`);
|
||||
|
||||
// ---- 2) start REAL mining -----------------------------------------
|
||||
const cluster = s().asteroidClusters[0];
|
||||
const member = cluster.members[0];
|
||||
const began = s().mining.begin(cluster, member); // the pop-up's own path
|
||||
if (began === false) throw new Error('mining.begin refused (already extending?)');
|
||||
const miningLive = await until(() => (s().mining.isActive ? s() : null), 8000);
|
||||
if (!miningLive) throw new Error('mining never went active');
|
||||
const phaseOk = busy('audio') === false && busy('ack') === true && busy('visual') === true;
|
||||
d.push(`mining live (state=${s().mining.state}, ship=${s().ship.state}): busy audio=${busy('audio')} (expect false — RIDES OVER) ack=${busy('ack')} (expect true) visual=${busy('visual')} (expect true) ${phaseOk ? '✔' : '✘'}`);
|
||||
|
||||
// ---- 3) the audio comm MID-MINING (the user ask) -------------------
|
||||
s().comms.trigger('researchComplete');
|
||||
const live = await until(
|
||||
() => (s().comms.active && s().comms.current?.id === 'oac-research-complete' ? s() : null),
|
||||
10000,
|
||||
);
|
||||
if (!live) throw new Error('the audio comm never came up mid-mining');
|
||||
const vKey = s().comms._voiceKey;
|
||||
const vPlaying = vKey ? s().sound.isPlaying(vKey) : false;
|
||||
const stillMining = s().mining.isActive === true && s().ship.state === 'mining';
|
||||
const step3 =
|
||||
s().comms.box === null && // NO box (the audio contract)
|
||||
s().comms.paused === false && // NO pause
|
||||
vPlaying === true && // the voice is talking
|
||||
stillMining; // the action KEEPS RUNNING
|
||||
d.push(`mid-mining: active=true box=${!!s().comms.box} (expect false) paused=${s().comms.paused} (expect false) voice=${vKey ? 'playing' : 'SILENT'} (expect playing) miningStillActive=${stillMining} (expect true) ${step3 ? '✔' : '✘'}`);
|
||||
|
||||
// End the voice the way the hub's poll sees an end; the comm must
|
||||
// finish on its own (and leave the action untouched).
|
||||
if (vKey) s().sound.stopByKey(vKey);
|
||||
const done = await until(() => (s().comms.current?.id !== 'oac-research-complete' ? true : null), 6000);
|
||||
d.push(`voice-ended: comm auto-finished=${!!done} miningStillActive=${s().mining.isActive} (expect true) paused=${s().comms.paused}`);
|
||||
const step3b = !!done && s().mining.isActive === true;
|
||||
|
||||
// ---- 4) screen-owned still holds audio (a jump in flight) ----------
|
||||
s()._jumping = true;
|
||||
const jumpAudio = busy('audio');
|
||||
const jumpAck = busy('ack');
|
||||
const jumpHolds = jumpAudio === true && jumpAck === true;
|
||||
s()._jumping = false;
|
||||
const afterAudio = busy('audio');
|
||||
const afterAck = busy('ack');
|
||||
d.push(`jump in flight: busy audio=${jumpAudio} ack=${jumpAck} (both expect true) → after reset audio=${afterAudio} ack=${afterAck} (mining still live → false/true) ${jumpHolds ? '✔' : '✘'}`);
|
||||
|
||||
const ok = freeOk && phaseOk && step3 && step3b && jumpHolds;
|
||||
window.__commsMine = { ok, details: d };
|
||||
console.log('[comms-mine]', ok ? 'PASS' : 'FAIL', d.join(' | '));
|
||||
} catch (err) {
|
||||
window.__commsMine = { ok: false, details: [...d, 'EXCEPTION: ' + String(err?.stack ?? err)] };
|
||||
console.log('[comms-mine] FAIL', String(err));
|
||||
}
|
||||
}, 800);
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Dev-only: HOLD Trestle's intro comm open (no ACK) for a clean
|
||||
* screenshot — the pair armed through the REAL discovery path
|
||||
* (foreign system + a planet), the box live (video rolling, text
|
||||
* decoded, button armed + pulsing).
|
||||
* (the first non-home world — the quest's own witness), the box live
|
||||
* (video rolling, text decoded, button armed + pulsing).
|
||||
*
|
||||
* node dev/cdp-shot.mjs "http://127.0.0.1:3000/dev/comms-trestle-hold.html" out.png \
|
||||
* "window.__trestleHold" 90000 "document.getElementById('report')?.remove()"
|
||||
|
|
@ -15,7 +15,7 @@ import { GameScene } from '../js/scenes/GameScene.js';
|
|||
|
||||
const data = await ConfigLoader.load();
|
||||
const shipped = data.comms;
|
||||
data.comms = { ...shipped, comms: (shipped?.comms ?? []).filter((c) => c.trigger === 'firstForeignPlanet') };
|
||||
data.comms = { ...shipped, comms: (shipped?.comms ?? []).filter((c) => c.trigger === 'firstWorld') };
|
||||
config.init(data);
|
||||
const gameConfig = createGameConfig();
|
||||
gameConfig.scene = [GameScene];
|
||||
|
|
@ -47,8 +47,10 @@ setTimeout(async () => {
|
|||
if (!ready) { report.textContent = 'FAIL — scene never ready'; window.__trestleHold = { ok: false }; return; }
|
||||
const planet = (s().systemPlanets ?? [])[0];
|
||||
if (!planet) { report.textContent = 'FAIL — no planet'; window.__trestleHold = { ok: false }; return; }
|
||||
// The real arm: a foreign system, a planet discovery.
|
||||
s().isHomeSystem = false;
|
||||
// The real arm: the FIRST non-home world discovered — the quest's own
|
||||
// witness (discoveredWorldCount). Tick the ledger first, exactly like
|
||||
// discovery.check does, then celebrate.
|
||||
s().discovery.bySystem.get(s().systemRecord.id)?.add(planet.discoveryId);
|
||||
s().celebrateDiscovery({
|
||||
id: planet.discoveryId,
|
||||
x: planet.x,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Dev-only: exercise TRESTLE's introduction pair in the live GameScene —
|
||||
* the FIRST planet the player discovers in a system that is NOT home
|
||||
* (trigger 'firstForeignPlanet'):
|
||||
* (trigger 'firstWorld'):
|
||||
*
|
||||
* trestle-meetup → ack: box holds (muted looping video + text + voice)
|
||||
* until ACKNOWLEDGE.
|
||||
|
|
@ -10,13 +10,16 @@
|
|||
* The roster is overridden at boot so ONLY the pair is live (the newGame
|
||||
* welcome stays out of the FIFO) — but everything else is the shipped
|
||||
* code: the real character entry (his clip + his speech keys), the real
|
||||
* GameScene.celebrateDiscovery arm (the scene flag + the witness check),
|
||||
* the real hub (FIFO, the ack contract, the ledger), the real box.
|
||||
* GameScene.celebrateDiscovery arm (the scene flag + the QUEST's OWN
|
||||
* witness, discoveredWorldCount), the real hub (FIFO, the ack contract,
|
||||
* the ledger), the real box.
|
||||
*
|
||||
* The witness matrix (all through the real celebrateDiscovery):
|
||||
* 1. home system, a planet discovered → NO arm (home doesn't count)
|
||||
* 2. foreign system, a cluster → NO arm (planets are the witness)
|
||||
* 3. foreign system, a PLANET → the pair arms, in config order
|
||||
* The witness matrix (all through the real celebrateDiscovery, with the
|
||||
* discovery ledger ticked first exactly like discovery.check does):
|
||||
* 1. the HOME WORLD discovered → NO arm (it never counts)
|
||||
* 2. a CLUSTER discovered → NO arm (not a world/station)
|
||||
* 3. a PLANET discovered (non-home) → the pair arms, the quest witness
|
||||
* reads 1 — comm and quest match
|
||||
*
|
||||
* Headless note: the AudioContext is suspended, so the clips never reach
|
||||
* a natural end — the ACK clicks drive the flow (ack comms hold until
|
||||
|
|
@ -37,7 +40,7 @@ const data = await ConfigLoader.load();
|
|||
const shipped = data.comms;
|
||||
data.comms = {
|
||||
...shipped,
|
||||
comms: (shipped?.comms ?? []).filter((c) => c.trigger === 'firstForeignPlanet'),
|
||||
comms: (shipped?.comms ?? []).filter((c) => c.trigger === 'firstWorld'),
|
||||
};
|
||||
config.init(data);
|
||||
|
||||
|
|
@ -79,7 +82,8 @@ setTimeout(async () => {
|
|||
const quiet0 = s.comms.active === false && s.comms.queue.length === 0;
|
||||
d.push(`boot: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`);
|
||||
|
||||
// A real planet of this system (the arm's witness: discoveryId match).
|
||||
// A real planet of this system (the arm's witness: the quest's
|
||||
// discoveredWorldCount — any non-home planet/station, any system).
|
||||
const planet = (s.systemPlanets ?? [])[0];
|
||||
const cluster = (s.asteroidClusters ?? [])[0];
|
||||
if (!planet) throw new Error('no system planet to discover');
|
||||
|
|
@ -91,48 +95,59 @@ setTimeout(async () => {
|
|||
name,
|
||||
typeLabel,
|
||||
});
|
||||
// A discovery, the way the real flow does it: discovery.check adds the
|
||||
// id to the ledger FIRST, then the scene celebrates it.
|
||||
const discover = (o) => {
|
||||
s.discovery.bySystem.get(s.systemRecord.id)?.add(o.id);
|
||||
s.celebrateDiscovery(o);
|
||||
};
|
||||
|
||||
// ---- witness 1 — home system: a planet does NOT count -------------
|
||||
// ---- witness 1 — the HOME WORLD never counts ----------------------
|
||||
if (s.isHomeSystem !== true) throw new Error('scene should boot in the home system');
|
||||
s.celebrateDiscovery(disc(planet, planet.discoveryName, 'Rocky World'));
|
||||
discover({ id: 'home', x: s.planet.x, y: s.planet.y, radius: s.planet.radius, name: s.planet.discoveryName, typeLabel: 'Home World' });
|
||||
await sleep(400);
|
||||
const homeGuard = s.comms.active === false && s.comms.queue.length === 0 && s._firstForeignPlanetSeen === false;
|
||||
d.push(`witness 1 (home planet): armed=${s.comms.queue.length} (expect 0 — home doesn't count) flag=${s._firstForeignPlanetSeen} (expect false)`);
|
||||
const homeGuard = s.comms.active === false && s.comms.queue.length === 0 && s._firstWorldSeen === false && s.discoveredWorldCount() === 0;
|
||||
d.push(`witness 1 (home world): armed=${s.comms.queue.length} (expect 0 — home never counts) flag=${s._firstWorldSeen} (expect false) count=${s.discoveredWorldCount()} (expect 0)`);
|
||||
if (!homeGuard) throw new Error('the home world armed the comm');
|
||||
|
||||
// ---- witness 2 — foreign system: a CLUSTER does not count ---------
|
||||
s.isHomeSystem = false; // simulate the jump: the scene now holds a foreign system
|
||||
// ---- witness 2 — a CLUSTER does not count --------------------------
|
||||
if (cluster) {
|
||||
s.celebrateDiscovery(disc(cluster, cluster.discoveryName, 'Asteroid Cluster'));
|
||||
discover(disc(cluster, cluster.discoveryName, 'Asteroid Cluster'));
|
||||
await sleep(400);
|
||||
const clusterGuard = s.comms.active === false && s.comms.queue.length === 0 && s._firstForeignPlanetSeen === false;
|
||||
d.push(`witness 2 (foreign cluster): armed=${s.comms.queue.length} (expect 0 — planets are the witness) flag=${s._firstForeignPlanetSeen} (expect false)`);
|
||||
if (!clusterGuard) throw new Error('a cluster armed the planet comm');
|
||||
const clusterGuard = s.comms.active === false && s.comms.queue.length === 0 && s._firstWorldSeen === false;
|
||||
d.push(`witness 2 (cluster): armed=${s.comms.queue.length} (expect 0 — not a world/station) flag=${s._firstWorldSeen} (expect false)`);
|
||||
if (!clusterGuard) throw new Error('a cluster armed the comm');
|
||||
} else {
|
||||
d.push('witness 2 (foreign cluster): skipped (no cluster in this system)');
|
||||
d.push('witness 2 (cluster): skipped (no cluster in this system)');
|
||||
}
|
||||
|
||||
// ---- witness 3 — foreign system: the first PLANET — the pair ------
|
||||
s.celebrateDiscovery(disc(planet, planet.discoveryName, 'Rocky World'));
|
||||
const armOk = s._firstForeignPlanetSeen === true && s.comms.queue.length + (s.comms.active ? 1 : 0) === 2;
|
||||
d.push(`witness 3 (foreign planet): flag=${s._firstForeignPlanetSeen} (expect true) armed=${s.comms.queue.length + (s.comms.active ? 1 : 0)} (expect 2 — the pair)`);
|
||||
if (!armOk) throw new Error('the planet did not arm the pair');
|
||||
// ---- witness 3 — the first NON-HOME planet — the pair --------------
|
||||
discover(disc(planet, planet.discoveryName, 'Rocky World'));
|
||||
const armOk = s._firstWorldSeen === true && s.discoveredWorldCount() === 1 && s.comms.queue.length + (s.comms.active ? 1 : 0) === 2;
|
||||
d.push(`witness 3 (planet): flag=${s._firstWorldSeen} (expect true) questWitness=${s.discoveredWorldCount()} (expect 1) armed=${s.comms.queue.length + (s.comms.active ? 1 : 0)} (expect 2 — the pair)`);
|
||||
if (!armOk) throw new Error('the planet did not arm the pair (or the quest witness drifted)');
|
||||
|
||||
const ackStep = async (id, voiceKey, videoKey) => {
|
||||
const live = await until(() => (s.comms.active && s.comms.current?.id === id ? s : null), 20000);
|
||||
if (!live) return false;
|
||||
const box = s.comms.box;
|
||||
const el = box?.video?.video;
|
||||
const clipIsHim =
|
||||
box.videoSpec?.key === videoKey && // his loader key (not OAC's)
|
||||
(!el || (el.currentSrc ?? '').includes('trestle.mp4')); // his file on the wire
|
||||
// The clip witness: his LOADER entry (the authoritative key→file map,
|
||||
// set before the box exists) + the element's src once the browser has
|
||||
// attached it (transiently empty right after attach — grace it).
|
||||
const assetUrl = s.cache?.video?.get?.(videoKey)?.url ?? '';
|
||||
const clipIsHim = assetUrl.includes('trestle.mp4');
|
||||
if (el) await until(() => (el.currentSrc ?? '') !== '', 2000); // src attach grace
|
||||
const elSrc = el ? el.currentSrc ?? el.src ?? '' : '';
|
||||
const elOk = !!el && el.muted === true && el.loop === true && el.paused === false &&
|
||||
(elSrc === '' || elSrc.includes('trestle.mp4')); // never OAC's clip
|
||||
const contract =
|
||||
s.comms.paused === true && // the ack pauses the action
|
||||
!!box &&
|
||||
!!box.button && // the ACKNOWLEDGE button
|
||||
clipIsHim &&
|
||||
!!el && el.muted === true && el.loop === true && el.paused === false && // muted, looping, playing
|
||||
clipIsHim && elOk && // his clip, muted + looping + playing
|
||||
s.sound.isPlaying(voiceKey) === true; // his voice
|
||||
d.push(`${id}: paused=${s.comms.paused} box=${!!box} ackBtn=${!!box?.button} clip=${el ? el.paused === false && el.muted : '—'} loop=${el?.loop ?? '—'} videoKey=${box?.videoSpec?.key ?? '—'} elSrc=${el ? (el.currentSrc ?? '').split('/').pop() : '—'} voice=${voiceKey} playing=${s.sound.isPlaying(voiceKey)}`);
|
||||
d.push(`${id}: paused=${s.comms.paused} box=${!!box} ackBtn=${!!box?.button} clip=${el ? el.paused === false && el.muted : '—'} loop=${el?.loop ?? '—'} asset=${assetUrl.split('/').pop()} elSrc=${(elSrc.split('/').pop() || '—')} voice=${voiceKey} playing=${s.sound.isPlaying(voiceKey)}`);
|
||||
// Press ACKNOWLEDGE (the player's one move while it holds).
|
||||
box.button.panel.emit('pointerdown');
|
||||
await until(() => (s.comms.current?.id !== id ? true : null), 6000);
|
||||
|
|
@ -149,8 +164,8 @@ setTimeout(async () => {
|
|||
const ledger = s.registry.get('commsPlayed');
|
||||
d.push(`ledger: played=${JSON.stringify(ledger ? Array.from(ledger) : [])}`);
|
||||
const playedOk = !!ledger && ledger.has('trestle-meetup') && ledger.has('trestle-quest');
|
||||
const rearm = s.comms.trigger('firstForeignPlanet');
|
||||
d.push(`re-arm: firstForeignPlanet=${rearm} (expect 0 — the ledger holds)`);
|
||||
const rearm = s.comms.trigger('firstWorld');
|
||||
d.push(`re-arm: firstWorld=${rearm} (expect 0 — the ledger holds)`);
|
||||
|
||||
const allOk = quiet0 && homeGuard && armOk && meetupOk && questOk && quiet && playedOk && rearm === 0 && errors.length === 0;
|
||||
const errLine = errors.length ? ['console errors:', ...errors.slice(0, 4)] : [];
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
* 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,
|
||||
* and trestle-meetup → trestle-quest on firstForeignPlanet);
|
||||
* and trestle-meetup → trestle-quest on firstWorld);
|
||||
* - 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),
|
||||
|
|
@ -199,25 +199,26 @@ check(
|
|||
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');
|
||||
|
||||
// The foreign-planet pair — Trestle meets the player the first time they
|
||||
// find a planet OUTSIDE the home system (data/comms.json: firstForeignPlanet).
|
||||
// The first-world pair — Trestle meets the player the moment the FIRST
|
||||
// world is discovered: the starter quest's own witness (any planet or
|
||||
// space station, the home world never counts — data/comms.json: firstWorld).
|
||||
check(
|
||||
'trestle-meetup: the foreign-planet intro (ack, firstForeignPlanet, trestle, once)',
|
||||
'trestle-meetup: the first-world intro (ack, firstWorld, trestle, once)',
|
||||
!!byId['trestle-meetup'] &&
|
||||
byId['trestle-meetup'].from === 'trestle' &&
|
||||
byId['trestle-meetup'].type === 'ack' &&
|
||||
byId['trestle-meetup'].trigger === 'firstForeignPlanet' &&
|
||||
byId['trestle-meetup'].trigger === 'firstWorld' &&
|
||||
byId['trestle-meetup'].once === true,
|
||||
);
|
||||
check('trestle-meetup: the meet-up brief (the brief, verbatim)', byId['trestle-meetup']?.text === 'Hey Kid. Not bad.. not bad at all.. You\'re getting the hang of this quicker than I thought. Listen, I\'m just out of system, but I think it\'s time we meet up. Finish exploring this system so you can unlock the jump gates. I\'ll ping you once you\'re ready.');
|
||||
check('trestle-meetup: speaks trestle/intro-01.mp3', byId['trestle-meetup']?.audio === 'assets/speech/trestle/intro-01.mp3');
|
||||
check('trestle-meetup: beats the discovery chime (delayMs > 0)', (byId['trestle-meetup']?.delayMs ?? 0) > 0);
|
||||
check('trestle-meetup: fires AT the moment of discovery (delayMs 0 — before the player can land)', (byId['trestle-meetup']?.delayMs ?? 0) === 0);
|
||||
check(
|
||||
'trestle-quest: chained after trestle-meetup (ack, firstForeignPlanet, once, immediate)',
|
||||
'trestle-quest: chained after trestle-meetup (ack, firstWorld, once, immediate)',
|
||||
!!byId['trestle-quest'] &&
|
||||
byId['trestle-quest'].from === 'trestle' &&
|
||||
byId['trestle-quest'].type === 'ack' &&
|
||||
byId['trestle-quest'].trigger === 'firstForeignPlanet' &&
|
||||
byId['trestle-quest'].trigger === 'firstWorld' &&
|
||||
byId['trestle-quest'].once === true &&
|
||||
(byId['trestle-quest']?.delayMs ?? 0) === 0,
|
||||
);
|
||||
|
|
@ -232,7 +233,7 @@ check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluste
|
|||
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('firstForeignPlanet arms the trestle pair in config order', JSON.stringify(commsForTrigger(cfg, 'firstForeignPlanet').map((c) => c.id)) === JSON.stringify(['trestle-meetup', 'trestle-quest']));
|
||||
check('firstWorld arms the trestle pair in config order', JSON.stringify(commsForTrigger(cfg, 'firstWorld').map((c) => c.id)) === JSON.stringify(['trestle-meetup', 'trestle-quest']));
|
||||
|
||||
check(
|
||||
'the chain arms in config order (intro → tether → scan)',
|
||||
|
|
@ -307,9 +308,9 @@ check('a played oac-research-complete RE-arms (once:false — every completion s
|
|||
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('firstForeignPlanet arms the trestle pair on a fresh run', commsForTrigger(cfg, 'firstForeignPlanet').length === 2);
|
||||
check('a played trestle-meetup does not re-arm its half (the other still does)', JSON.stringify(commsForTrigger(cfg, 'firstForeignPlanet', new Set(['trestle-meetup'])).map((c) => c.id)) === JSON.stringify(['trestle-quest']));
|
||||
check('a fully played trestle pair re-arms nothing', commsForTrigger(cfg, 'firstForeignPlanet', new Set(['trestle-meetup', 'trestle-quest'])).length === 0);
|
||||
check('firstWorld arms the trestle pair on a fresh run', commsForTrigger(cfg, 'firstWorld').length === 2);
|
||||
check('a played trestle-meetup does not re-arm its half (the other still does)', JSON.stringify(commsForTrigger(cfg, 'firstWorld', new Set(['trestle-meetup'])).map((c) => c.id)) === JSON.stringify(['trestle-quest']));
|
||||
check('a fully played trestle pair re-arms nothing', commsForTrigger(cfg, 'firstWorld', new Set(['trestle-meetup', 'trestle-quest'])).length === 0);
|
||||
check('an unknown trigger arms nothing', commsForTrigger(cfg, 'launchParty').length === 0);
|
||||
check('a blank trigger arms nothing', commsForTrigger(cfg, ' ').length === 0);
|
||||
|
||||
|
|
|
|||
|
|
@ -60,8 +60,11 @@ export class CommsHub {
|
|||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {{
|
||||
* busy?: () => boolean, the scene isn't free (a console is up, mining
|
||||
* is live, a clip is in flight) — the comm waits
|
||||
* busy?: (commType?: string) => boolean,
|
||||
* the scene isn't free for this comm type — the
|
||||
* comm waits (the scene may relax the hold for
|
||||
* 'audio' comms: voice over an in-progress
|
||||
* action like mining is fine)
|
||||
* anchor?: () => ({right:number, top:number}) | null,
|
||||
* the mineral bar's lower-right (the box docks
|
||||
* below it; null → the standard corner fallback)
|
||||
|
|
@ -175,7 +178,7 @@ export class CommsHub {
|
|||
const entry = this.queue[0];
|
||||
if (!entry) return;
|
||||
if (time < entry.dueAt) return;
|
||||
if (this.busy && this.busy()) return; // the console owns the screen — wait
|
||||
if (this.busy && this.busy(entry.def.type)) return; // the screen owns this comm type — wait
|
||||
this.queue.shift(); // COMMIT — a comm plays at most once (the played
|
||||
// ledger then bars its next trigger; without this the entry would
|
||||
// sit here and re-play every time the hub went idle)
|
||||
|
|
|
|||
|
|
@ -778,17 +778,31 @@ export class GameScene extends Phaser.Scene {
|
|||
this.comms = new CommsHub(this, {
|
||||
// The scene isn't free while any of these owns the screen — the
|
||||
// comm waits (re-pumped each frame) until the screen is clear.
|
||||
busy: () =>
|
||||
(this.savePanel && this.savePanel.isOpen) ||
|
||||
(this.researchWindow && this.researchWindow.isOpen) ||
|
||||
(this.mapWindow && this.mapWindow.isOpen) ||
|
||||
(this.questWindow && this.questWindow.isOpen) ||
|
||||
(this.commsPanel && this.commsPanel.isOpen) ||
|
||||
(this.miningPopup && this.miningPopup.isOpen) ||
|
||||
(this.menuSubBar && this.menuSubBar.isOpen) ||
|
||||
(this.mining && this.mining.isActive) ||
|
||||
(this.scanPulse && this.scanPulse.busy) ||
|
||||
!!this._jumping,
|
||||
// The hub passes the comm it's about to start (def.type):
|
||||
// - a MODAL owns the screen (a window/panel is up, or a jump is
|
||||
// in flight) — no comm of ANY type starts until it's clear;
|
||||
// - an ACTION is in progress (mining live, the scan sweep out) —
|
||||
// the box comms (ack/visual: they pause the action or take the
|
||||
// screen) wait for it to settle, but an audio-only comm is
|
||||
// voice with no box, no pause, no input — it rides over the
|
||||
// action (the research-complete sign-off lands mid-mining).
|
||||
busy: (type) => {
|
||||
const screenOwned =
|
||||
(this.savePanel && this.savePanel.isOpen) ||
|
||||
(this.researchWindow && this.researchWindow.isOpen) ||
|
||||
(this.mapWindow && this.mapWindow.isOpen) ||
|
||||
(this.questWindow && this.questWindow.isOpen) ||
|
||||
(this.commsPanel && this.commsPanel.isOpen) ||
|
||||
(this.menuSubBar && this.menuSubBar.isOpen) ||
|
||||
!!this._jumping;
|
||||
if (screenOwned) return true;
|
||||
if (type === 'audio') return false; // voice over the action — allowed
|
||||
return (
|
||||
(this.mining && this.mining.isActive) ||
|
||||
(this.miningPopup && this.miningPopup.isOpen) ||
|
||||
(this.scanPulse && this.scanPulse.busy)
|
||||
);
|
||||
},
|
||||
anchor: () => {
|
||||
const hud = this.mineralHud;
|
||||
if (!hud) return null;
|
||||
|
|
@ -811,7 +825,7 @@ export class GameScene extends Phaser.Scene {
|
|||
// authoritative dedupe across saves (a flag reset on load can never
|
||||
// re-play a comm the ledger already holds).
|
||||
this._firstClusterSeen = false;
|
||||
this._firstForeignPlanetSeen = false; // the first FOREIGN planet (Trestle's intro)
|
||||
this._firstWorldSeen = false; // the first DISCOVERED WORLD (Trestle's intro — the quest's own witness)
|
||||
|
||||
// The run's FIRST crossing of the 200-mineral line (the 'minerals200'
|
||||
// comm beat — see onOre): same one-shot shape; the threshold check
|
||||
|
|
@ -1368,6 +1382,11 @@ export class GameScene extends Phaser.Scene {
|
|||
// 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');
|
||||
// A loaded run that ALREADY holds discovered worlds (a save from
|
||||
// before the 'firstWorld' beat existed) arms it here too — on a fresh
|
||||
// run the count is zero so this is a no-op; the hub's ledger /
|
||||
// in-flight guards keep the pair a one-shot either way.
|
||||
if ((this.discoveredWorldCount?.() ?? 0) >= 1) this.comms.trigger('firstWorld');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2859,20 +2878,16 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// The comms beat (data/comms.json: firstForeignPlanet — the trestle
|
||||
// pair): Trestle meets the player the first time they discover a
|
||||
// planet in a system that is NOT home — they have made it out, and
|
||||
// he has seen. A PLANET is the witness (not a cluster/station/gate,
|
||||
// and not the home world — it exists only in home). The scene flag
|
||||
// fires the event once; the comm's once-ledger dedupes across saves.
|
||||
if (!this._firstForeignPlanetSeen) {
|
||||
const isForeignPlanet =
|
||||
this.isHomeSystem !== true &&
|
||||
(this.systemPlanets ?? []).some((p) => p.discoveryId === o.id);
|
||||
if (isForeignPlanet) {
|
||||
this._firstForeignPlanetSeen = true;
|
||||
this.comms?.trigger('firstForeignPlanet');
|
||||
}
|
||||
// The comms beat (data/comms.json: firstWorld — the trestle pair):
|
||||
// Trestle meets the player the moment the FIRST world is discovered.
|
||||
// The witness is the QUEST's own (discoveredWorldCount — the
|
||||
// 'Discover another planet or space station' check: any planet or
|
||||
// space station, the home world never counts, in any system), so the
|
||||
// comm and the quest can never drift apart. The scene flag fires the
|
||||
// event once; the comm's once-ledger dedupes across saves.
|
||||
if (!this._firstWorldSeen && (this.discoveredWorldCount() ?? 0) >= 1) {
|
||||
this._firstWorldSeen = true;
|
||||
this.comms?.trigger('firstWorld');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue