Add BOUNTY HUNT side quest with elite combat seam and verification #2

Merged
brianfertig merged 1 commits from Bounty-Next into main 2026-09-21 02:11:07 +00:00
27 changed files with 3230 additions and 124 deletions

View File

@ -1 +1 @@
{"version":1,"dirMtimeMs":1789880763752.7512,"goals":[]}
{"version":1,"dirMtimeMs":1789956413250.3848,"goals":[]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@
"seedLabel": "sidequests"
},
"categories": {
"_comment": "The four job types. ORDER = the canonical slot order (patrol, delivery, bounty, escort). `enabled` is the per-type availability gate — OFFLINE until that type's objective + completion logic is built (patrol is live — its rules under → patrol; the other three are stubs); flip a category to true and its jobs stand on the board with their pool + reward band already wired. `accent` = the row's category chip + the contract title (data colors, js/quests/SideQuests.js reads them through).",
"_comment": "The four job types. ORDER = the canonical slot order (patrol, delivery, bounty, escort). `enabled` is the per-type availability gate — OFFLINE until that type's objective + completion logic is built (patrol and BOUNTY HUNT are live — their rules under → patrol / → bounty; delivery and escort are stubs); flip a category to true and its jobs stand on the board with their pool + reward band already wired. `accent` = the row's category chip + the contract title (data colors, js/quests/SideQuests.js reads them through). A category may carry TWO MORE GATES beyond `enabled`: `minLevel` (the combat level the board demands — under it the posting still stands, but as a greyed LOCKED row whose accept is refused) and `contexts` (the boards that post it at all — ['neutral'] keeps a pirate-voice contract off the faction boards entirely; an omitted list means every context posts it).",
"patrol": {
"enabled": true,
"label": "PATROL",
@ -22,9 +22,13 @@
"accent": "#ffc94d"
},
"bounty": {
"enabled": false,
"label": "BOUNTY",
"accent": "#ff2d6f"
"enabled": true,
"label": "BOUNTY HUNT",
"accent": "#ff2d6f",
"minLevel": 2,
"contexts": [
"neutral"
]
},
"escort": {
"enabled": false,
@ -113,8 +117,86 @@
"contactColor": "#ff5c77"
}
},
"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.",
"minLevel": 2,
"contexts": [
"neutral"
],
"waypoints": [
3,
3
],
"visitRadius": 420,
"encounters": {
"_comment": "The hunt's CHAIN — rolled ONCE at the accept on the labelled fork (seed,'sidequests',<targetSystemId>,'bounty',<jobId>,'chain') and FIXED on the route record: a signed contract is a signed contract, so re-tuning these odds never re-rolls a standing hunt (and the plan is invisible until it fires — no UI, marker, compass entry or toast names the leg that holds anything). LEGS — every leg but the last rolls bountyChance for the mark; the LAST leg stands it at 100%. With bountyChance 0.2 and three legs that reads 20% on leg 1, 20% of what missed on leg 2 (16% overall), everything left on leg 3 (64%). A leg that does NOT hold the mark rolls skirmishChance for a pirate wave of its own — the leader's pickets working their own lane; the bounty leg never rolls pickets (the mark IS the fight), and once the mark is down no later leg owes anything. SKIRMISH — one rolling wave of the frame-0 established type, three at a time, each kill replaced: the patrol contact's shape with no elite in it. BOUNTY — one rolling wave of `total` NINE, three at a time: `raiders` frame-0 fighters plus ONE elite, the mark itself, flying the craft its own dossier names (→ descriptions.neutral.bounty[].craft, an id from data/combat.json → enemyTypes), buffed by healthScale × hull+shields and damageScale × its weapon's damage, deployed WITH the opening group (never buried at the back of the queue) and tagged `elite` so a kill that MATTERED is recognizable. An elite id that isn't in combat.enemyTypes (a data typo) falls back to a seeded pick from eliteFrames. TERMINATION — killing the mark halts every further deployment and replacement of that fight (js/combat/CombatSystem.js → haltWaves: the survivors already on the field stay hostile and must be put down); when the last of them drops the hunt is COMPLETE, every leg checks off at once, no further wave fires, and the reward stands ready to claim.",
"enabled": true,
"bountyChance": 0.2,
"skirmishChance": 0.5,
"skirmishWave": {
"kind": "rolling",
"total": 6,
"maxConcurrent": 3,
"replacePerKill": 1,
"roster": [
"raider"
]
},
"bountyWave": {
"kind": "rolling",
"total": 9,
"raiders": 8,
"maxConcurrent": 3,
"replacePerKill": 1,
"raiderType": "raider",
"eliteFrames": [
"bomber",
"spitter",
"stinger",
"hunter",
"gunner",
"barrage",
"warden"
],
"elite": {
"healthScale": 2,
"damageScale": 2
}
},
"bands": [
{
"id": "hunter",
"levels": [
2,
5
],
"_comment": "THE LIVE BAND — the level 2–5 bounty hunt this pass tunes: three legs, the chain's odds above, frame-0 pickets and a dossier-named mark at double armor and double damage. No wave overrides here (the band inherits the encounter block above); a band MAY carry its own skirmishWave / bountyWave / bountyChance / skirmishChance to retune a level window without touching the others."
},
{
"id": "marksman",
"levels": [
6,
10
],
"inherits": "hunter",
"stub": true,
"_comment": "STUB — the level 6+ bounty hunt, not yet tuned. `inherits` points it at the hunter band so a player who outruns level 5 keeps getting hunts (a hunt never vanishes mid-run), and `stub` marks the debt: replace the inheritance with real numbers — heavier pickets, more legs, tougher marks, a fatter band under → rewards.bounty — when levels 6+ get their pass."
}
],
"fallbackBand": "hunter"
},
"compass": {
"color": "#ff2d6f",
"typeLabel": "Hunt"
},
"marker": {
"color": "#ff2d6f",
"doneColor": "#67e863",
"_comment": "NO contact color, deliberately: a patrol paints its contact waypoints red (→ patrol.marker.contactColor) because those contacts are the job; a hunt's legs must look IDENTICAL until something shoots at you, so only the NEXT leg in sequence lights and nothing marks where the mark is."
}
},
"descriptions": {
"_comment": "The variant pools: context (neutral | faction) × category. The generator picks ONE variant per posting, seeded on (galaxy seed, system id, category, slot) — a system's contract is STABLE (the board doesn't reshuffle between visits or saves), and different systems draw different ones (3+ variants per pool keeps the galaxy's boards from reading as one repeated ad). Placeholders (resolved at generation, never stored): {system} = the system's name; in the faction pools {faction} = the system's faction label (data/factions.json), {issuer} = that faction's board name (→ factions above), {enemies} = the OTHER two factions ('MindWeave and Zenith'). The NEUTRAL pools are the pirate-safety voice; the FACTION pools are the push-the-other-factions-out voice.",
"_comment": "The variant pools: context (neutral | faction) × category. The generator picks ONE variant per posting, seeded on (galaxy seed, system id, category, slot) — a system's contract is STABLE (the board doesn't reshuffle between visits or saves), and different systems draw different ones (3+ variants per pool keeps the galaxy's boards from reading as one repeated ad). Placeholders (resolved at generation, never stored): {system} = the system's name; in the faction pools {faction} = the system's faction label (data/factions.json), {issuer} = that faction's board name (→ factions above), {enemies} = the OTHER two factions ('MindWeave and Zenith'). The NEUTRAL pools are the pirate-safety voice; the FACTION pools are the push-the-other-factions-out voice. THE BOUNTY POOLS ARE DIFFERENT — descriptions.neutral.bounty is the BOUNTY HUNT's WANTED LIST: 20 dossiers, each a named pirate leader with an epithet, a crime, and the craft it flies (`craft` = a data/combat.json → enemyTypes id, spread across all seven frames, so the copy and the fight agree; `craftLabel` is the readout's plain words for it). descriptions.faction.bounty is deliberately EMPTY — a documented stub: categories.bounty.contexts is ['neutral'], so a faction board never draws a hunt, and the pool stays empty until a faction author posts its own wanted list (the generator would otherwise fall back to the neutral list and post pirate-hunt copy on a faction board).",
"neutral": {
"patrol": [
{
@ -152,19 +234,204 @@
],
"bounty": [
{
"title": "Salt the Raiders",
"title": "The Ninth Bell",
"issuer": "The Fuel Wardens",
"name": "Ilse Vanturi",
"epithet": "Ninth Bell",
"crime": "Taxing the halcyon run at cannon point",
"craft": "bomber",
"craftLabel": "salvade heavy bomber, one long lance",
"body": "Ilse Vanturi rang the chapel bell nine times for every ship she let through, and when the chapel burned she kept the bell. Now the fuel tanker crews count nine chimes on the emergency band before a single heavy bolt walks the length of their hull — that lance reaches further than our wardens' radios, and she knows it. Six tankers have paid her toll and two of them are still burning. The Wardens pay for the pirate who invented the bell to stop ringing. Bring her down and we'll ring it once more for you."
},
{
"title": "Nine Winters",
"issuer": "The Salvage Court",
"name": "Ostvik Halvardson",
"epithet": "Nine Winters",
"crime": "Shelling the Kite Line harvest",
"craft": "bomber",
"craftLabel": "icebreak-turned gun platform, slow and patient",
"body": "Nine winters Ostvik Halvardson has sat his patched icebreaker off the Kite Line, patient as weather, dropping one enormous bolt into whatever the haulers thought was cover. The Court has written off nine salvage claims and eleven bodies. He is not a pirate with a conscience — he is an icebreaker with a grudge. He will not maneuver with you. He will not outrun you. He will sit exactly where he is and make you come into a lance that has been aiming at the same spot since the last moon. End the count at nine."
},
{
"title": "Protection Money",
"issuer": "The Pilots' Guild",
"name": "Berta Ondaine",
"epithet": "The Slow Smile",
"crime": "Selling insurance nobody agreed to buy",
"craft": "bomber",
"craftLabel": "bulk-bodied bomber with a fuse like a eulogy",
"body": "Berta Ondaine delivers her quotes in person: a fat hull on your wing, a smile on the open channel, and a bolt already cook-off on the long lance if you decline. Three Guild pilots took the quote anyway and are now part of the belt. The Guild has paid off raiders before; it refuses to start with her. We want the collection stopped, and we want it stopped in the order she prefers — nobody quoting our members ever again."
},
{
"title": "Two Of A Kind",
"issuer": "The Ledger House",
"name": "Sable & Rue Spidleau",
"epithet": "the sisters who finish each other's sentences",
"crime": "Strafing the passenger ferry lane for sport",
"craft": "spitter",
"craftLabel": "twin-seat skiff, a fan of bolts per sentence",
"body": "One steers, one shoots, and neither stops talking. The Spidleau sisters cross the ferry lane screaming and open up with a fan of cheap bolts wide enough that dodging is a rumour — they've never hit anything worth hitting, which the Ledger House considers a moral emergency rather than luck. Two ferry hulls are patched so full of bolt-holes they fly in daylight. Take the pirate sisters off the lane before arithmetic does it for us."
},
{
"title": "The Firefight",
"issuer": "The Station Keeper",
"name": "Cassia Drumm",
"epithet": "Firehose",
"crime": "Putting out a depot fire by spraying the depot",
"craft": "spitter",
"craftLabel": "pump-rigged skiff throwing a wide cone",
"body": "Cassia Drumm was the best fire response on this station until she answered her own alarm with her ship's pump and a bad idea. Now she works the lane for the same instinct: get close, open a cone across everything you see, call it done. The keeper who let her keep the rig is the keeper who owes us a barge, and every hauler on the lane now pays a raider's rate in delay. Put the fire out for good."
},
{
"title": "Fan Favourite",
"issuer": "The Tally",
"body": "A crew has been taking ships in this system for a month, and the bounties on their heads have grown into a small library. Bring us the leader's hull, its drive, and one of their names — in that order, or in whatever order you can manage. The Tally doesn't ask how you finish a pirate. The Tally asks that it be finished."
"name": "Havelock Vane",
"epithet": "Nine-Finger",
"crime": "Point-blank shakedown at the ore moorings",
"craft": "spitter",
"craftLabel": "ore-hauler refit, spray bars welded on",
"body": "Havelock Vane lost four fingers to his own spray rig and wears the rest as a threat — he'll show you the hand on the channel while the fan warms up. He shakes the ore moorings for whatever the crews have on them and paints the bulkheads when they don't. The Tally counts his takings in scraped hull plate and has decided that the count of this pirate is finished."
},
{
"title": "One Name on the List",
"title": "Three For The Road",
"issuer": "The Relay Masters",
"name": "Aruna Sekh",
"epithet": "Needle",
"crime": "Sprint-killing light freight above the relay field",
"craft": "stinger",
"craftLabel": "courier-racer hull, a tight three-shot burst",
"body": "Aruna Sekh used to fly mail for us and knew every relay gate in this system by sound. Now she comes off the relay field at racing speed, puts three bolts into your engine block in about a heartbeat, and is gone before your gunner has picked a side of the ship. The Masters pay for the pilot who taught them this lane to stop teaching it."
},
{
"title": "The Dark Side Visit",
"issuer": "The Warden Guild",
"body": "The Guild is paying for a single name on the wanted board, and no questions about the rest of the crew. The mark flies a stolen hull with a blue stripe on the bow and a habit for boarding from the dark side. You'll know it when you see it. Bring the mark down and the name comes off the list — the rest stays yours."
"name": "Ketch Marlowe",
"epithet": "Get-Dark",
"crime": "Ambush from moon-shadow with no running lights",
"craft": "stinger",
"craftLabel": "stripped scout, burst cannon and no transponder",
"body": "Ketch Marlowe flies dark on the shadow side of this system's moons and reads your approach like a menu — the burst lands while you're still calling out on an honest channel. Two wardens have gone chasing the dark and come back with fewer hulls than they left with. The Guild wants the dark side of these moons to be empty again, and it wants Marlowe to be the reason."
},
{
"title": "The Last Skiff",
"title": "The Long Sting",
"issuer": "The Harbormaster",
"body": "Every raider in the system has been driven out but one. A little black skiff, half the speed of its owners' nerve, and a cargo hold the harbor can't get a read on. Bring it in whole if you can — the wardens want to see what it's carrying — and if it breaks before it bows, bring the pieces. Either way the board is closed when it's done."
"name": "Vessa Nkemdi",
"epithet": "Stingtail",
"crime": "Running the toll on the ice road",
"craft": "stinger",
"craftLabel": "fast picket with a burst tail-gun",
"body": "Vessa Nkemdi works the ice road alone and calls it a toll booth; harbor law says pirate, she says independent traffic warden. She hangs off your stern where your cameras are worst, stings three bolts into your drive every time you slow for the ice, and invoices the wrecks she makes. The harbor has paid her invoice twice. It would prefer to pay you instead."
},
{
"title": "He Never Loses A Scent",
"issuer": "The Remand Office",
"name": "Aurelian Costa",
"epithet": "The Bloodhound",
"crime": "Killing bond-jumpers who owed him nothing",
"craft": "hunter",
"craftLabel": "hunting craft with a heat-seeker on a long leash",
"body": "Aurelian Costa was a bounty hunter before he was a pirate and never saw the difference. His missile finds whatever is running, and it does not get bored — out-turn it if you can, but he has watched a lot of people try. He has been collecting people the Remand Office owed nobody. Bring him in and the Office stops apologising for what its own paperwork caused."
},
{
"title": "Last Confession",
"issuer": "The Longshore Chapel",
"name": "Marilou Dax",
"epithet": "The Confessor",
"crime": "Absolving nine crews from the inside of their own ships",
"craft": "hunter",
"craftLabel": "chapel-hulled hunter, one missile per blessing",
"body": "Sister Marilou opens a channel, asks for the truth of your voyage, hears it all the way down, and then launches a seeker she calls absolution. Nine crews have been absolved. The Chapel that unfrocked her is paying from its own box, which tells you how seriously it takes the habit. Make her finish her own penance."
},
{
"title": "Toll Hound",
"issuer": "The Tally",
"name": "Baz Ferreira",
"epithet": "Foxhound",
"crime": "Hunting smugglers who skipped his toll",
"craft": "hunter",
"craftLabel": "tracker with a missile and infinite patience",
"body": "Baz Ferreira charges smugglers for the privilege of being robbed by him, and his seeker is how he audits. The trouble is he has begun auditing honest traffic too, on the theory that anyone flying this lane owes somebody. The Tally finds the reasoning unsound and pays for a correction."
},
{
"title": "Collect In Full",
"issuer": "The Ledger House",
"name": "Sefane Adu",
"epithet": "Two Ledgers",
"crime": "Debt collection with a machine gun",
"craft": "gunner",
"craftLabel": "fast interceptor, rapid cannon, no patience for instalments",
"body": "Sefane Adu keeps two ledgers: one for what you owe, one for what the interest costs. When the second one opens she walks a stream of bolts down your spine until something gives, and she has never once lost a balance. The Ledger House disowned her paperwork and would like the collection agency retired."
},
{
"title": "Dead Air",
"issuer": "The Relay Masters",
"name": "Moira Bellamy",
"epithet": "Static",
"crime": "Jamming distress, then finishing the job",
"craft": "gunner",
"craftLabel": "jam-topped interceptor, rapid bolts on a short cooldown",
"body": "Moira Bellamy kills your radio before she kills anything else — static first, then a machine gun while you're still trying to raise somebody. Two wrecks in this system died calling into dead air. The Masters built the relay network and are paying for the one voice on it that ought to be silenced forever."
},
{
"title": "The Mill",
"issuer": "The Warden Guild",
"name": "Isaac Vowell",
"epithet": "The Miller",
"crime": "Grinding convoys down until something breaks",
"craft": "gunner",
"craftLabel": "long-burn interceptor that never lifts off the trigger",
"body": "Isaac Vowell does not ambush, taunt, or bargain. He gets on your wing and pours bolts at you for as long as either ship lasts, which he has calculated will always be him. Three convoy escorts have run out of shields before he ran out of ammunition. The Guild would like his arithmetic tested by somebody with better armor."
},
{
"title": "Do Not Approach",
"issuer": "The Salvage Court",
"name": "Gwendolane Fisk",
"epithet": "Hedge",
"crime": "Ring-fire at a salvage crew that came alongside",
"craft": "barrage",
"craftLabel": "armored barge that answers boarding with a full ring",
"body": "Gwendolane Fisk welded armor to an already slow hull and solved the problem of close-quarters combat by firing outward in every direction at once. A salvage crew went alongside to cut her loose and did not come back. The Court would like a ruling that nothing may be approached at point-blank, and this is the only way it gets one."
},
{
"title": "The Halo",
"issuer": "The Longshore Chapel",
"name": "Kaleo Tui",
"epithet": "Sunburst",
"crime": "Claiming a pilgrimage lane and everything on it",
"craft": "barrage",
"craftLabel": "armored pilgrim craft that blooms into a ring of bolts",
"body": "Brother Kaleo declared the pilgrimage lane his own diocese, and every ship that uses it pays tithe or eats a halo of bolts fired in every direction at once. He broadcasts hymns while he does it, which the Chapel says is not what hymns are for. Take his little kingdom apart and take the sound off the air."
},
{
"title": "The Widow's Claim",
"issuer": "The Pilots' Guild",
"name": "Dorra Vance",
"epithet": "Anvil",
"crime": "Claiming the belt on behalf of a dead husband",
"craft": "barrage",
"craftLabel": "heavy barge, ring gun, unbothered by return fire",
"body": "Dorra Vance says the belt belonged to her husband and that his death transferred the deed to anyone stubborn enough to collect it. She collects in bolts — a full ring each time, since she stopped bothering to aim at anything so small as a ship. The Guild has never once seen a title this thick, and would like it voided with extreme prejudice."
},
{
"title": "The Meridian Line",
"issuer": "The Warden Guild",
"name": "Kazimir Vohl",
"epithet": "Black Meridian",
"crime": "The Sable Convoy massacre",
"craft": "warden",
"craftLabel": "warden-class heavy — a lance and a seeker on every volley",
"body": "Admiral Vohl, self-appointed, commanded the escort that let the Sable Convoy burn and then wrote the report blaming the convoy. He flies like a man with a warship's patience: a heavy lance to hold you in place and a seeker to follow wherever you dodge. The Guild has buried four of its own who went after him. This contract is written by people who are done burying anybody else."
},
{
"title": "Grey Judgement",
"issuer": "The Harbormaster",
"name": "Sera Alkantari",
"epithet": "Grey Judgement",
"crime": "Executing three launch marshals on the pad",
"craft": "warden",
"craftLabel": "judicial warden, lance and missile fired like a sentence",
"body": "Sera Alkantari holds court in open space: she reads the charge, gives you one channel to answer, and then fires a heavy lance and a seeker whether your defence was good or not. Three launch marshals were executed for calling her hull unsafe. The harbor she served is paying for the last word on this one to be somebody else's."
}
],
"escort": [
@ -214,20 +481,7 @@
"body": "The outer posts run on our rations and your nerve. {enemies} want the lane to starve them out, and they've said so in the intercepts. Run the lane anyway. Put the posts in the dark they wanted or in the light we pay for — the {issuer} is paying for the light, and only the light."
}
],
"bounty": [
{
"title": "A Foreign Flag",
"body": "There is a {enemies} vessel working the system with its lights off, and it's been here longer than is polite. Bring us its hull. The {issuer} is paying for the wreckage and the silence that follows, and the {issuer} has never once paid twice for the same silence. Bring the flag home in pieces."
},
{
"title": "One Operative",
"body": "A {enemies} operative has been buying our people and selling their secrets. We have a face, we have a name, and we don't have time. Bring us one or the other and the {issuer} will tell you which. The job ends when the system stops leaking — this is usually the last step, but not always. Be the last step."
},
{
"title": "The Surveyor",
"body": "They've been charting our lanes. {enemies} surveyors chart what they intend to take, and this system isn't for sale. Bring us the surveyor and its instruments, intact enough to ask questions. The {issuer} is paying extra for intact — the answers are worth more than the hull, and the hull is worth a fair sum."
}
],
"bounty": [],
"escort": [
{
"title": "Convoy Under Flag",
@ -259,6 +513,8 @@
"accepted": "CONTRACT ACCEPTED",
"active": "PATROL ACTIVE",
"activate": "SET PATROL ACTIVE",
"bountyActive": "HUNT ACTIVE",
"bountyActivate": "SET HUNT ACTIVE",
"complete": "CONTRACT COMPLETE",
"claim": "CLAIM REWARD — {reward} CR",
"claimXp": "CLAIM REWARD — {reward} CR · +{xp} XP",
@ -311,6 +567,44 @@
"glyph": "✓",
"color": "#67e863",
"text": "PATROL ROUTE COMPLETE — ITS REWARD IS READY TO CLAIM"
},
"_commentBounty": "The BOUNTY HUNT's face (js/ui/JobBoardWindow.js + GameScene.updateBountyHunt). NOTE THE SILENCE: nothing here names the leg that holds the mark or a picket wave — the chain is hidden until it fires, so these lines only ever speak of what the player has already flown into. bountyLockBadge = the greyed row's plate (and the detail's lock line) under categories.bounty.minLevel; bountyAcceptDenyToast = the scene's refusal when a locked contract is pressed anyway.",
"bountyLockBadge": "LOCKED — REQUIRES LEVEL {level}",
"bountyLockSub": "BRING YOUR RECORD UP TO LEVEL {level} AND THE BOARD WILL LIST YOU",
"bountyAcceptDenyToast": {
"glyph": "✗",
"color": "#ff5c77",
"text": "COMMISSION REFUSED — BOUNTY HUNTS STAND AT LEVEL {level}"
},
"dossierWanted": "WANTED — {name}",
"dossierEpithet": "{epithet}",
"dossierCrime": "CHARGES — {crime}",
"dossierCraft": "CRAFT — {craft}",
"bountyRouteProgress": "TRAVEL LANE — WAYPOINT {visited} OF {total} LOGGED",
"bountyLegToast": {
"glyph": "◈",
"color": "#ff2d6f",
"text": "WAYPOINT {i} OF {n} LOGGED — NOTHING ON THE LANE"
},
"bountySkirmishToast": {
"glyph": "⚔",
"color": "#ff5c77",
"text": "PIRATE PICKETS — {n} HOSTILES INBOUND"
},
"bountySightedToast": {
"glyph": "☠",
"color": "#ff2d6f",
"text": "THE MARK IS HERE — {name}, {epithet} · {n} HOSTILES INBOUND"
},
"bountyBroughtToBookToast": {
"glyph": "✓",
"color": "#67e863",
"text": "{name} BROUGHT TO BOOK — PUT DOWN THE LAST OF THE ESCORT"
},
"bountyCompleteToast": {
"glyph": "✓",
"color": "#67e863",
"text": "BOUNTY HUNT COMPLETE — {name} IS DOWN, ITS REWARD IS READY TO CLAIM"
}
}
}

View File

@ -0,0 +1,58 @@
/**
* Dev-only runner for the BOUNTY HUNT in-browser probe (dev/bounty-hunt-shot.mjs).
*
* node dev/server.mjs 8080 # the no-store dev server
* CHROME_PATH=/path/to/chrome node dev/bounty-hunt-shot-run.mjs [baseUrl]
*
* Loads each stage in a real headless Chromium, waits for the page's own
* report (window.__BH), writes dev/shots/bh-<stage>.json + .png for every
* stage, and prints one line per stage. Exit 0 only when no stage FAILED and
* no page error was captured.
*/
import fs from 'node:fs';
import path from 'node:path';
import { launch, CDP } from './cdpshot.mjs';
const BASE = process.argv[2] || 'http://127.0.0.1:8080/dev/bounty-hunt-shot.html';
const STAGES = ['locked', 'dossier', 'hunt', 'mark'];
const OUT = path.resolve('dev/shots');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
fs.mkdirSync(OUT, { recursive: true });
let bad = 0;
for (const stage of STAGES) {
const url = `${BASE}#${stage}`;
const { url: wsUrl, kill } = await launch(url);
const cdp = new CDP(wsUrl);
try {
await cdp.open();
await cdp.send('Runtime.enable');
await cdp.send('Page.enable');
let rep = null;
const t0 = Date.now();
while (Date.now() - t0 < 180000) {
try {
const r = await cdp.send('Runtime.evaluate', { expression: 'window.__BH && window.__BH.stage && window.__BH.stage !== "running" ? JSON.stringify(window.__BH) : null', returnByValue: true });
if (r.result?.value) { rep = JSON.parse(r.result.value); break; }
} catch { /* still booting */ }
await sleep(500);
}
if (!rep) {
bad++;
console.log(`${stage.padEnd(8)} ✘ TIMEOUT — the page never reported`);
continue;
}
const shot = await cdp.send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(OUT, `bh-${stage}.png`), Buffer.from(shot.data, 'base64'));
fs.writeFileSync(path.join(OUT, `bh-${stage}.json`), JSON.stringify(rep, null, 1));
if (rep.stage === 'FAILED' || (rep.errors && rep.errors.length)) bad++;
console.log(`${stage.padEnd(8)} ${rep.stage === 'FAILED' ? '✘ FAILED' : '✔'} ${JSON.stringify(rep).slice(0, 240)}`);
} finally {
cdp.close();
kill();
await sleep(400);
}
}
console.log(bad ? `\n${bad} stage(s) need attention` : '\nall BOUNTY HUNT browser stages reported clean');
process.exitCode = bad ? 1 : 0;

