Make patrol waypoints sequential like bounty hunt legs

- Enforce the sequential law in `visitWaypoint` for all route kinds: only the first unvisited waypoint logs (bounty's `anyOrder` escape hatch still applies to the kill's check-off)
- Compass target and in-world beacons now track the NEXT waypoint in order instead of the nearest unvisited; only that one pulses while the rest stand dim until their turn
- Contact checklist rows no longer name which waypoint a contact stands on, keeping the ambush hidden from the tracker
- Update data comments and tests to reflect patrols flying 1, 2, 3 in order
This commit is contained in:
Brian Fertig 2026-09-27 17:15:38 -06:00
parent a28aa6f7fa
commit d3341296ae
5 changed files with 59 additions and 50 deletions

View File

@ -67,7 +67,7 @@
"zenith": "Zenith Concord"
},
"patrol": {
"_comment": "The PATROL category's rules (the first live type — js/quests/SideQuests.js → buildPatrolRoute + patrolTargetSystem; the scene's updatePatrol/check-ins read visitRadius). ACCEPT builds the patrol's ROUTE in the posting's target system: waypoints [min,max] of plain coordinates, inside the system's UNLOCKED tether regions (the live tether field's union, the player's range there) when the target is the system the player stands in, and inside ONE tether region (a level-1 radius, tether.level1Radius) of any of the target's planets / space stations / jump gates when it is a system the player hasn't unlocked yet (a NEIGHBOR — below). Placement keeps the points ISOLATED: anchorClearance px clear of every anchor (planet/station/gate center) and minSeparation px apart, retrying seeded, relaxing the clearance and separation (never the region) if the field is tight. visitRadius = the ship is this far from a waypoint when it counts as REACHED (checked off, in any order). neighborMinD = the '40% mark diagonally from home' (the system record's d — the home→far diagonal, 0 at the home corner): in a FACTION system at d ≥ neighborMinD, a patrol may instead be assigned to a NEIGHBORING system (a jump-gate link) controlled by ANOTHER faction — the push-them-out voice made a route; neighborChance is the per-posting seeded odds of the neighbor draw (the posting's target stands stable for the run). compass + marker = the in-game face of the active patrol: the yellow next-waypoint arrow on the off-screen compass (js/ui/DiscoveryCompass's color override — the route gate's orange precedent) and the in-world waypoint beacons (unvisited = pulsing, visited = the dimmed done mark).",
"_comment": "The PATROL category's rules (the first live type — js/quests/SideQuests.js → buildPatrolRoute + patrolTargetSystem; the scene's updatePatrol/check-ins read visitRadius). ACCEPT builds the patrol's ROUTE in the posting's target system: waypoints [min,max] of plain coordinates, inside the system's UNLOCKED tether regions (the live tether field's union, the player's range there) when the target is the system the player stands in, and inside ONE tether region (a level-1 radius, tether.level1Radius) of any of the target's planets / space stations / jump gates when it is a system the player hasn't unlocked yet (a NEIGHBOR — below). Placement keeps the points ISOLATED: anchorClearance px clear of every anchor (planet/station/gate center) and minSeparation px apart, retrying seeded, relaxing the clearance and separation (never the region) if the field is tight. visitRadius = the ship is this far from a waypoint when it counts as REACHED (checked off IN ORDER — the route is a PATH flown 1, 2, 3, …; only the first unvisited waypoint logs). neighborMinD = the '40% mark diagonally from home' (the system record's d — the home→far diagonal, 0 at the home corner): in a FACTION system at d ≥ neighborMinD, a patrol may instead be assigned to a NEIGHBORING system (a jump-gate link) controlled by ANOTHER faction — the push-them-out voice made a route; neighborChance is the per-posting seeded odds of the neighbor draw (the posting's target stands stable for the run). compass + marker = the in-game face of the active patrol: the yellow next-waypoint arrow on the off-screen compass (js/ui/DiscoveryCompass's color override — the route gate's orange precedent) and the in-world waypoint beacons (unvisited = pulsing, visited = the dimmed done mark).",
"waypoints": [
3,
5
@ -122,7 +122,7 @@
}
},
"bounty": {
"_comment": "The BOUNTY HUNT category's rules (the second live type — the model: js/quests/SideQuests.js → deriveBountyPlan / bountyEngagements / bountyHuntXp / bountyRouteComplete / nextBountyWaypoint; the scene: GameScene._establishBountyHunt + updateBountyHunt). A hunt is a PATROL WITH A NAME ON IT: three waypoints along one pirate leader's recent travel lanes, flown STRICTLY IN ORDER (the chain's odds need a first, second and third leg — patrol's any-order check-ins do not apply here), with the ending hidden from the player: the mark stands on ONE of the legs and the fight around them IS the contract. minLevel = the combat level the board demands before the contract can be taken (js/combat/XpManager.js's level; under it the row is LOCKED and GameScene.acceptSideQuest refuses it). contexts = the boards that post it — ['neutral'], because the 20 dossiers below are the neutral pirate-hunt voice and the faction pool is a documented empty stub (→ descriptions.faction.bounty). waypoints = the leg count [min,max]: the design is exactly three, and the chain's guarantee reads the LENGTH (the LAST leg always holds the mark), so raising the max lengthens the hunt without touching code. Route placement reuses patrol's anchorClearance / minSeparation / regionInset unless this block overrides them; visitRadius = how close the ship must come for a leg to LOG.",
"_comment": "The BOUNTY HUNT category's rules (the second live type — the model: js/quests/SideQuests.js → deriveBountyPlan / bountyEngagements / bountyHuntXp / bountyRouteComplete / nextBountyWaypoint; the scene: GameScene._establishBountyHunt + updateBountyHunt). A hunt is a PATROL WITH A NAME ON IT: three waypoints along one pirate leader's recent travel lanes, flown IN ORDER (a patrol's waypoints too — the shared sequential law — and the chain's odds read a first, second and third leg), with the ending hidden from the player: the mark stands on ONE of the legs and the fight around them IS the contract. minLevel = the combat level the board demands before the contract can be taken (js/combat/XpManager.js's level; under it the row is LOCKED and GameScene.acceptSideQuest refuses it). contexts = the boards that post it — ['neutral'], because the 20 dossiers below are the neutral pirate-hunt voice and the faction pool is a documented empty stub (→ descriptions.faction.bounty). waypoints = the leg count [min,max]: the design is exactly three, and the chain's guarantee reads the LENGTH (the LAST leg always holds the mark), so raising the max lengthens the hunt without touching code. Route placement reuses patrol's anchorClearance / minSeparation / regionInset unless this block overrides them; visitRadius = how close the ship must come for a leg to LOG.",
"minLevel": 2,
"contexts": [
"neutral"

View File

@ -30,8 +30,9 @@
// - THE PAYOUT — bountyHuntXp is the worth of everything the hunt throws
// (mark × its scale + escort + every rolled picket wave), fixed at the
// accept and paid ONCE by the claim.
// - SEQUENTIAL LAW — only the NEXT leg logs (a patrol's any-order law stays);
// the compass/beacon track that one leg only.
// - SEQUENTIAL LAW — only the NEXT waypoint logs (a patrol's the
// same law as the hunt's legs); the compass/beacon track that one
// point only.
// - COMPLETION ONLY BY THE KILL — flying every leg finishes nothing; the
// mark's death checks the legs off, and only after the last escort stops
// shooting does the contract go COMPLETE (then a single claim).
@ -282,7 +283,7 @@ section('THE PAYOUT — bountyHuntXp is the worth of the whole hunt');
}
// ==========================================================================
section('SEQUENTIAL LAW — only the NEXT leg logs (patrol law untouched)');
section('SEQUENTIAL LAW — only the NEXT waypoint logs (patrol and hunt alike)');
{
const waypoints = [[0, 0], [1000, 0], [2000, 0]];
const plan = S.deriveBountyPlan(3, 'sq-seq-bounty-0', 'sys-free', 3, { craft: 'warden' });
@ -301,14 +302,13 @@ section('SEQUENTIAL LAW — only the NEXT leg logs (patrol law untouched)');
check('flying every leg completes NOTHING by itself', (st.visitWaypoint('sq-seq-bounty-0', 2), S.bountyRouteComplete(st.routeFor('sq-seq-bounty-0')) === false));
check('every leg is logged', st.routeFor('sq-seq-bounty-0').visited.length === 3);
// The patrol's own law, beside it (the regression guard).
// The patrol's own law, beside it (the same sequential law).
const pst = new SideQuestState();
pst.accept('sq-patrol');
pst.setRoute('sq-patrol', { targetSystemId: 'sys-free', waypoints });
check('a patrol still logs in ANY order', pst.visitWaypoint('sq-patrol', 2) === true && pst.visitWaypoint('sq-patrol', 0) === true);
check('a patrol flies its waypoints IN ORDER, too (1, 2, 3)', pst.visitWaypoint('sq-patrol', 2) === false && pst.routeFor('sq-patrol').visited.length === 0);
check('S.bountyRouteComplete falls back to patrol law for a patrol record', S.bountyRouteComplete(pst.routeFor('sq-patrol')) === false);
pst.visitWaypoint('sq-patrol', 1);
check('…and completes on the last leg', S.bountyRouteComplete(pst.routeFor('sq-patrol')) === true);
check('…logging them 1, 2, 3 — and completes on the last waypoint', (pst.visitWaypoint('sq-patrol', 0), pst.visitWaypoint('sq-patrol', 1), pst.visitWaypoint('sq-patrol', 2), S.bountyRouteComplete(pst.routeFor('sq-patrol')) === true));
}
// ==========================================================================

View File

@ -603,10 +603,11 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl
assert.equal(rtr.routeFor('sq-r-patrol-0').credits, 90, 'the contract’s payout is fixed on the route');
assert.equal(rtr.routeFor('sq-r-other-0'), null, 'routeFor: no route → null');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 7), false, 'an out-of-range index doesn’t log');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), true, 'a waypoint logs (any order — the route is a set of points)');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), false, 'the idempotent visit (a per-frame pass can’t double-log)');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), true, 'another waypoint logs');
assert.deepEqual(rtr.routeFor('sq-r-patrol-0').visited, [1, 0], 'the visited marks stand (in log order)');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), false, 'a LATER waypoint doesn’t log (the route is a path — 1, 2, 3, …)');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), true, 'the FIRST waypoint logs');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), false, 'the idempotent visit (a per-frame pass can’t double-log)');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), true, 'the next waypoint logs once the first stands logged');
assert.deepEqual(rtr.routeFor('sq-r-patrol-0').visited, [0, 1], 'the visited marks stand (in log order)');
// Malformed route records degrade (the finite pairs keep, the rest drops).
const rtm = new SideQuestState();
rtm.accept('sq-m-patrol-0');

