Compare commits
2 Commits
0bd93369a5
...
4595950fb9
| Author | SHA1 | Date |
|---|---|---|
|
|
4595950fb9 | |
|
|
d9ba476d57 |
|
|
@ -89,8 +89,8 @@
|
|||
],
|
||||
|
||||
"stationVideos": [
|
||||
{ "land": "ss-land-01.mp4", "surface": "ss-surface-01.mp4", "takeoff": "ss-takeoff-01.mp4", "shop": "ss-shop-01.mp4" },
|
||||
{ "land": "ss-land-02.mp4", "surface": "ss-surface-02.mp4", "takeoff": "ss-takeoff-02.mp4", "shop": "ss-shop-02.mp4" },
|
||||
{ "land": "ss-land-01.mp4", "surface": "ss-surface-01.mp4", "takeoff": "ss-takeoff-01.mp4", "shop": "ss-shop-01.mp4" },
|
||||
{ "land": "ss-land-03.mp4", "surface": "ss-surface-03.mp4", "takeoff": "ss-takeoff-03.mp4", "shop": "ss-shop-03.mp4" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"_comment": "THE FOURTH QUEST — 'Pirate Problems' (the run's third link after 'Special Delivery'). It GRANTS the moment 'Special Delivery' is COMPLETED AND CLAIMED AND its payoff chain is HEARD ALL THE WAY THROUGH (unlocks.claimed + unlocks.acked — the deliveredLaunch four-beat: carol-delivered-1/2/3 + oac-delivered-1, every one of them ACKed, not merely started — the run's comms acked-ledger, registry 'commsAcked', owned by js/comms/CommsHub.js's onAck hook; GameScene._grantUnlockedQuests supplies it to QuestState.isUnlockMet, so the grant lands on whichever of the two conditions completes last: the claim (claimQuest) or the final ACK (_commEnded's grant pass)). On its grant the run turns: the player's DESTINATION is set to the Neutral Capital world (GameScene._questStartBeats) and Trestle's three-beat warning plays (data/comms.json → trigger 'trestleQ4' — trestle-q4-1/2/3 on trestle-q4-01/02/03.mp3; the 'Speak with Trestle' step's witness is that sequence in the played-ledger, the same _trestleCheck seam as 'Leaving the Nest'). Then the run's story turns on the pirates: with the quest live, the ambush fires at the MIDPOINT of the entry-jumpgate → capital-world stretch — the player ENTERS the capital's system at the gate (a full stretch from the world), and the raiders materialize once the ship is within HALF that gate-to-world distance of the capital (GameScene._maybeStartPirateAmbush: the entry gate is the destination's return gate for the last jump's source system — the restore's lastJumpFrom, staged in jumpThroughGate and saved in captureState — falling back to the gate nearest the spawn, then the spawn itself; the trigger is dist(gate, capital)/2 via JumpTravel.ambushTriggerDistance, and update() arms it the frame the ship crosses the halfway point) — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0), the wave ships materializing OFF-SCREEN around the player, each from a random direction (CombatSystem._spawnWaveEnemy — the same off-screen materialization as the ?combat pack), streaming in on their own chase AI (GameScene._maybeStartPirateAmbush fires the run); the wave's completion marks the 'combatDone' ledger (registry, SaveData-captured) — the 'Defend your ship from pirates' step's monotonic witness. 'Return to the Neutral Capital World' completes on the ARRIVAL itself — the ship within the capital world's keep-out rim with the quest held + unclaimed sets story.capitalReturned (GameScene._maybeWitnessCapitalReturn; the story ledger is SaveData-captured, monotonic). The bar's follow-up conversation is a later layer (the arrival is the step; the scene lands on it).",
|
||||
"_comment": "THE FOURTH QUEST — 'Pirate Problems' (the run's third link after 'Special Delivery'). It GRANTS the moment 'Special Delivery' is COMPLETED AND CLAIMED AND its payoff chain is HEARD ALL THE WAY THROUGH (unlocks.claimed + unlocks.acked — the deliveredLaunch four-beat: carol-delivered-1/2/3 + oac-delivered-1, every one of them ACKed, not merely started — the run's comms acked-ledger, registry 'commsAcked', owned by js/comms/CommsHub.js's onAck hook; GameScene._grantUnlockedQuests supplies it to QuestState.isUnlockMet, so the grant lands on whichever of the two conditions completes last: the claim (claimQuest) or the final ACK (_commEnded's grant pass)). On its grant the run turns: the player's DESTINATION is set to the Neutral Capital world (GameScene._questStartBeats) and Trestle's three-beat warning plays (data/comms.json → trigger 'trestleQ4' — trestle-q4-1/2/3 on trestle-q4-01/02/03.mp3; the 'Speak with Trestle' step's witness is that sequence in the played-ledger, the same _trestleCheck seam as 'Leaving the Nest'). Then the run's story turns on the pirates: with the quest live, the ambush fires at the MIDPOINT of the entry-jumpgate → capital-world stretch — the player ENTERS the capital's system at the gate (a full stretch from the world), and the raiders materialize once the ship is within HALF that gate-to-world distance of the capital (GameScene._maybeStartPirateAmbush: the entry gate is the destination's return gate for the last jump's source system — the restore's lastJumpFrom, staged in jumpThroughGate and saved in captureState — falling back to the gate nearest the spawn, then the spawn itself; the trigger is dist(gate, capital)/2 via JumpTravel.ambushTriggerDistance, and update() arms it the frame the ship crosses the halfway point) — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0), the wave ships materializing OFF-SCREEN around the player, each from a random direction (CombatSystem._spawnWaveEnemy — the same off-screen materialization as the ?combat pack), streaming in on their own chase AI (GameScene._maybeStartPirateAmbush fires the run); the wave's completion marks the 'combatDone' ledger (registry, SaveData-captured) — the 'Defend your ship from pirates' step's monotonic witness. 'Return to the Neutral Capital World' completes on the ARRIVAL itself — the ship within the capital world's keep-out rim with the quest held + unclaimed sets story.capitalReturned (GameScene._maybeWitnessCapitalReturn; the story ledger is SaveData-captured, monotonic). The bar's follow-up conversation is a later layer (the arrival is the step; the scene lands on it). The PAYOFF: on the quest's READY moment — all checks standing, the reward claimable (GameScene._questRefresh's once-per-quest announcement, the same moment the QUEST READY toast + chime fire) — the quest's payoffTrigger arms Trestle's two-beat sign-off (data/comms.json → trigger 'trestleQ4Payoff' — trestle-q4-4/5 on trestle-q4-04/05.mp3: the job boards' unlock — patrols, bounty hunts, escorts — + the level-5 hook, the run's next door; the comms' once-ledger keeps the pair a one-shot, a loaded save hears the unplayed beats).",
|
||||
"id": "pirate_problems",
|
||||
"category": "main",
|
||||
"icon": "diamond",
|
||||
|
|
@ -182,6 +182,7 @@
|
|||
"claimed": ["special_delivery"],
|
||||
"acked": ["carol-delivered-1", "carol-delivered-2", "carol-delivered-3", "oac-delivered-1"]
|
||||
},
|
||||
"payoffTrigger": "trestleQ4Payoff",
|
||||
"checks": [
|
||||
{
|
||||
"type": "trestleTalked",
|
||||
|
|
|
|||
|
|
@ -93,9 +93,10 @@ const roster = listComms(cfg);
|
|||
// The shipped roster count (the data pins it: a stray unvalidated comm
|
||||
// landing in data/comms.json changes the number and fails this line —
|
||||
// 25 pre-'Pirate Problems' + the trestleQ4 warning trio (trestle-q4-1/
|
||||
// 2/3, data/comms.json) + the combat pair (oac-enemies-detected /
|
||||
// oac-enemies-defeated) = 30).
|
||||
check('the roster holds exactly the shipped comms', roster.length === 30);
|
||||
// 2/3, data/comms.json) + the payoff pair (trestle-q4-4/5, the
|
||||
// quest-complete sign-off) + the combat pair (oac-enemies-detected /
|
||||
// oac-enemies-defeated) = 32).
|
||||
check('the roster holds exactly the shipped comms', roster.length === 32);
|
||||
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
|
||||
const intro = byId['oac-intro'];
|
||||
check(
|
||||
|
|
@ -504,6 +505,44 @@ check(
|
|||
check('oac-delivered-1: speaks oac/q3-01.mp3', byId['oac-delivered-1']?.audio === 'assets/speech/oac/oac-q3-01.mp3');
|
||||
check('oac-delivered-1: the clip ships on disk', existsSync(join(root, byId['oac-delivered-1']?.audio ?? '')));
|
||||
|
||||
// The 'Pirate Problems' payoff — the moment the quest becomes COMPLETE
|
||||
// + UNCLAIMED (all its checks standing, the reward claimable),
|
||||
// Trestle's two-beat sign-off on the run's pirate arc: the job-board
|
||||
// unlock (patrols, bounty hunts, escorts) + the level-5 hook. Armed by
|
||||
// GameScene._questRefresh's once-per-quest READY pass (the same moment
|
||||
// the QUEST READY toast + chime fire); the comms' once-ledger keeps the
|
||||
// pair a one-shot (a loaded save hears the unplayed beats).
|
||||
check(
|
||||
'trestle-q4-4: the payoff beat 1/2 (ack, trestleQ4Payoff, trestle, once)',
|
||||
!!byId['trestle-q4-4'] &&
|
||||
byId['trestle-q4-4'].from === 'trestle' &&
|
||||
byId['trestle-q4-4'].type === 'ack' &&
|
||||
byId['trestle-q4-4'].trigger === 'trestleQ4Payoff' &&
|
||||
byId['trestle-q4-4'].once === true,
|
||||
);
|
||||
check(
|
||||
'trestle-q4-4: the job-board brief (the brief, verbatim)',
|
||||
byId['trestle-q4-4']?.text === 'Not bad kid. You made quick work of those pirates. There are many more where those came from. I\'m unlocking the job boards for you. From patrols to bounty hunts and escorts, it will get you the exposure you need to become a threat to be reckoned with.',
|
||||
);
|
||||
check('trestle-q4-4: speaks trestle-q4-04.mp3', byId['trestle-q4-4']?.audio === 'assets/speech/trestle/trestle-q4-04.mp3');
|
||||
check('trestle-q4-4: the clip ships on disk', existsSync(join(root, byId['trestle-q4-4']?.audio ?? '')));
|
||||
check('trestle-q4-4: lands after the READY toast settles (delayMs > 0)', (byId['trestle-q4-4']?.delayMs ?? 0) > 0);
|
||||
check(
|
||||
'trestle-q4-5: the payoff beat 2/2 (ack, trestleQ4Payoff, trestle, once, immediate)',
|
||||
!!byId['trestle-q4-5'] &&
|
||||
byId['trestle-q4-5'].from === 'trestle' &&
|
||||
byId['trestle-q4-5'].type === 'ack' &&
|
||||
byId['trestle-q4-5'].trigger === 'trestleQ4Payoff' &&
|
||||
byId['trestle-q4-5'].once === true &&
|
||||
(byId['trestle-q4-5']?.delayMs ?? 0) === 0,
|
||||
);
|
||||
check(
|
||||
'trestle-q4-5: the level-5 brief (the brief, verbatim)',
|
||||
byId['trestle-q4-5']?.text === 'Take some of these jobs on to help fight the pirate threat in the neutral star systems, and run a few cargo jobs. Earn enough experience to reach level 5 and I\'ll reach back out to you. We\'ll pick a class for your ship and talk about the factions controlling the rest of the galaxy then. Good luck!',
|
||||
);
|
||||
check('trestle-q4-5: speaks trestle-q4-05.mp3', byId['trestle-q4-5']?.audio === 'assets/speech/trestle/trestle-q4-05.mp3');
|
||||
check('trestle-q4-5: the clip ships on disk', existsSync(join(root, byId['trestle-q4-5']?.audio ?? '')));
|
||||
|
||||
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']),
|
||||
|
|
@ -521,6 +560,20 @@ check(
|
|||
JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch').map((c) => c.id)) ===
|
||||
JSON.stringify(['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1']),
|
||||
);
|
||||
check(
|
||||
'trestleQ4Payoff arms the payoff pair in config order (back-to-back)',
|
||||
JSON.stringify(commsForTrigger(cfg, 'trestleQ4Payoff').map((c) => c.id)) ===
|
||||
JSON.stringify(['trestle-q4-4', 'trestle-q4-5']),
|
||||
);
|
||||
check(
|
||||
'a played trestle-q4-4 does not re-arm its half (the other still does)',
|
||||
JSON.stringify(commsForTrigger(cfg, 'trestleQ4Payoff', new Set(['trestle-q4-4'])).map((c) => c.id)) ===
|
||||
JSON.stringify(['trestle-q4-5']),
|
||||
);
|
||||
check(
|
||||
'a fully played payoff pair re-arms nothing (the one-shot)',
|
||||
commsForTrigger(cfg, 'trestleQ4Payoff', new Set(['trestle-q4-4', 'trestle-q4-5'])).length === 0,
|
||||
);
|
||||
check(
|
||||
'a played carol-delivered-1 does not re-arm (the rest of the chain still does)',
|
||||
JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch', new Set(['carol-delivered-1'])).map((c) => c.id)) ===
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
* ones.
|
||||
* - BUFFER — a level-up landing MID-BEAT is buffered and plays right
|
||||
* after the live one (the queue never drops a level).
|
||||
* - LOCK — the digit slots lock onto the FINAL level on any scene
|
||||
* clock (the lock stamps are ELAPSED beat time, not scene
|
||||
* time — a live session's clock must lock like a zero one).
|
||||
* - TEARDOWN — destroy() ends the beat and every created object dies
|
||||
* with the scene (a scene mid-shutdown can't wedge it).
|
||||
*
|
||||
|
|
@ -263,4 +266,25 @@ const settled = (m) =>
|
|||
console.log('ok 5 — the cap level plays the MAXED line');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6 — LOCK ON A LIVE CLOCK (the digit locks compare ELAPSED beat time, so
|
||||
// a scene whose clock is minutes in must lock the same as one at zero —
|
||||
// the regression where c.lockAt was stamped as an ABSOLUTE scene time
|
||||
// and `el >= c.lockAt` never held, leaving the slots cycling random
|
||||
// values off the level the player actually reached)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const scene = makeScene();
|
||||
scene.time.now = 60000; // a real session's clock, not the zero origin
|
||||
const m = new LevelUpMarquee(scene);
|
||||
m.play({ from: 7, to: 9 });
|
||||
// Step just past the last lock (the rightmost locks at T.lockBase, the
|
||||
// leftmost at T.lastLock) and check the slots read the FINAL digits.
|
||||
step(m, scene, () => scene.time.now - m.t0 >= m.T.lastLock + 200, 5000);
|
||||
assert.equal(m._digitCols[0].gt.main.text, '0', 'the left slot locked onto the final digit');
|
||||
assert.equal(m._digitCols[1].gt.main.text, '9', 'the right slot locked onto the final digit');
|
||||
step(m, scene, () => settled(m), 20000);
|
||||
console.log('ok 6 — the digit locks land on a non-zero scene clock');
|
||||
}
|
||||
|
||||
console.log('\nmarquee: all checks passed');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* The 'Pirate Problems' payoff seam test (dev tool, run with Node — no
|
||||
* browser):
|
||||
*
|
||||
* node dev/quest-payoff.test.mjs
|
||||
*
|
||||
* The seam (GameScene._questRefresh — the once-per-quest READY pass):
|
||||
* the moment a quest becomes COMPLETE + UNCLAIMED (all its checks
|
||||
* standing, the reward claimable), the scene announces it (the READY
|
||||
* chime + the console's toast) AND arms the quest's PAYOFF comms — the
|
||||
* trigger is the quest's own (data/quests.json → the quest's
|
||||
* payoffTrigger → data/comms.json). For 'Pirate Problems' (Trestle's
|
||||
* warning heard, the ambush cleared, the capital's return — the reward
|
||||
* on the table), that is trigger 'trestleQ4Payoff': the trestle-q4-4 →
|
||||
* trestle-q4-5 ack pair (the job-board unlock + the level-5 hook).
|
||||
* Pinned:
|
||||
* - a complete + unclaimed 'pirate_problems' arms its payoff trigger
|
||||
* exactly once (the trigger name is the DATA's —
|
||||
* pirate_problems.payoffTrigger, not a scene-side string);
|
||||
* - the once-per-quest announcement keeps a second arm out (the hub's
|
||||
* played-ledger is the cross-save layer — this is the scene's);
|
||||
* - a complete + CLAIMED quest, an incomplete quest, and a quest with
|
||||
* no payoffTrigger (the starter) all arm nothing;
|
||||
* - a mixed ledger arms exactly the one trigger.
|
||||
*
|
||||
* (The config side of the pair — the roster, the order, the verbatim
|
||||
* text, the clips on disk — is pinned in dev/comms.test.mjs; the
|
||||
* quest's wiring, in dev/quests.test.mjs.)
|
||||
*/
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
class RectStub {
|
||||
constructor(x = 0, y = 0, w = 0, h = 0) { this.x = x; this.y = y; this.width = w; this.height = h; }
|
||||
contains(px, py) { return px >= this.x && px <= this.x + this.width && py >= this.y && py <= this.y + this.height; }
|
||||
static Contains(r, px, py) { return r.contains(px, py); }
|
||||
}
|
||||
class ContainerStub {
|
||||
constructor(scene) { this.scene = scene; this.childrenList = []; }
|
||||
add() { return this; } setVisible() { return this; } setAlpha() { return this; }
|
||||
setScale() { return this; } setDepth() { return this; } setScrollFactor() { return this; }
|
||||
setPosition() { return this; } destroy() { return this; }
|
||||
}
|
||||
const ClassStub = class {};
|
||||
const PhaserStub = {
|
||||
Scene: ClassStub,
|
||||
Physics: { Arcade: { Sprite: ClassStub } },
|
||||
GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub, Text: ClassStub },
|
||||
Geom: { Rectangle: RectStub },
|
||||
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).slice(1), 16) }) } },
|
||||
Filters: { Controller: class {} },
|
||||
Renderer: { WebGL: { RenderNodes: { BaseFilterShader: class {} } } },
|
||||
Math: { Linear: (a, b, t) => a + (b - a) * t, FloatBetween: (a, b) => a, Angle: { Wrap: (a) => a }, Clamp: (v, lo, hi) => Math.min(hi, Math.max(lo, v)) },
|
||||
BlendModes: { ADD: 2 },
|
||||
};
|
||||
globalThis.window = { Phaser: PhaserStub };
|
||||
|
||||
const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href);
|
||||
const dataDir = join(root, 'data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
|
||||
const { GameScene } = await import(pathToFileURL(join(root, 'js/scenes/GameScene.js')).href);
|
||||
const { questDef } = await import(pathToFileURL(join(root, 'js/quests/QuestState.js')).href);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
function makeScene(quests) {
|
||||
const scene = Object.create(GameScene.prototype);
|
||||
scene.triggers = [];
|
||||
scene.comms = { trigger: (n) => scene.triggers.push(n) };
|
||||
scene.questState = {};
|
||||
scene._questAnnounced = new Set();
|
||||
scene.questSnapshot = () => ({ categories: [], quests });
|
||||
scene.playSfx = () => {};
|
||||
scene.consoleToast = () => {};
|
||||
scene.questWindow = null;
|
||||
return scene;
|
||||
}
|
||||
|
||||
const q = (id, complete, claimed) => ({ id, title: id, complete, claimed, checks: [] });
|
||||
|
||||
// the data's own trigger name — the seam reads it off the quest, and the
|
||||
// test asserts the same read (a scene-side string would still pass the
|
||||
// arming, but not this: the expectation is the quest's own field)
|
||||
const EXPECTED = String(questDef('pirate_problems')?.payoffTrigger ?? '').trim();
|
||||
check('the data declares the payoff trigger (pirate_problems.payoffTrigger)', EXPECTED.length > 0);
|
||||
|
||||
// 1 — complete + unclaimed: the READY moment arms the payoff, once
|
||||
{
|
||||
const s = makeScene([q('pirate_problems', true, false)]);
|
||||
s._questRefresh();
|
||||
check('complete + unclaimed arms the quest\'s payoff trigger', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED]));
|
||||
s._questRefresh();
|
||||
check('the once-per-quest announcement keeps a second arm out', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED]));
|
||||
}
|
||||
|
||||
// 2 — complete + claimed: no READY moment, no arm
|
||||
{
|
||||
const s = makeScene([q('pirate_problems', true, true)]);
|
||||
s._questRefresh();
|
||||
check('complete + claimed arms nothing', s.triggers.length === 0);
|
||||
}
|
||||
|
||||
// 3 — incomplete: the reward is not on the table yet
|
||||
{
|
||||
const s = makeScene([q('pirate_problems', false, false)]);
|
||||
s._questRefresh();
|
||||
check('an incomplete quest arms nothing', s.triggers.length === 0);
|
||||
}
|
||||
|
||||
// 4 — a quest with no payoffTrigger (the starter): the READY moment
|
||||
// stays silent (the chime + the toast are all it gets)
|
||||
{
|
||||
const s = makeScene([q('start_your_journey', true, false)]);
|
||||
s._questRefresh();
|
||||
check('a quest without a payoffTrigger arms nothing', s.triggers.length === 0);
|
||||
}
|
||||
|
||||
// 5 — mixed ledger: exactly the one trigger
|
||||
{
|
||||
const s = makeScene([
|
||||
q('start_your_journey', true, false),
|
||||
q('pirate_problems', true, false),
|
||||
q('leaving_the_nest', false, false),
|
||||
]);
|
||||
s._questRefresh();
|
||||
check('a mixed ledger arms exactly the one trigger', JSON.stringify(s.triggers) === JSON.stringify([EXPECTED]));
|
||||
}
|
||||
|
||||
if (failures > 0) { console.error(`\n${failures} quest-payoff check(s) failed`); process.exit(1); }
|
||||
console.log('\nquest-payoff: all checks passed');
|
||||
|
|
@ -280,6 +280,19 @@ for (const c of starter?.checks ?? []) {
|
|||
check('pirates: the trestleQ4 beats are from Trestle, type ack, once', q4.every((c) => c.from === 'trestle' && c.type === 'ack' && c.once === true));
|
||||
check('pirates: the trestleQ4 beats carry text + a real audio file on disk',
|
||||
q4.every((c) => typeof c.text === 'string' && c.text.length > 0 && typeof c.audio === 'string' && c.audio.length > 0 && fs.existsSync(join(root, c.audio))));
|
||||
// The PAYOFF — the moment the quest becomes COMPLETE + UNCLAIMED (all
|
||||
// checks standing, the reward on the table), Trestle's two-beat
|
||||
// sign-off (the job boards' unlock + the level-5 hook): the trigger is
|
||||
// the quest's own (payoffTrigger — GameScene._questRefresh's
|
||||
// once-per-quest READY pass arms it, the same moment the QUEST READY
|
||||
// toast + chime fire; the comms' once-ledger keeps the pair a
|
||||
// one-shot across saves).
|
||||
check('pirates: the quest declares a payoff comm trigger', typeof q?.payoffTrigger === 'string' && q.payoffTrigger.length > 0);
|
||||
const payoff = (comms?.comms ?? []).filter((c) => c.trigger === q?.payoffTrigger);
|
||||
check('pirates: the payoff trigger holds exactly two comms', payoff.length === 2);
|
||||
check('pirates: the payoff beats are from Trestle, type ack, once', payoff.every((c) => c.from === 'trestle' && c.type === 'ack' && c.once === true));
|
||||
check('pirates: the payoff beats carry text + a real audio file on disk',
|
||||
payoff.every((c) => typeof c.text === 'string' && c.text.length > 0 && typeof c.audio === 'string' && c.audio.length > 0 && fs.existsSync(join(root, c.audio))));
|
||||
// The COMBAT step — the pirate_ambush wave script (data/combat.json):
|
||||
// ONE rolling wave of SIX frame-0 raiders (EnemyTypes: raider = the
|
||||
// sheet's frame 0), three at a time, each kill feeding a replacement.
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 71 KiB |
|
|
@ -5839,8 +5839,14 @@ export class GameScene extends Phaser.Scene {
|
|||
/**
|
||||
* A quest-relevant event happened (research completed, ore mined, a
|
||||
* build finished, a world discovered, a reward claimed). Announce each
|
||||
* quest that just became claimable ONCE (the console's voice), and kick
|
||||
* the open console's poll so it repaints without waiting the interval.
|
||||
* quest that just became claimable ONCE (the console's voice) and arm
|
||||
* the quest's PAYOFF comms on that same READY moment (the mission
|
||||
* complete, the reward on the table — data/quests.json → the quest's
|
||||
* payoffTrigger → data/comms.json; 'Pirate Problems' declares
|
||||
* 'trestleQ4Payoff': Trestle's two-beat sign-off, trestle-q4-4/5 on
|
||||
* the job boards + the level-5 hook — the comms' once-ledger keeps a
|
||||
* loaded save's re-arm honest, the unplayed beats only), and kick the
|
||||
* open console's poll so it repaints without waiting the interval.
|
||||
*/
|
||||
_questRefresh() {
|
||||
if (!this.questState) return;
|
||||
|
|
@ -5850,6 +5856,12 @@ export class GameScene extends Phaser.Scene {
|
|||
if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) {
|
||||
this._questAnnounced.add(q.id);
|
||||
this.playSfx('discovery'); // the READY chime — the 'something new is here' voice, once per quest
|
||||
// The quest's PAYOFF comms — the trigger is the quest's own
|
||||
// (data/quests.json → payoffTrigger), so a quest with no
|
||||
// payoff stays silent; the hub's FIFO orders the beats and the
|
||||
// comms' played-ledger keeps the chain a one-shot.
|
||||
const trig = String(questDef(q.id)?.payoffTrigger ?? '').trim();
|
||||
if (trig) this.comms?.trigger(trig);
|
||||
const t = config.get('quests.readyToast', {});
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD')
|
||||
|
|
|
|||
|
|
@ -402,7 +402,12 @@ export class LevelUpMarquee {
|
|||
for (const p of newBoard.parts) p.gt.setVisible(false);
|
||||
this._digitCols.forEach((c, i) => {
|
||||
c.final = c.ch;
|
||||
c.lockAt = this.t0 + this.T.lockBase + (D - 1 - i) * 140; // i = from the left
|
||||
// ELAPSED ms since the beat start (update() compares it against
|
||||
// el = time - t0) — NOT a scene-clock stamp: on a live session
|
||||
// s.time.now is minutes in, and an absolute stamp would make
|
||||
// `el >= c.lockAt` false for the whole beat, so the digit never
|
||||
// locks and the slots keep cycling random values off the level.
|
||||
c.lockAt = this.T.lockBase + (D - 1 - i) * 140; // i = from the left
|
||||
c.bucket = -1;
|
||||
c.phase = i * 1.7;
|
||||
});
|
||||
|
|
|
|||