17
dev/bounty-hunt-shot.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev BOUNTY HUNT screenshot page</title>
<base href="../" />
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/bounty-hunt-shot.mjs"></script>
</body>
</html>

290
dev/bounty-hunt-shot.mjs Normal file
View File

@ -0,0 +1,290 @@
/**
* Dev-only visual + behavioural probe for the BOUNTY HUNT side quest.
*
* node dev/server.mjs 8080
* node dev/bounty-hunt-shot-run.mjs (launches headless Chromium, drives
* every stage below, dumps JSON +
* PNGs into dev/shots/)
*
* Stages (location.hash):
* #locked — a fresh run (combat level 1) on a NEUTRAL world's job board: the
* BOUNTY HUNT row stands in its seeded slot, LOCKED, and the scene's
* accept DOOR refuses it (the console speaks data → ui.bountyAcceptDenyToast).
* #dossier — level 2: the same board, the hunt selected — the dossier readout
* (wanted leader / charges / craft / payout CR + XP) with NO hint of
* which leg holds the mark, and the accept lands for real.
* #hunt — the hunt ACTIVE in the flight world: legs logged strictly in
* sequence (an out-of-order visit is refused), the beacons read as
* lanes (no red contact), and the lane's first fight stood up.
* #mark — the bounty's own wave deployed: the mark alive on the field at its
* ×2 hull / ×2 damage, then put DOWN — deployments halt, the escort
* is finished, the contract goes COMPLETE, and the reward claims ONCE.
*
* The probe drives the SAME seams a player's presses reach (the board's door →
* SurfaceScene.acceptSurfaceJob/activateSurfaceJob, the scene's own update loop
* for the check-ins, CombatSystem.killEnemy for the shooting), staged only by:
* setting XP level through XpManager.restore and teleporting the ship.
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { MenuScene } from '../js/scenes/MenuScene.js';
import { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
const data = await ConfigLoader.load();
config.init(data);
const errors = [];
const origErr = console.error.bind(console);
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => errors.push(String(e.message)));
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
const gameConfig = createGameConfig();
gameConfig.scene = [MenuScene, GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
const report = document.createElement('pre');
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:78%;margin:0;padding:8px 12px;font:13px/1.45 monospace;color:#eaf6ff;background:rgba(6,20,16,0.93);border:1px solid #1b3a5a;white-space:pre-wrap;';
document.body.appendChild(report);
const say = (v) => { report.textContent = typeof v === 'string' ? v : JSON.stringify(v, null, 1); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(fn, tries = 260, everyMs = 100) {
for (let i = 0; i < tries; i++) {
const v = fn();
if (v) return v;
await sleep(everyMs);
}
return null;
}
const STAGE = (location.hash || '#locked').slice(1);
/** The hunt contracts on the standing world's board. */
const huntsOn = (gs) => gs.sideQuestPostings().filter((p) => p.category === 'bounty');
try {
// ── the real boot: menu → NEW GAME on a seed whose home system is Neutral ──
const menu = await waitFor(() => { const m = game.scene.getScene('MenuScene'); return m?.newGameBtn ? m : null; });
if (!menu) throw new Error('menu never booted');
menu.seedValue = 'VESTRA';
menu.startNewGame();
const gs = await waitFor(() => { const g = game.scene.getScene('GameScene'); return g?.ship ? g : null; }, 300);
if (!gs) throw new Error('GameScene never booted');
await sleep(700);
// The XP staged through the run's ONLY ledger (js/combat/XpManager.js).
const level = STAGE === 'locked' ? 1 : 2;
gs.combat.xp.restore({ level, xp: 0 });
if (STAGE === 'locked' || STAGE === 'dossier') {
// ── land on the world and open the JOB BOARD console (the player's path) ──
const target = gs.commsTargetFor(gs.planet);
gs.isHomeSystem = false; // PROBE STAGING — the EXPLORE drawer's Job Board slot (data/actionbar.json)
gs.startLanding(target);
const ss = await waitFor(() => { const c = game.scene.getScene('SurfaceScene'); return c?.jobBoardWindow ? c : null; }, 300);
if (!ss) throw new Error('surface never booted');
await sleep(600);
ss.startSurface();
// Capture every console line the surface speaks (the accept door's note).
const notes = [];
const note0 = ss.consoleNote.bind(ss);
ss.consoleNote = (t, o) => { notes.push({ text: String(t), glyph: o?.glyph }); return note0(t, o); };
await sleep(700);
ss.toggleExplore();
await sleep(1100);
ss.exploreAction('jobBoard');
await sleep(2000);
const win = ss.jobBoardWindow;
if (win?.video?.video) { win.video.video.muted = true; win.video.video.play().catch(() => {}); }
const h = huntsOn(gs)[0];
if (!h) throw new Error(`no BOUNTY HUNT posted on ${gs.systemRecord.name} at level ${level}`);
win.selectedId = h.id;
win.refresh();
await sleep(900);
const out = {
stage: STAGE,
system: gs.systemRecord.name,
neutralContext: !gs.systemRecord.faction,
combatLevel: gs.combatLevel(),
board: {
openState: win.openState,
rows: gs.sideQuestPostings().map((p) => ({ id: p.id, category: p.category, locked: p.locked === true, minLevel: p.minLevel ?? null })),
huntRow: {
title: h.title, issuer: h.issuer, reward: h.reward,
locked: h.locked === true, minLevel: h.minLevel ?? null,
dossier: h.dossier ? { name: h.dossier.name, epithet: h.dossier.epithet, crime: h.dossier.crime, craftLabel: h.dossier.craftLabel } : null,
},
// The hidden-plan rule: the posting carries no leg/mark/picket field of any kind.
leaks: Object.keys(h).filter((k) => /bountyAt|picket|mark|plan/i.test(k)),
},
};
// Press the accept DOOR (the window's own action seam → SurfaceScene.acceptSurfaceJob).
const before = gs.sideQuestState.isAccepted(h.id);
ss.acceptSurfaceJob(h.id);
await sleep(600);
out.press = {
id: h.id,
acceptedBefore: before,
acceptedAfter: gs.sideQuestState.isAccepted(h.id),
notes,
};
if (STAGE === 'dossier') {
// The contract taken + set ACTIVE for the flight stages to read.
ss.activateSurfaceJob(h.id);
await sleep(500);
const route = gs.sideQuestState.routeFor(h.id);
out.taken = {
active: gs.sideQuestState.activeId,
kind: route?.kind,
waypoints: route?.waypoints?.length ?? null,
xpOnRoute: route?.xp ?? null,
planFixedOnRecord: !!route?.bounty,
// The readout a player sees (the board's own progress seam), plus the fact that
// NO body line names the mark or its leg before the lane fires.
progressLine: gs.patrolProgress(h.id),
};
}
window.__BH = out;
say(out);
} else {
// ── the flight stages: accept + set active here, then fly the lanes ──────
const h = huntsOn(gs)[0];
if (!h) throw new Error('no BOUNTY HUNT posted at level 2');
const res = gs.acceptSideQuest(h.id);
gs.setActiveSideQuest(h.id);
const st = gs.sideQuestState;
const route = st.routeFor(h.id);
const wps = route.waypoints.map((w) => [Math.round(w[0]), Math.round(w[1])]);
// The console's own voice, captured (the leg + engagement lines are the story).
const toasts = [];
const toast0 = gs.consoleToast.bind(gs);
gs.consoleToast = (label, o) => { toasts.push({ text: String(label), glyph: o?.glyph }); return toast0(label, o); };
const out = { stage: STAGE, combatLevel: gs.combatLevel(), id: h.id, wps, log: [], waves: [], field: [] };
window.__BH = { stage: 'running' };
say(out);
// The staging: the ship is SET at the lane's waypoint (the check-in rule, the
// engagement and the payout underneath are all the scene's own update loop).
const teleport = (x, y) => { gs.ship.setPosition(x, y); };
const settle = async (frames = 26) => { for (let i = 0; i < frames; i++) await sleep(45); };
const waveSnap = () => {
const r = gs.combat.waveRun;
if (!r) return null;
const w = r.script?.waves?.[0] ?? {};
return {
state: r.state,
total: w.total ?? null,
maxConcurrent: w.maxConcurrent ?? null,
roster: w.roster ?? null,
elite: w.elite ? { type: w.elite.type, name: w.elite.name, healthScale: w.elite.healthScale, damageScale: w.elite.damageScale } : null,
live: gs.combat.enemies.filter((e) => e.wave && !e.dead).length,
};
};
if (STAGE === 'hunt') {
// The SEQUENTIAL LAW in the live world: leg 3 first is refused.
teleport(wps[2][0], wps[2][1]);
await settle(14);
out.log.push({ probe: 'out-of-order (leg 3 first)', visited: [...(st.routeFor(h.id).visited ?? [])] });
// Then the lane, in order.
for (let i = 0; i < wps.length; i++) {
teleport(wps[i][0], wps[i][1]);
await settle(24);
const r = st.routeFor(h.id);
out.waves.push({ leg: i + 1, wave: waveSnap() });
out.log.push({
probe: `leg ${i + 1} logged`,
visited: [...(r.visited ?? [])],
compass: gs.activeRouteCompassTarget?.() ?? null,
skirmishCleared: [...((r.bounty.skirmishCleared) ?? [])],
});
if ((gs.combat.waveRun && gs.combat.waveRun.state !== 'done' && gs.combat.waveRun.state !== 'cancelled')) break; // a fight stood up here
}
out.toasts = toasts;
out.claimedAt = null;
window.__BH = out;
say(out);
} else if (STAGE === 'mark') {
// Fly the lane until the MARK itself deploys (wall-clock bounded — the lane's
// pickets are shot down as they stand so the flight keeps reaching the mark).
const deadline = Date.now() + 45000;
let fired = false;
while (!fired && Date.now() < deadline) {
for (let i = 0; i < wps.length && !fired; i++) {
teleport(wps[i][0], wps[i][1]);
await settle(8);
const r = gs.combat.waveRun;
if (r && r.state !== 'done' && r.state !== 'cancelled') {
if (r.script?.waves?.[0]?.elite) { fired = true; break; }
for (const e of [...gs.combat.enemies]) if (e.wave && !e.dead) gs.combat.killEnemy(e, gs.time.now);
}
}
}
out.markWave = waveSnap();
const elite = gs.combat.enemies.find((e) => e.elite && !e.dead);
out.buff = elite ? {
name: elite.markName ?? null,
type: elite.type,
maxHull: elite.maxHull,
maxShields: elite.maxShields,
damageScale: elite.damageScale ?? null,
} : null;
out.fieldBeforeKill = gs.combat.enemies.filter((e) => e.wave && !e.dead).length;
if (elite) gs.combat.killEnemy(elite, gs.time.now);
await settle(12);
const r1 = st.routeFor(h.id);
out.afterMarkKill = {
waveState: gs.combat.waveRun?.state ?? null,
down: r1.bounty.down === true,
visited: [...(r1.visited ?? [])],
liveEscort: gs.combat.enemies.filter((e) => e.wave && !e.dead).length,
completedBeforeEscortKilled: st.isCompleted(h.id),
};
// The survivors must be put down before the contract reads COMPLETE.
const clearBy = Date.now() + 25000;
while (Date.now() < clearBy) {
const live = gs.combat.enemies.filter((e) => e.wave && !e.dead);
if (!live.length && gs.combat.waveRun?.state !== 'halted') break;
for (const e of live) gs.combat.killEnemy(e, gs.time.now);
await settle(6);
}
await settle(20);
const r2 = st.routeFor(h.id);
out.afterFieldClear = {
waveState: gs.combat.waveRun?.state ?? null,
completed: st.isCompleted(h.id),
visitedAll: (r2.visited ?? []).length === wps.length,
creditsHold: gs.ship.credits ?? null,
};
// The claim — ONCE.
const hold0 = gs.ship.credits ?? 0;
const xp0 = { ...gs.combat.xp };
const c1 = gs.claimSideQuest(h.id);
const c2 = gs.claimSideQuest(h.id);
await settle(6);
out.claim = {
first: c1, second: c2,
creditsBefore: hold0, creditsAfter: gs.ship.credits ?? null,
xpBankAfter: { level: gs.combat.xp.level, xp: Math.round(gs.combat.xp.xp) },
claimedFlag: st.isClaimed(h.id),
};
out.toasts = toasts;
window.__BH = out;
say(out);
}
}
} catch (err) {
window.__BH = { stage: 'FAILED', error: String(err?.message ?? err), errors: errors.slice(-6) };
say(window.__BH);
}

591
dev/bounty-hunts.test.mjs Normal file
View File