View File

@ -71,8 +71,9 @@
* stands active until it is done and claimed), the route record
* (waypoints + visited + the contract's payout — credits AND the
* XP reward, patrolEncounterXp), and the claimed marks. The
* scene's updatePatrol checks the ship in (visitRadius, any
* order), completes the contract, and pays the reward exactly
* scene's updatePatrol checks the ship in (visitRadius, IN ORDER
* — the route is a path flown 1, 2, 3, …), completes the
* contract, and pays the reward exactly
* once on claim (the credits + the XP, the claim's two payouts).
*
* DETERMINISM (the worldgen contract, PROJECT_NOTES → "World model") —
@ -2049,18 +2050,20 @@ export class SideQuestState {
/**
* Check off a waypoint (idempotent — the scene's per-frame pass may
* see the ship inside the radius on several frames; only the FIRST
* logs). @returns {boolean} true when NEWLY logged
* logs). The route is a PATH, flown IN ORDER (1, 2, 3, …): only the
* NEXT waypoint — the first unvisited one — logs, a patrol exactly as
* a hunt. @returns {boolean} true when NEWLY logged
*/
visitWaypoint(id, idx, opts = {}) {
const route = this.routes.get(id);
if (!route) return false;
if (!Number.isInteger(idx) || idx < 0 || idx >= route.waypoints.length) return false;
if (route.visited.includes(idx)) return false;
// A HUNT flies its legs IN ORDER — the chain's odds read a first, second
// and third leg — so only the NEXT leg logs, unless the caller is the
// kill's own check-off (`opts.anyOrder`: the mark came down and what is
// left of the lane is signed off with it). A patrol keeps its any-order law.
if (route.kind === 'bounty' && opts.anyOrder !== true) {
// The SEQUENTIAL LAW (both kinds — a patrol's waypoints and a hunt's
// legs): only the NEXT waypoint logs (the first unvisited, 1, 2, 3…).
// The ONE exception is the hunt's kill's own check-off (`opts.anyOrder`:
// the mark came down and what is left of the lane is signed off with it).
if (opts.anyOrder !== true) {
const next = nextBountyWaypoint(route);
if (!next || next.index !== idx) return false;
}

View File

@ -1005,7 +1005,8 @@ export class GameScene extends Phaser.Scene {
// The active PATROL's in-world WAYPOINT beacons (data/sidequests.json
// → patrol.marker): a diamond + ring + soft glow per waypoint (the
// yellow pulse while open, the dim done mark once visited) — plain
// yellow pulse on the NEXT waypoint — the route is a path flown in
// order — the dim done mark once visited) — plain
// coordinates, not world objects (the route's shape), repainted per
// frame (js/quests/SideQuests.js's route record on the scene's
// sideQuestState). Depth 9: above the tether line (6) + the route
@ -3139,8 +3140,9 @@ export class GameScene extends Phaser.Scene {
const destOffscreen = !!(destObj && !circleInView(destObj.x, destObj.y, destObj.radius, view));
// The active PATROL's next stop (data/sidequests.json → patrol.compass —
// the YELLOW arrow, its own color off the route gate's orange): the
// nearest unvisited waypoint. In the route's own system it is the
// the YELLOW arrow, its own color off the route gate's orange):
// the NEXT unvisited waypoint (the route is a path — 1, 2, 3, …).
// In the route's own system it is the
// waypoint itself (plain coordinates — no discovery entry to
// suppress, and only off-screen: on screen the beacon is visible);
// in another system it is the jumpgate that jumps there — the yellow
@ -5135,15 +5137,18 @@ export class GameScene extends Phaser.Scene {
}));
// The CONTACTS (the route's encounter waypoints — the contract's
// combat, cleared by the wave runs: the checklist's rows are the
// fight, the waypoints the flying). An old save's route (no
// encounters) stands on its waypoints alone.
// fight, the waypoints the flying). The rows count the contacts but
// do NOT name the waypoint a contact stands on — the ambush stays an
// ambush (where the hostiles fly is discovered in flight, not on the
// tracker). An old save's route (no encounters) stands on its
// waypoints alone.
const enc = route && route.encounters && Array.isArray(route.encounters.at) ? route.encounters : null;
const clearedSet = new Set(enc && Array.isArray(enc.cleared) ? enc.cleared : []);
if (enc) {
enc.at.forEach((wpIdx, i) => {
const cleared = clearedSet.has(wpIdx);
checks.push({
label: `Contact ${i + 1} — waypoint ${wpIdx + 1}`,
label: `Contact ${i + 1}`,
done: cleared,
detail: cleared ? 'CLEARED' : 'HOSTILES — CLEAR ON CONTACT',
});
@ -6503,11 +6508,11 @@ export class GameScene extends Phaser.Scene {
}
/**
* The compass's next stop for whichever contract flies. A HUNT points at ONE
* leg — the next in its sequence, never the set of them (the patrol's arrow
* picks the nearest unvisited; a hunt's order IS the rule) — in the hunt's own
* color with its own type label (data → bounty.compass), so it reads as the
* contract it is and never as a contact. Outside the lane's system it points
* The compass's next stop for whichever contract flies. A HUNT and a
* PATROL alike point at ONE point — the next in the route's sequence
* (the route is a path — 1, 2, 3, …, never the set of them) — in the
* contract's own color with its own type label (data → bounty.compass),
* so it reads as the contract it is and never as a contact. Outside the lane's system it points
* at the gate that jumps there (the patrol's rule).
*/
activeRouteCompassTarget() {
@ -6810,9 +6815,9 @@ export class GameScene extends Phaser.Scene {
/**
* The active PATROL's NEXT STOP for the off-screen compass (the yellow
* arrow — data/sidequests.json → patrol.compass; the route gate's
* orange precedent, a different color of its own). The route is a set
* of points, not a path (the check-ins are in any order), so "next"
* is the NEAREST unvisited waypoint: in the route's OWN system it is
* orange precedent, a different color of its own). The route is a PATH,
* flown IN ORDER (the check-ins log 1, 2, 3, …), so "next" is the FIRST
* unvisited waypoint: in the route's OWN system it is
* the waypoint itself (plain coordinates — a compass entry with no
* discovery object behind it); in ANOTHER system it is the jumpgate
* that jumps there (the yellow takes the gate's slot — the gate's
@ -6829,14 +6834,8 @@ export class GameScene extends Phaser.Scene {
const route = st.routeFor(id);
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return null;
let idx = -1;
let best = Infinity;
for (let i = 0; i < route.waypoints.length; i++) {
if (route.visited.includes(i)) continue;
const d = (this.ship.x - route.waypoints[i][0]) ** 2 + (this.ship.y - route.waypoints[i][1]) ** 2;
if (d < best) {
best = d;
idx = i;
}
if (!route.visited.includes(i)) { idx = i; break; } // the route is a path: 1, 2, 3, …
}
if (idx < 0) return null; // every waypoint logged (completion lands this frame)
const wp = route.waypoints[idx];
@ -6852,8 +6851,10 @@ export class GameScene extends Phaser.Scene {
/**
* The active patrol's CHECK-INS + CONTACTS (per frame, update): the
* ship within visitRadius (data/sidequests.json → patrol.visitRadius)
* of an unvisited waypoint logs it — any order, the route is a set of
* points (each check-in toasts + pings in the console's voice); at a
* of the NEXT waypoint logs it — the route is a PATH flown in order
* (1, 2, 3, …; a later waypoint stands open until its turn —
* visitWaypoint's sequential law), each check-in toasting + pinging
* in the console's voice; at a
* CONTACT's waypoint (one of the route's encounter waypoints) the
* contact's wave fires (_firePatrolContact — one rolling wave of the
* band's roster, six, three at a time — cleared marked on the run's
@ -7159,8 +7160,9 @@ export class GameScene extends Phaser.Scene {
/**
* The active patrol's IN-WORLD beacons (per frame, update): the route
* waypoints in the route's system — a diamond + ring + soft glow per
* point (the data's patrol.marker colors): the yellow PULSE while the
* waypoint stands, the dim done mark once visited — and the RED pulse
* point (the data's patrol.marker colors): the yellow PULSE on the
* NEXT waypoint (the route is a path — 1, 2, 3, …; the rest stand dim
* until their turn), the dim done mark once visited — and the RED pulse
* on the CONTACT's waypoints (the route's encounter waypoints, the
* contract's combat) while their contact stands, the done mark once
* the contact is cleared. Only the ACTIVE contract's route, only in
@ -7187,17 +7189,20 @@ export class GameScene extends Phaser.Scene {
// saves) is all ordinary waypoints.
const enc = route.encounters && Array.isArray(route.encounters.at) ? route.encounters : null;
const contactIdx = new Set(enc ? enc.at : []);
const next = nextBountyWaypoint(route); // the NEXT waypoint — the sequential law (1, 2, 3, …)
for (let i = 0; i < route.waypoints.length; i++) {
const [x, y] = route.waypoints[i];
const done = route.visited.includes(i);
const c = done ? doneColor : (contactIdx.has(i) ? contactColor : color);
const pulse = done ? 0 : 0.5 + 0.5 * Math.sin(time * 0.004 + i * 1.7); // each point on its own phase
const live = !!next && next.index === i; // ONE waypoint lights; the rest stand dim
const contact = contactIdx.has(i);
const c = done ? doneColor : (contact ? contactColor : color);
const pulse = done ? 0 : (live || contact) ? 0.5 + 0.5 * Math.sin(time * 0.004 + i * 1.7) : 0; // each point on its own phase
const s = 7 + (done ? 0 : 3 * pulse); // the diamond's half-diagonal
g.fillStyle(c, 0.05 + (done ? 0 : 0.05 * pulse)); // the soft glow
g.fillCircle(x, y, 26 + (done ? 0 : 6 * pulse));
g.lineStyle(1.5, c, done ? 0.4 : 0.45 + 0.4 * pulse); // the ring
g.lineStyle(1.5, c, done ? 0.4 : live ? 0.45 + 0.4 * pulse : 0.3); // the ring
g.strokeCircle(x, y, 15 + (done ? 0 : 5 * pulse));
g.fillStyle(c, done ? 0.5 : 0.95); // the diamond (45° square)
g.fillStyle(c, done ? 0.5 : live ? 0.95 : 0.45); // the diamond (45° square)
g.fillPoints([{ x, y: y - s }, { x: x + s, y }, { x, y: y + s }, { x: x - s, y }], true);
}
}