Add 'Pirate Problems' quest with combat ambush and save-state ledgers

- New main-story quest granted after 'Special Delivery' is claimed AND its payoff comms are ACKed all the way through (unlocks.acked gate)
- Three-step checklist: speak with Trestle (trestleQ4 warning beats), defend from a rolling wave of six raiders at the gate-to-capital midpoint, return to the Neutral Capital World
- CombatSystem.startWaves gains an origin option so ambush enemies materialize in a ring around a specified point rather than off-screen around the ship
- CommsHub fires a new onAck hook before onEnd so the scene can record acknowledgments and run its grant pass in the same tick
- QuestState.isUnlockMet / grantUnlocked accept a gates.acked set; GameScene supplies it from the comms acked-ledger
- SaveData captures/restores comms.acked, combat.done, and story.capitalReturned; resetRunState clears all three on New Game
- Combat is now built for every scene (not just ?combat demo); the hostile pack spawns only in the sandbox boot
- Test suites updated: quest unlock gating, ambush wave shape, roster count 25→28, save round-trip of new ledgers
This commit is contained in:
Brian Fertig 2026-09-16 20:15:21 -06:00
parent c352e089e8
commit 78b280d7db
15 changed files with 739 additions and 54 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -113,6 +113,16 @@
"raid": [
{ "kind": "fixed", "roster": { "raider": 4, "bomber": 2 }, "total": 6 },
{ "kind": "rolling", "roster": { "raider": 3, "bomber": 1 }, "total": 12, "maxConcurrent": 4, "replacePerKill": 1 }
],
"pirate_ambush": [
{
"$comment": "'Pirate Problems' (data/quests.json → pirate_problems) — the quest's combat step ('Defend your ship from pirates'): ONE rolling wave of SIX frame-0 raiders (EnemyTypes: raider = the sheet's frame 0). Fired when the player ENTERS the Neutral Capital's system with the quest live (GameScene._maybeStartPirateAmbush — create() and the grant's start beats), materializing at the MIDPOINT between the entry gate (the system's gate nearest the ship's arrival position) and the capital planet — the run.origin seam (startWaves' opts.origin), three at a time (maxConcurrent), each kill sending in a replacement (replacePerKill) until all six are down; the completion marks the 'combatDone' ledger (GameScene._recordCombatDone — the step's monotonic witness, SaveData-captured).",
"kind": "rolling",
"type": "raider",
"count": 6,
"maxConcurrent": 3,
"replacePerKill": 1
}
]
}
},

View File

@ -298,6 +298,39 @@
"audio": "assets/speech/oac/oac-q3-01.mp3",
"once": true,
"delayMs": 0
},
{
"$comment": "The 'Pirate Problems' quest (data/quests.json → pirate_problems) START beat 1 of 3 (trigger 'trestleQ4') — Trestle's warning, armed the moment the quest GRANTS (GameScene._questStartBeats — the 'Special Delivery' reward claimed AND this deliveredLaunch chain fully ACKed, the quest's unlocks gate; the grant pass runs on whichever completes last — claimQuest or the _commEnded pass), with the player's destination already set to the Neutral Capital world. It lands a beat after the grant settles (delayMs); the hub's FIFO chains beats 2–3 back-to-back, each 'ack' holding until its own ACKNOWLEDGE (the pause contract — the player cannot jump until all three are heard, so the warning always precedes the ambush). The quest's 'Speak with Trestle' witness is the SEQUENCE: all three in the run's comms played-ledger (registry 'commsPlayed', marked when a comm starts) — the same _trestleCheck seam as 'Leaving the Nest' (GameScene._questCheckEval).",
"id": "trestle-q4-1",
"from": "trestle",
"type": "ack",
"trigger": "trestleQ4",
"text": "Listen up kid. That delivery has gotten your ship the attention of some local pirates.",
"audio": "assets/speech/trestle/trestle-q4-01.mp3",
"once": true,
"delayMs": 1500
},
{
"$comment": "Pirate Problems START beat 2 of 3 (trigger 'trestleQ4') — the ship's basic cannon is enough for THIS fight, the upgrades are the horizon (the open-ended game's next hook). Queued behind beat 1: the FIFO starts it the moment beat 1 is acknowledged (delayMs 0).",
"id": "trestle-q4-2",
"from": "trestle",
"type": "ack",
"trigger": "trestleQ4",
"text": "Your ship has a basic cannon on it, which might handle these pirates, but you're going to need upgrades if you're going to travel much deeper in the galaxy.",
"audio": "assets/speech/trestle/trestle-q4-02.mp3",
"once": true,
"delayMs": 0
},
{
"$comment": "Pirate Problems START beat 3 of 3 (trigger 'trestleQ4') — the send-back: the bar is the destination (already set by the grant's _questStartBeats) and the warning names the ambush — the pirates live in the capital's system, and the ambush itself (GameScene._maybeStartPirateAmbush → the pirate_ambush wave at the gate-to-capital midpoint, data/combat.json) is what 'all bets are off' means. Queued behind beat 2 (delayMs 0).",
"id": "trestle-q4-3",
"from": "trestle",
"type": "ack",
"trigger": "trestleQ4",
"text": "Get back here and meet me in the bar again. As far as I know your system is clear, but once you jump in this system all bets are off. Be on the lookout for pirate fighters.",
"audio": "assets/speech/trestle/trestle-q4-03.mp3",
"once": true,
"delayMs": 0
}
],
"reservedTriggers": [

View File

@ -170,6 +170,39 @@
"credits": 200,
"_comment": "The completion reward — 200 Credits, claimed via the CLAIM REWARD action."
}
},
{
"_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: ENTERING the capital's system with the quest live, the ambush fires — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0) materializing at the MIDPOINT between the entry gate (the system's gate nearest the ship's arrival — jumpThroughGate parks the ship just past it) and the capital planet (GameScene._maybeStartPirateAmbush → CombatSystem.startWaves' run.origin seam); 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).",
"id": "pirate_problems",
"category": "main",
"icon": "diamond",
"title": "Pirate Problems",
"description": "That delivery has gotten your ship noticed, kid. Local pirates are circling your trail now, and the only way to shake them is to make them regret it. Fight your way through them and get back to the bar — I'll be waiting.",
"issuer": "Trestle",
"unlocks": {
"claimed": ["special_delivery"],
"acked": ["carol-delivered-1", "carol-delivered-2", "carol-delivered-3", "oac-delivered-1"]
},
"checks": [
{
"type": "trestleTalked",
"trigger": "trestleQ4",
"label": "Speak with Trestle"
},
{
"type": "combatCleared",
"script": "pirate_ambush",
"label": "Defend your ship from pirates"
},
{
"type": "capitalReturned",
"label": "Return to the Neutral Capital World"
}
],
"reward": {
"credits": 200,
"_comment": "The completion reward — 200 Credits, claimed via the CLAIM REWARD action."
}
}
]
}

View File

