diff --git a/data/sidequests.json b/data/sidequests.json index 158c2f2..416e5cc 100644 --- a/data/sidequests.json +++ b/data/sidequests.json @@ -1057,7 +1057,7 @@ "color": "#ff5c77", "text": "ACCESS DENIED — THE CONVOY'S DESTINATION HOLDS UNTIL THE TRANSPORT LANDS" }, - "_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.", + "_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. {bounty,escort}GateLockBadge / …GateLockSub / …GateDenyToast = the GATE ACCESS lock's words (the faction boards' hunts + rescues behind a hostile neighbor — the posting's gateLevel): the greyed row's badge, the detail's lock line, and the scene's refusal, each naming the target system the player needs access to FIRST ({system}) and the combat level that access opens at ({level}).", "bountyLockBadge": "LOCKED — REQUIRES LEVEL {level}", "bountyLockSub": "BRING YOUR RECORD UP TO LEVEL {level} AND THE BOARD WILL LIST YOU", "bountyAcceptDenyToast": { @@ -1095,19 +1095,19 @@ "color": "#67e863", "text": "BOUNTY HUNT COMPLETE — {name} IS DOWN, ITS REWARD IS READY TO CLAIM" }, - "bountyGateLockBadge": "LOCKED — GATE ACCESS LEVEL {level}", - "bountyGateLockSub": "THE TARGET SYSTEM BEHIND THE GATE OPENS AT COMBAT LEVEL {level}", + "bountyGateLockBadge": "LOCKED — ACCESS TO {system} REQUIRED", + "bountyGateLockSub": "ACCESS TO {system} OPENS AT COMBAT LEVEL {level}", "bountyGateDenyToast": { "glyph": "✕", "color": "#ff2d6f", - "text": "GATE LOCKED — THE TARGET SYSTEM OPENS AT COMBAT LEVEL {level}" + "text": "COMMISSION REFUSED — ACCESS TO {system} OPENS AT COMBAT LEVEL {level}" }, - "escortGateLockBadge": "LOCKED — GATE ACCESS LEVEL {level}", - "escortGateLockSub": "THE TRANSPORT'S GROUND BEHIND THE GATE OPENS AT COMBAT LEVEL {level}", + "escortGateLockBadge": "LOCKED — ACCESS TO {system} REQUIRED", + "escortGateLockSub": "ACCESS TO {system} OPENS AT COMBAT LEVEL {level}", "escortGateDenyToast": { "glyph": "✕", "color": "#ff2d6f", - "text": "GATE LOCKED — THE TRANSPORT'S GROUND OPENS AT COMBAT LEVEL {level}" + "text": "COMMISSION REFUSED — ACCESS TO {system} OPENS AT COMBAT LEVEL {level}" } } } \ No newline at end of file diff --git a/dev/bounty-hunts.test.mjs b/dev/bounty-hunts.test.mjs index 37572a7..c41e193 100644 --- a/dev/bounty-hunts.test.mjs +++ b/dev/bounty-hunts.test.mjs @@ -200,6 +200,22 @@ section('THE BOARD — rows on Neutral boards, LOCKED below level 2'); const at = S.sideQuestAcceptable(fb, gl); check(`${f} board's hunt is LOCKED below the gate level`, below.locked === true, `lvl=${gl - 1} locked=${below.locked}`); check(`${f} board's hunt OPENS at the gate level`, at.ok === true, `lvl=${gl} ok=${at.ok}`); + // The gate lock speaks the GATE's own words — naming the system the + // player needs access to FIRST (the row's badge, the detail's line, + // and the scene's refusal — data → ui.{bounty,escort}Gate*), not the + // level gate's generic "REQUIRES LEVEL" plate. + check(`${f} board's hunt's gate lock is the binding door`, S.postingGateLocked(fb) === true, `gateLevel=${fb.gateLevel} minLevel=${fb.minLevel}`); + for (const w of ['badge', 'sub', 'deny']) { + const copy = S.gateLockCopy(fb, w); + check(`${f} board's gate ${w} names the target system`, new RegExp(`ACCESS TO RIVAL ${f.toUpperCase()}`.replace(/\s+/g, '\\s+')).test(copy?.text ?? ''), copy?.text); + } + // The badge carries the system; the sub + the refusal add the level that opens it. + for (const w of ['sub', 'deny']) { + const copy = S.gateLockCopy(fb, w); + check(`${f} board's gate ${w} names the level that opens it`, new RegExp(`LEVEL ${gl}`).test(copy?.text ?? ''), copy?.text); + } + check('the gate-locked stamp rides the posting below the gate level', S.sideQuestPostingsFor(99, rec, 'p1', g, gl - 1).some((p) => p.category === 'bounty' && p.locked === true && p.gateLocked === true)); + check('the stamp lifts at the gate level', S.sideQuestPostingsFor(99, rec, 'p1', g, gl).every((p) => !(p.category === 'bounty' && p.gateLocked === true))); } } } diff --git a/dev/escort.test.mjs b/dev/escort.test.mjs index 9e0f588..f6a7252 100644 --- a/dev/escort.test.mjs +++ b/dev/escort.test.mjs @@ -45,6 +45,8 @@ const { sideQuestAcceptable, sideQuestPostingsFor, gateAccessLevel, + gateLockCopy, + postingGateLocked, escortDestinationFor, escortWaveRoster, escortEncounterXp, @@ -160,6 +162,22 @@ console.log('== the posting — neutral boards stand the classic escort; faction assert.ok(typeof fe.gateLevel === 'number' && fe.gateLevel >= gl && gl >= 1, `the rescue carries a GATE lock (target is faction-held) — gateLevel=${fe.gateLevel} rule=${gl}`); assert.equal(sideQuestAcceptable(fe, gl - 1).locked, true, 'the rescue is LOCKED below the gate level'); assert.equal(sideQuestAcceptable(fe, gl).ok, true, 'the rescue OPENS at the gate level'); + // The gate lock speaks the GATE's own words — naming the system the + // player needs access to FIRST (the transport's ground here, not the + // delivery's friendly destination), in the row's badge, the detail's + // line, and the scene's refusal (data → ui.escortGate*). + assert.equal(postingGateLocked(fe), true, 'the escort’s gate lock is the binding door'); + for (const w of ['badge', 'sub', 'deny']) { + const copy = gateLockCopy(fe, w); + assert.ok(/ACCESS TO APEX VERGE/.test(copy?.text ?? ''), `the escort gate ${w} names the transport's system`, copy?.text); + assert.ok(!/ZENITH VERGE/.test(copy?.text ?? ''), `the escort gate ${w} never names the delivery's own system`, copy?.text); + } + // The badge carries the system; the sub + the refusal add the level that opens it. + for (const w of ['sub', 'deny']) { + const copy = gateLockCopy(fe, w); + assert.ok(new RegExp(`LEVEL ${gl}`).test(copy?.text ?? ''), `the escort gate ${w} names the opening level`, copy?.text); + } + assert.ok(gateLockCopy(fe, 'deny')?.glyph !== '', 'the escort gate refusal carries its own glyph'); } // =========================================================================== diff --git a/js/quests/SideQuests.js b/js/quests/SideQuests.js index c15af81..1288a7a 100644 --- a/js/quests/SideQuests.js +++ b/js/quests/SideQuests.js @@ -1475,6 +1475,69 @@ export function bountyDenyCopy() { return bountyUiLine('bountyAcceptDenyToast', { level: bountyMinLevel() }, 'COMMISSION REFUSED — LEVEL {level} REQUIRED'); } +/** + * The system name the posting's GATE ACCESS lock stands on — the system the + * player needs access to FIRST: the BOUNTY's mark's home (targetSystemName) +or the ESCORT's trapped transport's ground (enemySystemName). Null when the + * posting names none (a neutral posting, a data gap — the copy then falls + * to the level gate's own words). + * @param {object} posting a sideQuestPostingsFor posting + * @returns {string|null} + */ +export function gateLockSystemName(posting) { + const p = posting && typeof posting === 'object' ? posting : {}; + const raw = p.category === 'escort' ? p.enemySystemName : p.targetSystemName; + const name = String(raw ?? '').trim(); + return name !== '' ? name : null; +} + +/** + * Is the posting's binding door the GATE ACCESS lock (its target system sits + * behind a faction gate) rather than the category's own level gate? The + * gate level must stand AND outrun the level gate — then the lock's words + * are the gate's, with the system named (gateLockCopy). + * @param {object} posting a sideQuestPostingsFor posting + * @returns {boolean} + */ +export function postingGateLocked(posting) { + const gl = Math.max(0, Math.floor(Number(posting?.gateLevel) || 0)); + if (gl <= 0) return false; + const minLevel = sideQuestCategoryMeta(posting?.category)?.minLevel ?? 0; + return gl >= minLevel; +} + +/** + * The GATE ACCESS lock's copy — the faction board's hunt / rescue stands + * behind a gate into the hostile neighbor's system, and the player is told + * WHICH system it needs access to before the contract stands open (the + * data's {system} placeholder, resolved against the posting: bounty → + * targetSystemName, escort → enemySystemName; the {level} is the posting's + * gateLevel — the capability that opens the hop). The `which` forms: 'badge' + * (the row's greyed plate + the door's label), 'sub' (the detail's lock + * line), 'deny' (the scene's refusal when a locked contract is pressed). + * A posting with no named system (a data gap) degrades to the level gate's + * own words rather than shipping an empty brace pair. + * @param {object} posting a sideQuestPostingsFor posting + * @param {'badge'|'sub'|'deny'} [which] the form + * @returns {{glyph:string,color:string,text:string}} + */ +export function gateLockCopy(posting, which = 'badge') { + const isEscort = String(posting?.category ?? '') === 'escort'; + const key = { + badge: isEscort ? 'escortGateLockBadge' : 'bountyGateLockBadge', + sub: isEscort ? 'escortGateLockSub' : 'bountyGateLockSub', + deny: isEscort ? 'escortGateDenyToast' : 'bountyGateDenyToast', + }[which] ?? (isEscort ? 'escortGateLockBadge' : 'bountyGateLockBadge'); + const level = + Math.max(0, Math.floor(Number(posting?.gateLevel) || 0)) || + Math.max(0, Math.floor(numOr(config.get('gates.jump.factionLock.minLevel', 5), 5))); + const name = gateLockSystemName(posting); + if (name === null) return which === 'deny' ? bountyDenyCopy() : bountyLockCopy(which === 'sub'); + const fb = + which === 'deny' ? 'COMMISSION REFUSED — ACCESS TO {system} OPENS AT COMBAT LEVEL {level}' : 'LOCKED — ACCESS TO {system} REQUIRED'; + return bountyUiLine(key, { system: String(name).toUpperCase(), level }, fb); +} + /** A hunt line's copy (a leg logged / pickets inbound / the mark sighted / down / complete). */ export function bountyText(key, vars = {}) { return bountyUiLine(key, vars, ''); @@ -1637,6 +1700,12 @@ export function sideQuestPostingsFor(seed, rec, worldKey, galaxy = null, level = const gate = sideQuestAcceptable(posting, level); posting.locked = gate.locked === true; posting.minLevel = gate.minLevel; + // The GATE ACCESS lock's mark (the posting's target system sits behind a + // faction gate and it is the binding door — the board's row / detail + + // the scene's refusal then speak in the gate's words, the system named, + // not the level gate's). + posting.gateLocked = + gate.locked === true && gate.gateLevel > 0 && gate.gateLevel >= gate.minLevel; out.push(posting); } return out; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 444f082..30e7874 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -77,6 +77,7 @@ import { patrolRouteComplete, sideQuestAcceptable, bountyDenyCopy, + gateLockCopy, deriveBountyPlan, bountySkirmishScript, bountyEngagementScript, @@ -5686,19 +5687,27 @@ export class GameScene extends Phaser.Scene { if (!this.sideQuestState) return { ok: false }; const posting = this.sideQuestPostings(worldName, isStation).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. + // The ACCEPT gates: the LEVEL gate (data/sidequests.json → + // categories.bounty.minLevel — the hunt's 2) and the GATE ACCESS gate + // (the posting's gateLevel — its target system behind a faction gate). 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 — the + // level gate's (bountyAcceptDenyToast) or the gate's (the system named — + // *GateDenyToast) — 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(); + // The GATE ACCESS lock (a faction board's hunt / rescue behind a + // hostile neighbor — the posting's gateLevel) speaks the GATE's own + // words, naming the system the player needs access to FIRST (data → + // ui.{bounty,escort}GateDenyToast); the level gate keeps its own. + const gateLocked = gate.gateLevel > 0 && gate.gateLevel >= gate.minLevel; + const deny = gateLocked ? gateLockCopy(posting, 'deny') : bountyDenyCopy(); this.consoleToast(String(deny?.text ?? 'COMMISSION REFUSED'), { glyph: deny?.glyph || '\u2717', glyphColor: toCss(deny?.color || '#ff5c77'), }); - return { ok: false, locked: true, posting }; + return { ok: false, locked: true, gateLocked, posting }; } if (!this.sideQuestState.accept(jobId)) return { ok: false }; // The PATROL's ROUTE stands WITH the accept — the route is the diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index c35d825..07fd128 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -22,6 +22,7 @@ import { CreditsHud } from '../ui/CreditsHud.js'; import { MineralHud } from '../ui/MineralHud.js'; import * as Economy from '../economy/Economy.js'; import { buildDefs } from '../research/ResearchModel.js'; +import { gateLockCopy } from '../quests/SideQuests.js'; /** * The double-click window (ms): two presses this close together during a @@ -1617,13 +1618,17 @@ export class SurfaceScene extends Phaser.Scene { { 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', {}); + // The GATE ACCESS lock's refusal (a faction board's hunt / rescue + // behind a hostile neighbor) names the system the player needs access + // to FIRST (data → ui.{bounty,escort}GateDenyToast — the gate's own + // words, the target system filled); the LEVEL GATE's refusal is the + // generic line, its level off the posting. The game scene's own toast + // lands behind this sleeping one. + const d = res?.gateLocked + ? gateLockCopy(res.posting, 'deny') + : 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)), { + this.consoleNote(String(d.text ?? 'COMMISSION REFUSED').replace(/\{level\}/g, String(level)), { glyph: d.glyph ?? '✗', glyphColor: d.color ? toCss(d.color) : undefined, }); diff --git a/js/ui/JobBoardWindow.js b/js/ui/JobBoardWindow.js index 51be078..acbc415 100644 --- a/js/ui/JobBoardWindow.js +++ b/js/ui/JobBoardWindow.js @@ -51,6 +51,7 @@ import { fontStack, themeColor } from '../utils/Theme.js'; import { ScrambleDecode } from '../utils/Decode.js'; import { playSfxOn } from '../utils/Sfx.js'; import { CyberShape } from './CyberShape.js'; +import { gateLockCopy } from '../quests/SideQuests.js'; const HEADER = fontStack('header'); const BODY = fontStack('body'); @@ -602,11 +603,16 @@ export class JobBoardWindow extends Phaser.GameObjects.Container { let rightTxt; let rightColor; 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); + // The gate reads on the row itself: the LEVEL gate reads LOCKED — + // REQUIRES LEVEL 2 (data → ui.bountyLockBadge); the GATE ACCESS lock + // (the faction boards' hunt / rescue behind a hostile neighbor) names + // the system the player needs access to FIRST (data → + // ui.{bounty,escort}GateLockBadge). The row still SELECTS (the + // dossier is readable before the level lands); the scene's accept + // is the door that refuses. + rightTxt = p.gateLocked === true + ? gateLockCopy(p, 'badge')?.text ?? 'LOCKED' + : fillLevel(String(ui.bountyLockBadge ?? 'LOCKED — REQUIRES LEVEL {level}'), p); rightColor = C.faint; rightSize = '9px'; } else if (state === 'claimed') { @@ -797,15 +803,18 @@ export class JobBoardWindow extends Phaser.GameObjects.Container { 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. + // what the record needs, not what the contract hides — or, under the + // GATE ACCESS lock, the GATE's own words naming the system the player + // needs access to first (data → ui.{bounty,escort}GateLockSub). if (sel.locked === true && !this.isAccepted(sel.id)) { + const gateLine = sel.gateLocked === true ? gateLockCopy(sel, 'sub') : null; 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, - }) + .text( + rightX + 14, + lineY, + gateLine?.text ?? 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); @@ -924,7 +933,12 @@ export class JobBoardWindow extends Phaser.GameObjects.Container { // 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); + // Under the GATE ACCESS lock the door reads the gate's own badge, + // the system named (data → ui.{bounty,escort}GateLockBadge). + label = + sel.gateLocked === true + ? gateLockCopy(sel, 'badge')?.text ?? 'LOCKED' + : fillLevel(String(ui.bountyLockBadge ?? 'LOCKED — REQUIRES LEVEL {level}'), sel); color = 0xff5c77; action = 'accept'; } else if (state === 'open') {