Add faction gate lock blocking neutral→faction jumps below level 5

- New `factionGateLocked` pure rule in JumpTravel.js: a gate from a neutral system into a faction-controlled one is locked while the player's combat level is below `minLevel`; gates from faction space and neutral→neutral hops are never locked
- Config section `gates.jump.factionLock` (enabled switch, minLevel 5, panel message) in data/gates.json
- Gate comm window shows the lock message in place of the LINK line and grays out REQUEST JUMP; case widens to fit the two-line message
- `jumpThroughGate` adds a flat denial toast on top as defense in depth
- Tests cover the rule's branches (neutral/faction/unknown dest, missing level, enabled switch, custom minLevel) and assert the shipped galaxy has locked gates at level 0 that all open at level 5
This commit is contained in:
Brian Fertig 2026-09-21 18:56:31 -06:00
parent 5e799bbd5a
commit 78593b1ea2
5 changed files with 319 additions and 22 deletions

View File

@ -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.",

View File

@ -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);

View File

@ -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);
}

View File

@ -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:

View File

@ -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();