@ -0,0 +1,591 @@
// dev/bounty-hunts.test.mjs — the BOUNTY HUNT side quest, verified headless
// (node dev/bounty-hunts.test.mjs). The JOB BOARD's second live type: a
// level-2+ contract posted only in Neutral systems, flown as a strictly
// sequential 3-waypoint patrol of a pirate leader's lanes, resolving to a
// HIDDEN encounter chain (bounty at leg 1 ≈20% → else leg 2 ≈16% → else
// guaranteed at leg 3; an independent 50% picket wave on each pre-bounty leg)
// and ending on the mark's DEATH — a rolling wave of nine, three at a time,
// eight raiders plus the dossier's own frame flown double-strength.
//
// What this pins (the design's contract):
// - THE GATE — the hunt posts at combat level 2+ (data → categories.bounty)
// and below it the posting still stands in its seeded slot but reads
// LOCKED and the SCENE refuses the accept (no ledger entry, no route).
// - NEUTRAL-ONLY — `contexts:['neutral']` keeps hunts off Apex / MindWeave /
// Zenith boards, and descriptions.faction.bounty stays the documented
// empty stub (a faction board could otherwise fall back to the pirate list).
// - THE WANTED LIST — 20 dossiers: unique leaders/epithets/titles, every
// declared `craft` a real data/combat.json enemyTypes id, all seven frames
// used, and the posting's dossier carrying name/epithet/crime/craft.
// - THE CHAIN'S ODDS — a seeded distribution over the plan fork: ≈20% / 16%
// / 64%, pickets on PRE-bounty legs only at ≈50%, never on the mark's leg
// or behind it, and always exactly one mark.
// - DETERMINISM + THE SAVE — the same (seed, jobId, system, level) rolls the
// same plan; the plan is FIXED on the route record and survives a save
// round-trip (toJSON → fromJSON) with its runtime marks intact.
// - THE WAVES — skirmish: one rolling wave, six frame-0 raiders, three at a
// time, replace-per-kill. Bounty: one rolling wave of nine (8 raiders + the
// dossier's frame), three at a time, the elite deployed with the opening
// group at ×2 hull/shields and ×2 damage and NAMED.
// - 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.
// - 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).
// - NO SPOILERS — no player-facing string, posting field or copy line says
// which leg holds the bounty or a picket wave.
//
// The model is js/quests/SideQuests.js; the flying is js/scenes/GameScene.js
// (driven here off its prototype over stubs, the dev/delivery-launch.test.mjs
// pattern) and the combat seam is js/combat/Waves.js + CombatSystem.js.
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
// --- The real data (data/*.json) in the config singleton -----------------
const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(root, 'data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const DATA = configData;
const SQ = configData.sidequests; // data/sidequests.json (categories / bounty / descriptions / ui)
const S = await import(pathToFileURL(join(root, 'js/quests/SideQuests.js')).href);
const { SideQuestState } = S;
const { GameScene } = await import(pathToFileURL(join(root, 'js/scenes/GameScene.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${extra && !cond ? ` — ${extra}` : ''}`);
if (!cond) failures++;
};
const section = (t) => console.log(`\n== ${t} ==`);
// ==========================================================================
section('THE CATEGORY — BOUNTY HUNT is live, level 2+, Neutral boards only');
{
const meta = S.sideQuestCategoryMeta('bounty');
check('the category is enabled with its own label + accent', meta?.enabled === true && meta.label === 'BOUNTY HUNT' && /^#/.test(meta.accent));
check('the gate is combat level 2', meta?.minLevel === 2 && S.bountyMinLevel() === 2, `minLevel=${meta?.minLevel}`);
check('it posts in Neutral systems only', JSON.stringify(meta?.contexts) === JSON.stringify(['neutral']));
const neutral = S.enabledSideQuestCategories('neutral');
const faction = S.enabledSideQuestCategories('faction');
check('a Neutral board draws it', neutral.includes('bounty'), neutral.join(','));
check('a FACTION board never does', !faction.includes('bounty'), faction.join(','));
check("the faction wanted-list stays the documented empty stub", JSON.stringify(SQ.descriptions.faction.bounty) === '[]');
check('the hunt carries no contact-red beacon color (patrol law, deliberately)', SQ.bounty?.marker?.contactColor === undefined);
const bandIds = SQ.bounty.encounters.bands.map((b) => b.id);
check('the bands stand: the live hunter + the stubbed marksman inheriting it', JSON.stringify(bandIds) === JSON.stringify(['hunter', 'marksman']) && SQ.bounty.encounters.fallbackBand === 'hunter');
const mm = S.bountyRules(9);
check('level 6+ lands on the stub band and inherits the hunter numbers (no content cliff)', mm.id === 'marksman' && mm.bountyChance === 0.2 && mm.bountyWave.total === 9, JSON.stringify(mm).slice(0, 160));
}
// ==========================================================================
section('THE WANTED LIST — 20 Neutral dossiers');
{
const pool = SQ.descriptions.neutral.bounty;
const valid = new Set(Object.keys(DATA.combat.enemyTypes).filter((k) => typeof DATA.combat.enemyTypes[k] === 'object'));
check('twenty dossiers', Array.isArray(pool) && pool.length === 20, `len=${pool?.length}`);
check('every leader is unique', new Set(pool.map((e) => e.name)).size === pool.length);
check('every epithet is unique', new Set(pool.map((e) => e.epithet)).size === pool.length);
check('every title is unique', new Set(pool.map((e) => e.title)).size === pool.length);
const fields = ['title', 'issuer', 'name', 'epithet', 'crime', 'craft', 'craftLabel', 'body'];
const missing = pool.filter((e) => !fields.every((k) => typeof e[k] === 'string' && e[k].trim() !== '')).map((e) => e.title);
check('every dossier carries title/issuer/name/epithet/crime/craft/craftLabel/body', missing.length === 0, missing.join(','));
const bad = pool.filter((e) => !valid.has(e.craft)).map((e) => `${e.name}:${e.craft}`);
check("every declared craft is a real combat.enemyTypes id", bad.length === 0, bad.join(','));
const frames = new Set(pool.map((e) => e.craft));
const eliteFrames = new Set(SQ.bounty.encounters.bountyWave.eliteFrames);
check('the stories spread across all seven frames', [...eliteFrames].every((f) => frames.has(f)), [...frames].join(','));
check('every frame a story names is deployable by the band', [...frames].every((f) => eliteFrames.has(f)));
const longBodies = pool.filter((e) => e.body.length < 60).map((e) => e.title);
check('every dossier reads like a wanted notice, not a placeholder', longBodies.length === 0, longBodies.join(','));
}
// ==========================================================================
section('THE BOARD — rows on Neutral boards, LOCKED below level 2');
{
const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40, zone: 'mid', type: 'void', capital: false };
const at = (level) => S.sideQuestPostingsFor(1234, REC, null, level);
const low = at(1), high = at(2), noLevel = at(null);
const huntsLow = low.filter((p) => p.category === 'bounty');
check('a Neutral board posts BOUNTY HUNT contracts', huntsLow.length > 0 || at(7).some((p) => p.category === 'bounty'), `low=${huntsLow.length} high=${high.filter((p) => p.category === 'bounty').length}`);
const anyHunt = [...low, ...high, ...at(3), ...at(4), ...at(5)].find((p) => p.category === 'bounty');
check('the hunt row carries the category label + accent', anyHunt?.label === 'BOUNTY HUNT' && /^#/.test(anyHunt?.accent));
for (const p of huntsLow) {
check(`locked at level 1 (${p.title})`, p.locked === true && p.minLevel === 2);
}
const lockedCopy = S.bountyLockCopy();
const subCopy = S.bountyLockCopy(true);
check('the lock badge reads REQUIRES LEVEL 2', /REQUIRES LEVEL 2/.test(lockedCopy?.text ?? ''), lockedCopy?.text);
check('the detail line names the level too', /LEVEL 2/.test(subCopy?.text ?? ''), subCopy?.text);
const deny = S.bountyDenyCopy();
check('the scene’s refusal exists and names the level', /LEVEL 2/.test(deny?.text ?? ''), deny?.text);
const huntHigh = high.filter((p) => p.category === 'bounty');
for (const p of huntHigh) check(`open at level 2 (${p.title})`, p.locked === false && p.minLevel === 2);
check('an unknown level stands the board open (the gate is the SCENE’s read)', noLevel.every((p) => p.locked === false));
check('sideQuestAcceptable refuses below and opens at the gate', S.sideQuestAcceptable({ category: 'bounty' }, 1).locked === true && S.sideQuestAcceptable({ category: 'bounty' }, 2).ok === true);
check('patrol stays level-1 (untouched)', S.sideQuestAcceptable({ category: 'patrol' }, 1).ok === true);
// The dossier rides the posting (the readout's source) and the hunt's own
// plan does NOT.
for (const p of huntHigh.slice(0, 3)) {
const d = p.dossier;
check(`the posting carries its dossier (${p.title})`, !!d && !!d.name && !!d.epithet && !!d.crime && !!d.craft && !!d.craftLabel);
const json = JSON.stringify(p);
check(`no plan leaks into the posting (${p.title})`, !/bountyAt|pickets|eliteType|"legs"|skirmish/i.test(json));
}
check('the hunt flies its OWN system (no DESTINATION line on the board)', huntHigh.every((p) => p.targetSystemId === p.systemId && p.targetSystemName === p.systemName));
// The faction boards.
for (const f of ['apex', 'mindweave', 'zenith']) {
const rec = { id: `sys-${f}`, name: `${f} Reach`, faction: f, d: 30, zone: 'mid', type: 'void', capital: false };
for (const lvl of [1, 2, 5]) {
const posts = S.sideQuestPostingsFor(99, rec, null, lvl);
check(`${f} boards never post a hunt (level ${lvl})`, posts.every((p) => p.category !== 'bounty'), posts.map((p) => p.category).join(','));
}
}
}
// ==========================================================================
section('THE CHAIN — seeded distribution reads 20% / 16% / 64%');
{
const N = 20000;
const at = [0, 0, 0];
let picketRolls = 0, pickets = 0, legsTotal = 0, onAfterMark = 0, marks = 0;
for (let i = 0; i < N; i++) {
// Every draw rides its OWN contract in its OWN system: the chain's fork is
// keyed by (seed, jobId, target), so a repeated pair would repeat its plan.
const plan = S.deriveBountyPlan(20260920 + (i % 97), `sq-sys-${i}-bounty-${i % 4}`, `sys-${i}`, 3);
legsTotal += plan.legs;
at[plan.bountyAt]++;
marks += plan.pickets.filter((v, idx) => v && idx < plan.bountyAt).length;
for (let k = 0; k < plan.pickets.length; k++) {
if (k >= plan.bountyAt) {
if (plan.pickets[k]) onAfterMark++;
continue;
}
picketRolls++;
if (plan.pickets[k]) pickets++;
}
}
const p1 = at[0] / N, p2 = at[1] / N, p3 = at[2] / N, rate = pickets / picketRolls;
check('leg 1 holds the mark ≈20% of contracts', Math.abs(p1 - 0.2) < 0.012, `${(p1 * 100).toFixed(2)}%`);
check('leg 2 ≈16% (20% of what leg 1 missed)', Math.abs(p2 - 0.16) < 0.012, `${(p2 * 100).toFixed(2)}%`);
check('leg 3 takes every remaining contract (the guarantee)', Math.abs(p3 - 0.64) < 0.012, `${(p3 * 100).toFixed(2)}%`);
check('exactly three legs every time', legsTotal === N * 3, `${legsTotal / N}`);
check('pickets roll ≈50% on each pre-bounty leg', Math.abs(rate - 0.5) < 0.01, `${(rate * 100).toFixed(2)}%`);
check('NO picket ever rolls on the mark’s leg or behind it', onAfterMark === 0, `${onAfterMark}`);
const p = S.deriveBountyPlan(7, 'sq-x-bounty-0', 'sys-free', 3);
check('the picket list is one mark per leg BEFORE the bounty', p.pickets.length === p.bountyAt || p.pickets.length === p.legs - 1);
}
// ==========================================================================
section('DETERMINISM — the roll is a function of its keys, not of history');
{
const a = S.deriveBountyPlan(4242, 'sq-sys-free-bounty-0', 'sys-free', 3, { craft: 'gunner', mark: { name: 'A', epithet: 'E' } });
// Interleave other work (a different job, system, level) — the fork is labelled.
S.deriveBountyPlan(11, 'sq-other-bounty-2', 'other-system', 5);
S.deriveBountyPlan(99, 'sq-third-bounty-1', 'third', 2);
const b = S.deriveBountyPlan(4242, 'sq-sys-free-bounty-0', 'sys-free', 3, { craft: 'gunner', mark: { name: 'A', epithet: 'E' } });
check('the same keys roll the same plan (scene restarts, re-reads)', JSON.stringify(a) === JSON.stringify(b));
const otherSystem = S.deriveBountyPlan(4242, 'sq-sys-free-bounty-0', 'other-system', 3);
const otherJob = S.deriveBountyPlan(4242, 'sq-sys-free-bounty-1', 'sys-free', 3);
check('another system / another contract rolls its own plan', JSON.stringify(a) !== JSON.stringify({ ...otherSystem, mark: a.mark }) || JSON.stringify(a) !== JSON.stringify(otherJob));
const declared = S.deriveBountyPlan(5, 'sq-dossier-bounty-0', 'sys-free', 3, { craft: 'barrage' });
check('the dossier’s own frame is the mark’s frame (story and fight agree)', declared.eliteType === 'barrage');
const typo = S.deriveBountyPlan(5, 'sq-typo-bounty-0', 'sys-free', 3, { craft: 'not-a-craft' });
check('a hand-edited craft falls back to a deployable frame, never a broken fight', SQ.bounty.encounters.bountyWave.eliteFrames.includes(typo.eliteType), typo.eliteType);
const band = S.bountyRules(1); // under the gate: the fallback band still stands
check('a level below the band list still resolves (the fallback band)', typeof band.id === 'string' && band.legs[0] === 3);
}
// ==========================================================================
section('THE WAVES — six raiders three at a time; nine with the mark');
{
const plan = S.deriveBountyPlan(31, 'sq-wave-bounty-0', 'sys-free', 3, { craft: 'hunter', mark: { name: 'Test Mark', epithet: 'THE TEST' } });
const sk = S.bountySkirmishScript(plan).waves[0];
check('the picket wave is ONE rolling wave', S.bountySkirmishScript(plan).waves.length === 1 && sk.kind === 'rolling');
check('the picket wave is six deep, three at a time, one replaced per kill', sk.total === 6 && sk.maxConcurrent === 3 && sk.replacePerKill === 1, JSON.stringify(sk));
check('the picket wave is raiders only (no elite rides it)', Object.keys(sk.roster).join(',') === 'raider' && !sk.elite);
const en = S.bountyEngagementScript(plan).waves[0];
check('the bounty wave is ONE rolling wave', S.bountyEngagementScript(plan).waves.length === 1 && en.kind === 'rolling');
check('nine deep, three at a time', en.total === 9 && en.maxConcurrent === 3, JSON.stringify(en));
check('exactly eight raiders plus the dossier’s frame', en.roster.raider === 8 && en.roster.hunter === 1 && Object.values(en.roster).reduce((a, b) => a + b, 0) === 9, JSON.stringify(en.roster));
check('the mark is the declared frame and carries ×2 hull / ×2 damage', en.elite?.type === 'hunter' && en.elite.healthScale === 2 && en.elite.damageScale === 2, JSON.stringify(en.elite));
check('the mark is NAMED (the toast/comms can say who fell)', en.elite?.name === 'Test Mark');
for (const frame of SQ.bounty.encounters.bountyWave.eliteFrames) {
const p = S.deriveBountyPlan(88, `sq-frame-bounty-0`, 'sys-free', 3, { craft: frame });
const w = S.bountyEngagementScript(p).waves[0];
check(`frame ${frame} deploys as the named elite beside eight raiders`, w.elite.type === frame && w.roster[frame] === 1 && w.roster.raider === 8 && w.total === 9);
}
// The combat seam itself (js/combat/Waves.js): the mark rides the OPENING group.
const { normalizeWaves, resolveRoster, waveDefaults } = await import(pathToFileURL(join(root, 'js/combat/Waves.js')).href);
const norm = normalizeWaves({ waves: [en], kind: 'rolling' }, waveDefaults());
check('the wave survives normalization with its elite block', norm.ok === true && norm.waves[0].elite?.type === 'hunter');
const order = resolveRoster(norm.waves[0]);
check('the force is still nine deep', order.length === 9, `${order.length}`);
const headIsMark = typeof order[0] === 'object' && order[0].type === 'hunter';
check('the mark deploys WITH the opening group (head of the spawn order)', headIsMark, JSON.stringify(order[0]));
const marks = order.filter((u) => typeof u === 'object');
check('exactly ONE mark in the force', marks.length === 1);
check('the escort is the eight raiders', order.filter((u) => u === 'raider').length === 8);
}
// ==========================================================================
section('THE PAYOUT — bountyHuntXp is the worth of the whole hunt');
{
// The worth the payout reads is the RUNTIME enemy config (js/combat/EnemyTypes.js
// merges combat.json's standing enemy block with each type's own — a raider
// carries only its frame in the data and takes its xp from the shared block).
const { enemyTypeConfig } = await import(pathToFileURL(join(root, 'js/combat/EnemyTypes.js')).href);
const raider = enemyTypeConfig('raider').xp;
const gunner = enemyTypeConfig('gunner').xp;
const mk = (pickets) => S.deriveBountyPlan(12, `sq-xp-${pickets.join('')}`, 'sys-free', 3, { craft: 'gunner' });
const plan = mk([]);
const withPicket = S.bountyHuntXp({ ...plan, pickets: [true, false], bountyAt: 2 });
const without = S.bountyHuntXp({ ...plan, pickets: [false, false], bountyAt: 2 });
const expectedBase = 8 * raider + gunner * 2;
check('the base pays the escort plus the mark at its scale', without === Math.round(expectedBase), `${without} vs ${expectedBase}`);
check('every rolled picket wave adds its own worth', withPicket - without === 6 * raider, `Δ=${withPicket - without}`);
const eliteTypes = SQ.bounty.encounters.bountyWave.eliteFrames;
const spread = eliteTypes.map((f) => S.bountyHuntXp({ ...plan, eliteType: f }));
check('a beefier frame is worth more than a cheap one', Math.max(...spread) > Math.min(...spread), spread.join(','));
check('the payout is finite and positive', spread.every((v) => Number.isFinite(v) && v > 0));
}
// ==========================================================================
section('SEQUENTIAL LAW — only the NEXT leg logs (patrol law untouched)');
{
const waypoints = [[0, 0], [1000, 0], [2000, 0]];
const plan = S.deriveBountyPlan(3, 'sq-seq-bounty-0', 'sys-free', 3, { craft: 'warden' });
const st = new SideQuestState();
st.accept('sq-seq-bounty-0');
st.setActive('sq-seq-bounty-0');
check('a hunt route stands on the ledger', st.setRoute('sq-seq-bounty-0', { kind: 'bounty', targetSystemId: 'sys-free', waypoints, credits: 500, xp: 200, bounty: plan }));
const route = st.routeFor('sq-seq-bounty-0');
check("the record keeps the hunt's kind", route.kind === 'bounty' && route.bounty);
check('the first leg is the only target', S.nextBountyWaypoint(route)?.index === 0);
check('a later leg cannot be logged out of order', st.visitWaypoint('sq-seq-bounty-0', 2) === false && st.routeFor('sq-seq-bounty-0').visited.length === 0);
check('legs log in order', st.visitWaypoint('sq-seq-bounty-0', 0) === true && st.visitWaypoint('sq-seq-bounty-0', 1) === true);
check('a double visit does not double-log', st.visitWaypoint('sq-seq-bounty-0', 1) === false);
const r2 = st.routeFor('sq-seq-bounty-0');
check('the beacon now points at leg 3', S.nextBountyWaypoint(r2)?.index === 2 && S.nextBountyWaypoint(r2).leg === 3 && S.nextBountyWaypoint(r2).of === 3);
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).
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('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);
}
// ==========================================================================
section('THE SAVE — a signed contract is a signed contract');
{
const JOB = 'sq-sys-free-bounty-0'; // a real posting id (sq-<sysId>-<cat>-<slot>) — the save's job space
const plan = S.deriveBountyPlan(606, JOB, 'sys-free', 3, { craft: 'spitter', mark: { name: 'Save Mark', epithet: 'OF THE SAVE' } });
const st = new SideQuestState();
st.accept(JOB);
st.setActive(JOB);
st.setRoute(JOB, { kind: 'bounty', targetSystemId: 'sys-free', waypoints: [[0, 0], [900, 0], [1800, 0]], credits: 640, xp: 300, bounty: plan });
st.visitWaypoint(JOB, 0);
if (plan.pickets[0]) st.bountySkirmishCleared(JOB, 0);
const known = S.allSideQuestJobIds([{ id: 'sys-free', name: 'Free Reach', faction: null, d: 40 }]);
check('the contract stands inside the save’s own job space', known.has(JOB));
const round = SideQuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON())), known);
const r = round.routeFor(JOB);
check('the hunt route survives JSON with its kind', r?.kind === 'bounty' && round.isAccepted(JOB) && round.isActive(JOB));
check("the hidden plan rides the save unchanged", r.bounty.bountyAt === plan.bountyAt && JSON.stringify(r.bounty.pickets) === JSON.stringify(plan.pickets) && r.bounty.eliteType === plan.eliteType, `${r.bounty.bountyAt}/${plan.bountyAt}`);
check('the mark’s identity rides the save (the comms still know who it was)', r.bounty.mark?.name === 'Save Mark');
check('the leg marks ride the save', JSON.stringify(r.visited) === JSON.stringify([0]));
check("the picket's cleared mark rides the save", JSON.stringify(r.bounty.skirmishCleared) === JSON.stringify(plan.pickets[0] ? [0] : []));
check('the payout rides the save (credits + XP fixed at the accept)', r.credits === 640 && r.xp === 300);
const rePlan = S.deriveBountyPlan(606, JOB, 'sys-free', 3, { craft: 'spitter', mark: { name: 'Save Mark', epithet: 'OF THE SAVE' } });
// The chain itself, field by field (the record normalizes its wave SHAPES —
// what must not move is the rolled plan: which leg, which pickets, which frame).
const chain = (p) => [p.legs, p.bountyAt, JSON.stringify(p.pickets), p.band, p.raiders, p.raiderType, p.eliteType, p.mark?.name, p.skirmishWave.total, p.bountyWave.total, p.bountyWave.maxConcurrent, p.visitRadius];
check('the restored plan is the rolled one (a load never re-draws it)', chain(r.bounty).join('|') === chain(plan).join('|'), `${chain(r.bounty).join('|')} vs ${chain(plan).join('|')}`);
// A hand-edited record must not manufacture a fight.
const waypoints = [[0, 0], [900, 0], [1800, 0]];
const bad = new SideQuestState();
bad.accept('sq-bad');
bad.setRoute('sq-bad', { kind: 'bounty', waypoints, bounty: { legs: 3, bountyAt: 9, pickets: [], eliteType: 'raider' } });
check('a bounty index outside the legs degrades (no plan, no fight)', bad.routeFor('sq-bad').waypoints.length === 3 && bad.routeFor('sq-bad').bounty === null);
bad.setRoute('sq-bad', { kind: 'bounty', waypoints, bounty: { legs: 3, bountyAt: 2, pickets: [false, false], eliteType: 'not-a-frame' } });
check('an unknown frame degrades the plan rather than crashing one', bad.routeFor('sq-bad').bounty === null);
const preHunt = SideQuestState.fromJSON({ accepted: ['sq-sys-free-patrol-1'], routes: { 'sq-sys-free-patrol-1': { waypoints, visited: [0] } } }, known);
check('a save written before hunts existed loads as a patrol', preHunt.routeFor('sq-sys-free-patrol-1').kind === 'patrol' && preHunt.routeFor('sq-sys-free-patrol-1').bounty === null);
}
// ==========================================================================
section('THE FLYING — accept → legs → mark → kill → claim (the real scene wiring)');
{
const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40, zone: 'mid', type: 'void', capital: false };
function makeScene({ level, seed = 20260920 }) {
const s = Object.create(GameScene.prototype);
s.galaxy = { seed, byId: new Map([[REC.id, REC]]), contentOf: () => null, jumpNetwork: { depth: new Map([[REC.id, 0]]) } };
s.systemRecord = REC;
s.systemPlanets = [{ x: -400, y: 0 }, { x: 400, y: 0 }];
s.systemStations = [];
s.systemGates = [];
s.planet = null;
s.tetherField = { tethers: [{ x: 0, y: 0, radius: 12000 }, { x: -400, y: 0, radius: 9000 }, { x: 400, y: 0, radius: 9000 }] };
s.sideQuestState = new SideQuestState();
s.sfx = [];
s.playSfx = (n) => { s.sfx.push(n); };
s.toasts = [];
s.consoleToast = (text, o = {}) => { s.toasts.push({ text, ...o }); };
s.credits = 0;
s.ship = { x: -99999, y: -99999, addCredits(n) { s.credits += n; } };
s.refreshCreditsHud = () => {};
s.grants = [];
s.combat = {
xp: { level, grant(n, src) { s.grants.push({ n, src }); } },
waveRun: null,
started: [],
halted: 0,
enemies: [],
startWaves(script, opts = {}) {
s.combat.started.push({ script, opts });
s.combat.waveRun = { state: 'running', complete() { s.combat.waveRun.state = 'done'; opts.onComplete?.(); } };
},
haltWaves() {
s.combat.halted++;
if (s.combat.waveRun) s.combat.waveRun.state = 'halted';
},
cancelWaves() { s.combat.waveRun = null; },
// CombatSystem._waveTick's HALTED rule (js/combat/CombatSystem.js): the
// run stays up while the survivors fight, and reads 'done' with the last
// of them down — which is what the scene waits on to complete the hunt.
tickWaves() {
const run = s.combat.waveRun;
if (run?.state === 'halted' && !s.combat.enemies.some((e) => e.wave && !e.dead)) run.state = 'done';
},
};
return s;
}
const huntOf = (s) => S.sideQuestPostingsFor(s.galaxy.seed, REC, s.galaxy, s.combatLevel()).filter((p) => p.category === 'bounty');
// ---- the gate refuses at level 1 -------------------------------------------------
{
const s = makeScene({ level: 1 });
const hunts = huntOf(s);
check('the board lists hunts at level 1 (LOCKED)', hunts.length > 0 && hunts.every((p) => p.locked === true), `${hunts.length}`);
const res = s.acceptSideQuest(hunts[0].id);
check('the SCENE refuses the locked accept', res.ok === false && res.locked === true);
check('nothing was taken and no route stands', s.sideQuestState.acceptedList().length === 0 && s.sideQuestState.routeFor(hunts[0].id) === null);
check('the refusal says why, from the data', /LEVEL 2/.test(s.toasts.at(-1)?.text ?? ''), s.toasts.at(-1)?.text);
}
// ---- level 2 accepts and stands the lanes ---------------------------------------
let s, id, route;
{
s = makeScene({ level: 2 });
const hunts = huntOf(s);
check('the board opens at level 2', hunts.length > 0 && hunts.every((p) => p.locked === false));
id = hunts[0].id;
const res = s.acceptSideQuest(id);
check('the contract is taken', res.ok === true && s.sideQuestState.isAccepted(id));
route = s.sideQuestState.routeFor(id);
check('the lanes are exactly three waypoints', route?.waypoints?.length === 3, `${route?.waypoints?.length}`);
check('the record is a HUNT with its hidden plan', route.kind === 'bounty' && !!route.bounty && Number.isInteger(route.bounty.bountyAt));
check('the payout is fixed on the record', route.credits > 0 && route.xp === S.bountyHuntXp(route.bounty), `${route.credits}/${route.xp}`);
check('a second accept never double-takes', s.acceptSideQuest(id).ok === false || s.sideQuestState.routeFor(id).waypoints.length === 3);
const again = makeScene({ level: 2 });
again.acceptSideQuest(id);
check('the same contract rolls the same lanes and plan (deterministic accept)', JSON.stringify(again.sideQuestState.routeFor(id).waypoints) === JSON.stringify(route.waypoints) && again.sideQuestState.routeFor(id).bounty.bountyAt === route.bounty.bountyAt);
// The readout seam keeps the hunt's shape: legs + payout, never a contact count.
const prog = s.patrolProgress(id);
check('the progress seam reports legs and XP', prog.visited === 0 && prog.total === 3 && prog.xp === route.xp);
check('a hunt has no contact count to leak (no red line on the board)', prog.contacts === null, JSON.stringify(prog));
// ---- sequential check-ins + the hidden engagements --------------------------------
s.sideQuestState.setActive(id);
const wp = route.waypoints;
const at = (i) => { s.ship.x = wp[i][0]; s.ship.y = wp[i][1]; };
const clearRun = () => { if (s.combat.waveRun?.state === 'running') s.combat.waveRun.complete(); s.combat.enemies = []; };
// Park at the LAST leg first: a hunt flies its legs in order, so nothing logs.
at(2);
s.updateSideQuestRoute();
check('flying to leg 3 first logs nothing', s.sideQuestState.routeFor(id).visited.length === 0);
const compass = s.activeRouteCompassTarget();
check('the compass points at the NEXT leg only', compass?.kind === 'waypoint' && compass.idx === 0, JSON.stringify(compass));
check('the hunt’s compass carries its own label/color (never a contact)', /hunt/i.test(String(compass?.typeLabel ?? '')) && String(compass?.color).toLowerCase() === String(SQ.bounty.compass.color).toLowerCase());
// Leg 1.
at(0);
s.updateSideQuestRoute();
route = s.sideQuestState.routeFor(id);
check('leg 1 logs', route.visited.includes(0));
const plan = route.bounty;
if (plan.pickets[0]) {
check('a rolled picket fires on its own leg', s.combat.started.length === 1 && s.combat.started[0].script.waves[0].total === 6, JSON.stringify(s.combat.started.map((x) => x.script.waves[0].total)));
const toast = s.toasts.at(-1).text;
check('the picket toast counts hostiles but never names the mark', /INBOUND/.test(toast) && !new RegExp(String(plan.mark?.name ?? '@NOPE@')).test(toast), toast);
clearRun();
s.updateSideQuestRoute();
check('a cleared picket does not re-fire (its witness was the completion)', s.combat.started.length === 1);
} else {
check('an unrolled leg fires nothing', s.combat.started.length === 0);
check('the quiet lane toasts the leg only', /LOGGED/.test(s.toasts.at(-1)?.text ?? ''), s.toasts.at(-1)?.text);
}
// Leg 2 (a mark standing here is sighted; otherwise it is another quiet/picket leg).
at(1);
s.updateSideQuestRoute();
route = s.sideQuestState.routeFor(id);
check('leg 2 logs after leg 1', route.visited.includes(1) && route.bounty === plan);
if (plan.bountyAt === 1) {
const w = s.combat.started.at(-1).script.waves[0];
check('a bounty on leg 2 fires the nine-deep wave', w.total === 9 && w.roster.raider === 8);
} else if (plan.bountyAt > 1 && plan.pickets[1]) {
check('leg 2’s picket fired at ≈six raiders', s.combat.started.at(-1).script.waves[0].total === 6);
} else {
check('a quiet leg 2 fires nothing', s.combat.started.length <= 1);
}
clearRun();
// Leg 3 — the guarantee: whichever leg held the mark, it is now visited.
at(2);
s.updateSideQuestRoute();
route = s.sideQuestState.routeFor(id);
check('leg 3 logs', route.visited.includes(2));
const bountyRun = s.combat.started.find((x) => x.script.waves[0].total === 9);
check('the mark’s wave fired on its own leg, never earlier', !!bountyRun && (plan.bountyAt === 2 ? s.combat.started.indexOf(bountyRun) > 0 : true));
const sighted = s.toasts.find((t) => /THE MARK IS HERE/.test(t.text));
check('the sighting names the leader only once the fight is here', !!sighted && sighted.text.includes(String(route.bounty.mark?.name ?? '').toUpperCase()));
check('every leg logged still does NOT complete the contract', s.sideQuestState.isCompleted(id) === false);
// The kill: halt everything, book the mark, finish only when the escort stops shooting.
const w = bountyRun.script.waves[0];
// killEnemy marks the craft DOWN before it calls onKill, and a wave ship's
// kill never reforms — so the witness sees a dead enemy.
const markEnemy = { elite: true, dead: true, wave: true, respawn: false, markName: w.elite.name };
const escort = Array.from({ length: 3 }, () => ({ dead: false, wave: true }));
s.combat.enemies = [markEnemy, ...escort];
s.combat.waveRun = { state: 'running' };
s.handleCombatKill(markEnemy);
check('the mark’s death halts every further deployment', s.combat.halted === 1 && s.combat.waveRun.state === 'halted');
check('the kill books the mark (every leg checked off)', s.sideQuestState.routeFor(id).bounty.down === true);
check('the brought-to-book line names the leader', /BROUGHT TO BOOK/.test(s.toasts.at(-1)?.text ?? '') && s.toasts.at(-1).text.includes(String(route.bounty.mark?.name).toUpperCase()));
check("a second elite kill can't book it twice", (s.combat.waveRun.state = 'halted', s.handleCombatKill({ elite: true, dead: true, wave: true, markName: w.elite.name }) === undefined && s.sideQuestState.routeFor(id).bounty.down === true));
at(plan.bountyAt); // stand in the lane so the field is read here
s.updateSideQuestRoute();
check('survivors keep it OUT of complete (the escort must stop shooting)', s.sideQuestState.isCompleted(id) === false);
for (const e of escort) e.dead = true;
s.combat.tickWaves(); // the last survivor drops — the run reads 'done'
s.updateSideQuestRoute();
check('with the last escort down the contract is COMPLETE', s.sideQuestState.isCompleted(id) === true, JSON.stringify(s.toasts.at(-1) ?? {}));
const completeToast = s.toasts.filter((t) => /COMPLETE/.test(t.text)).length;
check('the completion toasts once', completeToast === 1);
// The claim pays credits + XP exactly once.
const pay = s.claimSideQuest(id);
check('the claim pays the contract’s credits and XP', pay.ok === true && pay.credits === route.credits && pay.xp === route.xp, JSON.stringify(pay));
check('the credits land in the hold and the XP in the run’s bank (sidequest source)', s.credits === pay.credits && s.grants.some((g) => g.n === pay.xp && g.src === 'sidequest'));
const second = s.claimSideQuest(id);
check('a second claim pays nothing', second.ok === false && s.credits === pay.credits && s.grants.filter((g) => g.src === 'sidequest').length === 1);
check('no further wave fires once the hunt is done', (s.combat.started.length, s.updateSideQuestRoute(), s.combat.waveRun?.state !== 'running'));
// The whole hunt rides the save.
const known = S.allSideQuestJobIds([REC]);
const back = SideQuestState.fromJSON(JSON.parse(JSON.stringify(s.sideQuestState.toJSON())), known);
check('the finished hunt reloads claimed with its plan', back.isClaimed(id) && back.routeFor(id).bounty.down === true && back.routeFor(id).xp === route.xp);
}
// ---- the wp1 case and the wp3 guarantee, end to end -------------------------------
for (const wantAt of [0, 2]) {
let found = null;
for (let i = 0; i < 400 && !found; i++) {
const cand = makeScene({ level: 3, seed: 4000 + i }); // the board AND the plan ride the scene's own seed
const hunts = S.sideQuestPostingsFor(4000 + i, REC, cand.galaxy, 3).filter((p) => p.category === 'bounty');
for (const h of hunts) {
cand.acceptSideQuest(h.id);
const r = cand.sideQuestState.routeFor(h.id);
if (r && r.bounty.bountyAt === wantAt) { found = { s: cand, id: h.id, r }; break; }
}
}
check(`a contract with the mark on leg ${wantAt + 1} exists to be flown`, !!found);
if (!found) continue;
const { s: fs2, id: fid, r: fr } = found;
fs2.sideQuestState.setActive(fid);
const wps = fr.waypoints;
for (let i = 0; i < wps.length; i++) {
fs2.ship.x = wps[i][0]; fs2.ship.y = wps[i][1];
fs2.updateSideQuestRoute();
if (fs2.combat.waveRun?.state === 'running') { fs2.combat.waveRun.complete(); fs2.combat.enemies = []; }
}
const run9 = fs2.combat.started.filter((x) => x.script.waves[0].total === 9);
check(`leg ${wantAt + 1} fired the nine-deep mark wave (exactly once)`, run9.length === 1, `${run9.length}`);
check(`no mark wave fired before its leg`, fs2.combat.started.slice(0, fs2.combat.started.indexOf(run9[0])).every((x) => x.script.waves[0].total !== 9));
const mk = { elite: true, dead: true, wave: true, respawn: false, markName: run9[0].script.waves[0].elite.name };
fs2.combat.enemies = [mk];
fs2.combat.waveRun = { state: 'halted' };
fs2.handleCombatKill(mk);
fs2.combat.tickWaves(); // nothing of the hunt left shooting — the run reads 'done'
fs2.ship.x = wps[wps.length - 1][0]; fs2.ship.y = wps[wps.length - 1][1];
fs2.updateSideQuestRoute();
check(`the mark’s death alone completes the contract (leg ${wantAt + 1})`, fs2.sideQuestState.isCompleted(fid) === true);
}
}
// ==========================================================================
section('NO SPOILERS — nothing the player reads says where the mark stands');
{
const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40 };
const posts = S.sideQuestPostingsFor(777, REC, null, 3).filter((p) => p.category === 'bounty');
const planOf = (p) => S.deriveBountyPlan(777, p.id, p.targetSystemId, 3, { craft: p.dossier?.craft });
const copyKeys = ['bountyLockBadge', 'bountyLockSub', 'bountyAcceptDenyToast', 'bountyLegToast', 'bountySkirmishToast', 'bountySightedToast', 'bountyBroughtToBookToast', 'bountyCompleteToast', 'bountyRouteProgress'];
for (const k of copyKeys) {
const raw = SQ.ui[k];
const text = typeof raw === 'string' ? raw : raw?.text ?? '';
check(`ui.${k} exists and never speaks a leg index`, text !== '' && !/\b(waypoint|leg)\s*[123]\b/i.test(text), text);
}
for (const p of posts.slice(0, 4)) {
const plan = planOf(p);
const prog = S.bountyProgressCopy({ waypoints: [[0, 0], [1, 1], [2, 2]], visited: [0] });
check('the progress line counts legs only', /WAYPOINT 1 OF 3 LOGGED/.test(prog?.text ?? ''), prog?.text);
const dossier = S.bountyDossierCopy(p.dossier).map((l) => l.text).join(' | ');
check(`the readout’s dossier shows the leader/charges/craft and no lane plan (${p.title})`, /WANTED/.test(dossier) && /CHARGES/.test(dossier) && /CRAFT/.test(dossier) && !/WAYPOINT|LEG [123]|bountyAt/i.test(dossier), dossier.slice(0, 90));
check(`no posting field carries the plan (${p.title})`, !('bountyAt' in p) && !('pickets' in p) && !JSON.stringify(p).toLowerCase().includes('skirmish'));
void plan;
}
const src = fs.readFileSync(join(root, 'js/ui/JobBoardWindow.js'), 'utf8');
check('the board never renders a bountyAt/pickets field', !/bountyAt|plan\.pickets/.test(src));
const sceneSrc = fs.readFileSync(join(root, 'js/scenes/GameScene.js'), 'utf8');
check('the hunt’s beacon style comes from the data with no contact color', /_drawBountyMarkers/.test(sceneSrc) && !/bounty\.marker\.contactColor/.test(sceneSrc));
}
// ==========================================================================
if (failures > 0) {
console.error(`\n${failures} bounty hunt check(s) FAILED`);
process.exit(1);
}
console.log('\nAll BOUNTY HUNT checks passed ✔');

