diff --git a/data/gates.json b/data/gates.json index ac086ab..d5a285a 100644 --- a/data/gates.json +++ b/data/gates.json @@ -57,7 +57,13 @@ "toast": "JUMP — {dest}", "dormantToast": "JUMP GATE DORMANT — CHART {system} TO ACTIVATE ITS JUMP GATES", "miningToast": "CANNOT JUMP WHILE MINING", - "combatToast": "CANNOT JUMP WHILE UNDER ATTACK" + "combatToast": "CANNOT JUMP WHILE UNDER ATTACK", + "factionLock": { + "_comment": "FACTION GATE LOCK (the pure rule is js/galaxy/JumpTravel.js → factionGateLocked; the gate comm window shows it — js/ui/CommsPanel.js's gate variant — and GameScene.jumpThroughGate keeps its own flat denial on top): a jump gate that leads FROM a NEUTRAL system (faction null/'neutral') INTO a FACTION-CONTROLLED one (faction = apex/mindweave/zenith, the roster's faction pass — data/factions.json) is LOCKED while the player's combat level (js/combat/XpManager.js) is below minLevel — the window's LINK line is replaced by `message` (rendered verbatim under the panel's uppercase display convention) and REQUEST JUMP is a grayed ghost. At level >= minLevel the lock lifts and the gate reads as ordinary again (LINK ACTIVE/DORMANT). Gates FROM a faction system (any destination — the run is already inside faction space) and neutral→neutral hops are never locked. `enabled` is the feature switch (false = the lock is off); `minLevel` is the level that OPENS the lock (a fresh run starts at 0).", + "enabled": true, + "minLevel": 5, + "message": "Jump Gate is under Faction Control. Status: Locked" + } }, "route": { "_comment": "ROUTE (js/galaxy/Route.js + GameScene): the player's plotted course to a SET DESTINATION (SYSTEM tab of the MAP console). The run persists only the destination (systemId + objectId); the path from the current system is derived (planRoute — the jump network is a spanning tree, so it is the unique route). While a route is active, the compass marks the NEXT STOP in ORANGE (compassColor), replacing that object's ordinary arrow, and shows it even while still UNDISCOVERED (it is the thing to find): in an intermediate system the next stop is that system's jump gate toward the destination (typeLabel 'Route Gate'); in the DESTINATION system the next stop is the destination OBJECT itself (destTypeLabel) — the last hop is across the system to the world, and it stays orange until the ship actually reaches it (GameScene._checkDestinationReached fires reachedToast, then the route clears). The NEXT STOP in this system ALSO wears an in-world orange pulse the moment it is on screen (js/entities/RoutePulse.js — GameScene marks it each frame): the FULL pulse on the route GATE (pulse), the SAME pulse SUBDUED on the destination OBJECT in the destination system (objectPulse). A jump to any other system re-plots from the detour (replotToast). A destination with NO specific object clears the moment its system is entered. setToast fires when the destination is set ({dest}/{hops}). COLOR BY KIND (GameScene.setDestination stores destination.kind, _routeMarkColor resolves it): a CARGO delivery set by the PLAYER — the ship console's cargo manifest or the surface shop's scout (kind 'cargo') — wears the mark in BROWN (cargoColor), so the player's own delivery runs read apart from the story; a STORY-QUEST destination (the quest's own beat — kind 'quest') and the MAP console's manual pick (kind 'manual') keep the route's ORANGE (compassColor). The compass entry (arrow + chip) and the in-world pulse (RoutePulse) both follow the mark's color.", diff --git a/dev/jump-travel.test.mjs b/dev/jump-travel.test.mjs index de343a7..36d4b46 100644 --- a/dev/jump-travel.test.mjs +++ b/dev/jump-travel.test.mjs @@ -9,6 +9,11 @@ * destination's RETURN gate): * - config: data/gates.json → jump (the feature switch, the arrival * geometry, the toasts with their placeholders); + * - the FACTION GATE LOCK (data/gates.json → jump.factionLock, the + * pure rule js/galaxy/JumpTravel.js → factionGateLocked): a gate + * from a NEUTRAL system into a FACTION-CONTROLLED one is locked + * below the lock's minLevel (level 5) — and the REAL galaxy has + * such gates at a fresh run's level, all open at level 5; * - the pure geometry (js/galaxy/JumpTravel.js): the return-gate * lookup (tree-edge jumps have one, one-way shortcut jumps don't), * the spawn point (just past the gate's keepout, on the side the @@ -58,6 +63,17 @@ check('jump: the toast templates keep their placeholders', typeof jump?.miningToast === 'string' && jump.miningToast.length > 0); check('jump: combatToast is the live-fight denial (non-empty string)', typeof jump?.combatToast === 'string' && jump.combatToast.length > 0); + +// The FACTION GATE LOCK (GameScene.openGateCommsPanel → the gate comm +// window's grayed REQUEST JUMP + its message; GameScene.jumpThroughGate +// keeps its own flat denial): the shipped contract — the feature on, +// the level-5 rule, and the message the panel must show, verbatim. +const flock = jump?.factionLock; +check('factionLock: section present', !!flock && typeof flock === 'object'); +check('factionLock: enabled (feature switch, on)', flock?.enabled === true); +check('factionLock: minLevel is the level that OPENS the lock (5)', Number(flock?.minLevel) === 5); +check('factionLock: the message is the panel\'s verbatim copy', + flock?.message === 'Jump Gate is under Faction Control. Status: Locked'); check('jump: the gap keeps the ship outside the keepout with room to spare', Number(jump?.arrivalGap ?? -1) + Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0) > Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0)); @@ -73,7 +89,7 @@ check('landing: combatDenyToast configured (glyph + text + color)', // ---------------------------------------------------------------------- // The pure geometry (js/galaxy/JumpTravel.js) // ---------------------------------------------------------------------- -const { returnGateFor, arrivalPoint, jumpArrival, ambushTriggerDistance } = await import('../js/galaxy/JumpTravel.js'); +const { returnGateFor, arrivalPoint, jumpArrival, ambushTriggerDistance, factionGateLocked } = await import('../js/galaxy/JumpTravel.js'); const contentA = { jumps: [ @@ -129,6 +145,41 @@ check('ambushTriggerDistance: null on a missing point (no gate / no capital)', check('ambushTriggerDistance: null on a zero stretch (gate on the world)', ambushTriggerDistance({ x: 5, y: 5 }, { x: 5, y: 5 }) === null); +// ---------------------------------------------------------------------- +// The FACTION GATE LOCK (js/galaxy/JumpTravel.js → factionGateLocked) — +// a gate FROM a neutral system INTO a faction-controlled one, locked +// while the player's level is below minLevel (data/gates.json → +// jump.factionLock; the gate comm window's grayed REQUEST JUMP + the +// "under Faction Control" message are the visible half). +// ---------------------------------------------------------------------- +const NEUTRAL = { id: 'n', faction: null }; +const NEUTRAL_STR = { id: 'ns', faction: 'neutral' }; +const FACTION = { id: 'f', faction: 'apex' }; +const FACTION2 = { id: 'f2', faction: 'mindweave' }; + +check('factionGateLocked: neutral → faction, below minLevel, is LOCKED', + factionGateLocked({ minLevel: 5, level: 0, from: NEUTRAL, dest: FACTION }) === true && + factionGateLocked({ minLevel: 5, level: 4, from: NEUTRAL, dest: FACTION }) === true); +check('factionGateLocked: at/above minLevel the lock is OPEN (level 5)', + factionGateLocked({ minLevel: 5, level: 5, from: NEUTRAL, dest: FACTION }) === false && + factionGateLocked({ minLevel: 5, level: 80, from: NEUTRAL, dest: FACTION }) === false); +check('factionGateLocked: a missing level counts as 0 (a fresh run finds it locked)', + factionGateLocked({ minLevel: 5, level: null, from: NEUTRAL, dest: FACTION }) === true && + factionGateLocked({ minLevel: 5, level: undefined, from: NEUTRAL, dest: FACTION }) === true); +check('factionGateLocked: the neutral→neutral hop is never locked', + factionGateLocked({ minLevel: 5, level: 0, from: NEUTRAL, dest: NEUTRAL }) === false && + factionGateLocked({ minLevel: 5, level: 0, from: NEUTRAL_STR, dest: NEUTRAL }) === false); +check('factionGateLocked: gates FROM a faction system are never locked (either way)', + factionGateLocked({ minLevel: 5, level: 0, from: FACTION, dest: NEUTRAL }) === false && + factionGateLocked({ minLevel: 5, level: 0, from: FACTION, dest: FACTION2 }) === false); +check('factionGateLocked: an unknown destination is not faction-controlled (never locked)', + factionGateLocked({ minLevel: 5, level: 0, from: NEUTRAL, dest: null }) === false); +check('factionGateLocked: enabled:false lifts the lock entirely', + factionGateLocked({ enabled: false, minLevel: 5, level: 0, from: NEUTRAL, dest: FACTION }) === false); +check('factionGateLocked: a custom minLevel rules (10: level 5 is still locked, 10 open)', + factionGateLocked({ minLevel: 10, level: 5, from: NEUTRAL, dest: FACTION }) === true && + factionGateLocked({ minLevel: 10, level: 10, from: NEUTRAL, dest: FACTION }) === false); + // ---------------------------------------------------------------------- // A REAL galaxy (bootstrap config the way main.js does, from the data dir) // ---------------------------------------------------------------------- @@ -172,7 +223,32 @@ for (const rec of g.records) { } } check('galaxy: every gate\'s destination is a known system (no dangling links)', badFb === 0); -check(`galaxy: ${total} gates, ${withReturn} land at a return gate, the rest are one-way (origin fallback)`, + +// The FACTION GATE LOCK against this galaxy: at a fresh run's level (0) +// the neutral→faction gates stand LOCKED (the panel's grayed REQUEST +// JUMP + its message); at level 5 every lock is open — and the galaxy +// actually holds such gates (the lock has a target). +const lockedAt = (level) => { + let n = 0; + for (const rec of g.records) { + for (const j of g.ensureContent(rec.id).jumps ?? []) { + const dest = g.byId.get(j.to) ?? null; + if (factionGateLocked({ + enabled: flock.enabled !== false, + minLevel: Number(flock.minLevel) || 5, + level, + from: rec, + dest, + })) n += 1; + } + } + return n; +}; +const lockedFresh = lockedAt(0); +check(`galaxy: ${lockedFresh} neutral→faction gates stand at a fresh run's level (the lock has a target)`, + lockedFresh > 0); +check('galaxy: level 5 lifts every faction-gate lock', lockedFresh > 0 && lockedAt(5) === 0); +check(`galaxy: ${withReturn} gates land at a return gate, the rest are one-way (origin fallback)`, withReturn > 0 && total - withReturn >= 0); check('galaxy: every landing is past the keepout and inside the gate tether', badLanding === 0); diff --git a/js/galaxy/JumpTravel.js b/js/galaxy/JumpTravel.js index 9e41213..00419c5 100644 --- a/js/galaxy/JumpTravel.js +++ b/js/galaxy/JumpTravel.js @@ -24,6 +24,11 @@ * record). The scene falls back to the destination's origin (its * home-tether zone) when this is null. * + * factionGateLocked(o) + * The FACTION GATE LOCK (data/gates.json → jump.factionLock): a + * gate FROM a neutral system INTO a faction-controlled one, locked + * while the player's level is below minLevel. + * * GameScene.jumpThroughGate supplies the live numbers (the gate's * radius/clearance, the ship's radius, data/gates.json → jump) and * does the actual transport (re-stage the run for the destination @@ -105,3 +110,46 @@ export function ambushTriggerDistance(gate, world) { const d = Math.hypot(gx - wx, gy - wy); return d > 0 ? d / 2 : null; } + +/** + * The FACTION GATE LOCK (data/gates.json → jump.factionLock): a jump + * gate FROM a NEUTRAL system INTO a FACTION-CONTROLLED one is locked + * while the player's level is below `minLevel`. + * + * - A system is NEUTRAL when its record's `faction` is null, '' or the + * string 'neutral' (the roster defaults to null — neutral wild space); + * any other `faction` (apex/mindweave/zenith, data/factions.json) is + * FACTION-CONTROLLED. + * - Gates FROM a faction system are never locked (the run is already + * inside faction space — leaving it, or moving within it, stays open), + * and neutral→neutral hops are never locked. + * - A missing level (null — no XP bank yet) counts as level 0, so a + * fresh run finds the gates locked. + * - A missing destination (unknown id) is not faction-controlled, so it + * is never locked by this rule (the scene's own "unknown destination" + * denial stands on its own). + * + * Pure (no Phaser, no config import) — the caller resolves the config + * section and the system records and passes them in (Node-testable in + * dev/jump-travel.test.mjs). + * + * @param {object} o { enabled (the feature switch, default true), + * minLevel (the level that OPENS the lock, default 5), level (the + * player's combat level — null/undefined = 0), from (the current + * system's record), dest (the gate's DESTINATION system's record — + * null when unknown) } — a system record only contributes `faction` + * @returns {boolean} true while the gate's jump is blocked + */ +export function factionGateLocked(o = {}) { + if (o.enabled === false) return false; // the feature switch + const minLevel = Number.isFinite(Number(o.minLevel)) ? Number(o.minLevel) : 5; + const level = Number.isFinite(Number(o.level)) ? Number(o.level) : 0; + if (level >= minLevel) return false; // the lock is open at/above minLevel + const neutral = (rec) => { + const f = rec?.faction; + return f == null || f === '' || f === 'neutral'; + }; + if (!neutral(o.from)) return false; // already inside faction space + if (o.dest == null) return false; // an unknown destination is not faction-controlled + return !neutral(o.dest); +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index b66b92c..f69dc89 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -18,7 +18,7 @@ import { Planet } from '../entities/Planet.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { Station } from '../entities/Station.js'; import { JumpGate } from '../entities/JumpGate.js'; -import { jumpArrival, returnGateFor, ambushTriggerDistance } from '../galaxy/JumpTravel.js'; +import { jumpArrival, returnGateFor, ambushTriggerDistance, factionGateLocked } from '../galaxy/JumpTravel.js'; import { Starfield } from '../visuals/Starfield.js'; import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js'; import { NebulaLightning } from '../visuals/NebulaLightning.js'; @@ -1889,11 +1889,14 @@ export class GameScene extends Phaser.Scene { // A JUMP GATE under the cursor (solid — the ship can't pass // through it): the click opens the GATE COMM WINDOW - // (openGateCommsPanel) — the LINK line reads ACTIVE / DORMANT, - // REQUEST JUMP initiates the transport (GameScene.jumpThroughGate, + // (openGateCommsPanel) — the LINK line reads ACTIVE / DORMANT (or + // the FACTION LOCK's message — the gate is from a neutral system + // into a faction-controlled one and the player is below the lock's + // minLevel — data/gates.json → jump.factionLock), REQUEST JUMP + // initiates the transport (GameScene.jumpThroughGate, // data/gates.json → jump; a grayed ghost while the gate is - // dormant) and CANCEL closes the window. Like a planet click it - // is NOT a fly-here — the ship stays put. + // dormant or locked) and CANCEL closes the window. Like a planet + // click it is NOT a fly-here — the ship stays put. const gateObj = this.gateAt(pointer.worldX, pointer.worldY); if (gateObj) { this.hideHint(); @@ -3198,6 +3201,20 @@ export class GameScene extends Phaser.Scene { ); return; } + // FACTION GATE LOCK (data/gates.json → jump.factionLock) — the gate + // comm window's grayed REQUEST JUMP + the "under Faction Control" + // message are the visible half; this is the scene's own flat denial + // on top (the same shape as the mining/combat guards above): a gate + // from a neutral system into a faction-controlled one, below the + // lock's minLevel. + if (this.factionGateLockedFor(gt)) { + const t = config.get('gates.jump.factionLock', {}); + this.consoleToast( + String(t.message ?? 'JUMP GATE IS UNDER FACTION CONTROL. STATUS: LOCKED'), + { glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) }, + ); + return; + } // Where we arrive: just past the return gate's keepout (JumpTravel). // Fallback — no return gate (a one-way shortcut, shortcuts are OFF in // the current config): the destination's origin (its center is empty — @@ -3487,23 +3504,62 @@ export class GameScene extends Phaser.Scene { * A JUMP GATE was clicked: the comm window opens at the click — the * name decodes in, the LINK line reads ACTIVE / DORMANT, and the * buttons wait (REQUEST JUMP is a grayed ghost while the gate is - * dormant or gates.jump.enabled is off). Same contract as a planet - * click: NOT a fly-here (the ship stays put), world-anchored at the - * click, side-picked to stay on screen (CommsPanel.pickSideAndPlace). + * dormant or gates.jump.enabled is off). The FACTION GATE LOCK + * (data/gates.json → jump.factionLock) replaces the LINK line with its + * own message ("Jump Gate is under Faction Control. Status: Locked") + * and grays REQUEST JUMP too — a gate from a NEUTRAL system into a + * FACTION-CONTROLLED one, below the player's level 5. Same contract + * as a planet click: NOT a fly-here (the ship stays put), + * world-anchored at the click, side-picked to stay on screen + * (CommsPanel.pickSideAndPlace). */ openGateCommsPanel(gt, pointer) { + const fl = config.get('gates.jump.factionLock', {}); + const locked = this.factionGateLockedFor(gt); this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, { name: gt.discoveryName, kindLabel: String(config.get('gates.typeLabel', 'Jump Gate')), gate: { active: gt.active, jumpEnabled: config.get('gates.jump.enabled', true) !== false, + // FACTION GATE LOCK — the panel's gate variant shows `message` + // (the LINK line's place) + the grayed REQUEST JUMP; the scene's + // own flat denial sits in jumpThroughGate (defense in depth). + locked, + lockMessage: String( + fl.message ?? 'Jump Gate is under Faction Control. Status: Locked', + ), // The JumpGate entity — commsAction hands it to jumpThroughGate. entity: gt, }, }); } + /** + * The FACTION GATE LOCK (data/gates.json → jump.factionLock; the pure + * rule in js/galaxy/JumpTravel.js → factionGateLocked): true while + * this gate's jump is blocked — a gate FROM a NEUTRAL system INTO a + * FACTION-CONTROLLED one, while the player's combat level + * (js/combat/XpManager.js) is below the lock's minLevel. The gate + * comm window's grayed REQUEST JUMP + the "under Faction Control" + * message are the visible half; jumpThroughGate's flat denial is the + * scene's own guard on top (the same shape as its mining/combat + * guards). + * @param {object} gt the gate entity (JumpGate — .gate record) + */ + factionGateLockedFor(gt) { + const cfg = config.get('gates.jump.factionLock', {}); + if (cfg.enabled === false) return false; // the feature switch + const destId = gt?.gate?.to; + return factionGateLocked({ + enabled: true, + minLevel: Number(cfg.minLevel) || 5, + level: this.combatLevel(), + from: this.systemRecord ?? null, + dest: destId != null && this.galaxy ? (this.galaxy.byId.get(destId) ?? null) : null, + }); + } + /** * The panel's button presses (CommsPanel → onAction). The panel is * already closed by the time this runs: diff --git a/js/ui/CommsPanel.js b/js/ui/CommsPanel.js index 142d6dc..e84a2cb 100644 --- a/js/ui/CommsPanel.js +++ b/js/ui/CommsPanel.js @@ -196,6 +196,18 @@ class TermButton extends Phaser.GameObjects.Container { * the gate is dormant, or when gates.jump.enabled is off). CANCEL is * the same in both variants. * + * FACTION LOCK (the scene's gate.locked — a gate from a NEUTRAL system + * into a FACTION-CONTROLLED one, while the player is below the lock's + * minLevel; data/gates.json → jump.factionLock): the status line's + * slot instead holds the lock's own message, in two phosphor lines — + * + * JUMP GATE IS UNDER FACTION CONTROL. (amber, verbatim data copy) + * STATUS: LOCKED (red) + * + * — the case widens to fit the wider line, and REQUEST JUMP is a + * grayed ghost (the scene's jumpThroughGate keeps a flat denial on + * top). CANCEL is the same in both. + * * WORLD-ANCHORED (like the mining menu): the panel lives at the click * point in the world and stays with it as the camera trails. It opens * on the side (up/down/left/right) that keeps it fully on screen — the @@ -215,7 +227,7 @@ class TermButton extends Phaser.GameObjects.Container { * }); * // …or the gate variant (the two are mutually exclusive): * cp.open(wx, wy, screenX, screenY, { - * name, kindLabel, gate: { active, jumpEnabled }, + * name, kindLabel, gate: { active, jumpEnabled, locked?, lockMessage? }, * }); * cp.close(); cp.contains(wx, wy); cp.isOpen; cp.update(time); cp.destroy(); */ @@ -234,7 +246,10 @@ export class CommsPanel extends Phaser.GameObjects.Container { // ---- Geometry (px). The case frame wraps a phosphor screen; the // settled and unsettled layouts differ only in the reputation block. - this.W = 312; + // The FACTION LOCK variant widens the case to fit its two message + // lines (open() → _setLockLines); the buttons keep W_BASE's width. + this.W_BASE = 312; + this.W = this.W_BASE; // the lock variant widens (open()) this.framePad = 20; // the metal case's border this.padTop = 14; this.padBottom = 14; @@ -246,6 +261,7 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.marksH = 14; this.gapBtns = 12; // reputation block → buttons this.gapOpen = 14; // name block → buttons (unsettled) + this.lockGap = 4; // the lock message's two lines (the faction lock) this.btnH = 30; this.btnGap = 8; this.btnW = this.W - this.framePad * 2 - 28; // content width (14 px screen padding each side) @@ -264,6 +280,9 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.framePad * 2 + this.padTop + this.nameH + this.kindH + this.gapSettled + this.repLabelH + this.gapBtns + this.btnH + this.btnGap + this.btnH + this.padBottom; + // The gate + FACTION LOCK variant: the LINK line's slot becomes the + // lock's two message lines (one more line than H_GATE). + this.H_GATE_LOCKED = this.H_GATE + this.repLabelH + this.lockGap; this.H_OPEN = this.framePad * 2 + this.padTop + this.nameH + this.kindH + this.gapOpen + this.btnH + this.btnGap + this.btnH + this.padBottom; @@ -297,6 +316,28 @@ export class CommsPanel extends Phaser.GameObjects.Container { letterSpacing: 1, }) .setOrigin(1, 0.5); + // The FACTION LOCK's message lines (the gate variant's LINK slot — + // data/gates.json → jump.factionLock.message, split at "Status:"): + // the message in the case's warning amber, the status clause in the + // panel's red. Hidden except in the locked gate variant. + this.lockLine1 = scene.add + .text(nameX, 0, '', { + fontFamily: FONT, + fontSize: '10px', + color: toCss(AMBER), + letterSpacing: 2, + }) + .setOrigin(0, 0.5); + this.lockLine2 = scene.add + .text(nameX, 0, '', { + fontFamily: FONT, + fontSize: '10px', + color: toCss(MARK_RED), + letterSpacing: 2, + }) + .setOrigin(0, 0.5); + this.lockLine1.setVisible(false); + this.lockLine2.setVisible(false); // btn1's action id is DYNAMIC — the world variants read // REQUEST LANDING / LAND, the gate variant reads REQUEST JUMP and // must fire 'request-jump' (open() switches this). @@ -315,6 +356,8 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.kindText, this.repLabel, this.repValue, + this.lockLine1, + this.lockLine2, this.btn1, this.btn2, this.flickG, @@ -333,6 +376,8 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.isGate = false; this.gateActive = false; this.gateJumpEnabled = true; + this.gateLocked = false; + this.gateLockText = ''; this.H = this.H_OPEN; this.layout(); this.drawFrame(); @@ -350,7 +395,9 @@ export class CommsPanel extends Phaser.GameObjects.Container { * @param {number} wx, wy — the click, WORLD coords (the panel anchors here) * @param {number} sx, sy — the same click in SCREEN coords (decides the side) * @param {object} o — { name, settled, reputation, litMarks?, canLand?, key?, kindLabel?, - * gate?: { active, jumpEnabled } } (gate = the gate variant) + * gate?: { active, jumpEnabled, locked?, lockMessage? } } + * (gate = the gate variant; locked + lockMessage + * = the faction gate lock) */ open(wx, wy, sx, sy, o = {}) { if (this.scene === null || this.active === false) return; @@ -392,23 +439,44 @@ export class CommsPanel extends Phaser.GameObjects.Container { // primary button's action id (the gate variant switches it). this.repValue.setColor(toCss(GRN_MID)); this.btn1Id = 'request-landing'; + this.gateLocked = false; // a world opening starts with the lock lifted if (this.isGate) { // JUMP GATE — one LINK status line (ACTIVE lit / DORMANT amber) in // place of the reputation block, and the primary button is the // jump itself (a grayed ghost while the gate is dormant, or with // gates.jump.enabled off). CANCEL is the same in both. + // + // FACTION GATE LOCK (the scene's gate.locked — a gate from a + // NEUTRAL system into a FACTION-CONTROLLED one, below the + // player's minLevel): the LINK line is replaced by the lock's own + // message (data/gates.json → jump.factionLock.message, verbatim + // under the panel's uppercase convention) and REQUEST JUMP is a + // grayed ghost too (the scene's jumpThroughGate keeps a flat + // denial on top). this.repRevealT0 = null; this.repFinalLit = 0; this.marksG.setVisible(false); - this.repLabel.setVisible(true); - this.repValue.setVisible(true); - this.repLabel.setText('LINK'); - this.repValue.setText(this.gateActive ? 'ACTIVE' : 'DORMANT'); - this.repValue.setColor(toCss(this.gateActive ? GRN_BRIGHT : AMBER)); this.btn1Id = 'request-jump'; this.btn1.setLabel('REQUEST JUMP'); - this.btn1.setDisabled(!this.gateActive || !this.gateJumpEnabled); + this.gateLocked = o.gate.locked === true; + this.gateLockText = String(o.gate.lockMessage ?? ''); + this.W = this.W_BASE; // the lock variant may widen (below) + if (this.gateLocked) { + this.repLabel.setVisible(false); + this.repValue.setVisible(false); + this._setLockLines(); + this.btn1.setDisabled(true); + } else { + this.lockLine1.setVisible(false); + this.lockLine2.setVisible(false); + this.repLabel.setVisible(true); + this.repValue.setVisible(true); + this.repLabel.setText('LINK'); + this.repValue.setText(this.gateActive ? 'ACTIVE' : 'DORMANT'); + this.repValue.setColor(toCss(this.gateActive ? GRN_BRIGHT : AMBER)); + this.btn1.setDisabled(!this.gateActive || !this.gateJumpEnabled); + } } else if (this.settled) { // …then the bar draws itself to the standing (update() drives it). const repMin = config.get('reputation.min', -20); @@ -420,6 +488,8 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.repLabel.setVisible(true); this.repValue.setVisible(true); this.repValue.setText(this.rep > 0 ? `+${this.rep}` : String(this.rep)); + this.lockLine1.setVisible(false); + this.lockLine2.setVisible(false); this.marksG.setVisible(true); this.btn1.setLabel('REQUEST LANDING'); this.btn1.setDisabled(!this.canLand); @@ -429,6 +499,9 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.repLabel.setVisible(false); this.repValue.setVisible(false); this.marksG.setVisible(false); + this.gateLocked = false; // a world variant (a stale lock line is gone) + this.lockLine1.setVisible(false); + this.lockLine2.setVisible(false); this.btn1.setLabel('LAND'); this.btn1.setDisabled(false); } @@ -436,7 +509,7 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.btn2.setDisabled(false); this.H = this.isGate - ? this.H_GATE + ? (this.gateLocked ? this.H_GATE_LOCKED : this.H_GATE) : this.settled ? this.H_SETTLED : this.H_OPEN; @@ -462,6 +535,33 @@ export class CommsPanel extends Phaser.GameObjects.Container { }); } + /** + * Split the FACTION LOCK's message (data/gates.json → + * jump.factionLock.message — "Jump Gate is under Faction Control. + * Status: Locked") into the panel's two phosphor lines (the message + * up to the "Status:" clause, then the status clause), and WIDEN THE + * CASE to fit the wider line (the buttons keep W_BASE's width). + */ + _setLockLines() { + const msg = String( + this.gateLockText || 'Jump Gate is under Faction Control. Status: Locked', + ).trim(); + const m = /Status:/i.exec(msg); + const l1 = m && m.index > 0 ? msg.slice(0, m.index).trim() : msg; + const l2 = m && m.index > 0 ? msg.slice(m.index).trim() : ''; + this.lockLine1.setText(l1.toUpperCase()); + this.lockLine1.setVisible(l1.length > 0); + this.lockLine2.setText(l2.toUpperCase()); + this.lockLine2.setVisible(l2.length > 0); + // The case widens to the wider line (content = line + case + screen + // padding + a little breathing room). + const widest = Math.max(this.lockLine1.width, this.lockLine2.width); + this.W = Math.max( + this.W_BASE, + Math.ceil(widest) + this.framePad * 2 + this.innerPad * 2 + 12, + ); + } + /** Close (any click outside, a button press, or ESC). */ close() { if (this.state !== 'open') return; @@ -544,7 +644,16 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.kindText.setPosition(lx, st + this.padTop + this.nameH + this.kindH / 2); let next = st + this.padTop + this.nameH + this.kindH; - if (this.settled || this.isGate) { + if (this.isGate && this.gateLocked) { + // The FACTION LOCK's message — two phosphor lines where the LINK + // line sits (data/gates.json → jump.factionLock.message). + const blockH = this.repLabelH * 2 + this.lockGap; + const midY = next + this.gapSettled + blockH / 2; + this.lockLine1.setPosition(lx, midY - this.repLabelH / 2 - this.lockGap / 2); + this.lockLine2.setPosition(lx, midY + this.repLabelH / 2 + this.lockGap / 2); + this.marksY = -1e9; + next = midY + blockH / 2 + this.gapBtns; + } else if (this.settled || this.isGate) { // The label line — REPUTATION (world) or LINK (gate). const labelY = next + this.gapSettled + this.repLabelH / 2; this.repLabel.setPosition(lx, labelY); @@ -780,6 +889,8 @@ export class CommsPanel extends Phaser.GameObjects.Container { this.kindText?.destroy(); this.repLabel?.destroy(); this.repValue?.destroy(); + this.lockLine1?.destroy(); + this.lockLine2?.destroy(); this.btn1?.destroy(); this.btn2?.destroy(); super.destroy();