Simplify pirate ambush spawn to off-screen around player

- Remove the gate-to-capital midpoint origin logic (_pirateAmbushOrigin) and
  the startWaves opts.origin seam; wave ships now materialize off-screen
  around the player from a random direction, matching the ?combat pack.
- Update combat.json, comms.json, quests.json comments to reflect the new
  spawn behavior (off-screen around player, streaming in on chase AI).
This commit is contained in:
Brian Fertig 2026-09-16 20:35:03 -06:00
parent 9d86f5a481
commit 28fdfdb741
5 changed files with 36 additions and 87 deletions

View File

@ -116,7 +116,7 @@
],
"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).",
"$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); the wave ships materialize 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; 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,

View File

@ -322,7 +322,7 @@
"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).",
"$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, data/combat.json) is what 'all bets are off' means. Queued behind beat 2 (delayMs 0).",
"id": "trestle-q4-3",
"from": "trestle",
"type": "ack",

View File

@ -172,7 +172,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: 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).",
"_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), 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).",
"id": "pirate_problems",
"category": "main",
"icon": "diamond",

View File

@ -210,15 +210,13 @@ export class CombatSystem {
* (index, wave), onComplete(status) — the quest hook's 'combat over'
* seam — and onCancel().
*
* Every wave ship — the first burst (fixed AND rolling) and the rolling
* replacements alike — materializes OFF-SCREEN around the player, from
* a random direction (_spawnWaveEnemy), and streams in on its own chase
* AI: the wave never pops on top of the player.
*
* @param {Array|object|string} script the wave script (data/combat.json → waves)
* @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).
* @param {object} [opts] the callbacks (above)
* @returns {object|null} the run (query via waveStatus()) — null on a bad script
*/
startWaves(script, opts = {}) {
@ -229,16 +227,11 @@ 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),
@ -289,28 +282,21 @@ export class CombatSystem {
}
/**
* 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.
* One wave ship, bearing in on its own chase AI. It materializes
* OFF-SCREEN around the PLAYER — a random direction from the ship at
* _offscreenRadius's band (the same off-screen materialization as the
* ?combat pack): neither the first burst (fixed or rolling) nor the
* rolling replacements ever pop on top of the player; the chase AI
* streams each one in from whichever way it came out.
* @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 = 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);
const a = Math.random() * TAU; // a random direction from the player
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);
e.wave = true; // owned by the run: no self-respawn, the kill feeds the runner
return e;
}

View File

@ -912,7 +912,9 @@ export class GameScene extends Phaser.Scene {
// 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,
// frame-0 raiders, materializing off-screen around the player from a
// random direction each and streaming in on their chase AI), 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
@ -1782,13 +1784,13 @@ export class GameScene extends Phaser.Scene {
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).
// live and its combat step open, the pirate wave starts — its ships
// materialize OFF-SCREEN around the player, each from a random
// direction (CombatSystem._spawnWaveEnemy), and converge on their
// own chase AI. 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();
}
@ -4664,14 +4666,14 @@ export class GameScene extends Phaser.Scene {
/**
* 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:
* (the gate-to-capital stretch — 'all bets are off' out here) 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), three at a time,
* each kill feeding a replacement until all six are down. The wave
* ships materialize OFF-SCREEN around the player, each from a random
* direction (CombatSystem._spawnWaveEnemy), and stream in on their
* own chase AI. 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).
@ -4697,50 +4699,11 @@ export class GameScene extends Phaser.Scene {
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