View File

@ -1002,6 +1002,57 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
check('waves/system: destroy clears the run', combat.waveRun === null);
}
// ------------------------------------------- waves: THE ELITE + the halt seam
{
// The elite (a bounty hunt's mark beside its escort): declared on the wave,
// deployed with the OPENING group, exactly once, buffed by the DATA's scales
// and TAGGED so a quest can recognise the kill.
const eNorm = normalizeWaves([
{ kind: 'rolling', roster: { raider: 8, gunner: 1 }, total: 9, maxConcurrent: 3, replacePerKill: 1, elite: { type: 'gunner', healthScale: 2, damageScale: 2 } },
]);
check('waves/elite: the wave keeps its elite block through normalizeWaves', eNorm.ok && eNorm.waves[0].elite?.type === 'gunner' && eNorm.waves[0].elite.healthScale === 2);
const order = resolveRoster(eNorm.waves[0], () => 0.5);
check('waves/elite: the force is still nine deep', order.length === 9);
const marks = order.filter((s) => typeof s === 'object' && s?.type === 'gunner');
check('waves/elite: exactly ONE mark in the force (the rest are the escort)', marks.length === 1);
check('waves/elite: the mark rides the HEAD of the spawn order (the opening group)', typeof order[0] === 'object' && order[0].type === 'gunner');
check('waves/elite: the escort draws the remaining types only', order.slice(1).every((s) => s === 'raider'));
const noElite = resolveRoster(normalizeWaves([{ kind: 'rolling', roster: { raider: 1 }, total: 6 }]).waves[0], () => 0.5);
check('waves/elite: a wave WITHOUT an elite is untouched (bare type ids)', noElite.length === 6 && noElite.every((s) => s === 'raider'));
// The RUN: the mark lands with the first burst, doubled by the data, and
// its kill HALTS the run — survivors stay hostile, nothing else deploys.
const scene = makeScene({ withSheet: true });
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
combat.autoFire.enabled = false;
combat.startWaves({ waves: [{ kind: 'rolling', roster: { raider: 8, gunner: 1 }, total: 9, maxConcurrent: 3, replacePerKill: 1, elite: { type: 'gunner', healthScale: 2, damageScale: 2, name: 'Ilse Vanturi' } }], lead: 0 });
combat.update(1000, 16);
const burst = combat.enemies.filter((e) => e.wave && !e.dead);
const mark = combat.enemies.find((e) => e.elite);
const gunnerCfg = (await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href)).enemyTypeConfig('gunner');
check('waves/elite: the mark deploys WITH the opening group (3 on screen, mark among them)', burst.length === 3 && !!mark);
check('waves/elite: the mark is tagged + named (the kill is identifiable)', mark?.wave === true && mark?.markName === 'Ilse Vanturi');
check('waves/elite: hull AND shields ride the health scale', mark.maxHull === gunnerCfg.hull * 2 && mark.shields === (gunnerCfg.shields || 0) * 2);
check('waves/elite: its weapon rides the damage scale', Math.round(mark.wpn.damage) >= Math.round((Number(gunnerCfg.weapon?.damage) || 8) * 2));
const rawGunner = config.section('combat').enemyTypes.gunner;
check('waves/elite: the TYPE’s own data is never mutated (a buffed craft, not a re-tuned frame)', Number(rawGunner.weapon?.damage ?? 8) <= Number(mark.wpn.damage) / 2 + 1);
combat.haltWaves();
check('waves/halt: the run halts without pulling the ships out', combat.waveStatus().state === 'halted' && combat.enemies.filter((e) => e.wave && !e.dead).length === burst.length);
const before = combat.enemies.filter((e) => e.wave && !e.dead).length;
for (let i = 0; i < 12; i++) combat.update(2000 + i * 500, 16);
check('waves/halt: nothing further deploys while it is halted', combat.enemies.filter((e) => e.wave && !e.dead).length === before);
const survivors = combat.enemies.filter((e) => e.wave && !e.dead);
for (const e of survivors) { e.damage(9999); combat.killEnemy(e, 9000); }
check('waves/halt: the survivors do NOT reform (hostile, spent — no respawn clock)', survivors.every((e) => e._respawnPending !== true && e.respawnAt === 0));
combat.update(9100, 16);
check('waves/halt: the last survivor down ends the run (no onComplete re-arm)', combat.waveStatus().state === 'done' && combat.active === false);
combat.destroy();
}
// ------------------------------------------------- fx: white-flash integrity
{
const { Fx } = await import(pathToFileURL(join(__dirname, '../js/combat/Fx.js')).href);

73
dev/shots/bh-dossier.json Normal file
View File

@ -0,0 +1,73 @@
{
"stage": "dossier",
"system": "Avilu",
"neutralContext": true,
"combatLevel": 2,
"board": {
"openState": "open",
"rows": [
{
"id": "sq-S000075-patrol-0",
"category": "patrol",
"locked": false,
"minLevel": 0
},
{
"id": "sq-S000075-bounty-1",
"category": "bounty",
"locked": false,
"minLevel": 2
},
{
"id": "sq-S000075-patrol-2",
"category": "patrol",
"locked": false,
"minLevel": 0
}
],
"huntRow": {
"title": "Three For The Road",
"issuer": "The Relay Masters",
"reward": {
"credits": 120
},
"locked": false,
"minLevel": 2,
"dossier": {
"name": "Aruna Sekh",
"epithet": "Needle",
"crime": "Sprint-killing light freight above the relay field",
"craftLabel": "courier-racer hull, a tight three-shot burst"
}
},
"leaks": []
},
"press": {
"id": "sq-S000075-bounty-1",
"acceptedBefore": false,
"acceptedAfter": true,
"notes": [
{
"text": "CONTRACT ACCEPTED — THREE FOR THE ROAD",
"glyph": "◆"
},
{
"text": "PATROL ACTIVE — THREE FOR THE ROAD",
"glyph": "▸"
}
]
},
"taken": {
"active": "sq-S000075-bounty-1",
"kind": "bounty",
"waypoints": 3,
"xpOnRoute": 190,
"planFixedOnRecord": true,
"progressLine": {
"visited": 0,
"total": 3,
"xp": 190,
"contacts": null
}
}
}

BIN
dev/shots/bh-dossier.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

89
dev/shots/bh-hunt.json Normal file
View File

@ -0,0 +1,89 @@
{
"stage": "hunt",
"combatLevel": 2,
"id": "sq-S000075-bounty-1",
"wps": [
[
2264,
-2588
],
[
3217,
-3813
],
[
4265,
2028
]
],
"log": [
{
"probe": "out-of-order (leg 3 first)",
"visited": []
},
{
"probe": "leg 1 logged",
"visited": [
0
],
"compass": {
"kind": "waypoint",
"idx": 1,
"x": 3217,
"y": -3813,
"color": "#ff2d6f",
"typeLabel": "Hunt"
},
"skirmishCleared": []
},
{
"probe": "leg 2 logged",
"visited": [
0,
1
],
"compass": {
"kind": "waypoint",
"idx": 2,
"x": 4265,
"y": 2028,
"color": "#ff2d6f",
"typeLabel": "Hunt"
},
"skirmishCleared": []
}
],
"waves": [
{
"leg": 1,
"wave": null
},
{
"leg": 2,
"wave": {
"state": "lead",
"total": null,
"maxConcurrent": null,
"roster": null,
"elite": null,
"live": 0
}
}
],
"field": [],
"toasts": [
{
"text": "NEW QUEST — START YOUR JOURNEY",
"glyph": "◆"
},
{
"text": "WAYPOINT 1 OF 3 LOGGED — NOTHING ON THE LANE",
"glyph": "◈"
},
{
"text": "PIRATE PICKETS — 6 HOSTILES INBOUND",
"glyph": "⚔"
}
],
"claimedAt": null
}

BIN
dev/shots/bh-hunt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

56
dev/shots/bh-locked.json Normal file
View File

@ -0,0 +1,56 @@
{
"stage": "locked",
"system": "Avilu",
"neutralContext": true,
"combatLevel": 1,
"board": {
"openState": "open",
"rows": [
{
"id": "sq-S000075-patrol-0",
"category": "patrol",
"locked": false,
"minLevel": 0
},
{
"id": "sq-S000075-bounty-1",
"category": "bounty",
"locked": true,
"minLevel": 2
},
{
"id": "sq-S000075-patrol-2",
"category": "patrol",
"locked": false,
"minLevel": 0
}
],
"huntRow": {
"title": "Three For The Road",
"issuer": "The Relay Masters",
"reward": {
"credits": 120
},
"locked": true,
"minLevel": 2,
"dossier": {
"name": "Aruna Sekh",
"epithet": "Needle",
"crime": "Sprint-killing light freight above the relay field",
"craftLabel": "courier-racer hull, a tight three-shot burst"
}
},
"leaks": []
},
"press": {
"id": "sq-S000075-bounty-1",
"acceptedBefore": false,
"acceptedAfter": false,
"notes": [
{
"text": "COMMISSION REFUSED — BOUNTY HUNTS STAND AT LEVEL 2",
"glyph": "✗"
}
]
}
}

BIN
dev/shots/bh-locked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

82
dev/shots/bh-mark.json Normal file
View File

@ -0,0 +1,82 @@
{
"stage": "mark",
"combatLevel": 2,
"id": "sq-S000075-bounty-1",
"wps": [
[
2264,
-2588
],
[
3217,
-3813
],
[
4265,
2028
]
],
"log": [],
"waves": [],
"field": [],
"markWave": {
"state": "lead",
"total": null,
"maxConcurrent": null,
"roster": null,
"elite": null,
"live": 0
},
"buff": null,
"fieldBeforeKill": 0,
"afterMarkKill": {
"waveState": "lead",
"down": false,
"visited": [
0,
1,
2
],
"liveEscort": 0,
"completedBeforeEscortKilled": false
},
"afterFieldClear": {
"waveState": "lead",
"completed": false,
"visitedAll": true,
"creditsHold": 0
},
"claim": {
"first": {
"ok": false
},
"second": {
"ok": false
},
"creditsBefore": 0,
"creditsAfter": 0,
"xpBankAfter": {
"level": 2,
"xp": 0
},
"claimedFlag": false
},
"toasts": [
{
"text": "WAYPOINT 1 OF 3 LOGGED — NOTHING ON THE LANE",
"glyph": "◈"
},
{
"text": "PIRATE PICKETS — 6 HOSTILES INBOUND",
"glyph": "⚔"
},
{
"text": "NEW QUEST — START YOUR JOURNEY",
"glyph": "◆"
},
{
"text": "WAYPOINT 3 OF 3 LOGGED — NOTHING ON THE LANE",
"glyph": "◈"
}
]
}

BIN
dev/shots/bh-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -105,28 +105,36 @@ const setEnabled = (map) => {
config.data.sidequests.categories[cat].enabled = !!map[cat];
}
};
// The data’s default gates (PATROL live — the first category whose
// rules landed; the other three OFFLINE until theirs do).
const restoreDefaultGates = () => setEnabled({ patrol: true });
// The data’s default gates (PATROL + BOUNTY HUNT live — the two categories
// whose rules landed; DELIVERY and ESCORT stay OFFLINE until theirs do).
const restoreDefaultGates = () => setEnabled({ patrol: true, bounty: true });
// ===========================================================================
console.log('== the category gate — patrol LIVE by default, the rest OFFLINE ==');
console.log('== the category gate — patrol + bounty hunt LIVE by default, the rest OFFLINE ==');
{
assert.deepEqual(SIDE_QUEST_CATEGORIES, ['patrol', 'delivery', 'bounty', 'escort'], 'the four types, canonical slot order');
assert.equal(sideQuestsEnabled(), true, 'the feature’s master switch is ON (the data’s enabled flag)');
assert.deepEqual(enabledSideQuestCategories(), ['patrol'], 'PATROL is the first live category — the board stands live');
assert.deepEqual(enabledSideQuestCategories(), ['patrol', 'bounty'], 'PATROL and the BOUNTY HUNT are the live categories (the gates that landed)');
assert.deepEqual(enabledSideQuestCategories('faction'), ['patrol'], 'a FACTION board never draws a hunt (categories.bounty.contexts is ["neutral"])');
assert.equal(sideQuestCategoryMeta('huntr'), null, 'an unknown category → null (no fabrication)');
for (const cat of SIDE_QUEST_CATEGORIES) {
const meta = sideQuestCategoryMeta(cat);
assert.equal(meta.id, cat, `${cat}: its meta stands`);
assert.equal(meta.enabled, cat === 'patrol', `${cat}: live iff its gate says so (patrol live, the rest OFFLINE by default)`);
assert.equal(meta.enabled, cat === 'patrol' || cat === 'bounty', `${cat}: live iff its gate says so (patrol + bounty live, the rest OFFLINE by default)`);
assert.ok(meta.label.length > 0, `${cat}: a label for the board’s rows`);
assert.match(meta.accent, /^#[0-9a-f]{6}$/i, `${cat}: a row-chip accent`);
const band = DATA.sidequests.rewards[cat];
assert.ok(Array.isArray(band) && band.length === 2 && band.every((n) => Number.isFinite(n)), `${cat}: a reward band in the data`);
for (const ctx of ['neutral', 'faction']) {
const pool = DATA.sidequests.descriptions[ctx][cat];
if (cat === 'bounty' && ctx === 'faction') {
// The documented stub: the hunt’s dossiers are the neutral pirate-hunt
// voice, so the faction wanted-list stays EMPTY (and its category’s
// contexts keep it off a faction board regardless).
assert.deepEqual(pool, [], `bounty.faction: the empty stub the data documents`);
continue;
}
assert.ok(Array.isArray(pool) && pool.length >= 3, `${cat}.${ctx}: a variant pool of 3+ (the boards don’t read as one repeated ad)`);
for (const v of pool) {
assert.ok(typeof v.title === 'string' && v.title.length > 0, `${cat}.${ctx}: a title per variant`);
@ -135,18 +143,18 @@ console.log('== the category gate — patrol LIVE by default, the rest OFFLINE =
}
}
// The DEFAULT board stands live with the live category’s postings
// (patrol — the other three gates stay OFFLINE).
// The DEFAULT board stands live with the live categories’ postings (patrol +
// bounty — delivery/escort stay OFFLINE).
const def = sideQuestPostingsFor(SEED, REC_NEUTRAL);
const [lo, hi] = sideQuestCountRange();
assert.ok(def.length >= lo && def.length <= hi, `the default board: ${def.length} postings within [${lo},${hi}]`);
assert.ok(def.every((p) => p.category === 'patrol'), 'the default board posts only the live type (patrol)');
assert.ok(def.every((p) => p.category === 'patrol' || p.category === 'bounty'), 'the default board posts only the live types');
}
// ===========================================================================
console.log('== the gate flips — patrol live → only patrol posts; all live → the spread ==');
console.log('== the gate flips — one live type → only it posts; all live → the spread ==');
{
restoreDefaultGates();
setEnabled({ patrol: true }); // ONE live type, so the shape asserts below are patrol alone
{
assert.deepEqual(enabledSideQuestCategories(), ['patrol'], 'one live category');
const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL);
@ -497,8 +505,11 @@ console.log('== the XP REWARD — patrolEncounterXp (the claim’s second payout
{
const { patrolEncounterXp } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
// The payout's XP = the XP WORTH of the patrol's defeated contacts —
// the contacts × the wave's total × the roster's weighted-average xp
// (data/combat.json: the raider's 10 XP, the bomber's 25 XP).
// the contacts × the wave's total × the roster's weighted-average xp.
// The per-type XP is read from data/combat.json rather than pinned here
// (the tuning breathes — a re-balance moves the numbers, not the rule).
const { enemyTypeConfig } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href);
const XP = Object.fromEntries(['raider', 'bomber'].map((t) => [t, Number(enemyTypeConfig(t)?.xp) || 0]));
const enc = (at, extra = {}) => ({
at,
cleared: [],
@ -508,13 +519,13 @@ console.log('== the XP REWARD — patrolEncounterXp (the claim’s second payout
});
assert.equal(patrolEncounterXp(null), 0, 'no encounters (old saves, the feature off) → 0 (the credits stand alone)');
assert.equal(patrolEncounterXp({ at: [] }), 0, 'no contacts → 0');
assert.equal(patrolEncounterXp(enc([1])), 60, '1 contact × 6 raiders × 10 XP (the base hostile) = 60');
assert.equal(patrolEncounterXp(enc([1, 2])), 120, '2 contacts → 120 (the XP worth of every defeated contact)');
assert.equal(patrolEncounterXp(enc([0, 2, 4])), 180, '3 contacts → 180');
assert.equal(patrolEncounterXp(enc([1], { roster: ['bomber'] })), 150, 'a bomber band (25 XP each) → 6 × 25 = 150');
assert.equal(patrolEncounterXp(enc([1], { roster: ['raider', 'bomber'] })), 105, 'a mixed roster (equal weights) → 6 × (10 + 25) / 2 = 105');
assert.equal(patrolEncounterXp(enc([1], { wave: { kind: 'rolling', total: 4, maxConcurrent: 2, replacePerKill: 1 } })), 40, 'a smaller wave total rules: 4 × 10 = 40');
assert.equal(patrolEncounterXp(enc([1], { roster: ['not-a-type'] })), 60, 'an unknown roster type falls back to the established type’s wave (the script’s rule) → 60');
assert.equal(patrolEncounterXp(enc([1])), 6 * XP.raider, `1 contact × 6 raiders × ${XP.raider} XP (the base hostile)`);
assert.equal(patrolEncounterXp(enc([1, 2])), 2 * 6 * XP.raider, '2 contacts → double the XP worth of every defeated contact');
assert.equal(patrolEncounterXp(enc([0, 2, 4])), 3 * 6 * XP.raider, '3 contacts → triple');
assert.equal(patrolEncounterXp(enc([1], { roster: ['bomber'] })), 6 * XP.bomber, `a bomber band (${XP.bomber} XP each) → 6 × ${XP.bomber}`);
assert.equal(patrolEncounterXp(enc([1], { roster: ['raider', 'bomber'] })), Math.round(6 * (XP.raider + XP.bomber) / 2), `a mixed roster (equal weights) → 6 × (${XP.raider} + ${XP.bomber}) / 2`);
assert.equal(patrolEncounterXp(enc([1], { wave: { kind: 'rolling', total: 4, maxConcurrent: 2, replacePerKill: 1 } })), 4 * XP.raider, `a smaller wave total rules: 4 × ${XP.raider}`);
assert.equal(patrolEncounterXp(enc([1], { roster: ['not-a-type'] })), 6 * XP.raider, 'an unknown roster type falls back to the established type’s wave (the script’s rule)');
}
// ===========================================================================

View File

@ -1462,6 +1462,79 @@ The player holds a REPUTATION (standing) on each planet and space station:
- **See it:** `dev/test-game.html?fx=habitable&seed=<seed>` — fly for a
minute; strikes land every ~10–16 s per cluster, staggered.
## Bounty Hunt — the job board's wanted contracts (data-driven)
The job board's old offline `bounty` slot is now a live contract: a level-2+
**BOUNTY HUNT** posted only in **Neutral** systems. The ship is sent on a
strictly sequential 3-waypoint patrol of a named pirate leader's known travel
lanes. Each leg *may* stand up a raiders-only skirmish (the lane is hot); one
leg — rolled at accept and never shown to the player — holds the mark itself,
which deploys with its escort in the engagement. The mark goes down → the
contract is COMPLETE, every leg is checked, the reward claims once.
- **Data owns the numbers and the stories** (`data/sidequests.json`):
- `categories.bounty` — `enabled`, `minLevel: 2`, `contexts: ["neutral"]`,
`waypoints: [3, 3]`, `encounter.bountyChance: 0.2` (per remaining leg, so
20% / 16% / 64% across the three), `encounter.skirmishChance: 0.5` (each
pre-bounty leg, independent), `encounter.skirmishWave` (rolling 6 raiders,
3 concurrent), `encounter.bountyWave` (rolling 9 total: 8 raiders + the
elite, 3 concurrent), `encounter.elite` (`healthScale`/`damageScale` ×2,
the elite's frames 7), `bands` (level bands, each a roster of dossiers;
the level-6+ band is a **documented stub**, `fallbackBand: 'hunter'`),
`compass`/`marker` (hunt accent — deliberately **no contact color**),
and the `ui.*` copy (`bountyLockBadge`, `dossierWanted/Epithet/Crime/
Craft`, the leg/sighting/skirmish/complete toasts, `bountyActivate`/
`bountyActive` door labels). Every key has a code-side fallback — missing
JSON never throws.
- `descriptions.neutral.bounty` — **20 written leader dossiers** (unique
title/issuer/name/epithet/crime + `craft` + `craftLabel`), spread across
all seven enemy frames, so the elite always flies its *story's* craft.
- `faction` — the faction (Apex/MindWeave/Zenith) bounty pool, kept a
documented **empty stub**: faction boards never draw a hunt today.
- **The model is pure + tested** (`js/quests/SideQuests.js`):
- `sideQuestCategoryMeta` — an *omitted* `minLevel` means no gate (0), so a
fresh run's level-0 board stays open; a declared gate stands (bounty = 2).
- `sideQuestAcceptable(posting, level)` — the single accept gate (the
board's row state, the window's door and the scene all ask it the same
way); an unknown level stands open.
- `bountyRules(level)` → the band's odds/waves; `deriveBountyPlan(seed,
jobId, targetSystemId, level)` → the **hidden plan** (dossier pick,
`bountyAt` leg, the per-leg `pickets` rolls) on one labelled
`Rng.derive` fork, rolled **at accept** and fixed on the route record —
a signed contract is a signed contract.
- `nextBountyWaypoint(route)` (the sequential law), `bountySkirmishScript` /
`bountyEngagementScript` (the rolling waves), `bountyHuntXp` (paid out,
never pre-shown), `bountyRouteComplete` (all legs visited + `bounty.down`),
and `SideQuestState`'s `bountySkirmishCleared` / `bountyDown` (the down
leg auto-checks every remaining waypoint).
- **The hidden-info rule** (the hunt's whole feel): the *posting* carries no
leg/picket/elite field of any kind — the board's readout shows only the
dossier + payout + leg progress; the beacons read as **lanes** (no red
contact, no count); the compass tracks the *next* waypoint only; and the XP
total appears on the contract only *after* accept (pre-accept totals would
leak the skirmish count). The scene's dispatcher (`_sideRouteKind`) keeps
the patrol and bounty paths apart so patrol stays byte-for-byte identical.
- **The combat seam** (minimal): `Waves.normalize`/`roster` take one optional
**elite** entry (deployed at the head of the opening group, named);
`EnemyShip.applyEliteBuff` scales hull/shields/damage; `CombatSystem` spawns
elite entries, and `haltWaves()` stops deployments/replacements while
on-screen fighters stay hostile — the mark kill halts, the escort must be
finished, then the contract reads COMPLETE (`GameScene.handleCombatKill`
recognises the elite by `markName`).
- **Board + scene wiring**: `JobBoardWindow` renders the greyed LOCKED row +
badge below the gate (the door stays pressable so the press speaks the
rule), the dossier block, the hunt progress line, and the
`bountyActivate`/`bountyActive` door labels on hunt rows;
`SurfaceScene.acceptSurfaceJob` refuses a locked accept with the data's own
copy. `GameScene` adds the hunt dispatchers (`updateBountyHunt`,
`_fireBountyEngagements`, `_bountyFieldClear`, `_drawBountyMarkers`) beside
the untouched patrol bodies.
- **Evidence**: `dev/bounty-hunts.test.mjs` (gate, plan distribution, the
chain's odds, the wave scripts, the down/claim rules, the hidden-info rule,
patrol regression) — plus the in-browser probe `dev/bounty-hunt-shot.html`
+ `dev/bounty-hunt-shot-run.mjs` (locked row → accept → hunt legs → mark
kill → claim; JSON + PNGs land in `dev/shots/`).
## Roadmap (working list, intentionally rough)
- [x] v0.1 foundation — menu → New Game → click-to-fly ship

View File

@ -281,6 +281,11 @@ export class CombatSystem {
* whole force at once, no replacements. The roster mixes the
* enemyTypes' ids by weight (type/count is the single-type shorthand).
*
* A wave may declare ONE elite (`elite: { type, healthScale?, damageScale?,
* name? }`) — a named, buffed craft deployed with the opening group and
* tagged on the ship (`e.elite`, `e.markName`) so a quest can recognise its
* kill; see js/combat/Waves.js + EnemyShip.applyEliteBuff.
*
* Wave ships are RUN-OWNED: they never self-respawn (the force gets
* spent — that's the point), and each kill feeds the runner's rolling
* replacements (killEnemy). The bar stays up from the lead through the
@ -369,19 +374,56 @@ export class CombatSystem {
* ?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)
* A spawn ENTRY is a type id, or an elite object from the wave's `elite`
* block (`{ type, healthScale, damageScale, name }`) — an elite gets the
* buff applied (EnemyShip.applyEliteBuff) and stays tagged.
*
* @param {string|object} entry the hostile to send (a combat.enemyTypes id, or an elite entry)
* @returns {EnemyShip} the fresh hostile (tagged `wave`)
*/
_spawnWaveEnemy(typeId) {
_spawnWaveEnemy(entry) {
const px = this.ship ? this.ship.x : 0;
const py = this.ship ? this.ship.y : 0;
const a = Math.random() * TAU; // a random direction from the player
const r = this._offscreenRadius() * (0.9 + Math.random() * 0.2);
const elite = entry != null && typeof entry === 'object' && !Array.isArray(entry) ? entry : null;
const typeId = elite ? elite.type : entry;
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
if (elite) e.applyEliteBuff(elite);
return e;
}
/**
* HALT the run in flight — the "the mark is down, stop sending the rest"
* seam a bounty hunt uses after it kills its target. Unlike cancelWaves it
* takes nothing out of the world: every live wave ship stays HOSTILE and
* must be flown down, but nothing else deploys (the wave's unspent force is
* dropped and no replacement follows) and none of them reform. When the last
* one drops the run reads 'done' (waveStatus), which is what the scene waits
* on before it completes the contract.
*
* @returns {object|null} the run that was halted (null when none / already over)
*/
haltWaves() {
const run = this.waveRun;
if (!run || run.state === 'done' || run.state === 'cancelled' || run.state === 'halted') return null;
run.state = 'halted';
run.nextAt = null;
for (const w of run.runner.waves) {
w.queue.length = 0; // nothing more deploys, replacements included
}
for (const e of this.enemies) {
if (e.wave && !e.destroyed) {
e.respawn = false; // the survivors fight it out — no reform clock
e._respawnPending = false;
e.respawnAt = 0;
}
}
this._setActive();
return run;
}
/**
* The run's state step (the tail of update()): lead/gap → deploy once
* the clock has run, live → hand a cleared wave over (the gap, or the
@ -394,6 +436,16 @@ export class CombatSystem {
if (!run || run.state === 'done' || run.state === 'cancelled') return;
const runner = run.runner;
// HALTED (the mark went down): no deployments, no replacements — the run
// is over when the ships still on screen go down with it.
if (run.state === 'halted') {
if (!this.enemies.some((e) => e.wave && !e.dead)) {
run.state = 'done';
this._setActive();
}
return;
}
// The current wave's force is spent → the gap (or the end of the run).
if (run.state === 'live' && runner.waveCleared()) {
if (run.onWaveClear) run.onWaveClear(runner.index, runner.current, this);

View File

@ -240,6 +240,57 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
return dead;
}
/**
* THE ELITE BUFF — one named, tougher craft inside a wave script (a bounty
* hunt's mark beside its escort; js/combat/Waves.js deploys the `elite`
* entry of the wave with the OPENING group). The scales come from the data
* (side-quest band / combat wave), so nothing here is a magic number:
*
* healthScale multiplies hull AND shields (both refilled to the buffed max)
* damageScale multiplies the weapon's bolt AND missile damage (a copied
* weapon config — the type's own config is never mutated)
*
* The craft is TAGGED (`.elite`, `.markName`) so the kill is identifiable
* from the scene's onKill seam, and VISUALLY DISTINGUISHED without new art:
* a bigger silhouette plus a gold multiply-tint (the white hit-flash stores
* and restores whatever tint was live, so the read survives an impact).
*
* @param {object} [o]
* healthScale (1 = unbuffed) damageScale (1 = unbuffed)
* name the wanted leader's name (the toast/comms line)
* scale the silhouette multiplier (default 1.25)
* @returns {EnemyShip} this (chainable)
*/
applyEliteBuff({ healthScale = 1, damageScale = 1, name = '', scale = 1.25 } = {}) {
const hs = Number.isFinite(Number(healthScale)) && Number(healthScale) > 0 ? Number(healthScale) : 1;
const ds = Number.isFinite(Number(damageScale)) && Number(damageScale) > 0 ? Number(damageScale) : 1;
this.maxHull = Math.max(1, Math.round(this.maxHull * hs));
this.maxShields = Math.max(0, Math.round(this.maxShields * hs));
this.hull = this.maxHull;
this.shields = this.maxShields;
if (ds !== 1) {
const wpn = { ...(this.wpn ?? {}) };
for (const key of ['damage', 'missileDamage']) {
if (Number.isFinite(Number(wpn[key])) || key === 'damage') {
wpn[key] = Math.max(1, Math.round((Number(wpn[key]) || 8) * ds));
}
}
this.wpn = wpn;
}
this.elite = true;
this.healthScale = hs;
this.damageScale = ds;
this.markName = typeof name === 'string' ? name : '';
const s = Number.isFinite(Number(scale)) && Number(scale) > 0 ? Number(scale) : 1;
if (s !== 1) this.setScale(this.scale * s);
if (typeof this.setTint === 'function') {
try {
this.setTint(0xffc16a); // multiply — the mark reads hot against the escort
} catch { /* a stub sprite carries no tint seam */ }
}
return this;
}
/** Bring the craft back to full (the scene re-positions + re-arms it). */
revive() {
this.hull = this.maxHull;

View File

@ -15,11 +15,20 @@
* roster's weight sum — so {raider: 2, bomber: 1} + total 6 is a 2:1 mix
* of six hostiles). `type` + `count` is the single-type shorthand.
*
* THE ELITE (`elite: { type, healthScale?, damageScale? }`) — one NAMED,
* BUFFED unit inside the wave (the bounty hunt's mark beside its escort).
* resolveRoster puts it at the HEAD of the spawn order, so a rolling wave
* always deploys it with the OPENING group, exactly once, and the rest of
* the force draws from the roster's remaining weights. A spawn order entry
* is then an OBJECT (`{ type, healthScale, damageScale }`) instead of a bare
* type id — js/combat/CombatSystem.js applies the buff (EnemyShip.applyEliteBuff).
*
* The runner's contract (CombatSystem drives it from update()):
* begin() → type ids to spawn (wave 1, once the lead elapses)
* onKill() → type ids to spawn (the rolling replacements)
* begin() → spawn entries to deploy (wave 1, once the lead elapses)
* onKill() → spawn entries to deploy (the rolling replacements)
* waveCleared() → the current wave's force is spent
* next() → type ids to spawn (the next wave — null when done)
* next() → spawn entries for the next wave (null when done)
* An entry is a type id, or an elite object (above).
*
* The runner owns no clock: the caller passes "now" and decides when the
* lead (norm.lead ms) / the gap (a wave's gapMs) has elapsed.
@ -75,14 +84,35 @@ function normalizeWave(w, defs, gapSec) {
}
if (Object.keys(roster).length === 0) return null; // nothing to send
// The force: the units deployed (default = the weight sum).
// The force: the units deployed (default = the weight sum). An elite is
// ONE of the declared force, so the weight sum counts it (roster
// {raider: 8, gunner: 1} + elite gunner + total 9 is eight raiders and
// exactly one mark).
const weightSum = Object.values(roster).reduce((a, b) => a + b, 0);
const total = Math.floor(Math.max(1, num(w.total ?? w.totalForce, weightSum)));
if (total < 1) return null;
// The elite (optional): a declared type + the buff's scales (the data owns
// the numbers — combat.waves / a side-quest band; 1 = unbuffed).
let elite = null;
if (isPlain(w.elite)) {
const et = typeof w.elite.type === 'string' ? w.elite.type.trim() : '';
if (et !== '') {
elite = {
type: et,
healthScale: Math.max(0, num(w.elite.healthScale, 1)),
damageScale: Math.max(0, num(w.elite.damageScale, 1)),
// The name the kill is announced under (a hunt's wanted leader) — carried
// through to the ship so the fight, not just the quest log, can say who.
name: typeof w.elite.name === 'string' ? w.elite.name : '',
};
}
}
return {
kind,
roster,
elite,
total,
maxConcurrent: Math.max(1, num(w.maxConcurrent, defs.maxConcurrent)),
replacePerKill: Math.max(0, num(w.replacePerKill, defs.replacePerKill)),
@ -149,20 +179,38 @@ export function normalizeWaves(script, defs = waveDefaults()) {
* Deterministic given the same `rng` (tests seed one; the game uses
* Math.random).
*
* @param {{total: number, roster: object}} wave a normalized wave
* The elite rides at the HEAD of the order (deployed with the opening group)
* as an object entry; its own weight is taken OUT of the roster's draw so
* the force holds exactly one of it.
*
* @param {{total: number, roster: object, elite?: object|null}} wave a normalized wave
* @param {() => number} [rng] random source in [0, 1)
* @returns {string[]} the type ids (length = wave.total)
* @returns {Array<string|object>} the spawn entries (length = wave.total)
*/
export function resolveRoster(wave, rng = Math.random) {
const entries = Object.entries(wave.roster ?? {}).filter(
([t, n]) => typeof t === 'string' && t !== '' && num(n, 0) > 0,
);
const sum = entries.reduce((a, [, n]) => a + num(n, 0), 0);
const total = Math.max(0, Math.floor(num(wave.total, 0)));
if (entries.length === 0 || total === 0) return [];
if (total === 0) return [];
const draw = typeof rng === 'function' ? rng : Math.random;
const picks = [];
for (let i = 0; i < total; i++) {
// The elite first, then the escort drawn from what the roster has left.
const el = isPlain(wave.elite) ? wave.elite : null;
let entries = Object.entries(wave.roster ?? {}).filter(
([t, n]) => typeof t === 'string' && t !== '' && num(n, 0) > 0,
);
if (el?.type) {
picks.push({ ...el });
entries = entries
.map(([t, n]) => (t === el.type ? [t, num(n, 0) - 1] : [t, n]))
.filter(([, n]) => n > 0);
}
if (entries.length === 0) {
// The roster held nothing but the mark (elite-only wave).
while (picks.length < total && el?.type) picks.push({ ...el });
}
const sum = entries.reduce((a, [, n]) => a + num(n, 0), 0);
if (entries.length === 0 || sum <= 0) return picks.slice(0, total);
for (let i = picks.length; i < total; i++) {
let r = draw() * sum;
let pick = entries[0][0];
for (const [t, n] of entries) {

View File

@ -131,17 +131,37 @@ function seedLabel() {
/**
* A category's meta (data/sidequests.json → categories.<id>).
* @returns {{id:string, enabled:boolean, label:string, accent:string}|null} null when unknown
*
* `minLevel` (the BOUNTY HUNT's gate — data → categories.bounty.minLevel)
* is the combat LEVEL the board demands (js/combat/XpManager.js): under
* it the posting still STANDS (its slot is seeded — a board that
* reshuffled as the player leveled would never settle), but the row
* renders LOCKED and the scene's accept refuses it. 1 = no gate
* (patrol's, and every category's, default). `contexts` (the BOUNTY
* HUNT's neutral-only posting) lists which boards post it — ['neutral'],
* ['faction'], or both (the default when a category says nothing).
*
* @returns {{id:string, enabled:boolean, label:string, accent:string, minLevel:number, contexts:string[]}|null} null when unknown
*/
export function sideQuestCategoryMeta(id) {
if (!SIDE_QUEST_CATEGORIES.includes(id)) return null;
const c = config.get(`sidequests.categories.${id}`, null);
if (!c || typeof c !== 'object') return null;
// An omitted minLevel demands NOTHING (0) — that is every legacy category,
// which stood open long before the hunt's gate existed. A fresh run's combat
// level is 0 (js/combat/XpManager.js), so a default of 1 would have rendered
// the standing PATROLS locked; only a category that SAYS so asks for a level.
const minLevel = Math.max(0, Math.floor(numOr(c.minLevel, 0)));
const contexts = (Array.isArray(c.contexts) ? c.contexts : [])
.map((v) => String(v).trim().toLowerCase())
.filter((v) => v === 'neutral' || v === 'faction');
return {
id,
enabled: c.enabled === true, // strict — a missing flag is OFFLINE (never fabricate jobs)
label: String(c.label ?? id.toUpperCase()),
accent: String(c.accent ?? '#00e5ff'),
minLevel,
contexts: contexts.length > 0 ? contexts : ['neutral', 'faction'],
};
}
@ -149,10 +169,42 @@ export function sideQuestCategoryMeta(id) {
* The LIVE categories (enabled:true, canonical order). The board's gate:
* only these ever generate a posting — a category is accounted for but
* unavailable until its rules are built and its flag flipped.
*
* @param {string} [context] 'neutral' | 'faction' — when given, the
* category's own `contexts` list also gates it (a BOUNTY HUNT never
* stands on a faction board; omitting the argument is every category
* that says nothing about contexts, i.e. patrol unchanged)
* @returns {string[]}
*/
export function enabledSideQuestCategories() {
return SIDE_QUEST_CATEGORIES.filter((id) => sideQuestCategoryMeta(id)?.enabled === true);
export function enabledSideQuestCategories(context = null) {
const ctx = context === 'neutral' || context === 'faction' ? context : null;
return SIDE_QUEST_CATEGORIES.filter((id) => {
const meta = sideQuestCategoryMeta(id);
if (!meta || meta.enabled !== true) return false;
return ctx === null || meta.contexts.includes(ctx);
});
}
/**
* Can this posting be ACCEPTED at this combat level? The gate is the
* category's `minLevel` (the scene's accept refuses with the data's deny
* toast, and the board renders the row LOCKED); a posting without a
* known level (a test, an old call) stands open.
* @param {object} posting sideQuestPostingsFor's shape
* @param {number|null} level the player's combat level
* @returns {{ok:boolean, locked:boolean, minLevel:number}}
*/
export function sideQuestAcceptable(posting, level = null) {
const meta = sideQuestCategoryMeta(posting?.category);
const minLevel = meta ? meta.minLevel : 0;
// UNKNOWN is not LEVEL 0. `null` / `undefined` (no XP bank in the boot, an
// older caller that never reads one) stands the contract OPEN — Number(null)
// is 0, which would otherwise lock every gated category on a missing read.
if (level === null || level === undefined || !Number.isFinite(Number(level))) {
return { ok: true, locked: false, minLevel };
}
const lvl = Math.floor(Number(level));
return { ok: lvl >= minLevel, locked: lvl < minLevel, minLevel };
}
/**
@ -318,8 +370,15 @@ export function patrolTargetSystem(rec, r, galaxy = null) {
* @param {object[]} [anchors] [{ x, y }] the keep-out centers (planets / stations / gates)
* @returns {{targetSystemId:string, waypoints:number[][], visited:number[]}}
*/
export function buildPatrolRoute(seed, jobId, targetSystemId, region, anchors = []) {
const rawRange = config.get('sidequests.patrol.waypoints', [3, 5]);
export function buildPatrolRoute(seed, jobId, targetSystemId, region, anchors = [], opts = {}) {
// The placement LAW is shared (region inset, anchor clearance, separation);
// `opts` lets another contract type stand its OWN leg count + fork label on
// top of it — a BOUNTY HUNT asks for the plan's exact leg count under its
// 'bounty' fork (GameScene._establishBountyHunt). No opts = patrol exactly
// as it always was.
const rawRange = Array.isArray(opts?.waypoints) && opts.waypoints.length > 0
? opts.waypoints
: config.get('sidequests.patrol.waypoints', [3, 5]);
let lo = Math.floor(Number(Array.isArray(rawRange) ? rawRange[0] : 3));
let hi = Math.floor(Number(Array.isArray(rawRange) ? rawRange[1] : 5));
if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
@ -353,7 +412,8 @@ export function buildPatrolRoute(seed, jobId, targetSystemId, region, anchors =
const areas = eff.map((t) => t.r * t.r); // the sampler's weights (disc area)
const inside = (x, y) => eff.some((t) => (x - t.x) ** 2 + (y - t.y) ** 2 <= t.r * t.r);
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', jobId, 'route');
const fork = String(opts?.fork ?? 'patrol');
const r = Rng.derive(seed, seedLabel(), targetSystemId, fork, jobId, 'route');
const n = r.int(lo, hi);
const pickRegion = () => {
const total = areas.reduce((a, b) => a + b, 0);
@ -730,6 +790,521 @@ export function patrolRouteComplete(route) {
return at.every((i) => cleared.has(i));
}
// --------------------------------------------- the BOUNTY HUNT (category 'bounty')
// The contract with a NAME ON IT (data/sidequests.json → bounty). A hunt is
// a patrol with its ending hidden: three legs along one pirate leader's
// travel lanes, flown STRICTLY IN ORDER, and the mark stands on ONE of them
// (20% on the first leg, else 20% of what is left on the second, else the
// third at 100%). The rolls happen ONCE at the accept on their own labelled
// fork and ride the route record — a signed contract is a signed contract —
// and NOTHING here is spoken to the player before it fires (the plan object
// is model-side only; the board, the compass, and the beacons never read
// `bountyAt`).
//
// The scene's share (js/scenes/GameScene.js → _establishBountyHunt /
// updateBountyHunt) uses these pure pieces:
// bountyRules(level) — the standing band's numbers (the data's,
// with `inherits` resolved and fallbacks)
// deriveBountyPlan(…) — the rolled chain (bountyAt + per-leg pickets)
// bountySkirmishScript / bountyEngagementScript — CombatSystem.startWaves shapes
// bountyHuntXp(plan) — the claim's XP beside the credits
// nextBountyWaypoint(route) — the SEQUENTIAL check-in rule (one leg at a time)
// bountyRouteComplete(route) — the kill is the whole job
/** The elite's fallback frames (a dossier naming a craft that isn't in combat.enemyTypes). */
const BOUNTY_FALLBACK_FRAMES = [
'bomber',
'spitter',
'stinger',
'hunter',
'gunner',
'barrage',
'warden',
];
/**
* The wanted leader's OWN identity, carried on the plan (the dossier's name +
* epithet). The comms name the mark only once its fight stands in front of the
* ship — the plan itself never reads out where it hides — and because the mark
* rides the route record it survives a save. Empty strings when the board had
* no dossier (a hand-edited pool): the hunt still flies, silent.
*/
function bountyMark(raw) {
const m = raw && typeof raw === 'object' ? raw : {};
return {
name: String(m.name ?? '').trim(),
epithet: String(m.epithet ?? '').trim(),
};
}
/** One wave shape, clamped (a hunt's fight is never smaller than one hostile). */
function bountyWaveShape(raw, fb) {
const w = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
const kind = (w.kind ?? fb.kind) === 'fixed' ? 'fixed' : 'rolling';
const total = Math.max(1, Math.floor(numOr(w.total, fb.total)));
const out = {
kind,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, fb.maxConcurrent)))),
replacePerKill:
kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, fb.replacePerKill))) : 0,
};
if (Array.isArray(w.roster) && w.roster.length > 0) out.roster = w.roster.map(String);
if (w.raiderType != null) out.raiderType = String(w.raiderType);
if (w.raiders != null) out.raiders = Math.max(0, Math.floor(numOr(w.raiders, fb.raiders ?? 0)));
if (Array.isArray(w.eliteFrames) && w.eliteFrames.length > 0) out.eliteFrames = w.eliteFrames.map(String);
return out;
}
/**
* The standing band's RULES (data/sidequests.json → bounty + bounty.encounters).
* A BAND is a level window's tuning: the live 'hunter' band (levels 2–5) and
* the stubbed 'marksman' band (6+), which `inherits` the hunter's numbers so a
* player who outruns level 5 keeps getting hunts instead of losing the content
* mid-run. Resolution: the first band whose `levels` window holds the player's
* combat level, else the fallbackBand, else the first band; the chosen band's
* own overrides (bountyChance / skirmishChance / waypoints / skirmishWave /
* bountyWave) sit on top of the shared encounter block, and `inherits` pulls
* that band's numbers underneath them.
*
* @param {number} level the player's combat level (js/combat/XpManager.js)
* @returns {{id:string, legs:[number,number], visitRadius:number, bountyChance:number, skirmishChance:number, skirmishWave:object, bountyWave:object}}
*/
export function bountyRules(level = 0) {
const enc = config.get('sidequests.bounty.encounters', null);
const e = enc && typeof enc === 'object' && !Array.isArray(enc) ? enc : {};
const legRange = (() => {
const raw = config.get('sidequests.bounty.waypoints', [3, 3]);
let lo = Math.floor(numOr(Array.isArray(raw) ? raw[0] : 3, 3));
let hi = Math.floor(numOr(Array.isArray(raw) ? raw[1] : 3, 3));
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [3, 3];
lo = Math.max(1, lo);
hi = Math.max(lo, hi);
return [lo, hi];
})();
const base = {
id: 'hunter',
legs: legRange,
visitRadius: Math.max(60, numOr(config.get('sidequests.bounty.visitRadius', 420), 420)),
bountyChance: Math.min(1, Math.max(0, numOr(e.bountyChance, 0.2))),
skirmishChance: Math.min(1, Math.max(0, numOr(e.skirmishChance, 0.5))),
skirmishWave: bountyWaveShape(e.skirmishWave, {
kind: 'rolling',
total: 6,
maxConcurrent: 3,
replacePerKill: 1,
roster: ['raider'],
}),
bountyWave: bountyWaveShape(e.bountyWave, {
kind: 'rolling',
total: 9,
raiders: 8,
maxConcurrent: 3,
replacePerKill: 1,
raiderType: 'raider',
eliteFrames: BOUNTY_FALLBACK_FRAMES.slice(),
}),
elite: {
healthScale: Math.max(1, numOr(e.bountyWave?.elite?.healthScale, 2)),
damageScale: Math.max(1, numOr(e.bountyWave?.elite?.damageScale, 2)),
},
enabled: e.enabled !== false,
};
// The elite's own scales ride the wave shape (the runner reads one object).
base.bountyWave.elite = { ...base.elite };
const bands = Array.isArray(e.bands) ? e.bands : [];
const byId = new Map();
for (const b of bands) {
if (b && typeof b === 'object' && !Array.isArray(b) && b.id != null) byId.set(String(b.id), b);
}
const lvl = numOr(level, 0);
let band = null;
for (const b of bands) {
if (!b || typeof b !== 'object' || Array.isArray(b)) continue;
const w = Array.isArray(b.levels) ? b.levels : null;
if (!w) continue;
const lo = numOr(w[0], NaN);
const hi = numOr(w[1] ?? w[0], lo);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi)) {
band = b; // the first window holding the level wins (list order)
break;
}
}
if (!band) band = byId.get(String(e.fallbackBand ?? '')) ?? bands[0] ?? null;
if (!band || typeof band !== 'object' || Array.isArray(band)) return base;
// The band's overrides, with `inherits` standing underneath them.
const layers = [];
const seen = new Set();
let cur = band;
while (cur && !seen.has(String(cur.id ?? ''))) {
seen.add(String(cur.id ?? ''));
layers.unshift(cur);
cur = cur.inherits != null ? byId.get(String(cur.inherits)) : null;
}
const out = { ...base, id: String(band.id ?? base.id) };
for (const layer of layers) {
if (Array.isArray(layer.waypoints)) {
const lo = Math.max(1, Math.floor(numOr(layer.waypoints[0], base.legs[0])));
const hi = Math.max(lo, Math.floor(numOr(layer.waypoints[1] ?? layer.waypoints[0], base.legs[1])));
out.legs = [lo, hi];
}
if (Number.isFinite(Number(layer.bountyChance))) out.bountyChance = Math.min(1, Math.max(0, Number(layer.bountyChance)));
if (Number.isFinite(Number(layer.skirmishChance))) out.skirmishChance = Math.min(1, Math.max(0, Number(layer.skirmishChance)));
if (Number.isFinite(Number(layer.visitRadius))) out.visitRadius = Math.max(60, Number(layer.visitRadius));
if (layer.skirmishWave) out.skirmishWave = bountyWaveShape(layer.skirmishWave, out.skirmishWave);
if (layer.bountyWave) {
out.bountyWave = bountyWaveShape(layer.bountyWave, out.bountyWave);
const sc = layer.bountyWave.elite || {};
out.bountyWave.elite = {
healthScale: Math.max(1, numOr(sc.healthScale, base.elite.healthScale)),
damageScale: Math.max(1, numOr(sc.damageScale, base.elite.damageScale)),
};
}
}
return out;
}
/**
* The hunt's PLAN — the hidden shape of one contract, rolled at the accept
* and FIXED on the route record (never re-drawn; never shown).
*
* The chain rides its own labelled fork (seed,'sidequests',
* <targetSystemId>,'bounty',<jobId>,'chain'): legs 1..N-1 each roll
* `bountyChance` for the mark and the LAST leg stands it at 100% — with the
* data's 0.2 and three legs that reads 20% / 16% / 64%. Each leg BEFORE the
* mark independently rolls `skirmishChance` for the leader's pickets (the
* bounty leg never does — the mark IS that fight, and no leg after it owes
* anything).
*
* @param {string|number} seed the galaxy's seed
* @param {string} jobId the accepted contract's id (the fork's key)
* @param {string} targetSystemId the hunt's system (the fork's key)
* @param {number} level the player's combat level at the accept (the band)
* @param {object} [opts] { craft, mark } — the dossier's declared craft (its own
* story's frame id); an id outside combat.enemyTypes falls back to a SEEDED
* pick from the band's eliteFrames, so a data typo stands a fight instead of
* breaking one. `mark` ({ name, epithet }) is the wanted leader's OWN name —
* carried on the plan so the fight's comms can say who it was AFTER the fact
* (never before: the plan itself stays invisible), and it rides the save.
* @returns {{legs:number, bountyAt:number, pickets:boolean[], band:string, raiders:number, eliteType:string, skirmishWave:object, bountyWave:object}}
*/
export function deriveBountyPlan(seed, jobId, targetSystemId, level = 0, opts = {}) {
const rules = bountyRules(level);
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'bounty', String(jobId), 'chain');
const legs = Math.max(1, r.int(rules.legs[0], rules.legs[1]));
let bountyAt = legs - 1; // the LAST leg stands the mark (the chain's guarantee)
for (let i = 0; i < legs - 1; i++) {
if (r.chance(rules.bountyChance)) {
bountyAt = i;
break;
}
}
const pickets = [];
for (let i = 0; i < legs - 1; i++) {
if (i >= bountyAt) {
pickets.push(false); // the mark's own leg, and anything after it, never rolls
continue;
}
pickets.push(r.chance(rules.skirmishChance));
}
const valid = new Set(enemyTypeIds());
const declared = String(opts?.craft ?? '').trim();
const frames = (Array.isArray(rules.bountyWave.eliteFrames) ? rules.bountyWave.eliteFrames : BOUNTY_FALLBACK_FRAMES)
.map((t) => String(t).trim())
.filter((t) => valid.has(t));
let eliteType = declared !== '' && valid.has(declared) ? declared : null;
if (!eliteType) {
const pool = frames.length > 0 ? frames : BOUNTY_FALLBACK_FRAMES.filter((t) => valid.has(t));
eliteType = pool.length > 0 ? String(r.pick(pool)) : ESTABLISHED_ENEMY_TYPES[0];
}
const raiderType = valid.has(String(rules.bountyWave.raiderType ?? ''))
? String(rules.bountyWave.raiderType)
: ESTABLISHED_ENEMY_TYPES[0];
const raiders = Math.max(0, Math.floor(numOr(rules.bountyWave.raiders, rules.bountyWave.total - 1)));
return {
legs,
bountyAt,
pickets,
band: rules.id,
raiders,
raiderType,
eliteType,
mark: bountyMark(opts?.mark),
skirmishWave: { ...rules.skirmishWave },
bountyWave: { ...rules.bountyWave, total: Math.max(1, raiders + 1) },
visitRadius: rules.visitRadius,
};
}
/** A hunt's picket wave — one rolling wave of the established type, three at a time. */
export function bountySkirmishScript(plan = {}) {
const w = plan?.skirmishWave || bountyRules(0).skirmishWave;
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
const type = ESTABLISHED_ENEMY_TYPES[0];
const wave = {
kind,
roster: { [type]: 1 },
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
};
if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1)));
return { waves: [wave] };
}
/**
* The BOUNTY's wave — one rolling wave of `raiders + 1`: the escort plus the
* mark itself, declared on the wave as `elite` so js/combat/Waves.js deploys
* it with the OPENING group and js/combat/EnemyShip.js doubles its hull /
* shields and weapon damage (the data's healthScale / damageScale). The kill
* is the contract: the scene halts the run (CombatSystem.haltWaves) and waits
* out the survivors.
*/
export function bountyEngagementScript(plan = {}) {
const rules = bountyRules(0);
const w = plan?.bountyWave || rules.bountyWave;
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
const eliteType = String(plan?.eliteType ?? 'raider');
const raiders = Math.max(0, Math.floor(numOr(plan?.raiders, Math.max(0, numOr(w.total, 9) - 1))));
const raiderType = String(w.raiderType ?? ESTABLISHED_ENEMY_TYPES[0]);
const total = Math.max(1, raiders + 1); // the escort + exactly one mark
const roster = {};
if (raiders > 0) roster[raiderType] = raiders;
roster[eliteType] = (roster[eliteType] ?? 0) + 1;
const wave = {
kind,
roster,
total,
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
elite: {
type: eliteType,
healthScale: numOr(w.elite?.healthScale, rules.elite.healthScale),
damageScale: numOr(w.elite?.damageScale, rules.elite.damageScale),
// The name the KILL reads out (js/combat/Waves.js keeps it; EnemyShip
// tags it; GameScene's witness puts it in the comms line).
name: String(plan?.mark?.name ?? '').trim(),
},
};
if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1)));
return { waves: [wave] };
}
/**
* The hunt's XP payout — the worth of everything it throws at the ship: the
* mark (its own type's worth × its scale-up) plus the escort plus every
* rolled picket wave. Fixed at the accept beside the credits, so the claim
* pays what the plan promised even if the data is re-tuned mid-run.
*/
export function bountyHuntXp(plan = {}) {
const p = plan && typeof plan === 'object' ? plan : {};
const xpOf = (t) => Math.max(0, Number(enemyTypeConfig(String(t))?.xp) || 0);
const rules = bountyRules(0);
const raiders = Math.max(0, Math.floor(numOr(p.raiders, numOr(rules.bountyWave.raiders, 8))));
const raiderType = p.raiderType != null ? String(p.raiderType) : String(rules.bountyWave.raiderType ?? 'raider');
const eliteType = String(p.eliteType ?? ESTABLISHED_ENEMY_TYPES[0]);
const scale = numOr(p.bountyWave?.elite?.healthScale, rules.bountyWave.elite?.healthScale ?? 2);
let xp = raiders * xpOf(raiderType) + xpOf(eliteType) * Math.max(1, scale);
const skirmishTotal = Math.max(0, Math.floor(numOr(p.skirmishWave?.total, rules.skirmishWave.total)));
const pickets = (Array.isArray(p.pickets) ? p.pickets : []).filter(Boolean).length;
xp += pickets * skirmishTotal * xpOf(ESTABLISHED_ENEMY_TYPES[0]);
return Math.round(xp);
}
/**
* The SEQUENTIAL check-in rule — the hunt's one departure from patrol law.
* The chain's odds read an ORDER, so a hunt logs its legs one at a time: the
* NEXT leg is the lowest index not yet logged, and only that leg's beacon and
* compass point anywhere. `null` when every leg stands logged.
*
* @param {object} route the route record
* @returns {?{index:number, x:number, y:number, leg:number, of:number}}
*/
export function nextBountyWaypoint(route) {
if (!route || typeof route !== 'object') return null;
const wp = Array.isArray(route.waypoints) ? route.waypoints : [];
if (wp.length === 0) return null;
const visited = new Set(Array.isArray(route.visited) ? route.visited : []);
for (let i = 0; i < wp.length; i++) {
const w = wp[i];
if (!Array.isArray(w) || w.length < 2) continue;
if (visited.has(i)) continue;
return { index: i, x: Number(w[0]), y: Number(w[1]), leg: i + 1, of: wp.length };
}
return null;
}
/**
* The hunt is COMPLETE when the mark is DOWN (the contract's only witness —
* the legs are a formality the kill checks off at once). A malformed or
* missing bounty record falls back to the patrol law (every leg logged) so a
* degraded save can still finish.
*/
export function bountyRouteComplete(route) {
if (!route || typeof route !== 'object') return false;
const b = route.bounty && typeof route.bounty === 'object' ? route.bounty : null;
if (b?.down === true) return true;
if (!b) return patrolRouteComplete(route);
return false;
}
/**
* A bounty posting's DOSSIER (the wanted list's fields — data →
* descriptions.neutral.bounty[]). Resolved for the readout, with the craft
* VALIDATED against combat.enemyTypes and the copy placeholder-free. Returns
* null when the pool entry isn't a dossier (a hand-edited pool) — the posting
* still stands on its title/description alone.
*/
function buildBountyDossier(v, ctx) {
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
const name = resolveSideQuestText(String(v.name ?? ''), ctx).trim();
if (name === '') return null;
const valid = new Set(enemyTypeIds());
const craft = String(v.craft ?? '').trim();
return {
name,
epithet: resolveSideQuestText(String(v.epithet ?? ''), ctx).trim(),
crime: resolveSideQuestText(String(v.crime ?? ''), ctx).trim(),
craft: valid.has(craft) ? craft : null, // null = the plan picks a frame (seeded)
craftLabel: resolveSideQuestText(String(v.craftLabel ?? ''), ctx).trim(),
};
}
/**
* One line of the hunt's UI copy (data/sidequests.json → ui.<key>) with its
* `{placeholders}` filled. A key can stand as a plain string or as
* `{ glyph, color, text }` (the toasts carry their own glyph + color); an
* unknown key falls back to `fb` so a data typo degrades to the design's own
* words instead of blanking a toast. Placeholders with no value blank out.
* @returns {{glyph:string,color:string,text:string}|null} null when neither the key nor a fallback stands
*/
function bountyUiLine(key, vars = {}, fb = '') {
const raw = config.get(`sidequests.ui.${key}`, null);
const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
const text = obj ? String(obj.text ?? '') : typeof raw === 'string' ? raw : '';
if (text === '' && fb === '') return null;
let out = text === '' ? String(fb) : text;
for (const [k, value] of Object.entries(vars)) out = out.split(`{${k}}`).join(String(value));
out = out.replace(/\{[a-zA-Z]+\}/g, ''); // an unfilled placeholder never ships
return {
glyph: obj ? String(obj.glyph ?? '') : '',
color: obj ? String(obj.color ?? '') : '',
text: out,
};
}
/** The bounty gate's combat level (data → categories.bounty.minLevel; the design's 2). */
export function bountyMinLevel() {
return sideQuestCategoryMeta('bounty')?.minLevel ?? 2;
}
/** A locked row's badge (“LOCKED — REQUIRES LEVEL 2”); `sub` = the detail line under it. */
export function bountyLockCopy(sub = false) {
const level = bountyMinLevel();
return sub
? bountyUiLine('bountyLockSub', { level }, 'BRING YOUR RECORD UP TO LEVEL {level}')
: bountyUiLine('bountyLockBadge', { level }, 'LOCKED — REQUIRES LEVEL {level}');
}
/** The scene's refusal when a locked contract is pressed anyway. */
export function bountyDenyCopy() {
return bountyUiLine('bountyAcceptDenyToast', { level: bountyMinLevel() }, 'COMMISSION REFUSED — LEVEL {level} REQUIRED');
}
/** A hunt line's copy (a leg logged / pickets inbound / the mark sighted / down / complete). */
export function bountyText(key, vars = {}) {
return bountyUiLine(key, vars, '');
}
/** The readout's progress line (logged legs only — never what waits on them). */
export function bountyProgressCopy(route) {
const wp = Array.isArray(route?.waypoints) ? route.waypoints : [];
const visited = Array.isArray(route?.visited) ? new Set(route.visited).size : 0;
return bountyUiLine('bountyRouteProgress', { visited, total: wp.length }, 'WAYPOINT {visited} OF {total} LOGGED');
}
/** The contract readout's dossier lines (wanted / epithet / charges / craft). */
export function bountyDossierCopy(dossier) {
const d = dossier && typeof dossier === 'object' ? dossier : null;
if (!d || !d.name) return [];
const craftLine = bountyUiLine('dossierCraft', { craft: d.craftLabel ?? '' }, 'CRAFT — {craft}');
const lines = [
bountyUiLine('dossierWanted', { name: d.name }, 'WANTED — {name}'),
d.epithet ? bountyUiLine('dossierEpithet', { epithet: d.epithet }, '{epithet}') : null,
bountyUiLine('dossierCrime', { crime: d.crime ?? '' }, 'CHARGES — {crime}'),
d.craftLabel ? craftLine : null,
];
return lines.filter((l) => l && l.text !== '');
}
/**
* A bounty route record's PLAN, normalized (SideQuestState.setRoute's pass).
* The rolled shape is VALIDATED, never re-drawn: `bountyAt` inside the leg
* count, one `pickets` mark per leg with nothing standing on the mark's own
* leg or behind it, the elite a real combat.enemyTypes id, the wave shapes
* finite, and the runtime marks (`down`, the cleared picket legs) kept. A
* malformed record degrades to null — the route's waypoints still stand, the
* hunt simply never fires (a hand-edited save must not manufacture a fight).
*
* @param {*} raw deriveBountyPlan's result (or a save's)
* @param {number} wpCount the route's waypoint count
* @returns {?object}
*/
function normalizeBountyPlan(raw, wpCount) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const legs = Math.max(1, Math.floor(numOr(raw.legs, wpCount)));
const bountyAt = Math.floor(numOr(raw.bountyAt, NaN));
if (!Number.isInteger(bountyAt) || bountyAt < 0 || bountyAt >= legs) return null;
const valid = new Set(enemyTypeIds());
const eliteType = String(raw.eliteType ?? '').trim();
if (!valid.has(eliteType)) return null;
// The pickets: one mark per leg BEFORE the mark (the chain's rule — the leg
// that holds the hunt, and every leg behind it, never rolls).
const pickets = [];
for (let i = 0; i < legs - 1; i++) {
pickets.push(i < bountyAt && raw.pickets?.[i] === true);
}
const raiders = Math.max(0, Math.floor(numOr(raw.raiders, 8)));
const raiderType = valid.has(String(raw.raiderType ?? '').trim())
? String(raw.raiderType).trim()
: ESTABLISHED_ENEMY_TYPES[0];
const rules = bountyRules(0);
const mark = bountyMark(raw.mark);
const sw = raw.skirmishWave && typeof raw.skirmishWave === 'object' ? raw.skirmishWave : {};
const bw = raw.bountyWave && typeof raw.bountyWave === 'object' ? raw.bountyWave : {};
const el = bw.elite && typeof bw.elite === 'object' ? bw.elite : {};
return {
legs,
bountyAt,
pickets,
band: String(raw.band ?? 'hunter'),
raiders,
raiderType,
eliteType,
mark,
skirmishWave: bountyWaveShape(sw, rules.skirmishWave),
bountyWave: {
...bountyWaveShape({ ...bw, total: Math.max(1, raiders + 1) }, rules.bountyWave),
raiderType,
raiders,
elite: {
healthScale: Math.max(1, numOr(el.healthScale, 2)),
damageScale: Math.max(1, numOr(el.damageScale, 2)),
},
},
visitRadius: Math.max(60, numOr(raw.visitRadius, rules.visitRadius)),
// The runtime marks (the scene's witness marks — a fresh plan carries none).
down: raw.down === true,
skirmishCleared: [...new Set(
(Array.isArray(raw.skirmishCleared) ? raw.skirmishCleared : []).filter(
(i) => Number.isInteger(i) && i >= 0 && i < bountyAt && raw.pickets?.[i] === true,
),
)].sort((a, b) => a - b),
};
}
/**
* The system's LIVE postings (the board's list) — 3–4 by design
* (sideQuestCountRange), drawn from the LIVE categories only
@ -757,30 +1332,39 @@ export function patrolRouteComplete(route) {
* @param {object|null} [galaxy] the galaxy (the patrol's neighbor rule's jumpGatesFor) — null in tests
* @returns {object[]} [] when the feature is off or no category is live
*/
export function sideQuestPostingsFor(seed, rec, galaxy = null) {
export function sideQuestPostingsFor(seed, rec, galaxy = null, level = null) {
if (!sideQuestsEnabled()) return [];
if (!rec || typeof rec.id !== 'string' || rec.id === '') return [];
const cats = enabledSideQuestCategories();
const ctx = postingContext(rec);
const cats = enabledSideQuestCategories(ctx.context); // a faction board never draws a neutral-only type
if (cats.length === 0) return [];
const [lo, hi] = sideQuestCountRange();
const base = Rng.derive(seed, seedLabel(), rec.id);
const count = base.int(lo, hi);
const order = base.shuffle(cats); // the system's slot order (seeded, stable)
const ctx = postingContext(rec);
const out = [];
const usedDossier = new Set(); // one board never posts the SAME wanted leader twice
for (let slot = 0; slot < count; slot++) {
const cat = order[slot % order.length];
const r = Rng.derive(seed, seedLabel(), rec.id, cat, slot); // per-posting stream
const posting = buildPosting(seed, rec, ctx, cat, slot, r, galaxy);
if (posting) out.push(posting);
const posting = buildPosting(seed, rec, ctx, cat, slot, r, galaxy, usedDossier);
if (!posting) continue;
if (cat === 'bounty' && posting.dossier?.name) usedDossier.add(posting.dossier.name);
// The level gate is PRESENTATION + ACCEPT, never generation: the
// posting stands in its seeded slot below the gate too (a locked
// row is still the same row), and the scene refuses the accept.
const gate = sideQuestAcceptable(posting, level);
posting.locked = gate.locked === true;
posting.minLevel = gate.minLevel;
out.push(posting);
}
return out;
}
/** One posting from its pool variant + reward roll (null on a bad pool entry). */
function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null) {
function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null, usedDossier = null) {
const meta = sideQuestCategoryMeta(cat);
if (!meta) return null;
@ -788,7 +1372,21 @@ function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null) {
let pool = config.get(poolPath, null);
if (!Array.isArray(pool)) pool = config.get(`sidequests.descriptions.neutral.${cat}`, null); // the neutral voice as fallback
pool = Array.isArray(pool) ? pool : [];
const v = pool.length > 0 ? pool[r.int(0, pool.length - 1)] : null;
let v = pool.length > 0 ? pool[r.int(0, pool.length - 1)] : null;
// A WANTED LIST never posts the same leader twice on one board: the seeded
// draw stands when it is fresh, otherwise the next entry forward (wrapping)
// takes it. Deterministic — the slots are visited in a fixed order and the
// used set is built by this loop alone, so a system's board never depends on
// call order or on any other system.
if (cat === 'bounty' && pool.length > 0 && usedDossier && usedDossier.size > 0) {
for (let step = 0; step < pool.length; step++) {
const cand = pool[(pool.indexOf(v) + step + pool.length) % pool.length];
if (!cand?.name || !usedDossier.has(cand.name)) {
v = cand;
break;
}
}
}
const title = resolveSideQuestText(v?.title ?? `${meta.label} Contract`, ctx);
// The row's issuer line: the pool's own name (the neutral pools' —
@ -821,6 +1419,13 @@ function buildPosting(seed, rec, ctx, cat, slot, r, galaxy = null) {
// the other categories' routes (when they land) carry their own
// shape.
...(cat === 'patrol' ? patrolTargetFields(rec, r, galaxy) : {}),
// The BOUNTY HUNT's DOSSIER (data → descriptions.neutral.bounty[]) — the
// wanted leader's name/epithet/charges and the CRAFT its own story names
// (`craft`, validated against combat.enemyTypes; null on a pool entry that
// doesn't declare one, where the plan picks a frame seeded). The hunt's
// plan (which leg holds the mark) is NOT here — it rolls at the accept and
// rides the route record, never the board.
...(cat === 'bounty' ? { dossier: buildBountyDossier(v, ctx), targetSystemId: rec.id, targetSystemName: String(rec.name ?? rec.id) } : {}),
};
}
@ -1061,8 +1666,15 @@ export class SideQuestState {
(i) => Number.isInteger(i) && i >= 0 && i < waypoints.length,
),
)];
const kind = route.kind === 'bounty' ? 'bounty' : 'patrol';
const encounters = normalizePatrolEncounters(route.encounters, waypoints.length);
const bounty = kind === 'bounty' ? normalizeBountyPlan(route.bounty, waypoints.length) : null;
this.routes.set(id, {
// The route's TYPE — 'patrol' (the default: every patrol, and every
// save written before the hunts existed) or 'bounty' (a hunt: SEQUENTIAL
// legs + the hidden plan). The type is what branches the scene's update;
// a patrol record normalizes exactly as it always did.
kind,
targetSystemId: String(route.targetSystemId ?? ''),
credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's CREDITS payout — fixed at the accept
// The contract's XP payout (the claim's second reward — beside the
@ -1070,10 +1682,16 @@ export class SideQuestState {
// the scene's _establishPatrol); a route that stands without one
// (an old save) derives it from its own spec — the same contract,
// a signed contract is a signed contract.
xp: Number.isFinite(Number(route.xp)) && Number(route.xp) >= 0 ? Number(route.xp) : patrolEncounterXp(encounters),
xp:
Number.isFinite(Number(route.xp)) && Number(route.xp) >= 0
? Number(route.xp)
: kind === 'bounty'
? bountyHuntXp(bounty)
: patrolEncounterXp(encounters),
waypoints,
visited,
encounters,
bounty,
});
return true;
}
@ -1098,7 +1716,7 @@ export class SideQuestState {
/**
* A contract's route record (the scene's check-ins + the board's route
* line + the claim's payout read it).
* @returns {{targetSystemId:string, credits:number, xp:number, waypoints:number[][], visited:number[], encounters:?object}|null}
* @returns {{kind:string, targetSystemId:string, credits:number, xp:number, waypoints:number[][], visited:number[], encounters:?object, bounty:?object}|null}
*/
routeFor(id) {
return this.routes.get(id) ?? null;
@ -1109,15 +1727,58 @@ export class SideQuestState {
* see the ship inside the radius on several frames; only the FIRST
* logs). @returns {boolean} true when NEWLY logged
*/
visitWaypoint(id, idx) {
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) {
const next = nextBountyWaypoint(route);
if (!next || next.index !== idx) return false;
}
route.visited.push(idx);
return true;
}
/**
* A hunt's PICKET wave cleared (the leg's raid wave ran to completion — the
* witness is the completion, not the attempt, exactly as a patrol contact).
* Only a leg that ROLLED a picket can clear. @returns {boolean} true when NEWLY cleared
*/
bountySkirmishCleared(id, idx) {
const route = this.routes.get(id);
const b = route?.bounty;
if (!b || !Number.isInteger(idx) || idx < 0 || idx >= (b.pickets?.length ?? 0)) return false;
if (b.pickets[idx] !== true) return false;
if (b.skirmishCleared.includes(idx)) return false;
b.skirmishCleared.push(idx);
return true;
}
/**
* THE MARK IS DOWN — the hunt's completion witness (the kill, then every
* survivor of its escort put down). It sets the plan's `down` mark and
* checks off EVERY remaining leg in the same breath (a hunt ends on the
* kill, not on the flying), so bountyRouteComplete stands from here and no
* further leg fires. Idempotent — a double witness marks once.
* @returns {boolean} true when NEWLY witnessed
*/
bountyDown(id) {
const route = this.routes.get(id);
const b = route?.bounty;
if (!b) return false;
for (let i = 0; i < route.waypoints.length; i++) {
if (!route.visited.includes(i)) route.visited.push(i);
}
if (b.down === true) return false;
b.down = true;
return true;
}
toJSON() {
return {
accepted: [...this.acceptedOrder],

View File

@ -63,7 +63,24 @@ import { ShipWindow } from '../ui/ShipWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js';
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
import { SideQuestState, sideQuestPostingsFor, buildPatrolRoute, derivePatrolEncounters, patrolEncounterScript, patrolEncounterXp, patrolRouteComplete } from '../quests/SideQuests.js';
import {
SideQuestState,
sideQuestPostingsFor,
buildPatrolRoute,
derivePatrolEncounters,
patrolEncounterScript,
patrolEncounterXp,
patrolRouteComplete,
sideQuestAcceptable,
bountyDenyCopy,
deriveBountyPlan,
bountySkirmishScript,
bountyEngagementScript,
bountyHuntXp,
nextBountyWaypoint,
bountyRouteComplete,
bountyText,
} from '../quests/SideQuests.js';
import { tutorialTarget } from '../quests/TutorialGuide.js';
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
@ -1009,9 +1026,14 @@ export class GameScene extends Phaser.Scene {
xp: savedXp ? XpManager.fromJSON(savedXp) : new XpManager(),
onActiveChange: (active) => this._setCombatBar(active),
// The readability layer's bookkeeping (js/combat/CombatHud.js —
// the same shared layer the range runs): a kill lands in the
// feed, and a hit flashes the bar's gauge that took it.
onKill: () => this.combatHud?.kill('HOSTILE DESTROYED'),
// the same shared layer the range runs): a kill lands in the feed,
// and a hit flashes the bar's gauge that took it. The enemy rides
// along for the BOUNTY HUNT's witness (handleCombatKill — the mark
// is the only elite the game deploys, so its death IS the contract).
onKill: (e) => {
this.combatHud?.kill('HOSTILE DESTROYED');
this.handleCombatKill(e);
},
// A combat level landed (the system fires its own burst + voice +
// the armor bump on the manager's 'levelup' event — the skill
// layer will subscribe to that same event later): the feed's line.
@ -2575,8 +2597,8 @@ export class GameScene extends Phaser.Scene {
// story.capitalReturned (monotonic; _maybeWitnessCapitalReturn is a
// no-op once it stands, so the per-frame cost is a ledger read).
this._maybeWitnessCapitalReturn();
this._drawPatrolMarkers(_time); // the active patrol's beacons (the route's system only)
this.updatePatrol(); // the check-ins (the compass + the beacons see the fresh ledger this frame)
this._drawSideQuestMarkers(_time); // the active contract's beacons (patrol or hunt — the route record's kind picks)
this.updateSideQuestRoute(); // its check-ins + engagements (the compass + beacons read the fresh ledger this frame)
if (!_frozen) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
}
@ -2748,7 +2770,7 @@ export class GameScene extends Phaser.Scene {
// in another system it is the jumpgate that jumps there — the yellow
// takes the gate's slot, so the gate's ordinary cyan entry is
// suppressed (the route gate's rule, a different color).
const patrolT = this.patrolCompassTarget();
const patrolT = this.activeRouteCompassTarget();
const patrolGateId = patrolT?.kind === 'gate' ? patrolT.gate?.discoveryId : null;
const offscreen = [];
@ -2795,8 +2817,11 @@ export class GameScene extends Phaser.Scene {
x: patrolT.gate.x,
y: patrolT.gate.y,
radius: patrolT.gate.bound,
typeLabel: config.get('sidequests.patrol.compass.typeLabel', 'Patrol'),
color: config.get('sidequests.patrol.compass.color', '#ffd60a'),
// The target carries its OWN colors when it stands (a hunt's arrow is
// the data's crimson, a patrol's the yellow); the patrol keys are the
// fallback, so an old route reads exactly as it always did.
typeLabel: patrolT.typeLabel ?? config.get('sidequests.patrol.compass.typeLabel', 'Patrol'),
color: patrolT.color ?? config.get('sidequests.patrol.compass.color', '#ffd60a'),
name: patrolT.gate.discoveryName,
// The patrol's next gate — the full readout (like the route gate),
// autopilot-resolvable (the `patrol:gate:` id's own branch).
@ -2808,8 +2833,8 @@ export class GameScene extends Phaser.Scene {
x: patrolT.x,
y: patrolT.y,
radius: 32,
typeLabel: config.get('sidequests.patrol.compass.typeLabel', 'Patrol'),
color: config.get('sidequests.patrol.compass.color', '#ffd60a'),
typeLabel: patrolT.typeLabel ?? config.get('sidequests.patrol.compass.typeLabel', 'Patrol'),
color: patrolT.color ?? config.get('sidequests.patrol.compass.color', '#ffd60a'),
name: `WAYPOINT ${patrolT.idx + 1}`,
// The patrol's next point — the full readout (the thing to fly),
// autopilot-resolvable (the `patrol:wp:` id's own branch).
@ -4497,6 +4522,12 @@ export class GameScene extends Phaser.Scene {
});
});
}
// A HUNT's checklist stands on the ledger's mark alone: its legs check off
// WITH the kill, so a visited-count would open the CLAIM door in the frame
// between the mark going down and its escort stopping (the contract lands
// when the field clears — updateBountyHunt). A patrol derives its complete
// from the route itself, exactly as it always did.
const hunt = route?.kind === 'bounty';
const credits =
route && Number.isFinite(route.credits) && route.credits > 0
? route.credits
@ -4506,7 +4537,7 @@ export class GameScene extends Phaser.Scene {
// defeated contacts — the accept's patrolEncounterXp). A route
// without one (old saves, the stubs) stands credits-only.
const xp = route && Number.isFinite(route.xp) ? Math.max(0, route.xp) : 0;
const complete =
const complete = !hunt &&
waypoints.length > 0 && visited.length === waypoints.length &&
(!enc || enc.at.every((i) => clearedSet.has(i)));
return {
@ -4865,7 +4896,16 @@ export class GameScene extends Phaser.Scene {
sideQuestPostings() {
const rec = this.systemRecord ?? null;
if (!this.galaxy || !rec) return [];
return sideQuestPostingsFor(this.galaxy.seed, rec, this.galaxy);
// The player's combat level rides the read (the BOUNTY HUNT's gate): below
// categories.bounty.minLevel the row still stands in its seeded slot, LOCKED
// — the gate is presentation + accept, never generation.
return sideQuestPostingsFor(this.galaxy.seed, rec, this.galaxy, this.combatLevel());
}
/** The run's combat level (js/combat/XpManager.js); null when no XP bank stands. */
combatLevel() {
const n = Number(this.combat?.xp?.level);
return Number.isFinite(n) ? n : null;
}
/**
@ -4883,6 +4923,20 @@ export class GameScene extends Phaser.Scene {
if (!this.sideQuestState) return { ok: false };
const posting = this.sideQuestPostings().find((p) => p.id === jobId) ?? null;
if (!posting) return { ok: false };
// The LEVEL GATE (data/sidequests.json → categories.bounty.minLevel — the
// hunt's 2): a locked contract is a REFUSAL, never a record. The board
// renders the row LOCKED; pressing it anyway says why in the data's own
// words (`bountyAcceptDenyToast`) and hands the caller `locked` so the
// surface console can say the same thing out loud.
const gate = sideQuestAcceptable(posting, this.combatLevel());
if (!gate.ok) {
const deny = bountyDenyCopy();
this.consoleToast(String(deny?.text ?? 'COMMISSION REFUSED'), {
glyph: deny?.glyph || '\u2717',
glyphColor: toCss(deny?.color || '#ff5c77'),
});
return { ok: false, locked: true, posting };
}
if (!this.sideQuestState.accept(jobId)) return { ok: false };
// The PATROL's ROUTE stands WITH the accept — the route is the
// contract (the scene's _establishPatrol builds it from the player's
@ -4890,6 +4944,7 @@ export class GameScene extends Phaser.Scene {
// ledger's route record rides the save, so a re-accept after a load
// keeps the SAME coordinates — the route never re-rolls).
if (posting.category === 'patrol') this._establishPatrol(jobId, posting);
else if (posting.category === 'bounty') this._establishBountyHunt(jobId, posting);
this.playSfx('construct'); // the power-up tick — the contract lands
return { ok: true, posting };
}
@ -4911,28 +4966,7 @@ export class GameScene extends Phaser.Scene {
_establishPatrol(jobId, posting) {
const targetId = posting.targetSystemId ?? this.systemRecord?.id ?? '';
if (!targetId || !this.sideQuestState) return;
let region = [];
let anchors = [];
if (targetId === this.systemRecord?.id) {
// The system the player stands in: their UNLOCKED tether regions.
const live = [...(this.systemPlanets ?? []), ...(this.systemStations ?? []), ...(this.systemGates ?? [])];
if (this.planet) live.push(this.planet);
anchors = live.map((e) => ({ x: e.x, y: e.y }));
region = (this.tetherField?.tethers ?? []).map((t) => ({ x: t.x, y: t.y, radius: t.radius }));
} else {
// A NEIGHBOR system (unlocked or not): one tether region around
// each of its planets / stations / gates (the content's layout —
// seeded, the same the player will find on arrival).
const content = this.galaxy?.contentOf?.(targetId) ?? null;
const pts = [
...(Array.isArray(content?.planets) ? content.planets : []),
...(Array.isArray(content?.settlements) ? content.settlements : []),
...(Array.isArray(content?.jumps) ? content.jumps : []),
].filter((a) => Number.isFinite(a?.x) && Number.isFinite(a?.y));
anchors = pts.map((a) => ({ x: a.x, y: a.y }));
const radius = Tether.radiusForLevel(1);
region = pts.map((a) => ({ x: a.x, y: a.y, radius }));
}
const { region, anchors } = this._routeFlightEnvelope(targetId);
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, targetId, region, anchors);
route.credits = posting.reward?.credits ?? 0; // the contract's payout (fixed at the accept)
// The contract's ENCOUNTERS — its hostile contacts (data/sidequests.json
@ -4959,6 +4993,133 @@ export class GameScene extends Phaser.Scene {
this.sideQuestState.setRoute(jobId, route);
}
/**
* The contract's FLIGHT ENVELOPE (shared by the patrol's route and the
* BOUNTY HUNT's lanes — the same worldgen law, the same keep-out): the
* player's UNLOCKED tether regions when the target is the system they stand
* in (their live tether field), else one tether region (a level-1 radius)
* around each of the target's planets / stations / gates (its seeded content
* — the layout they will find on arrival). The anchors are those same
* objects' centers. A pure read of the scene's world; behavior identical to
* the block it was lifted out of.
* @returns {{region: Array<{x:number,y:number,radius:number}>, anchors: Array<{x:number,y:number}>}}
*/
_routeFlightEnvelope(targetId) {
let region = [];
let anchors = [];
if (targetId === this.systemRecord?.id) {
// The system the player stands in: their UNLOCKED tether regions.
const live = [...(this.systemPlanets ?? []), ...(this.systemStations ?? []), ...(this.systemGates ?? [])];
if (this.planet) live.push(this.planet);
anchors = live.map((e) => ({ x: e.x, y: e.y }));
region = (this.tetherField?.tethers ?? []).map((t) => ({ x: t.x, y: t.y, radius: t.radius }));
} else {
// A NEIGHBOR system (unlocked or not): one tether region around
// each of its planets / stations / gates (the content's layout —
// seeded, the same the player will find on arrival).
const content = this.galaxy?.contentOf?.(targetId) ?? null;
const pts = [
...(Array.isArray(content?.planets) ? content.planets : []),
...(Array.isArray(content?.settlements) ? content.settlements : []),
...(Array.isArray(content?.jumps) ? content.jumps : []),
].filter((a) => Number.isFinite(a?.x) && Number.isFinite(a?.y));
anchors = pts.map((a) => ({ x: a.x, y: a.y }));
const radius = Tether.radiusForLevel(1);
region = pts.map((a) => ({ x: a.x, y: a.y, radius }));
}
return { region, anchors };
}
// -------------------------------------------------- the BOUNTY HUNT's lanes
// (js/quests/SideQuests.js owns the MODEL — bountyRules / deriveBountyPlan /
// bountySkirmishScript / bountyEngagementScript / bountyHuntXp /
// nextBountyWaypoint / bountyRouteComplete. What follows is the FLYING: the
// accept's record, the per-frame check-in and engagements, the kill's witness,
// and the beacons. A hunt's route record stands `kind:'bounty'`, and that one
// field is what branches every path here away from patrol law.)
/**
* The BOUNTY HUNT's accept — stand its LANES + its hidden PLAN on the ledger.
* The plan rolls FIRST (deriveBountyPlan, its own labelled fork) because it
* owns the leg count; the route is then built through the SAME tether-safe,
* seeded builder as a patrol, asked for exactly that many legs under the
* 'bounty' fork. Which leg holds the mark, and which legs stand the leader's
* pickets, rides the record INVISIBLE — no board row, beacon, compass entry or
* toast reads it out before the fight stands up (the design's one rule).
* The payout is fixed beside it (credits + bountyHuntXp): a signed contract is
* a signed contract.
*/
_establishBountyHunt(jobId, posting) {
const targetId = posting.targetSystemId ?? this.systemRecord?.id ?? '';
if (!targetId || !this.sideQuestState) return;
const level = Number(this.combat?.xp?.level) || 0;
// The dossier's own story names the craft the leader flies (`craft`,
// validated in the model against combat.enemyTypes) and its NAME — the name
// rides the plan so the comms can say who it was once the fight is here.
const plan = deriveBountyPlan(this.galaxy?.seed ?? 0, jobId, targetId, level, {
craft: posting.dossier?.craft ?? null,
mark: { name: posting.dossier?.name ?? '', epithet: posting.dossier?.epithet ?? '' },
});
const { region, anchors } = this._routeFlightEnvelope(targetId);
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, targetId, region, anchors, {
waypoints: [plan.legs, plan.legs], // exactly the plan's legs — no more, no fewer
fork: 'bounty', // its own seeded stream (a patrol of the same id never collides)
});
route.kind = 'bounty'; // THE branch flag — the update / compass / markers read it
route.bounty = plan; // the hidden chain, fixed at the accept (rides the save)
route.credits = posting.reward?.credits ?? 0;
route.xp = bountyHuntXp(plan); // the claim's second reward, fixed here too
this.sideQuestState.setRoute(jobId, route);
}
/** The active contract's ROUTE kind ('patrol' | 'bounty'); null with nothing flying. */
_sideRouteKind() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || st.isCompleted(id) || st.isClaimed(id)) return null;
return st.routeFor(id)?.kind ?? null;
}
/** Per-frame route update — the record's OWN kind picks the law (patrol untouched). */
updateSideQuestRoute() {
if (this._sideRouteKind() === 'bounty') this.updateBountyHunt();
else this.updatePatrol();
}
/** The route's in-world beacons, same dispatch (the hunt paints no contact red — see _drawBountyMarkers). */
_drawSideQuestMarkers(time) {
if (this._sideRouteKind() === 'bounty') this._drawBountyMarkers(time);
else this._drawPatrolMarkers(time);
}
/**
* 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
* at the gate that jumps there (the patrol's rule).
*/
activeRouteCompassTarget() {
if (this._sideRouteKind() !== 'bounty') return this.patrolCompassTarget();
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.ship || !this.systemRecord) return null;
const route = st.routeFor(id);
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return null;
const next = nextBountyWaypoint(route);
if (!next) return null; // every leg logged (the kill's check-off / the last leg lands the contract)
const style = {
color: config.get('sidequests.bounty.compass.color', '#ff2d6f'),
typeLabel: config.get('sidequests.bounty.compass.typeLabel', 'Hunt'),
};
if (route.targetSystemId === this.systemRecord.id) {
return { kind: 'waypoint', idx: next.index, x: next.x, y: next.y, ...style };
}
for (const gt of this.systemGates ?? []) if (gt.gate?.to === route.targetSystemId) return { kind: 'gate', gate: gt, ...style };
return null;
}
/**
* The board's SET ACTIVE (the window's door → here): stand the ONE
* contract the player flies right now (the ledger validates — taken,
@ -5205,6 +5366,193 @@ export class GameScene extends Phaser.Scene {
});
}
/**
* The active BOUNTY HUNT's frame: ONE leg at a time, the lane's fights at
* their legs, and the contract landing on the kill. A hunt checks in strictly
* IN ORDER (the chain's odds read a first / second / third leg — the ledger
* refuses any other sequence), so only the NEXT leg logs when the ship stands
* on it; the rolled pickets and the mark itself fire at their own legs, one
* fight at a time, each awaiting its own witness (a picket's is its wave run's
* COMPLETION — the patrol contact's rule — so a mid-fight jump-out re-arms it;
* the mark's witness is its DEATH, which halts the run and stops nothing else
* deploying). The contract goes COMPLETE when the mark is down AND the last of
* its escort stops shooting — never before, and no amount of flying finishes a
* hunt whose mark still stands.
*/
updateBountyHunt() {
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.ship || !this.systemRecord) return;
if (st.isCompleted(id) || st.isClaimed(id)) return;
const route = st.routeFor(id);
if (!route || route.kind !== 'bounty' || !route.bounty) return; // a degraded record stands no hunt
if (route.targetSystemId !== this.systemRecord.id) return; // the lanes are in the contract's system — the arrow points there
const plan = route.bounty;
const radius = Math.max(60, Number(plan.visitRadius) || 420);
let logged = null;
const next = nextBountyWaypoint(route);
if (next && Math.hypot(this.ship.x - next.x, this.ship.y - next.y) <= radius) {
if (st.visitWaypoint(id, next.index)) logged = next; // idempotent — only the first frame logs
}
// The lane's fight speaks BEFORE the quiet line: an engagement standing at
// the leg just logged supersedes "NOTHING ON THE LANE" (the leg still logs).
const fired = this._fireBountyEngagements(st, id, route, radius);
if (logged && !fired) {
this.playSfx('discovery'); // the leg's soft chime
this._huntToast(bountyText('bountyLegToast', { i: String(next.leg), n: String(next.of) }), '\u25c8');
}
if (bountyRouteComplete(route) && !st.isCompleted(id) && this._bountyFieldClear()) {
st.complete(id); // the done contract drops out of the ACTIVE slot (reconcileActive stands the next top one)
this.playSfx('construct');
this._huntToast(bountyText('bountyCompleteToast', { name: this._huntMarkName(plan) }), '\u2713');
}
}
/**
* The hunt's engagements, one at a time (data → bounty.encounters): the MARK
* first — its own leg, once logged, while it still stands — otherwise the
* rolled PICKET wave of an uncleared pre-bounty leg the ship is standing on.
* Both are one rolling wave through the SAME machinery as a patrol contact /
* the 'Pirate Problems' ambush (CombatSystem.startWaves); the mark's wave
* carries its `elite` block (the dossier's frame at the data's hull / shield /
* damage scales, named — js/combat/Waves.js deploys it with the opening group).
* A halted or in-flight run holds the fire; a run that ended WITHOUT the kill
* (a jump out mid-fight) simply re-arms here on return, because nothing but
* the death is witnessed.
* @returns {boolean} true when a wave stood up this frame (the leg's own toast speaks)
*/
_fireBountyEngagements(st, id, route, radius) {
const plan = route.bounty;
if (!plan || !this.combat || typeof this.combat.startWaves !== 'function') return false;
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return false; // one fight at a time
const visited = new Set(Array.isArray(route.visited) ? route.visited : []);
const at = (i) => {
const wp = route.waypoints[i];
return Array.isArray(wp) && Math.hypot(this.ship.x - wp[0], this.ship.y - wp[1]) <= radius;
};
if (!plan.down && visited.has(plan.bountyAt) && at(plan.bountyAt)) {
const script = bountyEngagementScript(plan);
const total = script.waves[0]?.total ?? plan.raiders + 1;
this.playSfx('discovery'); // the sighting's chime (the wave's own comms ride the bar)
this._huntToast(bountyText('bountySightedToast', {
name: this._huntMarkName(plan),
epithet: String(plan.mark?.epithet ?? '').toUpperCase(),
n: String(total),
}), '\u2620');
this.combat.startWaves(script, {}); // NO onComplete witness — the kill is the witness (handleCombatKill)
return true;
}
if (plan.down) return false; // the mark is down: the hunt throws nothing else at the ship
const cleared = new Set(Array.isArray(plan.skirmishCleared) ? plan.skirmishCleared : []);
for (let i = 0; i < (Array.isArray(plan.pickets) ? plan.pickets.length : 0); i++) {
if (plan.pickets[i] !== true || cleared.has(i) || !visited.has(i) || !at(i)) continue;
const script = bountySkirmishScript(plan);
const total = script.waves[0]?.total ?? 6;
this.playSfx('discovery');
this._huntToast(bountyText('bountySkirmishToast', { n: String(total) }), '\u2694');
this.combat.startWaves(script, {
onComplete: () => st.bountySkirmishCleared(id, i), // the witness is the COMPLETION (a quit mid-fight re-arms)
});
return true;
}
return false;
}
/** The field's clear when nothing of the hunt's own escort is left shooting. */
_bountyFieldClear() {
if (!this.combat) return true; // no combat system — the kill stands on its own (a degraded boot still finishes)
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return false; // live, or halted with survivors
return !this.combat.enemies.some((e) => e.wave && !e.dead);
}
/**
* A hostile went DOWN (CombatSystem's onKill — the enemy rides along for
* exactly this). The only elite the game deploys is a hunt's mark, so its
* death IS the contract: every further deployment and replacement STOPS
* (CombatSystem.haltWaves — the escort stays hostile and must be put down),
* and the remaining legs check off in the same breath (SideQuestState.bountyDown),
* which is what bountyRouteComplete reads. The completion + payout land
* upstream in updateBountyHunt once the field clears. Any other kill — a
* raider, an escort, a patrol contact, or a foreign elite that isn't THIS
* contract's mark — says nothing here.
*/
handleCombatKill(e) {
if (!e || e.elite !== true) return;
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !st.isAccepted(id) || st.isCompleted(id) || st.isClaimed(id)) return;
const route = st.routeFor(id);
if (!route || route.kind !== 'bounty' || !route.bounty || route.bounty.down === true) return;
// Only the mark THIS contract's plan flies books it (a hand-edited or future
// elite from elsewhere can't close a hunt).
const mark = String(route.bounty.mark?.name ?? '').trim();
const fallen = String(e.markName ?? '').trim();
if (mark !== '' && fallen !== '' && fallen !== mark) return;
this.combat?.haltWaves?.(); // nothing else deploys or reforms — the survivors fight it out
if (!st.bountyDown(id)) return; // idempotent — a second elite can't book it twice
this.playSfx('construct');
this._huntToast(bountyText('bountyBroughtToBookToast', { name: mark.toUpperCase() || 'THE MARK' }), '\u2713');
}
/** The wanted leader's name for the comms (upper-case, like every console line); a plan with no dossier still reads as THE MARK. */
_huntMarkName(plan) {
return String(plan?.mark?.name ?? '').trim().toUpperCase() || 'THE MARK';
}
/** One hunt comms line (the data's copy, glyph + color), in the console's voice. */
_huntToast(line, fallbackGlyph = '\u25b8') {
if (!line?.text) return;
this.consoleToast(String(line.text), {
glyph: line.glyph || fallbackGlyph,
glyphColor: toCss(line.color || '#ff2d6f'),
});
}
/**
* The active hunt's IN-WORLD beacons — and the design's one visual law:
* EVERY leg looks the same until something shoots at you. Only the NEXT leg in
* sequence pulses (the data's bounty.marker color); a logged leg takes the dim
* done mark; an unflown leg stands dim and STATIC, all of them identical —
* there is NO contact beacon, because a hunt's marker block deliberately
* carries no `contactColor` (a patrol paints its contacts red because those
* ARE the job; a hunt's mark is hidden until it fires). The route's system
* only, the ACTIVE contract only, clear of the world's solids by construction.
*/
_drawBountyMarkers(time) {
const g = this.patrolG; // the same beacon layer (one contract's route draws at a time)
if (!g) return;
g.clear();
const st = this.sideQuestState;
const id = st?.activeId ?? null;
if (!id || !this.systemRecord || st.isCompleted(id) || st.isClaimed(id)) return;
const route = st.routeFor(id);
if (!route || route.kind !== 'bounty' || route.targetSystemId !== this.systemRecord.id) return;
const color = toColor(config.get('sidequests.bounty.marker.color', '#ff2d6f'), 0xff2d6f);
const doneColor = toColor(config.get('sidequests.bounty.marker.doneColor', '#67e863'), 0x67e863);
const next = nextBountyWaypoint(route);
for (let i = 0; i < route.waypoints.length; i++) {
const [x, y] = route.waypoints[i];
const done = route.visited.includes(i);
const live = !!next && next.index === i; // ONE leg lights; the rest keep their distance
const c = done ? doneColor : color;
const pulse = live ? 0.5 + 0.5 * Math.sin(time * 0.004 + i * 1.7) : 0;
const s = 7 + (live ? 3 * pulse : 0); // the diamond's half-diagonal
g.fillStyle(c, 0.05 + (live ? 0.05 * pulse : 0)); // the soft glow
g.fillCircle(x, y, 26 + (live ? 6 * pulse : 0));
g.lineStyle(1.5, c, done ? 0.4 : live ? 0.45 + 0.4 * pulse : 0.3); // the ring
g.strokeCircle(x, y, 15 + (live ? 5 * pulse : 0));
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);
}
}
/**
* The active patrol's IN-WORLD beacons (per frame, update): the route
* waypoints in the route's system — a diamond + ring + soft glow per

View File

@ -1571,6 +1571,17 @@ export class SurfaceScene extends Phaser.Scene {
String(t.text ?? 'CONTRACT ACCEPTED — {title}').replace('{title}', String(res.posting.title).toUpperCase()),
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : undefined },
);
} else if (res?.locked) {
// The LEVEL GATE's refusal (a BOUNTY HUNT under categories.bounty.minLevel
// — data → ui.bountyAcceptDenyToast): the board greyed the row, so this is
// the line that says WHY, in the level's own number off the posting. The
// game scene's own toast lands behind this sleeping one.
const d = config.get('sidequests.ui.bountyAcceptDenyToast', {});
const level = Number.isFinite(Number(res?.posting?.minLevel)) ? Math.floor(Number(res.posting.minLevel)) : 2;
this.consoleNote(String(d.text ?? 'COMMISSION REFUSED — LEVEL {level} REQUIRED').replace(/\{level\}/g, String(level)), {
glyph: d.glyph ?? '✗',
glyphColor: d.color ? toCss(d.color) : undefined,
});
} else {
const d = config.get('sidequests.ui.acceptDenyToast', {});
this.consoleNote(String(d.text ?? 'COMMISSION DENIED'), {

View File

@ -77,6 +77,19 @@ function hexNum(str, fallback = 0xffffff) {
return m ? parseInt(m[1], 16) : fallback;
}
/**
* A data line's `{level}` filled (the lock badge + its sub carry the gate).
* The posting carries its own `minLevel` (sideQuestPostingsFor stamps it off
* categories.<cat>.minLevel); a posting without one falls to the bounty
* category's gate, then the design's 2.
*/
function fillLevel(text, p) {
let level = Number(p?.minLevel);
if (!Number.isFinite(level)) level = Number(config.get('sidequests.categories.bounty.minLevel', 2));
if (!Number.isFinite(level)) level = 2;
return String(text ?? '').replace(/\{level\}/g, String(Math.floor(level)));
}
/**
* A copy's line fit (deterministic — no Phaser measure, so the headless
* stub and the game agree): cut at a word boundary + '…' when the text
@ -495,6 +508,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
if (this.isCompleted(p.id)) return 'complete';
if (this.isActive(p.id)) return 'active';
if (this.isAccepted(p.id)) return 'accepted';
if (p.locked === true) return 'locked'; // the level gate's greyed row (BOUNTY HUNT under its minLevel)
return 'open';
}
@ -563,7 +577,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
.text(rightX + 32, y + 9, String(p.title ?? '').toUpperCase(), {
fontFamily: HEADER,
fontSize: '11px',
color: toCss(C.ink),
color: toCss(p.locked === true ? C.faint : C.ink), // a locked contract's title stands greyed with the rest of the row
fontStyle: 'bold',
letterSpacing: 1.5,
})
@ -582,17 +596,31 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
// taken (the state is read off the seams at repaint — the ledger is
// the truth, the scene's refresh() lands it after each action).
const state = this._rowState(p);
const locked = state === 'locked'; // the row stands, greyed, under its level gate
const unit = String(ui.rewardUnit ?? 'CR');
let rightSize = '10px';
let rightTxt;
let rightColor;
if (state === 'claimed') {
if (locked) {
// The gate reads on the row itself: LOCKED — REQUIRES LEVEL 2 (data →
// ui.bountyLockBadge), greyed in place of the payout. The row still
// SELECTS (the dossier is readable before the level lands); the
// scene's accept is the door that refuses.
rightTxt = fillLevel(String(ui.bountyLockBadge ?? 'LOCKED — REQUIRES LEVEL {level}'), p);
rightColor = C.faint;
rightSize = '9px';
} else if (state === 'claimed') {
rightTxt = String(ui.claimed ?? 'REWARD CLAIMED');
rightColor = C.faint;
} else if (state === 'complete') {
rightTxt = String(ui.complete ?? 'CONTRACT COMPLETE');
rightColor = C.neon;
} else if (state === 'active') {
rightTxt = String(ui.active ?? 'PATROL ACTIVE');
// A hunt flying reads as a HUNT, not a patrol (data → ui.bountyActive);
// every other contract keeps the standing copy.
rightTxt = p.category === 'bounty'
? String(ui.bountyActive ?? 'HUNT ACTIVE')
: String(ui.active ?? 'PATROL ACTIVE');
rightColor = this._activeColor();
} else if (state === 'accepted') {
rightTxt = String(ui.accepted ?? 'CONTRACT ACCEPTED');
@ -604,7 +632,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
const right = s
.text(rightX + rightW - 16, y + 10, rightTxt, {
fontFamily: BODY,
fontSize: '10px',
fontSize: rightSize,
color: toCss(rightColor),
fontStyle: 'bold',
letterSpacing: 2,
@ -626,7 +654,7 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
g.fillStyle(chipColor, 1);
g.fillRect(rightX + 3, y + 7, 3, rowH - 14);
}
g.fillStyle(chipColor, 0.95);
g.fillStyle(chipColor, locked ? 0.35 : 0.95); // the locked row's chip reads greyed too
g.fillRect(rightX + 15, y + rowH / 2 - 6, 11, 11);
};
@ -736,6 +764,50 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
// by the wave runs — old saves' routes carry none and the line stays
// down).
let lineY = py + 52;
// The BOUNTY HUNT's DOSSIER (data → descriptions.neutral.bounty[]): the
// wanted leader, their epithet, the charges, and the CRAFT the story says
// they fly. It rides the posting, so it reads BEFORE the contract is taken
// — and it stays silent about WHERE: the chain of legs rolls at the accept
// and never touches the board (ui.dossier* carry the shapes).
const dossier = sel.category === 'bounty' && sel.dossier && typeof sel.dossier === 'object' ? sel.dossier : null;
if (dossier) {
const dline = (tpl, vars, o = {}) => {
let text = String(tpl ?? '');
for (const [k, v] of Object.entries(vars)) text = text.split(`{${k}}`).join(String(v ?? ''));
if (text.replace(/\{[a-zA-Z]+\}/g, '').trim() === '') return null; // an unfilled line stands down
const t = s
.text(rightX + 14, lineY, text, {
fontFamily: BODY,
fontSize: String(o.size ?? '9.5px'),
color: toCss(o.color ?? C.dim),
fontStyle: o.style ?? 'bold',
letterSpacing: 2,
})
.setScrollFactor(0);
this.add(t);
this.detailSection.content.push(t);
lineY += 15;
};
dline(ui.dossierWanted ?? 'WANTED — {name}', { name: String(dossier.name ?? '').toUpperCase() }, { color: accent });
if (dossier.epithet) dline(ui.dossierEpithet ?? '{epithet}', { epithet: dossier.epithet }, { color: C.faint, style: 'italic' });
if (dossier.crime) dline(ui.dossierCrime ?? 'CHARGES — {crime}', { crime: dossier.crime }, { color: C.dim });
if (dossier.craftLabel) dline(ui.dossierCraft ?? 'CRAFT — {craft}', { craft: String(dossier.craftLabel).toUpperCase() }, { color: C.faint });
}
// The level gate's own line under the dossier (data → ui.bountyLockSub):
// what the record needs, not what the contract hides.
if (sel.locked === true && !this.isAccepted(sel.id)) {
const lock = s
.text(rightX + 14, lineY, fillLevel(String(ui.bountyLockSub ?? 'BRING YOUR RECORD UP TO LEVEL {level} AND THE BOARD WILL LIST YOU'), sel), {
fontFamily: BODY,
fontSize: '9px',
color: toCss(0xff5c77),
letterSpacing: 2,
})
.setScrollFactor(0);
this.add(lock);
this.detailSection.content.push(lock);
lineY += 15;
}
if (sel.targetSystemName && sel.targetSystemName !== sel.systemName) {
const dest = s
.text(
@ -756,21 +828,29 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
prog = null; // a seam fault stands the line out, never a crash
}
if (prog && Number.isFinite(prog.visited) && Number.isFinite(prog.total)) {
// A HUNT reads its own line (data → ui.bountyRouteProgress — WAYPOINT n
// OF m LOGGED): the legs flown, never what stands on them. Its color is
// the category's accent, not the patrol's yellow.
const hunt = sel.category === 'bounty';
const route = s
.text(
rightX + 14,
lineY,
String(ui.routeProgress ?? 'ROUTE — {visited}/{total} WAYPOINTS LOGGED')
String(hunt ? ui.bountyRouteProgress ?? 'TRAVEL LANE — WAYPOINT {visited} OF {total} LOGGED' : ui.routeProgress ?? 'ROUTE — {visited}/{total} WAYPOINTS LOGGED')
.replace('{visited}', String(prog.visited))
.replace('{total}', String(prog.total)),
{ fontFamily: BODY, fontSize: '9.5px', color: toCss(this._activeColor()), letterSpacing: 2, fontStyle: 'bold' },
{ fontFamily: BODY, fontSize: '9.5px', color: toCss(hunt ? accent : this._activeColor()), letterSpacing: 2, fontStyle: 'bold' },
)
.setScrollFactor(0);
this.add(route);
this.detailSection.content.push(route);
lineY += 16;
}
if (prog && prog.contacts && Number.isFinite(prog.contacts.cleared) && prog.contacts.total > 0) {
// The CONTACTS line is the patrol's own (a hunt's route carries no
// encounters spec — its fights are the hidden chain), so a bounty never
// paints a contact count or a red cleared/total line here.
const isHunt = sel.category === 'bounty';
if (!isHunt && !dossier && prog && prog.contacts && Number.isFinite(prog.contacts.cleared) && prog.contacts.total > 0) {
const contacts = s
.text(
rightX + 14,
@ -791,8 +871,13 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
this.detailSection.content.push(contacts);
lineY += 16;
}
// The contract's OWN words (the pool's body — a hunt's written dossier).
// The panel is a fixed height, so the copy takes whatever room the lines
// above it left (the dossier's WANTED block + the gate line spend some of
// it) and fitText carries an ellipsis when the copy would run past.
const bodyLines = Math.max(2, Math.min(5, Math.floor((py + ph - 50 - lineY) / 13)));
const body = s
.text(rightX + 14, lineY, fitText(String(sel.description ?? ''), Math.floor(wrapW / 5.4), 5), {
.text(rightX + 14, lineY, fitText(String(sel.description ?? ''), Math.floor(wrapW / 5.4), bodyLines), {
fontFamily: BODY,
fontSize: '10.5px',
color: toCss(C.dim),
@ -831,16 +916,28 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
let label;
let color;
let action = null; // 'accept' | 'activate' | 'claim' | null (a plate)
if (state === 'open') {
if (state === 'locked') {
// The door stays LIVE under the level gate and reads its own badge —
// pressing it is how the player learns the rule (the scene's accept
// refuses with data/sidequests.json → ui.bountyAcceptDenyToast, which
// the surface console speaks out). No contract is taken, ever.
label = fillLevel(String(ui.bountyLockBadge ?? 'LOCKED — REQUIRES LEVEL {level}'), sel);
color = 0xff5c77;
action = 'accept';
} else if (state === 'open') {
label = String(ui.accept ?? 'ACCEPT CONTRACT');
color = accent;
action = 'accept';
} else if (state === 'accepted') {
label = String(ui.activate ?? 'SET PATROL ACTIVE');
label = sel.category === 'bounty'
? String(ui.bountyActivate ?? 'SET HUNT ACTIVE')
: String(ui.activate ?? 'SET PATROL ACTIVE');
color = C.green;
action = 'activate';
} else if (state === 'active') {
label = String(ui.active ?? 'PATROL ACTIVE');
label = sel.category === 'bounty'
? String(ui.bountyActive ?? 'HUNT ACTIVE')
: String(ui.active ?? 'PATROL ACTIVE');
color = this._activeColor();
} else if (state === 'complete') {
label =