@ -723,6 +723,10 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
{
check('waves: the shipped scripts (probe/skirmish/raid)',
['probe', 'skirmish', 'raid'].every((n) => Array.isArray(config.get(`combat.waves.scripts.${n}`))));
check('waves: the pirate_ambush script ships (Pirate Problems — one rolling wave of six frame-0 raiders)', (() => {
const w = config.get('combat.waves.scripts.pirate_ambush');
return Array.isArray(w) && w.length === 1 && w[0]?.kind === 'rolling' && w[0]?.type === 'raider' && w[0]?.count === 6 && w[0]?.maxConcurrent === 3 && w[0]?.replacePerKill === 1;
})());
check('waves: the script defaults ship',
config.get('combat.waves.defaults.maxConcurrent', 0) >= 1 && config.get('combat.waves.defaults.replacePerKill', 0) >= 0);

View File

@ -87,7 +87,11 @@ check('Carol is registered (label + role + her own clip)', !!carol && carol.labe
check('Carol\'s space clip ships on disk', existsSync(join(root, 'assets/videos/characters/carol.mp4')));
const roster = listComms(cfg);
check('the roster holds exactly the shipped comms', roster.length === 25);
// 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) = 28).
check('the roster holds exactly the shipped comms', roster.length === 28);
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
const intro = byId['oac-intro'];
check(
@ -524,9 +528,9 @@ 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 === 25 && ghostList[0].id === 'oac-intro');
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === roster.length && ghostList[0].id === 'oac-intro');
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
check('drop: duplicate ids (first wins)', dupList.length === 25 && dupList[0].id === 'oac-intro');
check('drop: duplicate ids (first wins)', dupList.length === roster.length && dupList[0].id === 'oac-intro');
warnOn = true;
// ----------------------------------------------------------------------

View File

@ -129,7 +129,7 @@ check('starter: reward fits the ship\u2019s hold',
// The requirement types the scene evaluates (GameScene._questCheckEval)
// — a typo here would be a checklist row that never completes.
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet', 'capitalLanded', 'cargoUpgraded', 'packageDelivered', 'vignettePlayed']);
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet', 'capitalLanded', 'cargoUpgraded', 'packageDelivered', 'vignettePlayed', 'combatCleared', 'capitalReturned']);
check('starter: every check type is a known live check',
(starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
@ -237,6 +237,66 @@ for (const c of starter?.checks ?? []) {
byType?.cargoUpgraded?.id === pkgId && typeof pkgId === 'string' && pkgId.length > 0);
}
// ----------------------------------------------------------------------
// 5d. The 'Pirate Problems' quest — the delivery quest's follow-up chain
// ----------------------------------------------------------------------
{
const comms = read('comms.json');
const combat = read('combat.json');
const q = (quests.quests ?? []).find((x) => x.id === 'pirate_problems');
check('pirates: the quest is defined', !!q);
check('pirates: sits in the MAIN story', q?.category === 'main');
check('pirates: has id/title/description/issuer', typeof q?.id === 'string' && typeof q?.title === 'string' && typeof q?.description === 'string' && typeof q?.issuer === 'string');
check('pirates: icon names a research icon', typeof q?.icon === 'string' && q.icon.length > 0);
check('pirates: NOT a starter (it grants by its unlock requirement)', q?.starter !== true);
// The grant condition — 'Special Delivery' CLAIMED AND its payoff chain
// ACKed (unlocks.acked — the comms acked-ledger, GameScene._commsAcked;
// the scene supplies the set to QuestState.isUnlockMet's gates).
check('pirates: unlocks on special_delivery being claimed', JSON.stringify(q?.unlocks?.claimed) === JSON.stringify(['special_delivery']));
check('pirates: the id in unlocks.claimed is a defined quest', (q?.unlocks?.claimed ?? []).every((id) => questDef(id) !== null));
const PAYOFF = ['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1'];
check('pirates: waits on the deliveredLaunch payoff chain being ACKed (unlocks.acked)', JSON.stringify(q?.unlocks?.acked) === JSON.stringify(PAYOFF));
const commById = Object.fromEntries((comms?.comms ?? []).map((c) => [c.id, c]));
check('pirates: every acked comm exists in data/comms.json', PAYOFF.every((id) => !!commById[id]));
check('pirates: the acked comms are all type "ack" (the press is the witness)', PAYOFF.every((id) => commById[id]?.type === 'ack'));
check('pirates: the acked comms are the deliveredLaunch chain', PAYOFF.every((id) => commById[id]?.trigger === 'deliveredLaunch'));
// The three checks, in order — speak, defend, return.
check('pirates: the three checklist lines (the brief, verbatim)', JSON.stringify((q?.checks ?? []).map((c) => c.label)) === JSON.stringify([
'Speak with Trestle',
'Defend your ship from pirates',
'Return to the Neutral Capital World',
]));
check('pirates: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c]));
// The TALK step — the trestleQ4 trigger's roster (data/comms.json), the
// same _trestleCheck seam as 'Leaving the Nest': all three beats in the
// run's played-ledger = done. The check's trigger parameter keeps the
// two quests' Trestle lines apart.
check('pirates: the talk step rides the trestleQ4 trigger', byType?.trestleTalked?.trigger === 'trestleQ4');
const q4 = (comms?.comms ?? []).filter((c) => c.trigger === 'trestleQ4');
check('pirates: the trestleQ4 trigger holds exactly three comms', q4.length === 3);
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 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.
const script = byType?.combatCleared?.script;
check('pirates: the combat step names the pirate_ambush script', script === 'pirate_ambush');
const waves = combat?.waves?.scripts?.[script];
check('pirates: the pirate_ambush script exists in data/combat.json', Array.isArray(waves) && waves.length > 0);
check('pirates: ONE wave (the brief: a single rolling wave)', Array.isArray(waves) && waves.length === 1);
check('pirates: the wave is rolling', waves?.[0]?.kind === 'rolling');
check('pirates: SIX frame-0 raiders (type raider, count 6)', waves?.[0]?.type === 'raider' && waves?.[0]?.count === 6);
check('pirates: the wave rolls three at a time (maxConcurrent 3, replacePerKill 1)', waves?.[0]?.maxConcurrent === 3 && waves?.[0]?.replacePerKill === 1);
check('pirates: the raider enemy type exists in data/combat.json', !!combat?.enemyTypes?.raider);
// The RETURN step — the capital's arrival witness (story.capitalReturned,
// GameScene._maybeWitnessCapitalReturn; the destination the grant sets
// is the neutral capital — the same world _atCapitalWorld reads).
check('pirates: the return step is the capitalReturned check', byType?.capitalReturned?.type === 'capitalReturned');
check('pirates: the reward is 200 credits', Number.isInteger(q?.reward?.credits) && q.reward.credits === 200);
}
// ----------------------------------------------------------------------
// 6. The quest ledger — QuestState (give/claim/save-restore)
// ----------------------------------------------------------------------
@ -291,6 +351,43 @@ check('helpers: starterQuestIds() is exactly [the starter]',
})());
check('unlock: the delivery quest is now held', st.isGranted('special_delivery'));
check('unlock: a re-call with nothing new is a no-op', st.grantUnlocked().length === 0);
// The THIRD link — 'Pirate Problems' waits on BOTH special_delivery's
// claim AND its payoff chain being ACKed (unlocks.acked — the comms
// acked-ledger; the pure ledger can't see the registry, so the scene
// supplies the set via the `gates` argument — GameScene passes
// { acked: this._commsAcked() }): the grant lands on whichever of the
// two conditions completes last (claimQuest's pass or _commEnded's).
const PAYOFF = ['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1'];
check('unlock: special_delivery\u2019s claim ALONE does not arm pirate_problems (the payoff chain must be ACKed)', (() => {
st.claim('special_delivery');
return st.grantUnlocked().length === 0 && !st.isGranted('pirate_problems');
})());
check('unlock: the payoff chain\u2019s final ACK arms pirate_problems (gates.acked, a Set)', (() => {
const acked = new Set(PAYOFF);
return JSON.stringify(st.grantUnlocked({ acked })) === JSON.stringify(['pirate_problems']) && st.isGranted('pirate_problems');
})());
check('unlock: a re-call after the acked grant is a no-op', st.grantUnlocked({ acked: new Set(PAYOFF) }).length === 0);
check('unlock: a PARTIAL ack chain does not arm pirate_problems', (() => {
const st2 = new QuestState();
st2.give(starter.id); st2.claim(starter.id);
st2.give('leaving_the_nest'); st2.claim('leaving_the_nest');
st2.give('special_delivery'); st2.claim('special_delivery');
return st2.grantUnlocked({ acked: new Set(['carol-delivered-1', 'oac-delivered-1']) }).length === 0 && !st2.isGranted('pirate_problems');
})());
check('unlock: gates.acked accepts an array too (the same gate)', (() => {
const st2 = new QuestState();
st2.give(starter.id); st2.claim(starter.id);
st2.give('leaving_the_nest'); st2.claim('leaving_the_nest');
st2.give('special_delivery'); st2.claim('special_delivery');
return JSON.stringify(st2.grantUnlocked({ acked: PAYOFF })) === JSON.stringify(['pirate_problems']);
})());
check('unlock: an acked chain of the WRONG comms does not arm pirate_problems', (() => {
const st2 = new QuestState();
st2.give(starter.id); st2.claim(starter.id);
st2.give('leaving_the_nest'); st2.claim('leaving_the_nest');
st2.give('special_delivery'); st2.claim('special_delivery');
return st2.grantUnlocked({ acked: new Set(['trestle-brief-1', 'trestle-brief-2', 'trestle-brief-3', 'trestle-arrive-1']) }).length === 0;
})());
check('unlock: a pre-follow-up save (no leaving_the_nest) still loads', (() => {
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [starter.id] });
return r.isGranted(starter.id) && r.isClaimed(starter.id) && !r.isGranted('leaving_the_nest');

View File

@ -212,6 +212,13 @@ const makeStorage = (fail = false) => {
// The comms played-ledger (the hub's registry Set) rides the save.
scene.registry.set('commsPlayed', new Set(['oac-intro', 'wraith-parting']));
// The comms ACKED-ledger (CommsHub's onAck hook — the 'Pirate
// Problems' grant gate stands on it) + the combat done-ledger (the
// 'Pirate Problems' combat step's witness) + the story ledger (the
// return arrival's witness) ride it too.
scene.registry.set('commsAcked', new Set(['carol-delivered-1', 'oac-delivered-1']));
scene.registry.set('combatDone', new Set(['pirate_ambush']));
scene.story = { capitalLanded: false, launchedFromCapital: false, lastLandedWorld: null, delivered: true, capitalReturned: true };
const rec = captureState(scene);
check('capture: seed carried', rec.seed === SEED);
@ -231,6 +238,12 @@ const makeStorage = (fail = false) => {
check('capture: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`));
check('capture: the comms ledger is carried (record.comms.played)',
Array.isArray(rec.comms?.played) && rec.comms.played.includes('oac-intro') && rec.comms.played.includes('wraith-parting'));
check('capture: the comms acked-ledger is carried (record.comms.acked)',
Array.isArray(rec.comms?.acked) && rec.comms.acked.includes('carol-delivered-1') && rec.comms.acked.includes('oac-delivered-1'));
check('capture: the combat done-ledger is carried (record.combat.done)',
Array.isArray(rec.combat?.done) && rec.combat.done.includes('pirate_ambush'));
check('capture: the story ledger rides (record.story.delivered + capitalReturned)',
rec.story?.delivered === true && rec.story?.capitalReturned === true);
// A FRESH registry = a new browser session: prepareLoad rebuilds the
// galaxy from the seed and restores the discovery state.
@ -257,6 +270,15 @@ const makeStorage = (fail = false) => {
&& reg.get('commsPlayed').has('oac-intro')
&& reg.get('commsPlayed').has('wraith-parting')
&& reg.get('commsPlayed').size === 2);
check('prepare: the comms acked-ledger is restored (a Set, same ids)',
reg.get('commsAcked') instanceof Set
&& reg.get('commsAcked').has('carol-delivered-1')
&& reg.get('commsAcked').has('oac-delivered-1')
&& reg.get('commsAcked').size === 2);
check('prepare: the combat done-ledger is restored (a Set, same ids)',
reg.get('combatDone') instanceof Set && reg.get('combatDone').has('pirate_ambush') && reg.get('combatDone').size === 1);
check('prepare: the story ledger is restored (delivered + capitalReturned)',
reg.get('story')?.delivered === true && reg.get('story')?.capitalReturned === true);
check('prepare: the batch remainders are restored (registry.economyStock)',
JSON.stringify(reg.get('economyStock')) === JSON.stringify({ [system.id]: { day: 1234, left: 2 } }));
@ -289,6 +311,12 @@ const makeStorage = (fail = false) => {
regNoBuilds.get(PENDING_RESTORE_KEY).builds === null);
check('prepare: a legacy record (no comms field) stages an empty ledger',
regNoBuilds.get('commsPlayed') instanceof Set && regNoBuilds.get('commsPlayed').size === 0);
check('prepare: a legacy record (no comms.acked) stages an empty acked-ledger',
regNoBuilds.get('commsAcked') instanceof Set && regNoBuilds.get('commsAcked').size === 0);
check('prepare: a legacy record (no combat field) stages an empty done-ledger',
regNoBuilds.get('combatDone') instanceof Set && regNoBuilds.get('combatDone').size === 0);
check('prepare: a legacy record (no story field) stages the fresh story ledger',
regNoBuilds.get('story')?.capitalLanded === false && regNoBuilds.get('story')?.delivered === false && regNoBuilds.get('story')?.capitalReturned === false);
check('prepare: a legacy record (no economyStock) restores an empty batch state',
JSON.stringify(regNoBuilds.get('economyStock')) === '{}');
// A hand-edited record with a bad shape can't leak into the scene.
@ -306,12 +334,17 @@ const makeStorage = (fail = false) => {
// resetRunState: New Game clears the run-state seams.
reg.set('discovery', reg.get('discovery'));
reg.set('commsPlayed', new Set(['oac-intro']));
reg.set('commsAcked', new Set(['oac-intro']));
reg.set('combatDone', new Set(['raid']));
reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } });
resetRunState(reg);
check('reset: discovery cleared (fresh run)', reg.get('discovery') === null);
check('reset: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null);
check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null);
check('reset: the comms ledger is cleared (the welcome arms again)', reg.get('commsPlayed') === null);
check('reset: the comms acked-ledger is cleared (the quest gate re-arms)', reg.get('commsAcked') === null);
check('reset: the combat done-ledger is cleared (the ambush re-arms)', reg.get('combatDone') === null);
check('reset: the story ledger is cleared (the witnesses re-arm)', reg.get('story') === null);
check('reset: the batch state is cleared (a fresh run starts full)', reg.get('economyStock') === null);
}

View File

@ -211,7 +211,14 @@ export class CombatSystem {
* seam — and onCancel().
*
* @param {Array|object|string} script the wave script (data/combat.json → waves)
* @param {object} [opts] the callbacks (above)
* @param {object} [opts] the callbacks (above) + `origin {x,y}` — when
* set, the run's ships materialize in a tight ring AROUND THAT POINT
* instead of off-screen around the ship (the quest ambush's seam:
* 'Pirate Problems'' wave sits at the gate-to-capital midpoint —
* GameScene._maybeStartPirateAmbush — and converges on the player on
* its own chase AI); the run's `origin` is read by _spawnWaveEnemy
* (the first burst AND the rolling replacements — the ambush keeps
* coming from where it was waiting).
* @returns {object|null} the run (query via waveStatus()) — null on a bad script
*/
startWaves(script, opts = {}) {
@ -222,11 +229,16 @@ export class CombatSystem {
}
this.cancelWaves(true); // a new fight replaces the old one (its ships go down)
const cb = (f) => (typeof f === 'function' ? f : null);
const origin =
opts.origin && Number.isFinite(opts.origin.x) && Number.isFinite(opts.origin.y)
? { x: opts.origin.x, y: opts.origin.y }
: null;
this.waveRun = {
runner: new WaveRunner(norm),
state: 'lead',
nextAt: null, // set on the first tick (the clock is the scene's update time)
lead: norm.lead,
origin, // null = off-screen around the ship (the pack's behavior)
onWaveStart: cb(opts.onWaveStart),
onWaveClear: cb(opts.onWaveClear),
onComplete: cb(opts.onComplete),
@ -277,18 +289,28 @@ export class CombatSystem {
}
/**
* One wave ship, off-screen around the ship (_offscreenRadius's band —
* the same off-screen materialization as the ?combat pack), bearing in
* on its own chase AI.
* One wave ship, bearing in on its own chase AI. Default: off-screen
* around the ship (_offscreenRadius's band — the same off-screen
* materialization as the ?combat pack). When the run's `origin` stands
* (startWaves' opts.origin — the quest ambush), a tight ring around
* THAT point instead: the ambush's point is far off the ship's camera
* by definition (the midpoint between the entry gate and the capital
* planet), so the ring still materializes off-screen, and the chase
* AI streams the wave in.
* @param {string} typeId the hostile type to send (a combat.enemyTypes id)
* @returns {EnemyShip} the fresh hostile (tagged `wave`)
*/
_spawnWaveEnemy(typeId) {
const run = this.waveRun;
const px = this.ship ? this.ship.x : 0;
const py = this.ship ? this.ship.y : 0;
const a = Math.random() * TAU;
const r = this._offscreenRadius() * (0.9 + Math.random() * 0.2);
const e = this.spawnEnemy(px + Math.cos(a) * r, py + Math.sin(a) * r, this._spawnSerial++, typeId);
const r = run?.origin
? 70 * (0.6 + Math.random() * 0.8) // the ambush ring (a cluster, not a band)
: this._offscreenRadius() * (0.9 + Math.random() * 0.2);
const ox = run?.origin ? run.origin.x : px;
const oy = run?.origin ? run.origin.y : py;
const e = this.spawnEnemy(ox + Math.cos(a) * r, oy + Math.sin(a) * r, this._spawnSerial++, typeId);
e.wave = true; // owned by the run: no self-respawn, the kill feeds the runner
return e;
}

View File

@ -77,6 +77,19 @@ export class CommsHub {
* the ACKNOWLEDGE press itself —
* scene bookkeeping that rides on
* a comm being acknowledged)
* onAck?: (comm: object) => void, an 'ack' comm was ACKNOWLEDGED —
* the press itself, fired BEFORE
* onEnd for the same def (so the
* scene's end-side pass can read
* the acked-ledger mark the press
* just made): the run's comms
* acked-ledger mark (registry
* 'commsAcked' —
* GameScene._commAcked) rides
* here; the 'Pirate Problems'
* grant gate (data/quests.json →
* pirate_problems.unlocks.acked)
* stands on it
* }} [o]
*/
constructor(scene, o = {}) {
@ -88,6 +101,7 @@ export class CommsHub {
this.onPause = typeof o.onPause === 'function' ? o.onPause : null;
this.onStart = typeof o.onStart === 'function' ? o.onStart : null;
this.onEnd = typeof o.onEnd === 'function' ? o.onEnd : null;
this.onAck = typeof o.onAck === 'function' ? o.onAck : null;
this.queue = []; // { def, dueAt, assets, loadingDone }
this.current = null; // the comm in play (its def)
@ -365,6 +379,17 @@ export class CommsHub {
this.current = null;
this._voiceDone = true;
if (box) box.close(); // the fade-out; the hub moves on (the next pump)
// The scene's bookkeeping for the ACKED comm — the press itself,
// fired BEFORE the onEnd hook so the end-side pass can read the
// acked-ledger mark this press just made (a failing hook must not
// hold the queue, and neither must a failing onEnd).
if (def.type === 'ack' && this.onAck) {
try {
this.onAck(def);
} catch (e) {
console.warn('[comms] onAck hook failed', e);
}
}
// The scene's bookkeeping for the ENDED comm (for 'ack' comms this
// fires on the ACKNOWLEDGE press — GameScene rides it: Trestle's
// last brief sets the player's destination). A failing hook must

View File

@ -74,15 +74,36 @@ export function starterQuestIds() {
* The quest's unlock requirement (data/quests.json → quests[].unlocks):
* claimed: [ids] — the quests whose rewards must be CLAIMED (the
* ledger's paid-out set) before this one is granted.
* acked: [ids] — the COMMS the run must have ACKed (their ids in the
* run's comms acked-ledger — registry 'commsAcked',
* CommsHub's onAck hook owns it) before this one is
* granted. The acknowledgment itself, not the start
* (the played-ledger marks on the comm's START — a
* chain's final beat can have started and still be
* un-ACKed; 'the payoff comms are DONE' = ACKed).
* Absent/empty = no requirement (the quest grants on first sight — the
* boot's own are flagged starter:true instead). Pure: data + the ledger.
* boot's own are flagged starter:true instead). Pure: data + the ledger
* + the caller's `gates` (the scene supplies the acked set — GameScene.
* _grantUnlockedQuests passes { acked: this._commsAcked() }); a call
* with no gates fails any quest carrying an acked requirement.
* @returns {boolean}
*/
export function isUnlockMet(state, id) {
export function isUnlockMet(state, id, gates = {}) {
const def = questDef(id);
if (!def || !state) return false;
const req = Array.isArray(def?.unlocks?.claimed) ? def.unlocks.claimed : [];
return req.every((x) => typeof x === 'string' && x !== '' && state.claimed.has(x));
if (!req.every((x) => typeof x === 'string' && x !== '' && state.claimed.has(x))) return false;
const ack = Array.isArray(def?.unlocks?.acked) ? def.unlocks.acked : [];
if (!ack.length) return true;
const have =
gates?.acked instanceof Set
? gates.acked
: new Set(
Array.isArray(gates?.acked)
? gates.acked.filter((x) => typeof x === 'string')
: [],
);
return ack.every((x) => typeof x === 'string' && x !== '' && have.has(x));
}
/**
@ -117,18 +138,21 @@ export class QuestState {
/**
* Grant every not-yet-held quest whose unlock requirement (unlocks.
* claimed) is met. The starter quests are the boot's own (grant via
* starterQuestIds on a fresh ledger); this is the RUN's chain — the
* claim that pays one quest is what arms the next (the scene calls it
* on the claim + on scene create for loaded saves). give() is
* claimed + unlocks.acked — isUnlockMet, the scene's `gates.acked` set
* supplying the acked half) is met. The starter quests are the boot's
* own (grant via starterQuestIds on a fresh ledger); this is the RUN's
* chain — the claim that pays one quest is what arms the next (the
* scene calls it on the claim + on scene create for loaded saves + on
* the comm's ACK when the acked half completes last). give() is
* idempotent, so re-calls are silent no-ops.
* @param {{acked?: (Set<string>|string[])}} [gates] scene-side gates
* @returns {string[]} the NEWLY granted ids (data order)
*/
grantUnlocked() {
grantUnlocked(gates = {}) {
const fresh = [];
for (const def of questDefs()) {
if (!def || this.granted.has(def.id) || def.starter === true) continue;
if (isUnlockMet(this, def.id) && this.give(def.id)) fresh.push(def.id);
if (isUnlockMet(this, def.id, gates) && this.give(def.id)) fresh.push(def.id);
}
return fresh;
}

View File

@ -181,7 +181,20 @@ export function captureState(scene, now) {
// ('commsPlayed' — js/comms/CommsHub.js owns it). A save predating
// comms has no field; the restore stages an empty set (old saves
// load).
comms: { played: Array.from(scene.registry.get('commsPlayed') ?? []) },
comms: {
// The ALREADY-PLAYED ids (marked on the comm's START) — the 'once'
// comms' one-shot ledger (above).
played: Array.from(scene.registry.get('commsPlayed') ?? []),
// The ALREADY-ACKED ids (data/comms.json → type 'ack' comms; marked
// on the ACKNOWLEDGE press — CommsHub's onAck hook, the OPPOSITE
// mark of the played-ledger's: a chain's final beat can have
// started and still be un-ACKed). 'Pirate Problems' (data/quests.json
// → pirate_problems.unlocks.acked) stands on this: its grant waits
// for the 'Special Delivery' payoff chain to be HEARD ALL THE WAY
// THROUGH. A save predating it has no field; the restore stages an
// empty set (old saves load; the quest simply hasn't armed yet).
acked: Array.from(scene.registry.get('commsAcked') ?? []),
},
// Vignettes — the run's ALREADY-COMPLETED scene ids (data/vignettes.json:
// the `once` scenes the run has finished, so a load doesn't re-play them
// and a new game re-plays them). Marked on COMPLETION (the opposite of
@ -193,6 +206,18 @@ export function captureState(scene, now) {
// predating vignettes has no field; the restore stages an empty set
// (old saves load).
vignettes: { played: Array.from(scene.registry.get('vignettesPlayed') ?? []) },
// COMBAT — the run's CLEARED WAVE SCRIPTS (data/combat.json →
// waves.scripts names; the 'Pirate Problems' step — check type
// 'combatCleared' — is the first to stand on one). Marked on the
// run's COMPLETION (CombatSystem's onComplete → GameScene.
// _recordCombatDone — the OPPOSITE mark of a comm's: a wave's IN-
// FLIGHT state is scene-local and dies with the scene, so the
// ledger — not the run — is the step's witness: a mid-fight quit
// re-arms the ambush on load, but a cleared one never re-fires).
// The ledger lives in the registry ('combatDone'). A save predating
// it has no field; the restore stages an empty set (old saves
// load — the step simply hasn't earned its witness yet).
combat: { done: Array.from(scene.registry.get('combatDone') ?? []) },
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
// requirement's input — the hold is cargo, this is the record).
// A save predating it has no field; the restore stages 0.
@ -213,6 +238,13 @@ export function captureState(scene, now) {
? scene.story.lastLandedWorld
: null,
delivered: scene.story?.delivered === true, // step 3's proof — the home-world sale (monotonic)
// 'Pirate Problems' step 3 — the RETURN arrival at the capital
// world (GameScene._maybeWitnessCapitalReturn set it: the ship
// within the world's keep-out rim while the quest is held +
// unclaimed — monotonic; the 'capitalReturned' check reads it).
// A save predating it has no field; the restore stages false
// (old saves load).
capitalReturned: scene.story?.capitalReturned === true,
},
// DEPLETED asteroid fields — the clusters the run has mined to
// NOTHING (ids keyed "<systemId>:<clusterId>"), so a load keeps them
@ -334,6 +366,11 @@ export function prepareLoad(registry, record) {
? record.story.lastLandedWorld
: null,
delivered: record.story?.delivered === true,
// 'Pirate Problems' step 3 — the return ARRIVAL at the capital world
// (GameScene._maybeWitnessCapitalReturn set it; monotonic, the
// 'capitalReturned' check reads it). A save predating it has no
// field → false (old saves load; the step simply stands open).
capitalReturned: record.story?.capitalReturned === true,
});
// DEPLETED asteroid fields (a save predating them has no field → empty
// set — old saves load; GameScene.create() splices them out of the
@ -358,6 +395,33 @@ export function prepareLoad(registry, record) {
: [],
),
);
// The run's COMMS ACKED-ledger (a save predating it has no field → an
// empty set — old saves load; 'Pirate Problems' simply hasn't armed).
// Marked on the ACKNOWLEDGE press (the opposite of the played-ledger's
// mark-on-start) by CommsHub's onAck hook (GameScene._commAcked).
registry.set(
'commsAcked',
new Set(
Array.isArray(record.comms?.acked)
? record.comms.acked.filter((id) => typeof id === 'string')
: [],
),
);
// The run's COMBAT DONE-ledger — the WAVE RUNS cleared (data/combat.json
// → waves.scripts names; CombatSystem's onComplete marks it via
// GameScene._recordCombatDone). 'Pirate Problems'' 'Defend your ship'
// step (check type 'combatCleared') reads it — the combat's monotonic
// witness (a wave re-fired on a load stands on the script name, so a
// cleared ambush never re-fights once its run finished). A save
// predating it has no field → an empty set (old saves load).
registry.set(
'combatDone',
new Set(
Array.isArray(record.combat?.done)
? record.combat.done.filter((id) => typeof id === 'string')
: [],
),
);
// The run's VIGNETTES played-ledger (a save predating vignettes has no
// field → an empty set — old saves load; a fresh run's scenes arm on
// their quest gates in data/vignettes.json).
@ -408,6 +472,8 @@ export function resetRunState(registry) {
registry.set('economyStock', null); // the new run's batches are full
registry.set('story', null); // the bar, the handoff, the leg — all fresh
registry.set('commsPlayed', null); // the welcome arms again on the new run
registry.set('commsAcked', null); // the ack gates re-arm on the new run
registry.set('combatDone', null); // the cleared waves re-arm on the new run
registry.set('vignettesPlayed', null); // the scenes re-arm on the new run
registry.set(PENDING_RESTORE_KEY, null);
}

View File

@ -906,19 +906,30 @@ export class GameScene extends Phaser.Scene {
})
: null;
// ---- COMBAT (the ?combat boot) — the hostile pack + the combat bar --
// data/combat.json (game.*): when the player starts, spawnCount
// hostiles appear in a ring around the ship (spawnRadius px). The
// mouse steers; while a hostile is in range the ship's cannon
// ---- COMBAT — the hostile pack (the ?combat boot) + the combat bar --
// COMBAT IS LIVE IN THE MAIN GAME: the 'Pirate Problems' quest
// (data/quests.json → pirate_problems) ambushes the player on the
// ENTER of the Neutral Capital's system — GameScene.
// _maybeStartPirateAmbush fires the pirate_ambush wave run (data/
// combat.json → waves.scripts.pirate_ambush — one rolling wave of six
// frame-0 raiders) at the gate-to-capital midpoint, and the deck swap,
// the readability layer, the auto-fire, and the armor are all the
// same shared parts (the ?combat sandbox is where the numbers get
// tuned). data/combat.json (game.*): the ?combat boot adds the hostile
// pack — spawnCount hostiles in a ring around the ship (spawnRadius px).
// The mouse steers; while a hostile is in range the ship's cannon
// AUTO-FIRES at the nearest one (game.autoFire — the cannon's numbers
// are combat.player.weapon, shared with the range). While combat is
// LIVE the deck steps aside for the combat bar (js/ui/CombatBar.js):
// shields + hull left, ENERGY right, eight skill seats (keys 1-8) in
// the middle — the skills land later. The real spawn SCHEDULES
// (places, waves) replace spawnPack when combat ships for real.
// the middle — the skills land later. Built for every scene (an idle
// system with no enemies and no wave run costs nothing in update()),
// the bar hidden until a wave makes combat live.
this.combat = null;
this.combatBar = null;
if (this._wantsCombatDemo()) {
this.combatHud = null;
this._combatSettleFrames = 0;
if (config.get('combat.enabled', true) !== false && this.ship) {
this.combatBar = new CombatBar(this, {
onCast: (i) => this.combat?.cast(i),
});
@ -936,13 +947,21 @@ export class GameScene extends Phaser.Scene {
if (a.hullHit > 0) this.combatHud.flash('hull');
},
});
this.combat.spawnPack(this.ship.x, this.ship.y);
// SETTLE WINDOW — the ship's start position is finalized AFTER
// create() (a fresh run's home rim comes from the tether barrier;
// a load's from the save), so the pack — spawned around the
// create-time position — is re-anchored around the ship for the
// first few frames (update() → CombatSystem.resyncAroundShip).
this._combatSettleFrames = 30;
// ?combat boot (the tuning sandbox): the hostile pack around the
// ship. The main game's combat arrives with the quest's ambush
// (the wave run above) — the boot just adds the practice pack.
if (this._wantsCombatDemo()) {
this.combat.spawnPack(this.ship.x, this.ship.y);
// SETTLE WINDOW — the ship's start position is finalized AFTER
// create() (a fresh run's home rim comes from the tether barrier;
// a load's from the save), so the pack — spawned around the
// create-time position — is re-anchored around the ship for the
// first few frames (update() → CombatSystem.resyncAroundShip).
this._combatSettleFrames = 30;
console.info(
'[orbit] ?combat — hostile pack live (data/combat.json, js/combat/CombatSystem.js)',
);
}
// The READABILITY LAYER (shared with the range): the lock on the
// auto-fire target, the threat markers, the off-screen arrows
// (the camera follows the ship — hostiles can sit outside it),
@ -983,8 +1002,9 @@ export class GameScene extends Phaser.Scene {
cast: (i) => this.combat?.cast(i),
// WAVE RUNS (js/combat/Waves.js — the quest-step combat): start a
// script (a combat.waves.scripts NAME or a raw wave list), watch
// it, cancel it. The QUEST HOOK that fires startWaves lands
// later; until then this console seam IS the entry point.
// it, cancel it. The quest's own hook is live (the 'Pirate
// Problems' ambush — GameScene._maybeStartPirateAmbush); this
// seam is the sandbox's entry point.
waves: {
start: (script, opts) => this.combat?.startWaves(script, opts) ?? null,
cancel: (destroy = false) => this.combat?.cancelWaves(destroy) ?? null,
@ -998,9 +1018,6 @@ export class GameScene extends Phaser.Scene {
},
};
}
console.info(
'[orbit] ?combat — hostile pack live (data/combat.json, js/combat/CombatSystem.js)',
);
}
// ---- The SAVE SYSTEM — the deck's Menu sub-bar + the slot pop-up --
@ -1094,6 +1111,15 @@ export class GameScene extends Phaser.Scene {
onStart: (def) => {
if (def && def.from === 'trestle') this._questRefresh();
},
// The comm was ACKNOWLEDGED (the 'ack' comm's press itself — fired
// BEFORE onEnd, so the end-side pass already sees the mark): the
// run's comms ACKED-ledger (registry 'commsAcked' —
// GameScene._commAcked) records the id. 'Pirate Problems' (data/
// quests.json → pirate_problems.unlocks.acked) stands on this:
// its grant waits for the 'Special Delivery' payoff chain to be
// ACKed all the way through (SaveData captures it under
// record.comms.acked).
onAck: (def) => this._commAcked(def),
// The comm ENDED (for 'ack' comms: the ACKNOWLEDGE press): the
// scene's ack-side bookkeeping — Trestle's last brief ('trestle
// sends the coordinates to your computer right now') sets the
@ -1318,6 +1344,11 @@ export class GameScene extends Phaser.Scene {
? storyRaw.lastLandedWorld
: null, // the last world landed on
delivered: storyRaw?.delivered === true, // step 3's proof (the home-world SALE of the package — monotonic)
// 'Pirate Problems' step 3 — the return ARRIVAL at the capital
// world (GameScene._maybeWitnessCapitalReturn set it: the ship
// within the world's keep-out rim while the quest is held +
// unclaimed — monotonic; the 'capitalReturned' check reads it).
capitalReturned: storyRaw?.capitalReturned === true,
};
this.registry.set('story', this.story);
@ -1742,6 +1773,23 @@ export class GameScene extends Phaser.Scene {
// once-ledger dedupes the rest (a mid-sequence quit re-arms only the
// unplayed beats).
this._trestleBeats();
// The 'Pirate Problems' warning (data/comms.json: trestle-q4-1/2/3,
// trigger 'trestleQ4') — the same arm for a loaded save that holds
// the quest unclaimed: the comm's once-ledger dedupes the already-
// heard beats (a mid-sequence quit re-arms only the unplayed ones),
// and the quest guard (_trestleQ4Beats) keeps the beats off the
// player before the grant.
this._trestleQ4Beats();
// The 'Pirate Problems' AMBUSH — this is the ENTER of the Neutral
// Capital's system (the scene was just built for it): with the quest
// live and its combat step open, the pirate wave starts at the
// midpoint between the entry gate and the capital planet (the ship's
// arrival position stands just past that gate, so the nearest gate
// is the entry gate — _maybeStartPirateAmbush's gate). The other
// arm is the grant itself (a save whose grant landed while the
// player was already IN the system — the same method is a no-op
// there only when the quest isn't live or the wave already ran).
this._maybeStartPirateAmbush();
}
/**
@ -2318,6 +2366,11 @@ export class GameScene extends Phaser.Scene {
this.tetherField.draw(_time); // the barrier (on-screen dots only)
this.actionBar?.update(_time, delta); // the deck's living details
this._checkDestinationReached(); // last leg done? clear the destination (REACHED)
// 'Pirate Problems' step 3 — the RETURN's witness: the ship at the
// capital world's rim with the quest held + unclaimed sets
// story.capitalReturned (monotonic; _maybeWitnessCapitalReturn is a
// no-op once it stands, so the per-frame cost is a ledger read).
this._maybeWitnessCapitalReturn();
if (!_commsPaused) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
}
@ -4076,11 +4129,14 @@ export class GameScene extends Phaser.Scene {
return { label, done, detail: `${have}/${keys.length}` };
}
if (type === 'trestleTalked') {
// Trestle's THREE-BRIEF sequence (data/comms.json, trigger
// 'trestleBrief' — the gates-online briefing): the run's comms
// played-ledger (registry 'commsPlayed') is the witness — all
// three heard = the player talked to him.
return this._trestleCheck('trestleBrief', label, 'TALKED');
// A TRESTLE SEQUENCE (data/comms.json, trigger check.trigger —
// default 'trestleBrief', the 'Leaving the Nest' gates-online
// briefing; 'Pirate Problems' points at 'trestleQ4', the grant's
// three-beat warning): the run's comms played-ledger (registry
// 'commsPlayed') is the witness — all of them heard = the player
// talked to him (the check's trigger parameter keeps the two
// quests' Trestle lines apart, the played-ledger shared).
return this._trestleCheck(String(check.trigger ?? 'trestleBrief'), label, 'TALKED');
}
if (type === 'trestleMet') {
// His ARRIVAL PAIR (trigger 'trestleArrival' — the player arrives
@ -4141,6 +4197,33 @@ export class GameScene extends Phaser.Scene {
const done = id !== '' && played.has(id);
return { label, done, detail: done ? 'SEEN' : 'PENDING' };
}
// 'Pirate Problems' (data/quests.json → pirate_problems) — the run's
// first COMBAT step + the return leg.
if (type === 'combatCleared') {
// The pirate wave CLEARED (check.script — data/combat.json →
// waves.scripts' name, 'pirate_ambush'): the run's combat done-
// ledger (registry 'combatDone' — CombatSystem's onComplete marks
// it via GameScene._recordCombatDone; SaveData captures it under
// record.combat.done) is the monotonic witness — the wave can be
// re-fired on a load (its in-flight state is not saved) but once
// it has FINISHED the step stands, no re-fight required. The ambush
// only starts in the capital's system (GameScene.
// _maybeStartPirateAmbush), so the step opens with the quest.
const script = String(check.script ?? '').trim();
const done = script !== '' && this._combatDone().has(script);
return { label, done, detail: done ? 'CLEARED' : 'PENDING' };
}
if (type === 'capitalReturned') {
// The RETURN arrival (the quest's destination, the Neutral Capital
// world): the story ledger's capitalReturned flag — set the moment
// the ship reaches the world's keep-out rim with the quest held +
// unclaimed (GameScene._maybeWitnessCapitalReturn, called from
// update()'s destination pass; monotonic, SaveData-captured under
// record.story). 'I'll add more speech' is a later layer — the
// arrival IS the step (the bar scene lands on it).
const done = this.story?.capitalReturned === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
return { label, done: false, detail: '??' };
}
@ -4326,20 +4409,25 @@ export class GameScene extends Phaser.Scene {
/**
* Grant every not-yet-held quest whose unlock requirement is met
* (QuestState.grantUnlocked — pure ledger work) and announce each new
* grant in the console's voice: the 'something new is here' SFX, the
* OAC's quest-grant voice (data/comms.json: questNew — once:false,
* re-armed on every grant), and the NEW QUEST toast (the starter's
* own toast style — quests.starterToast, one toast slot: the 2.7 s
* delay lands it after the claim toast that preceded it). A fresh
* run's starter boot and a loaded save's boot both call this (the
* create() arm); claimQuest calls it on the claim. Idempotent —
* give() is, and the announcement is per-NEW-grant.
* (QuestState.grantUnlocked — pure ledger work; the scene supplies the
* ACKED half of the gate via this._commsAcked() — 'Pirate Problems'
* waits for the 'Special Delivery' payoff chain to be ACKed all the
* way through) and announce each new grant in the console's voice: the
* 'something new is here' SFX, the OAC's quest-grant voice (data/comms.json:
* questNew — once:false, re-armed on every grant), and the NEW QUEST
* toast (the starter's own toast style — quests.starterToast, one toast
* slot: the 2.7 s delay lands it after the claim toast that preceded
* it). Then the quest's START beats (_questStartBeats — 'Pirate
* Problems': the destination set + Trestle's warning + the ambush's
* grant-side arm). A fresh run's starter boot and a loaded save's boot
* both call this (the create() arm); claimQuest calls it on the claim;
* _commEnded calls it on the ACK that completes the last gate.
* Idempotent — give() is, and the announcement is per-NEW-grant.
* @returns {string[]} the newly granted ids
*/
_grantUnlockedQuests() {
if (config.get('quests.enabled', true) === false || !this.questState) return [];
const fresh = this.questState.grantUnlocked();
const fresh = this.questState.grantUnlocked({ acked: this._commsAcked() });
for (const id of fresh) {
const def = questDef(id);
this.playSfx('discovery'); // the 'something new is here' voice
@ -4358,11 +4446,41 @@ export class GameScene extends Phaser.Scene {
}
if (fresh.length) {
this._questRefresh(); // a grant may have landed already-complete
this._questStartBeats(fresh); // the per-quest start beats (destination + comms)
this._trestleBeats(); // the gates may already be live (the claim was the last piece)
}
return fresh;
}
/**
* A quest's START beats — the one-shot work its grant owns (the
* 'the run turns' moment; runs exactly once per grant, on the scene
* that saw it). 'Pirate Problems' (data/quests.json →
* pirate_problems): the player's DESTINATION becomes the Neutral
* Capital world (the 'get back here' — set BEFORE the comms, so the
* warning answers the plotted route) and Trestle's warning sequence
* arms (trigger 'trestleQ4' — data/comms.json: trestle-q4-1/2/3);
* then the ambush's grant-side arm (a grant that lands while the
* player is already IN the capital's system). Other quests carry no
* start beats today; the seam is the shape for the ones that will.
* @param {string[]} fresh the newly granted ids (from _grantUnlockedQuests)
*/
_questStartBeats(fresh) {
if (!Array.isArray(fresh) || !fresh.length) return;
if (!fresh.includes('pirate_problems')) return;
const t = this.galaxy?.neutralCapitalTarget?.();
if (t?.systemId) {
this.setDestination(t.systemId, t.objectId);
}
this._trestleQ4Beats();
// The AMBUSH's other arm: the grant may have landed while the player
// was already IN the capital's system (a late claim out there) — the
// create() arm only covers the enter. _maybeStartPirateAmbush is
// no-op-gated (quest live + step open + system match + not already
// running/done), so re-calling it here is safe.
this._maybeStartPirateAmbush();
}
/**
* Is the ship AT the NEUTRAL CAPITAL WORLD (the quest's destination
* planet — the outermost world of the designated neutral-capital
@ -4430,6 +4548,211 @@ export class GameScene extends Phaser.Scene {
if (this._atCapitalWorld()) this.comms?.trigger('trestleArrival');
}
/**
* A comm was ACKNOWLEDGED (the CommsHub's onAck hook — the press
* itself, fired BEFORE onEnd): the run's comms ACKED-ledger mark.
* The played-ledger (registry 'commsPlayed' — GameScene._commPlayed) marks
* a comm when it STARTS; the acked-ledger (registry 'commsAcked')
* marks it when the ACKNOWLEDGE is pressed — 'heard all the way
* through'. 'Pirate Problems' (data/quests.json →
* pirate_problems.unlocks.acked) stands on this: its grant waits for
* the 'Special Delivery' payoff chain (carol-delivered-1/2/3 +
* oac-delivered-1) to be ACKed before it turns the run, and the mark
* must already stand when _commEnded's grant pass reads it (hence the
* onAck-before-onEnd order). SaveData captures the ledger under
* record.comms.acked; New Game clears it (resetRunState).
* @param {object} def the comm def that was acknowledged
*/
_commAcked(def) {
if (!def?.id) return;
const set = this._commsAcked();
if (!set.has(def.id)) set.add(def.id);
}
/**
* The run's comms ACKED-ledger (registry 'commsAcked') — the Set of
* comm ids acknowledged this run (_commAcked adds; SaveData restores
* it on load; resetRunState clears it on New Game). Created on first
* read, so old saves and new runs both find a live ledger.
* @returns {Set<string>} the acked ids
*/
_commsAcked() {
if (!this.registry) return new Set(); // a harness scene without a registry: nothing acked
let set = this.registry.get('commsAcked');
if (!(set instanceof Set)) {
set = new Set();
this.registry.set('commsAcked', set);
}
return set;
}
/**
* The run's combat done-ledger (registry 'combatDone') — the Set of
* wave-script names that have FINISHED this run (_recordCombatDone
* adds on the run's onComplete; SaveData captures it under
* record.combat.done and restores it on load; resetRunState clears it
* on New Game). The 'Pirate Problems' step's monotonic witness:
* the wave run's IN-FLIGHT state is scene-local (a load re-arms the
* ambush if it was mid-fight) but once it has cleared, the step
* stands — no re-fight.
* @returns {Set<string>} the cleared script names
*/
_combatDone() {
if (!this.registry) return new Set(); // a harness scene without a registry: nothing cleared
let set = this.registry.get('combatDone');
if (!(set instanceof Set)) {
set = new Set();
this.registry.set('combatDone', set);
}
return set;
}
/**
* A wave run FINISHED (its onComplete — the run's force is spent): the
* step's witness lands on the combat done-ledger (monotonic — a later
* re-fire of the same script, or a re-fire on load, cannot undo it)
* and the checklist row ticks over ('Defend your ship from pirates' —
* the 'Pirate Problems' step). The bar's departure (the combat going
* idle) is the player-facing cue; the console feed marks it too.
* @param {string} script the wave-script name (combat.waves.scripts key)
*/
_recordCombatDone(script) {
const id = String(script ?? '').trim();
if (!id) return;
const set = this._combatDone();
if (!set.has(id)) set.add(id);
this.combatHud?.kill('PIRATE AMBUSH CLEARED'); // the feed's moment
this._questRefresh(); // the checklist row may tick over (READY/READY)
}
/**
* The 'Pirate Problems' (data/quests.json → pirate_problems) grant's
* WARNING — Trestle's three beats (data/comms.json: trestle-q4-1/2/3,
* trigger 'trestleQ4', type 'ack': the player acknowledges each):
* the pirates' attention, the cannon + the upgrades he's seen the
* player earn, and the 'be on the lookout' that closes it. Armed the
* moment the quest is HELD + UNCLAIMED (the trigger's comm guard,
* GameScene._commTriggerGate — data/comms.json), so the beats ride
* the grant's announcement and never land before it; the comm's
* once-ledger + the hub's in-flight guard keep each beat a one-shot
* across the scene's life and across saves (a mid-sequence quit re-
* arms only the unplayed beats — the same contract as _trestleBeats).
* Arms from the grant (_questStartBeats) and the scene-create pass
* (create()'s tail — a loaded save that holds the quest unclaimed).
*/
_trestleQ4Beats() {
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
this.comms?.trigger('trestleQ4');
}
/**
* The 'Pirate Problems' AMBUSH — the quest's 'Defend your ship from
* pirates' step: when the player ENTERS the Neutral Capital's system
* with the quest live (held + unclaimed) and its combat step still
* open, the pirate wave starts — ONE rolling wave of six frame-0
* raiders (data/combat.json → waves.scripts.pirate_ambush) — at the
* MIDPOINT between the ENTRY GATE and the capital planet
* (_pirateAmbushOrigin: the gate nearest the ship's arrival position
* is the entry gate — jumpThroughGate parks the ship just past it),
* via startWaves' origin seam (three at a time, each kill feeding a
* replacement until all six are down). Two arms call this:
* · create()'s tail — the ENTER of the system (the normal case);
* · _questStartBeats — a grant that lands while the player is
* already in the system (a late claim out there).
* Guards (all must hold, in order): combat enabled + built; the
* quest held + unclaimed; the combat step not yet cleared (the
* monotonic done-ledger — a cleared ambush never re-fires); no wave
* run in flight (a live fight is the fight); the capital target
* resolves in THIS system (the ambush lives where Trestle is).
* A mid-fight quit re-arms the ambush on load (the in-flight run is
* scene-local) — the step's witness is the completion, not the
* attempt, so the re-fire is the fair read.
*/
_maybeStartPirateAmbush() {
if (config.get('combat.enabled', true) === false) return;
if (!this.combat) return;
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
const qd = questDef('pirate_problems');
const combatStep = (qd?.checks ?? []).find((c) => c?.type === 'combatCleared');
const script = String(combatStep?.script ?? '').trim() || 'pirate_ambush';
if (this._combatDone().has(script)) return; // cleared once — the step stands
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // in flight
if (this.galaxy?.neutralCapitalTarget?.()?.systemId !== this.systemRecord?.id) return;
const origin = this._pirateAmbushOrigin();
if (!origin) return; // no entry gate to anchor the midpoint on
this.combat.startWaves(script, {
origin, // the ambush point (the gate-to-capital midpoint)
onComplete: () => this._recordCombatDone(script),
});
}
/**
* The ambush's point: the MIDPOINT between the entry jump gate and
* the Neutral Capital planet (the quest's combat step — the wave
* materializes in a ring around it and converges on the player on
* its chase AI). The ENTRY GATE is inferred as the system's gate
* NEAREST THE SHIP'S ARRIVAL POSITION — jumpThroughGate parks the
* ship just past the return gate (gates.jump.arrivalGap), so on an
* enter the nearest gate is the one the ship came in through (the
* capital system's other gates, if the roster gave it any, sit far
* off). Null when the capital target doesn't resolve in this system
* or the system holds no gate (a degenerate anchor — the ambush
* waits for a real enter).
* @returns {{x: number, y: number}|null} the midpoint (or null)
*/
_pirateAmbushOrigin() {
const cap = this.galaxy?.neutralCapitalTarget?.();
if (!cap?.objectId || cap.systemId !== this.systemRecord?.id) return null;
const planet = (this.systemPlanets ?? []).find((p) => p.discoveryId === cap.objectId);
if (!planet || !this.ship) return null;
const gates = (this.systemGates ?? []).filter(
(g) => g && Number.isFinite(g.x) && Number.isFinite(g.y),
);
if (!gates.length) return null;
let best = null;
let bestD = Infinity;
for (const g of gates) {
const d = (g.x - this.ship.x) ** 2 + (g.y - this.ship.y) ** 2;
if (d < bestD) {
bestD = d;
best = g;
}
}
if (!best) return null;
return { x: (best.x + planet.x) / 2, y: (best.y + planet.y) / 2 };
}
/**
* The 'Pirate Problems' RETURN witness — the step's 'Return to the
* Neutral Capital World': the moment the ship is at the capital
* world's keep-out rim (the same _atCapitalWorld rim the 'Leaving
* the Nest' arrival beat reads) with the quest held + unclaimed,
* the story ledger's capitalReturned flag stands — monotonic, and
* the 'capitalReturned' check (GameScene._questCheckEval) reads it,
* so the step completes on ARRIVAL (in-space, the player hasn't even
* landed) regardless of how the player got here (the quest set the
* destination, but the witness is the position, not the route). The
* bar scene's own 'I'll add more speech' layer lands on top of this
* moment later. Called from update()'s destination pass (a no-op once
* the flag stands — the per-frame cost is a ledger read + a distance
* check) and from create()'s tail (a loaded save parked at the rim —
* the same arm shape as the 'Leaving the Nest' arrival beat).
*/
_maybeWitnessCapitalReturn() {
if (this.story?.capitalReturned) return; // monotonic — the step stands
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
if (!this._atCapitalWorld()) return; // not at the capital world's rim
this.story.capitalReturned = true;
this._questRefresh(); // the checklist row may tick over (READY)
}
/**
* A comm ENDED (the CommsHub's onEnd hook — for 'ack' comms this is
* the ACKNOWLEDGE press, for 'audio'/'visual' comms the moment the
@ -4455,6 +4778,17 @@ export class GameScene extends Phaser.Scene {
* reaches the capital world (GameScene._checkDestinationReached).
*/
_commEnded(def) {
// The ACK that just landed is ALREADY in the acked ledger (CommsHub
// fires onAck BEFORE onEnd — GameScene._commAcked), so this end-side
// pass can read it. GRANT PASS: a comm ending can complete the last
// gate of a quest unlock — 'Pirate Problems' (data/quests.json →
// pirate_problems.unlocks) waits on the 'Special Delivery' payoff
// chain to be ACKed all the way through, and THIS press may be the
// last piece. _grantUnlockedQuests is a no-op when nothing is newly
// due (grantUnlocked reports only the new ones; give() is idempotent),
// so the pass runs on every comm end.
this._grantUnlockedQuests();
// THE RESEARCH-COMPLETE SIGN-OFF (data/comms.json: oac-research-complete,
// audio-only — onEnd fires the moment the voice finishes): the moment
// it has spoken, a completed tech may have OPENED a new one. If any