Compare commits

..

No commits in common. "3137a800bf85ad71e5ebeed12f7d7adf47e4fb2c" and "a8246bb594af67f0e1d54b544e20b720c96f05bc" have entirely different histories.

31 changed files with 278 additions and 1643 deletions

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -113,7 +113,7 @@
"minObjectSpacing": 1024,
"minRadius": 2048,
"maxRadius": 18432,
"tetherMargin": 512,
"tetherMargin": 96,
"maxPlacementAttempts": 400
}
}

View File

@ -1,5 +1,5 @@
{
"$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open); 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's NAV chart COMPLETES — its last planet/space station/jump-gate discovery (GameScene.celebrateDiscovery), which is exactly when its 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js) — NOT on the research's completion; once per system (once:false re-arms per system; the scene's arming set keeps it a single fire, so later cluster discoveries in a charted system don't re-arm it). 'questNew' — oac-quest-new: the quest-grant voice (GameScene._grantUnlockedQuests — the starter on the first boot, 'Leaving the Nest' on the starter's claim, every future unlock; once:false re-arms per grant); 'trestleBrief' — trestle-brief-1 → trestle-brief-2 → trestle-brief-3: the 'Leaving the Nest' quest's three back-to-back ACK briefs — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — the player can now jump out of the nest; also the quest's grant, and scene create for loaded saves whose gates are already online — GameScene._trestleBeats). The hub's FIFO plays the sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed); ALL THREE in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Talk to Trestle' witness (GameScene._questCheckEval), and a quit mid-sequence re-arms only the unplayed beats on the next load; 'trestleArrival' — trestle-arrive: the 'Leaving the Nest' quest's arrival in a home-neighbor system (GameScene._trestleBeats on scene create; it in the played-ledger is the quest's 'Meet Trestle' witness). Reserved for future comms: researchNew, questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.",
"$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open); 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's 'Unlock Jumpgates' research is unlocked (GameScene._applyResearchEffects, the activateGates effect) — that research is only researchable once EVERY NAV point of the system is discovered (the SYSTEM tree's available hook), so the full chart and the jumpgate unlock land together, once per system (once:false re-arms per system). It arms BEFORE oac-research-complete on the same completion, so the FIFO plays the specific milestone first. Reserved for future comms: researchNew, questNew, questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.",
"enabled": true,
"audio": {
"$comment": "The comm's voice plays at its OWN volume (volume) — the manager's master is left untouched (in v4 `sound.setVolume` IS the master, so ducking it would quiet the voice too). While the voice talks, the other playing sounds (the game music) are ducked PER-SOUND by musicDuck (a fraction of each one's own volume) and restored exactly when the voice ends.",
@ -42,16 +42,6 @@
"audio": "assets/speech/oac/intro-02.mp3",
"once": true
},
{
"$comment": "The quest-grant voice — the run's NEW QUEST beat (trigger 'questNew'). once:false: it re-arms on EVERY quest grant — the starter on the first boot, 'Leaving the Nest' (data/quests.json → leaving_the_nest) on the starter's claim, and future unlocks — a fresh voice each time, no box (voice only; the NEW QUEST toast + the dossier already show the quest). Armed by GameScene._grantUnlockedQuests (the grant itself — starter boot + every unlock).",
"id": "oac-quest-new",
"from": "oac",
"type": "audio",
"trigger": "questNew",
"audio": "assets/speech/oac/quest-new.mp3",
"once": false,
"delayMs": 1800
},
{
"$comment": "Welcome beat three (the first instruction): scan the tether region. The player clicks SCAN on the deck after acknowledging this — oac-upgrade (firstScan) is the reaction to that click.",
"id": "oac-scan",
@ -115,7 +105,7 @@
"delayMs": 1500
},
{
"$comment": "The system-complete milestone (trigger 'systemComplete' — GameScene.celebrateDiscovery: the moment the system's NAV chart COMPLETES — its last planet / space station / jump-gate discovery — which is exactly when the 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js — the node flips live in the research console); NOT on the research's completion). One fire per system: once:false just lets the NEXT system re-arm it, and the scene's _systemCompleteSeen set keeps the fire to the chart-completing discovery (a later cluster discovery in a charted system still passes isSystemNavComplete() — clusters aren't NAV points). type 'visual': OAC's box READS the milestone line and the voice rides with it, no pause — a passing sign-off, held by the busy() contract while the research console (or any modal/action) is open. delayMs beats the discovery chime (1.5 s, sfx.discovery). audioPool: the voice rolls ONE of the three discovery-complete clips at random per arm (the hub's deferred load fetches only the chosen file).",
"$comment": "The system-complete milestone (trigger 'systemComplete' — GameScene._applyResearchEffects, the activateGates effect: the moment the system's 'Unlock Jumpgates' research is unlocked). That research was only researchable while EVERY NAV point of the system — planets, space stations, jump gates — was discovered (the SYSTEM tree's available hook, js/research/SystemCategory.js), so the chart-complete + jumpgate-unlock conditions hold together, and the node unlocks permanently — the comm plays once per system (once:false just lets the NEXT system re-arm it). type 'visual': OAC's box READS the milestone line and the voice rides with it, no pause — a passing sign-off, held by the busy() contract until the research console (or any modal/action) clears. Arming happens BEFORE the generic 'researchComplete' sign-off on the same completion, so the FIFO plays specific → generic. delayMs beats the completion chime (1.5 s, sfx.discovery). audioPool: the voice rolls ONE of the three discovery-complete clips at random per arm (the hub's deferred load fetches only the chosen file).",
"id": "oac-system-complete",
"from": "oac",
"type": "visual",
@ -183,53 +173,11 @@
"audio": "assets/speech/trestle/intro-02.mp3",
"once": true,
"delayMs": 0
},
{
"$comment": "The 'Leaving the Nest' quest's BRIEFING, beat 1 of 3 (trigger 'trestleBrief') — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — i.e. the moment the player can first jump OUT of the nest), or already-online when the quest is granted / a save loads (GameScene._trestleBeats). It lands in FRONT of the generic 'researchComplete' sign-off _completeResearch queues after the effects — the hub's FIFO plays this sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed), then OAC's sign-off. The quest's 'Talk to Trestle' witness is the SEQUENCE: all three briefs in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) complete the check (GameScene._questCheckEval); a quit mid-sequence re-arms only the unplayed beats on the next load.",
"id": "trestle-brief-1",
"from": "trestle",
"type": "ack",
"trigger": "trestleBrief",
"text": "Hey Kid. You've discovered all the important objects in the system. That means Planets, Space Stations and Jump gates. As you move around the galaxy, you can always find out how many objects a system has in the map view on your console.",
"audio": "assets/speech/trestle/q2-01.mp3",
"once": true,
"delayMs": 1500
},
{
"$comment": "Briefing beat 2 of 3 (trigger 'trestleBrief') — queued right behind beat 1: the hub's FIFO plays it the moment beat 1 is acknowledged (delayMs 0). He sets up the arc that follows (moving cargo, a friend named Carol who has something to deliver).",
"id": "trestle-brief-2",
"from": "trestle",
"type": "ack",
"trigger": "trestleBrief",
"text": "Listen, I could use your help. Nothing major, but I think it's time you learned how to move cargo. I have a friend here named Carol who has something we need to get delivered pronto.",
"audio": "assets/speech/trestle/q2-02.mp3",
"once": true,
"delayMs": 0
},
{
"$comment": "Briefing beat 3 of 3 (trigger 'trestleBrief') — the last of the sequence: the rendezvous (Trestle is in the home-NEIGHBOR system — 'the neighboring system' of the quest's final check) and the way to find it (the orange markers on the compass/map). With all three heard, the quest's 'Talk to Trestle' check is complete (the played-ledger witness above).",
"id": "trestle-brief-3",
"from": "trestle",
"type": "ack",
"trigger": "trestleBrief",
"text": "I'm in the system right next to you in this star cluster's capital. I'm sending the coordinates to your computer right now. Just follow the orange markers on your compass or map and we'll talk more when you get here.",
"audio": "assets/speech/trestle/q2-03.mp3",
"once": true,
"delayMs": 0
},
{
"$comment": "The 'Leaving the Nest' quest's ARRIVAL (trigger 'trestleArrival') — Trestle's side of the gate: plays when the player LANDS in a home-neighbor system (the jump destination; a loaded save parked there too) — the payoff of his briefing's last line ('we'll talk more when you get here'). Text-only (his voice for this beat isn't recorded yet — his recorded lines are the two starter clips + the q2-01/02/03 briefs). It in the run's comms played-ledger is the quest's 'Meet Trestle in the neighboring system' witness (GameScene._questCheckEval). Armed by GameScene._trestleBeats on scene create — AFTER the boot comms, so the jump-in landing doesn't lose it to the queue race.",
"id": "trestle-arrive",
"from": "trestle",
"type": "ack",
"trigger": "trestleArrival",
"text": "There he is. You left the nest, kid… not bad for a first jump. Out here the galaxy gets a lot bigger — and a lot stranger. You're on your own now. Well… not entirely. I'll be around. Go make something of it, kid.",
"once": true,
"delayMs": 1500
}
],
"reservedTriggers": [
"researchNew",
"questNew",
"questComplete",
"discovery",
"landing",

View File

@ -1,5 +1,5 @@
{
"_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways); `shortcuts` is OFF — no mesh, no one-way links, no closed loops: the tree has EXACTLY ONE route between any two systems, so the galaxy reads as a MAZE of dead ends and long hauls. Strong connectivity still holds by construction (a bidirected tree — from any star you can reach any other, no closed systems, no trapped sets), and every jump is to a nearby star with a RETURN gate (the destination's gate pointing back — the player can always jump back the way they came). No system ever holds more than maxGates gates. A BARREN system (objectCount → 0, no planets/stations) is a DEAD-END LEAF of the tree — exactly one gate, in and out the same way (JumpNetwork takes the barren set from the per-system composition roll and never lets a barren node adopt children; the repair pass prefers non-barren attach targets). PLACEMENT (anchored systems): each gate sits EXACTLY one of the radius steps spanning `anchorMinDistance`..`anchorMaxDistance` (default 3500–4608 px) — CAPPED by the level-`anchorTetherLevel` tether range — from an ANCHOR — a PLANET, or the home world in the starting system. The band keeps the gate CLEARLY inside the level-1 tether of data/tether.json — ≤ 5120 − edgeMargin (512 px rim margin, so no gate sits at the edge of a tether region), hence STRICTLY < 5000 px from the anchor (Rule 1); a per-gate preferred step (a seeded fork) makes the distance VARY between gates — clearance/gap pressure only ever pushes a gate outward from its preference; all hard by construction. Free-space stations are deliberately NOT anchors: the build console lives on a WORLD, so every gate must be tether-reachable from a planet the player can build and expand out from (the composition roll demotes a station to a planet when a system's budget would otherwise run out, guaranteeing the anchor exists). Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the single gate sits ON the ray from the center toward its destination, `barrenDistance` px out (stepped outward within the minRadius..maxRadius band if the gap forces it), facing its destination; its payload is 1–2 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
"_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways); `shortcuts` is OFF — no mesh, no one-way links, no closed loops: the tree has EXACTLY ONE route between any two systems, so the galaxy reads as a MAZE of dead ends and long hauls. Strong connectivity still holds by construction (a bidirected tree — from any star you can reach any other, no closed systems, no trapped sets), and every jump is to a nearby star with a RETURN gate (the destination's gate pointing back — the player can always jump back the way they came). No system ever holds more than maxGates gates. A BARREN system (objectCount → 0, no planets/stations) is a DEAD-END LEAF of the tree — exactly one gate, in and out the same way (JumpNetwork takes the barren set from the per-system composition roll and never lets a barren node adopt children; the repair pass prefers non-barren attach targets). PLACEMENT (anchored systems): each gate sits EXACTLY one of the radius steps spanning `anchorMinDistance`..`anchorMaxDistance` (default 3500–4990 px) — CAPPED by the level-`anchorTetherLevel` tether range — from an ANCHOR — a PLANET, or the home world in the starting system. The band keeps the gate inside the level-1 tether of data/tether.json (5120 px) and STRICTLY < 5000 px from the anchor (Rule 1); a per-gate preferred step (a seeded fork) makes the distance VARY between gates — clearance/gap pressure only ever pushes a gate outward from its preference; all hard by construction. Free-space stations are deliberately NOT anchors: the build console lives on a WORLD, so every gate must be tether-reachable from a planet the player can build and expand out from (the composition roll demotes a station to a planet when a system's budget would otherwise run out, guaranteeing the anchor exists). Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the single gate sits ON the ray from the center toward its destination, `barrenDistance` px out (stepped outward within the minRadius..maxRadius band if the gap forces it), facing its destination; its payload is 1–2 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
"enabled": true,
"minGates": 1,
"maxGates": 3,
@ -16,7 +16,7 @@
"maxRadius": 20480,
"anchorTetherLevel": 1,
"anchorMinDistance": 3500,
"anchorMaxDistance": 4608,
"anchorMaxDistance": 4990,
"barrenDistance": 8192,
"typeLabel": "Jump Gate",
"theme": { "color": "#5fd4ff" },

View File

@ -1,5 +1,5 @@
{
"_comment": "Planet visuals + home-planet rules + the invisible star's flavor (class colors — non-home systems have no central body) + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — present ONLY at the origin of the system they start in (the starting system's central body); every OTHER system's center is empty — the star is invisible flavor (`star` below: name/class feed the dossier and gate names, never rendered). scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). compassColor = the compass-arrow accent for planets (the home world + the system's worlds — data/stations.json's stations and data/asteroids.json's clusters carry their own; the scan signal compass reads it too). solarSystem = the top-down LAYOUT RULES (js/galaxy/SystemGenerator.js → layoutSystem), built around TETHER DISCOVERY (data/tether.json): the objects of a system (planets + free-space stations + the home world in the starting system) sit on a compact arc at one orbital radius — the star (or home world) at the origin — with CONSECUTIVE objects a level-1 tether apart (clearly inside level 1: ≤ 5120 − edgeMargin) so a chain of level-1 tethers always discovers the whole system, and level-2 DEFINITELY discovers while undiscovered objects remain (its adjacency graph is connected) — level-3 never needs to be required. NO object pair (among anchorable objects — planets, stations, the home world) may sit in the no-man's-land band around a tether rim: (L1 − edgeMargin, L1 + edgeMargin) or (L2 − edgeMargin, L2 + edgeMargin) — objects are clearly inside or clearly outside each tether region, never at the edge (the scan edge case). The invisible star (non-home systems) is not a tether anchor, so its distances only need minSpacing..maxSpacing; the home world IS an anchor, so its distances also respect the bands (homeMaxSpacing is additionally capped at L2 − edgeMargin). minSpacing = star/home-world-to-object floor; maxSpacing = star-to-object ceiling (6400..15360 px); homeMaxSpacing = home-world-to-object ceiling; neighborMin = the planet/station-to-planet/station floor (disc clearance + the chain-gap lower bound). The rotation biases toward the jump-gate directions (data/gates.json). All values are center-to-center px (the same unit as the 1024 px world disc).",
"_comment": "Planet visuals + home-planet rules + the invisible star's flavor (class colors — non-home systems have no central body) + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — present ONLY at the origin of the system they start in (the starting system's central body); every OTHER system's center is empty — the star is invisible flavor (`star` below: name/class feed the dossier and gate names, never rendered). scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). compassColor = the compass-arrow accent for planets (the home world + the system's worlds — data/stations.json's stations and data/asteroids.json's clusters carry their own; the scan signal compass reads it too). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the home world — at the local origin of the starting system only; every other center is empty) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).",
"texture": "assets/images/planets.png",
"frameWidth": 1024,
"frameHeight": 1024,
@ -53,7 +53,6 @@
"enabled": true,
"minSpacing": 6400,
"maxSpacing": 15360,
"homeMaxSpacing": 10240,
"neighborMin": 2048
"homeMaxSpacing": 10240
}
}

View File

@ -38,7 +38,7 @@
],
"sideStandbyNote": "SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET",
"starterToast": {
"_comment": "The one-shot note when a quest lands on the ledger — the starter on the run's first boot (GameScene.create) and an unlock grant (leaving_the_nest, when the starter's reward is claimed — GameScene._grantUnlockedQuests).",
"_comment": "The one-shot note when the starter quest lands on a fresh ledger (GameScene.create).",
"glyph": "◆",
"color": "#ffc94d",
"text": "NEW QUEST — {title}"
@ -97,29 +97,6 @@
"minerals": 200,
"_comment": "The completion reward — 200 Minerals, claimed via the CLAIM REWARD action (fits the ship's hold: ship.stats.mineralStorage)."
}
},
{
"id": "leaving_the_nest",
"category": "main",
"icon": "diamond",
"title": "Leaving the Nest",
"description": "The nest did its job — it kept you alive long enough to learn the sky. Now the sky is yours. Chart the whole system, light its gates, and come find me out past the gate. I'll be waiting.",
"issuer": "Trestle",
"_comment": "The run's SECOND quest — the starter's follow-up (the 'Leaving the Nest' arc). It GRANTS the moment the starter quest is COMPLETED AND CLAIMED (unlocks.claimed — QuestState.isUnlockMet, armed by GameScene._grantUnlockedQuests on the claim itself + on scene create for loaded saves whose starter is already paid out). Trestle — the 'A Friend' behind the starter — is the issuer: his firstWorld pair (data/comms.json) promised the meeting. The four checks are the run's exit arc, witnessed by the run's COMMS played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run, so the witness survives reloads and a quit mid-sequence): the system's full NAV chart (the same set that gates the jumpgate research), the home system's gates ACTIVATED (the activateGates effect), Trestle's THREE-BRIEF sequence (trestle-brief-1/2/3, trigger 'trestleBrief' — armed when the home gates go online, GameScene._trestleBeats; ALL THREE heard = 'Talk to Trestle'), and his ARRIVAL beat when the player lands in a home-neighbor system (trestle-arrive, trigger 'trestleArrival'; 'Meet Trestle'). Both Trestle checks are read data-driven from the trigger's comms (GameScene._questCheckEval).",
"unlocks": {
"_comment": "The grant condition: the listed quests' rewards CLAIMED (the ledger's paid-out set) — 'unlocked as soon as the first quest is completed and its reward collected'.",
"claimed": ["start_your_journey"]
},
"checks": [
{ "type": "systemCharted", "system": "home", "label": "Discover all Planets, Space Stations and Jump Gates in the system" },
{ "type": "gatesActivated", "system": "home", "label": "Activate System Jump Gates" },
{ "type": "trestleTalked", "label": "Talk to Trestle" },
{ "type": "trestleMet", "label": "Meet Trestle in the neighboring system" }
],
"reward": {
"minerals": 250,
"_comment": "The completion reward — the ship's full hold (ship.stats.mineralStorage), claimed via the CLAIM REWARD action."
}
}
]
}

View File

@ -1,9 +1,8 @@
{
"_comment": "TETHER — the player's range. A tether anchors on a world or station and defines a circular zone: radius = distance from the anchor's center. Level 1 = 5120 px (design rule); radius(level) = level1Radius × radiusGrowth^(level−1). The ship may fly anywhere in the UNION of its tethers' zones — where zones overlap there is no line and no wall; outside all of them is a hard barrier, drawn as a thick cyberpunk glitch dotted line along the union's boundary. homeId/homeLevel/homeLabel = the tether the player starts with (anchored on the home world, which sits at the system origin). line = the barrier's look & motion (dash/gap px along the rim, thicknesses, march speed = how fast dashes flow, colors/alphas = the neon passes, glitch = ambient bursts: dash flicker, radial displacement, sparks). contact = feedback when the ship bumps the barrier (pulse = the local shudder of the line; toastCooldown = console warning throttle, ms). edgeMargin = the DISCOVERY-DESIGN margin (px): a no-man's-land band around EVERY tether boundary where no object sits — discoverable objects are either clearly inside a tether region (d ≤ radius − edgeMargin) or clearly outside it (d ≥ radius + edgeMargin), never at the rim (the scan edge case: an object whose center hugs a tether boundary is where 'scan doesn't find it' bites — the layout keeps everything off the rims instead, data/planets.json → solarSystem + data/gates.json → anchorMin/MaxDistance). Used by SystemGenerator; not a gameplay value.",
"_comment": "TETHER — the player's range. A tether anchors on a world or station and defines a circular zone: radius = distance from the anchor's center. Level 1 = 5120 px (design rule); radius(level) = level1Radius × radiusGrowth^(level−1). The ship may fly anywhere in the UNION of its tethers' zones — where zones overlap there is no line and no wall; outside all of them is a hard barrier, drawn as a thick cyberpunk glitch dotted line along the union's boundary. homeId/homeLevel/homeLabel = the tether the player starts with (anchored on the home world, which sits at the system origin). line = the barrier's look & motion (dash/gap px along the rim, thicknesses, march speed = how fast dashes flow, colors/alphas = the neon passes, glitch = ambient bursts: dash flicker, radial displacement, sparks). contact = feedback when the ship bumps the barrier (pulse = the local shudder of the line; toastCooldown = console warning throttle, ms).",
"level1Radius": 5120,
"radiusGrowth": 2.0,
"maxLevel": 3,
"edgeMargin": 512,
"homeId": "home",
"homeLevel": 1,
"homeLabel": null,

View File

@ -12,11 +12,7 @@
* oac-relaunch → oac-explore on tetherL2Launch, and
* oac-research-complete (audio-only, once:false) on researchComplete,
* oac-system-complete (visual, once:false, audioPool) on systemComplete,
* oac-quest-new (audio-only, once:false) on questNew, and
* trestle-meetup → trestle-quest on firstWorld,
* trestle-brief-1 → trestle-brief-2 → trestle-brief-3 (the
* 'Leaving the Nest' three-brief sequence, ack) on trestleBrief,
* trestle-arrive (ack) on trestleArrival);
* and trestle-meetup → trestle-quest on firstWorld);
* - the reserved triggers are RESERVED (no comm uses them yet);
* - normalizeComm drops what can't be shown (bad type / no trigger /
* no text for a box / no speech for an audio comm / unknown sender),
@ -77,7 +73,7 @@ const trestle = charEntry(cfg, 'trestle');
check('Trestle is registered (label + role + his own clip)', !!trestle && trestle.label === 'TRESTLE' && !!trestle.role && trestle.video === 'assets/videos/characters/trestle.mp4');
const roster = listComms(cfg);
check('the roster holds exactly the shipped comms', roster.length === 19);
check('the roster holds exactly the shipped comms', roster.length === 14);
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
const intro = byId['oac-intro'];
check(
@ -191,7 +187,7 @@ check(
]),
);
check('oac-system-complete: every pool clip ships on disk', sysPool.every((f) => existsSync(join(root, f))));
check('oac-system-complete: beats the discovery chime (delayMs > 0)', (byId['oac-system-complete']?.delayMs ?? 0) > 0);
check('oac-system-complete: beats the completion chime (delayMs > 0)', (byId['oac-system-complete']?.delayMs ?? 0) > 0);
// The 200-mineral threshold — OAC sends the player home to build.
check(
@ -255,81 +251,10 @@ check(
check('trestle-quest: the first-quest/claim brief (the brief, verbatim)', byId['trestle-quest']?.text === 'Oh and by the way, you just completed your first quest. If you click on the quest in the upper left, or click the quests button below, you can complete the quest and claim 200 minerals I set aside just for you. See you soon kid!');
check('trestle-quest: speaks trestle/intro-02.mp3', byId['trestle-quest']?.audio === 'assets/speech/trestle/intro-02.mp3');
// The quest-grant voice — the NEW QUEST beat (audio-only, once:false:
// every grant re-arms it — the starter boot + every unlock, the scene's
// _grantUnlockedQuests triggers 'questNew').
check(
'oac-quest-new: the quest-grant voice (audio, questNew, once:false)',
!!byId['oac-quest-new'] &&
byId['oac-quest-new'].from === 'oac' &&
byId['oac-quest-new'].type === 'audio' &&
byId['oac-quest-new'].trigger === 'questNew' &&
byId['oac-quest-new'].once === false,
);
check('oac-quest-new: speaks oac/quest-new.mp3', byId['oac-quest-new']?.audio === 'assets/speech/oac/quest-new.mp3');
check('oac-quest-new: the clip ships on disk', existsSync(join(root, byId['oac-quest-new']?.audio ?? '')));
check('oac-quest-new: beats the grant SFX (delayMs > 0)', (byId['oac-quest-new']?.delayMs ?? 0) > 0);
// The 'Leaving the Nest' beats — Trestle's THREE-BRIEF sequence (the
// home gates go online; ALL THREE in the run's comms played-ledger are
// the quest's 'Talk to Trestle' witness — GameScene._trestleCheck) and
// the arrival (the player lands in the neighboring system; it in the
// ledger is the 'Meet Trestle' witness).
check(
'trestle-brief-1: the gates-online briefing, beat 1/3 (ack, trestleBrief, trestle, once)',
!!byId['trestle-brief-1'] &&
byId['trestle-brief-1'].from === 'trestle' &&
byId['trestle-brief-1'].type === 'ack' &&
byId['trestle-brief-1'].trigger === 'trestleBrief' &&
byId['trestle-brief-1'].once === true,
);
check('trestle-brief-1: the charted-system brief (the brief, verbatim)', byId['trestle-brief-1']?.text === 'Hey Kid. You\'ve discovered all the important objects in the system. That means Planets, Space Stations and Jump gates. As you move around the galaxy, you can always find out how many objects a system has in the map view on your console.');
check('trestle-brief-1: speaks trestle/q2-01.mp3', byId['trestle-brief-1']?.audio === 'assets/speech/trestle/q2-01.mp3');
check('trestle-brief-1: the clip ships on disk', existsSync(join(root, byId['trestle-brief-1']?.audio ?? '')));
check('trestle-brief-1: lands after the gates-online beat (delayMs > 0)', (byId['trestle-brief-1']?.delayMs ?? 0) > 0);
check(
'trestle-brief-2: the cargo-setup brief, beat 2/3 (ack, trestleBrief, once, immediate)',
!!byId['trestle-brief-2'] &&
byId['trestle-brief-2'].from === 'trestle' &&
byId['trestle-brief-2'].type === 'ack' &&
byId['trestle-brief-2'].trigger === 'trestleBrief' &&
byId['trestle-brief-2'].once === true &&
(byId['trestle-brief-2']?.delayMs ?? 0) === 0,
);
check('trestle-brief-2: the cargo/Carol brief (the brief, verbatim)', byId['trestle-brief-2']?.text === 'Listen, I could use your help. Nothing major, but I think it\'s time you learned how to move cargo. I have a friend here named Carol who has something we need to get delivered pronto.');
check('trestle-brief-2: speaks trestle/q2-02.mp3', byId['trestle-brief-2']?.audio === 'assets/speech/trestle/q2-02.mp3');
check('trestle-brief-2: the clip ships on disk', existsSync(join(root, byId['trestle-brief-2']?.audio ?? '')));
check(
'trestle-brief-3: the rendezvous brief, beat 3/3 (ack, trestleBrief, once, immediate)',
!!byId['trestle-brief-3'] &&
byId['trestle-brief-3'].from === 'trestle' &&
byId['trestle-brief-3'].type === 'ack' &&
byId['trestle-brief-3'].trigger === 'trestleBrief' &&
byId['trestle-brief-3'].once === true &&
(byId['trestle-brief-3']?.delayMs ?? 0) === 0,
);
check('trestle-brief-3: the coordinates/orange-marker brief (the brief, verbatim)', byId['trestle-brief-3']?.text === 'I\'m in the system right next to you in this star cluster\'s capital. I\'m sending the coordinates to your computer right now. Just follow the orange markers on your compass or map and we\'ll talk more when you get here.');
check('trestle-brief-3: speaks trestle/q2-03.mp3', byId['trestle-brief-3']?.audio === 'assets/speech/trestle/q2-03.mp3');
check('trestle-brief-3: the clip ships on disk', existsSync(join(root, byId['trestle-brief-3']?.audio ?? '')));
check(
'trestle-arrive: the arrival (ack, trestleArrival, trestle, once, text-only)',
!!byId['trestle-arrive'] &&
byId['trestle-arrive'].from === 'trestle' &&
byId['trestle-arrive'].type === 'ack' &&
byId['trestle-arrive'].trigger === 'trestleArrival' &&
byId['trestle-arrive'].once === true,
);
check('trestle-arrive: the arrival brief (you left the nest)', byId['trestle-arrive']?.text.includes('You left the nest'));
check('trestle-arrive: no speech clip yet (the voice is text-only)', byId['trestle-arrive']?.audio === null || byId['trestle-arrive']?.audio === '');
check('trestle-arrive: lands after the jump-in beat (delayMs > 0)', (byId['trestle-arrive']?.delayMs ?? 0) > 0);
check(
'tetherResearch arms the pair in config order (minerals → fly-mine)',
JSON.stringify(commsForTrigger(cfg, 'tetherResearch').map((c) => c.id)) === JSON.stringify(['oac-minerals', 'oac-fly-mine']),
);
check('questNew arms the quest-grant voice', commsForTrigger(cfg, 'questNew').length === 1 && commsForTrigger(cfg, 'questNew')[0].id === 'oac-quest-new');
check('trestleBrief arms the three-brief sequence in config order (back-to-back)', JSON.stringify(commsForTrigger(cfg, 'trestleBrief').map((c) => c.id)) === JSON.stringify(['trestle-brief-1', 'trestle-brief-2', 'trestle-brief-3']));
check('trestleArrival arms the arrival', commsForTrigger(cfg, 'trestleArrival').length === 1 && commsForTrigger(cfg, 'trestleArrival')[0].id === 'trestle-arrive');
check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluster').length === 1 && commsForTrigger(cfg, 'firstCluster')[0].id === 'oac-first-cluster');
check('researchComplete arms the sign-off', commsForTrigger(cfg, 'researchComplete').length === 1 && commsForTrigger(cfg, 'researchComplete')[0].id === 'oac-research-complete');
check('systemComplete arms the milestone comm', commsForTrigger(cfg, 'systemComplete').length === 1 && commsForTrigger(cfg, 'systemComplete')[0].id === 'oac-system-complete');
@ -344,7 +269,7 @@ check(
check(
'the reserved triggers are reserved (no comm uses them yet)',
['researchNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
['researchNew', 'questNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
(t) => !roster.some((c) => c.trigger === t),
),
);
@ -406,9 +331,9 @@ warnOn = true;
// unknown senders are dropped by the ROSTER (listComms), not normalize
const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] };
const ghostList = listComms(withGhost);
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 19 && ghostList[0].id === 'oac-intro');
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 14 && ghostList[0].id === 'oac-intro');
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
check('drop: duplicate ids (first wins)', dupList.length === 19 && dupList[0].id === 'oac-intro');
check('drop: duplicate ids (first wins)', dupList.length === 14 && dupList[0].id === 'oac-intro');
warnOn = true;
// ----------------------------------------------------------------------

View File

@ -7,19 +7,17 @@
* - the DISCOVERY RULE (data/game.json → discovery.distance): within that
* many px of an object's EDGE ⇒ discovered, exactly once, per system;
* state round-trips through toJSON/fromJSON (saves-ready);
* - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem +
* data/tether.json): the objects of a system (planets + free-space
* stations + the home world in the starting system) sit on a compact
* arc — orbital radius in [minSpacing, maxSpacing] (home ≤
* level-2 − edgeMargin), consecutive objects a level-1 tether apart
* (≤ L1 − edgeMargin — a level-1 chain discovers the whole system),
* no anchorable pair in a tether rim band (± edgeMargin — the scan
* edge case), the level-2 graph connected — planets scaled by class,
* the OBJECT COMPOSITION holding (data/systems.json → objectCount:
* every non-home system 0/2/3/4/5 objects, in the configured
* proportions) — deterministically (same seed ⇒ same layout,
* different seed ⇒ different); the full rule set is covered by
* dev/system-layout.test.mjs;
* - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem): every
* pair of layout objects — the system's planets + free-space
* stations + the central body (the origin's home world, or the star
* elsewhere) — sits in the band [minSpacing, maxSpacing] px
* center-to-center (the home system's band tightens to
* [minSpacing, homeMaxSpacing] and it holds ≤ 3 objects — 5 points
* cannot sit 6400..10240 px apart), planets scaled by class, the
* OBJECT COMPOSITION holding (data/systems.json → objectCount: every
* non-home system 0/2/3/4/5 objects, in the configured proportions)
* — deterministically (same seed ⇒ same layout, different seed ⇒
* different);
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
* the screen-edge rect (edges AND corners), circleInView is exact,
* lerpAngle always takes the short arc;
@ -136,11 +134,6 @@ const band = config.get('planets.solarSystem');
const MIN_SEP = band.minSpacing;
const MAX_SP = band.maxSpacing; // normal systems: the band's maximum
const HOME_MAX = band.homeMaxSpacing; // the home system's tighter maximum
// The tether discovery bands (data/tether.json) — the layout's real rule.
const L1R = config.get('tether.level1Radius', 5120);
const L2R = L1R * (config.get('tether.radiusGrowth', 2.0));
const M = config.get('tether.edgeMargin', 512);
const L1in = L1R - M, L1out = L1R + M, L2in = L2R - M, L2out = L2R + M;
const g = Galaxy.create('discovery-layout-test');
let layoutOk = true;
@ -152,7 +145,7 @@ const probe = (systems) => {
for (const rec of systems) {
const c = g.ensureContent(rec.id);
const isHome = rec.id === g.currentSystemId;
const MAX = isHome ? Math.min(HOME_MAX, L2in) : MAX_SP;
const MAX = isHome ? HOME_MAX : MAX_SP;
for (const p of c.planets) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
layoutOk = false;
@ -190,74 +183,23 @@ const probe = (systems) => {
if (!layoutWhy) layoutWhy = `${rec.id}: holds ${nObjects} objects (composition wants 0/2/3/4/5)`;
}
// The home system holds at most 3 objects (its 2 fixed planets + at
// most one free-space station) — by design, the home system stays a
// small start the player expands out of.
// most one free-space station) — 5 points cannot sit 6400..10240 px
// apart (the tightest 5-point spacing needs ratio ≥ φ > 1.6).
if (isHome) {
if (nObjects > 3 || c.planets.length !== 2) {
homeOk = false;
if (layoutOk) layoutWhy = `${rec.id}: home system holds ${nObjects} objects`;
}
}
// THE LAYOUT RULES (data/tether.json + data/planets.json →
// solarSystem):
// (a) orbital band — every object in [minSpacing, MAX] from the
// center (MAX = level-2 − edgeMargin for home — the home world
// IS an anchor; the star of a normal system is not);
for (let i = 1; i < objs.length && layoutOk; i++) {
const d = Math.hypot(objs[i].x, objs[i].y);
if (d < MIN_SEP - 1e-6 || d > MAX + 1e-6) {
layoutOk = false;
layoutWhy = `${rec.id}: object ${Math.round(d)} px outside the orbital band [${MIN_SEP}, ${MAX}]`;
}
}
if (nObjects >= 2) {
// The anchorable subgraph: the objects + (home only) the home world
// at the origin — the star of a normal system is NOT a tether
// anchor (no tether, no discovery from it), so its pairs are
// exempt from (b)–(d).
const anc = objs.slice(isHome ? 0 : 1);
const n = anc.length;
const adj1 = Array.from({ length: n }, () => []);
const adj2 = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const d = Math.hypot(anc[i].x - anc[j].x, anc[i].y - anc[j].y);
// (b) the edge margin — no pair in a tether rim band.
if ((d > L1in + 1e-6 && d < L1out - 1e-6) || (d > L2in + 1e-6 && d < L2out - 1e-6)) {
layoutOk = false;
layoutWhy = `${rec.id}: a pair ${Math.round(d)} px in a tether rim band (±${M})`;
i = j = n; // break both loops
}
if (d <= L1in + 1e-6) { adj1[i].push(j); adj1[j].push(i); }
if (d <= L2in + 1e-6) { adj2[i].push(j); adj2[j].push(i); }
}
}
if (layoutOk) {
// (c) every object has a level-1 neighbor — EXCEPT the home
// world, the bootstrap anchor: minSpacing (6400) > L1out puts
// its objects in the level-2 zone by design (covered by (d)).
for (let i = 0; i < n; i++) {
if (isHome && i === 0) continue;
if (adj1[i].length === 0) {
layoutOk = false;
layoutWhy = `${rec.id}: an object has no level-1 neighbor`;
break;
}
}
// (d) the level-2 graph is connected (level 2 DEFINITELY
// discovers while undiscovered objects remain; level 3 never
// required — the level-1 chain above already spans it).
if (layoutOk) {
const seen = new Set([0]);
const q = [0];
while (q.length) {
const i = q.shift();
for (const j of adj2[i]) if (!seen.has(j)) (seen.add(j), q.push(j));
}
if (seen.size !== n) {
layoutOk = false;
layoutWhy = `${rec.id}: level-2 graph splits ${seen.size}/${n}`;
}
// The SOLAR SYSTEM BAND: every pair of layout objects — incl. the
// central body — sits in [minSpacing, the system's maximum],
// center to center.
for (let i = 0; i < objs.length && layoutOk; i++) {
for (let j = i + 1; j < objs.length; j++) {
const d = Math.hypot(objs[i].x - objs[j].x, objs[i].y - objs[j].y);
if (d < MIN_SEP - 1e-6 || d > MAX + 1e-6) {
layoutOk = false;
layoutWhy = `${rec.id}: a pair ${Math.round(d)} px outside the band [${MIN_SEP}, ${MAX}]`;
}
}
}
@ -266,7 +208,7 @@ const probe = (systems) => {
probe(g.records);
const nProbe = g.records.length;
check(
`layout: ${nProbe} systems obey the rules — orbital band [${MIN_SEP}, ${MAX_SP}] (home ≤ ${Math.min(HOME_MAX, L2in)}), level-1 chain, level-2 connected, no pair in a rim band (±${M})${layoutOk ? '' : ' — ' + layoutWhy}`,
`layout: ${nProbe} systems obey the band — every pair (incl. the central body) in [${MIN_SEP}, ${MAX_SP}] px, home in [${MIN_SEP}, ${HOME_MAX}] px${layoutOk ? '' : ' — ' + layoutWhy}`,
layoutOk && homeOk,
);
check(`layout: no system exceeds the 5-object maximum (3 planets + 2 stations) — max in galaxy: ${maxObjectsSeen}`, maxObjectsSeen <= 5);

View File

@ -20,7 +20,7 @@
* - content.jumps matches the network (count + destinations);
* - ANCHORED systems (a planet — or the home world in the starting
* system): every gate sits in the anchorMinDistance–anchorMaxDistance
* band (default 3500–4608 px — inside the level-1 tether, strictly
* band (default 3500–4990 px — inside the level-1 tether, strictly
* < 5000) from a planet anchor and FACES its destination star on the
* 2-D map
* (soft rule — the gate sits on the DESTINATION SIDE of the system
@ -86,10 +86,10 @@ const POOL = Math.max(1, GATES.neighborPool ?? 8);
const TETHER = config.get('tether.level1Radius', 5120);
// The anchored-gate design rule (data/gates.json → anchorMinDistance /
// anchorMaxDistance): STRICTLY < 5000 px from a planet anchor, with the
// distance VARYING across the band (default 3500–4608 — inside the
// distance VARYING across the band (default 3500–4990 — inside the
// level-1 tether). The generator places every anchored gate EXACTLY on
// one of the radius steps spanning that band.
const ANCHOR = Math.min(TETHER, config.get('gates.anchorMaxDistance', 4608));
const ANCHOR = Math.min(TETHER, config.get('gates.anchorMaxDistance', 4990));
const ANCHOR_MIN = Math.min(ANCHOR, config.get('gates.anchorMinDistance', 3500));
const SIZE = GATES.size ?? 96;
const CLEAR = GATES.clearance ?? 256;
@ -267,7 +267,7 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
}
} else {
// TETHER (hard): the gate's own anchor sits in the
// anchorMinDistance..anchorMaxDistance band (default 3500–4608 —
// anchorMinDistance..anchorMaxDistance band (default 3500–4990 —
// strictly < 5000). A DIFFERENT anchor may be closer (or farther)
// — the band is about the anchoring planet.
let inBand = false;

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev: the quest tracker's READY state</title>
<!-- This page lives in /dev, but the game's relative asset paths are rooted
at the project root — resolve them against it. ("../" keeps this
working even if the project is served from a subdirectory.) -->
<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/quest-ready.mjs"></script>
</body>
</html>

View File

@ -1,86 +0,0 @@
/**
* Dev-only: the quest tracker's READY state (the "A + C" attract), shot live.
*
* Boots the GameScene (deterministic seed), waits for the open dossier to
* auto-fold (the tracker slides in), then completes the starter quest all
* at once — the flip fires the one-shot moment (flash + ◆ burst) and the
* same-frame repaint goes READY (amber row). `window.__QUEST_READY__` is
* set the instant the burst is still alive, so the capture lands on it:
*
* node dev/server.mjs 8080
* node dev/shot-firefox.mjs http://localhost:8080/dev/quest-ready.html \
* "window.__QUEST_READY__" /tmp/quest-ready.png 45000
*/
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 { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
const data = await ConfigLoader.load();
config.init(data);
const gameConfig = createGameConfig();
globalThis.__ORBIT_DEV_SEED = '7'; // deterministic galaxy — reproducible shots
// Dev: surface console errors on the flag (a shot with a READY row AND a
// console error is a regression, even if the shot looks fine).
const __errors = [];
if (typeof window !== 'undefined') {
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)));
}
gameConfig.scene = [GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 30000) => {
const t0 = Date.now();
for (;;) {
const v = fn();
if (v) return v;
if (Date.now() - t0 > ms) return null;
await sleep(200);
}
};
setTimeout(async () => {
try {
const s = await until(() => (game.scene.getScene('GameScene')?.questTracker ? game.scene.getScene('GameScene') : null));
if (!s) { window.__QUEST_READY__ = { error: 'scene never became ready' }; return; }
// Wait for the open dossier to auto-fold → the tracker slides in
// (the tracker's first poll establishes its READY baseline while the
// quest is still open, so the flip below is a true transition).
await until(() => s.questTracker.shown === true);
await sleep(250); // let the slide fully settle
// Complete the starter quest (all four checks), live — the flip:
s.totalMined = 200;
s.researchState.isUnlocked = (cat, node) => true;
s.tetherLevelFor = (name) => 2;
s.discoveredWorldCount = () => 1;
s._questRefresh(); // toast + chime + tracker repaint (the same frame)
await sleep(380); // inside the burst's life (700–1000 ms) — catch the glyphs
const snap = s.questSnapshot();
const q = snap.quests.find((x) => x.id === 'start_your_journey');
window.__QUEST_READY__ = {
trackerShown: s.questTracker.shown,
questComplete: q?.complete === true,
questClaimed: q?.claimed === true,
baselineHasStarter: s.questTracker._readyBaseline?.has('start_your_journey') === true,
errors: __errors.slice(0, 3),
};
// This geckodriver can't read the flag back (execute/sync → null), so
// paint the verdict on the canvas too — the screenshot IS the readout.
s.add.text(16, 690, `READY-FLAG ${JSON.stringify(window.__QUEST_READY__)}`,
{ fontFamily: 'monospace', fontSize: '11px', color: '#7ce8a4', align: 'left' })
.setOrigin(0, 1).setScrollFactor(0).setDepth(9000);
} catch (err) {
window.__QUEST_READY__ = { error: String(err?.stack ?? err) };
}
}, 400);

View File

@ -125,10 +125,6 @@ const PhaserStub = {
GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub, Text: ClassStub },
Geom: { Rectangle: RectStub },
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
// Filter plumbing (js/visuals/SystemEffects.js — the class declarations
// the GameScene import pulls in; only the base classes are needed):
Filters: { Controller: class {} },
Renderer: { WebGL: { RenderNodes: { BaseFilterShader: class {} } } },
Math: {
Linear: (a, b, t) => a + (b - a) * t,
FloatBetween: (a, b) => a + Math.random() * (b - a),
@ -774,8 +770,7 @@ const T0 = 100000;
const checksAfter = snapCheckStates(s);
check('the checklist tracks LIVE: 0/4 → 4/4 as the run progresses',
checksBefore.every((c) => c.done === false) && checksAfter.every((c) => c.done === true));
check('a READY row flips its % readout to READY (amber, bold)',
s.add.texts.some((t) => t.text === 'READY' && t.color === '#ffc94d' && t.style?.fontStyle === 'bold'));
check('the % readout hits 100%', s.add.texts.some((t) => t.text === '100%'));
// The scene's priority actions (real setPriorityQuest / clearPriorityQuest).
const saved = config.data;
@ -865,116 +860,6 @@ const T0 = 100000;
/onSetPriority/.test(QuestWindow.toString()) && /getPriority/.test(QuestWindow.toString()));
}
// ===========================================================================
// 8) READY — a completed, unclaimed quest: the amber row (the persistent
// attract), the one-shot moment (flash + ◆ burst — once, shown only),
// and the popup's CLAIM REWARD (claims in place via onClaim)
// ===========================================================================
{
const recordTweens = (sc) => {
const log = [];
const orig = sc.tweens.add;
sc.tweens.add = (opts) => {
log.push(opts);
return orig(opts);
};
return log;
};
const glyphs = (log) => log.filter((t) => t.targets instanceof FakeText && t.targets.text === '◆');
const scene = makeScene();
scene.time.now = 5000;
const snap = {
categories: [],
quests: [{ id: 'q_open', category: 'main', title: 'Open Quest', checks: [{ label: 'C1', done: false, detail: '0/1' }], claimed: false, complete: false, progress: '0/1' }],
};
const claimed = [];
const tweens = recordTweens(scene);
const trk = new QuestTrackerHud(scene, {
anchorX: 16,
anchorY: 40,
getSnapshot: () => snap,
resolvePriority: () => null,
onClaim: (id) => claimed.push(id),
});
trk.show(scene.time.now);
trk.update(scene.time.now);
const rowTints = () => trk._kids.filter((k) => k instanceof FakeGraphics && !k.input).length;
check('an open row stays plain: 0% readout, no READY label, no tint',
scene.add.texts.some((t) => t.text === '0%') &&
!scene.add.texts.some((t) => t.text === 'READY') &&
rowTints() === 0);
// The quest COMPLETES (a check flips) — the same-frame repaint goes READY.
snap.quests[0].checks[0].done = true;
snap.quests[0].complete = true;
snap.quests[0].progress = '1/1';
tweens.length = 0; // (the constructor's first poll is not the moment)
trk.update(scene.time.now);
check('READY row: the % readout flips to READY (amber, bold)',
scene.add.texts.some((t) => t.text === 'READY' && t.color === '#ffc94d' && t.style?.fontStyle === 'bold'));
check('READY row: the title brightens to ink',
(() => {
const t = scene.add.texts.find((x) => x.text === 'OPEN QUEST' && x.x === 24);
return !!t && t.color === '#eaf6ff';
})());
check('READY row: the breathing tint is up (a plate + a tint per row)', rowTints() === 1);
check('READY row: the tint breathes (a slow yoyo sine, 0.1 → 0.3)',
tweens.some((t) => t.targets instanceof FakeGraphics && !t.targets.input && t.alpha === 0.3 && t.repeat === -1 && t.yoyo === true));
check('the MOMENT fired: a row-flash + four ◆ glyph bursts (tweened)',
tweens.some((t) => t.targets instanceof FakeGraphics && !t.targets.input && t.alpha === 0) &&
glyphs(tweens).length === 4);
// NO re-fire: a drop-and-re-flip of the SAME quest stays silent.
tweens.length = 0;
snap.quests[0].checks[0].done = false;
snap.quests[0].complete = false;
trk.update(scene.time.now);
snap.quests[0].checks[0].done = true;
snap.quests[0].complete = true;
trk.update(scene.time.now);
check('a re-flip of the SAME quest does not re-burst (once per quest)', glyphs(tweens).length === 0);
// The popup: CLAIM REWARD — FIRST, and it claims in place.
const plate = trk._kids.find((k) => k instanceof FakeGraphics && k.input);
plate.fire('pointerdown');
check('the READY row\u2019s popup offers CLAIM REWARD FIRST (before SET/VIEW)',
trk._popup.btns.length === 3 &&
trk._popup.btns[0].labelText.text === 'CLAIM REWARD' &&
trk._popup.btns[1].labelText.text === 'SET PRIORITY' &&
trk._popup.btns[2].labelText.text === 'VIEW QUEST');
trk._popup.btns[0].panel.fire('pointerdown');
check('CLAIM REWARD → onClaim(q_open) in place + the popup closes',
claimed.length === 1 && claimed[0] === 'q_open' && trk.popupOpen === false);
trk.destroy();
// While SLID OFF, the flip is silent (the scene\u2019s toast + chime carry it)
// — but the row still paints READY when the tracker re-shows.
const sceneH = makeScene();
sceneH.time.now = 5100;
const snapH = {
categories: [],
quests: [{ id: 'q2', category: 'main', title: 'Hidden Quest', checks: [{ label: 'C1', done: false, detail: '0/1' }], claimed: false, complete: false, progress: '0/1' }],
};
const tweensH = recordTweens(sceneH);
const trkH = new QuestTrackerHud(sceneH, {
anchorX: 16,
anchorY: 40,
getSnapshot: () => snapH,
resolvePriority: () => null,
});
check('slid off: the tracker starts hidden (the dossier is open)', trkH.state === 'hidden' && trkH.shown === false);
snapH.quests[0].checks[0].done = true;
snapH.quests[0].complete = true;
tweensH.length = 0;
trkH.update(sceneH.time.now);
check('a flip while slid off: NO burst (the toast + chime carry it)',
glyphs(tweensH).length === 0 && !tweensH.some((t) => t.targets instanceof FakeGraphics && t.alpha === 0));
check('…and the row is READY-styled when it re-shows',
sceneH.add.texts.some((t) => t.text === 'READY' && t.color === '#ffc94d'));
trkH.destroy();
}
if (failures > 0) {
console.error(`\n${failures} quest-tracker test(s) FAILED`);
process.exit(1);

View File

@ -12,19 +12,12 @@
* - the starter quest is well-formed: Main Story, one starter flag,
* a non-empty checklist of KNOWN requirement types, a reward that
* fits the ship's hold;
* - the 'Leaving the Nest' quest (the starter's follow-up) is
* well-formed: its four checks (the system's full nav chart, the
* home gates activated, Trestle's two story beats), its unlock
* requirement (the starter's reward CLAIMED), and its reward fits
* the ship's hold;
* - the 'research' check points at a REAL node of its category's tree
* (data/research/<category>.json — the check would never complete
* otherwise);
* - the quest ledger (js/quests/QuestState.js) behaves: idempotent
* give/claim, claimed ⊆ granted, a save round-trip, a corrupted
* save degrades to an empty ledger (not a throw), and the unlock
* chain (grantUnlocked arms the follow-up exactly when its
* prerequisite's reward is claimed);
* give/claim, claimed ⊆ granted, a save round-trip, and a
* corrupted save degrades to an empty ledger (not a throw);
* - the PRIORITY (the tracker HUD's featured quest): set/clear,
* the auto-assignment (first active story quest), the cleared-
* flag sticks until a grant/claim, and the save round-trip;
@ -53,6 +46,7 @@ config.init({ quests, ship });
const { QuestState, questDefs, questDef, starterQuestIds } = await import(
join(root, 'js/quests/QuestState.js')
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
@ -119,7 +113,7 @@ check('starter: reward fits the ship\u2019s hold',
// The requirement types the scene evaluates (GameScene._questCheckEval)
// — a typo here would be a checklist row that never completes.
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet']);
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld']);
check('starter: every check type is a known live check',
(starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
@ -143,36 +137,6 @@ for (const c of starter?.checks ?? []) {
}
}
// ----------------------------------------------------------------------
// 5b. The 'Leaving the Nest' quest — the starter's follow-up chain
// ----------------------------------------------------------------------
{
const q = (quests.quests ?? []).find((x) => x.id === 'leaving_the_nest');
check('nest: the quest is defined', !!q);
check('nest: sits in the MAIN story', q?.category === 'main');
check('nest: has id/title/description/issuer', typeof q?.id === 'string' && typeof q?.title === 'string' && typeof q?.description === 'string' && typeof q?.issuer === 'string');
check('nest: icon names a research icon', typeof q?.icon === 'string' && q.icon.length > 0);
check('nest: NOT a starter (it grants by its unlock requirement)', q?.starter !== true);
// The grant condition — the starter's reward CLAIMED ('unlocked as
// soon as the first quest is completed and its reward collected').
check('nest: unlocks on the starter quest being claimed', JSON.stringify(q?.unlocks?.claimed) === JSON.stringify([starter.id]));
check('nest: the starter id in unlocks.claimed is a defined quest', (q?.unlocks?.claimed ?? []).every((id) => questDef(id) !== null));
// The four checks, in order — the run's exit arc.
const labels = (q?.checks ?? []).map((c) => c.label);
check('nest: the four checklist lines (the brief, verbatim)', JSON.stringify(labels) === JSON.stringify([
'Discover all Planets, Space Stations and Jump Gates in the system',
'Activate System Jump Gates',
'Talk to Trestle',
'Meet Trestle in the neighboring system',
]));
check('nest: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c]));
check('nest: systemCharted names the home system', byType?.systemCharted?.system === 'home');
check('nest: gatesActivated names the home system', byType?.gatesActivated?.system === 'home');
check('nest: the reward is a positive integer (minerals)', Number.isInteger(q?.reward?.minerals) && q.reward.minerals > 0);
check('nest: the reward fits the ship\u2019s hold', q?.reward?.minerals <= (ship.stats?.mineralStorage ?? 0));
}
// ----------------------------------------------------------------------
// 6. The quest ledger — QuestState (give/claim/save-restore)
// ----------------------------------------------------------------------
@ -203,31 +167,6 @@ check('helpers: starterQuestIds() is exactly [the starter]',
check('ledger: save round-trip keeps granted + claimed',
restored.isGranted(starter.id) && restored.isClaimed(starter.id));
}
// The UNLOCK CHAIN — the 'Leaving the Nest' grant requirement:
// - unclaimed starter → nothing new grants;
// - claimed starter → exactly [leaving_the_nest] grants;
// - idempotent (a re-call is a no-op).
{
const st = new QuestState();
st.give(starter.id);
check('unlock: fresh — the follow-up is NOT granted (starter unclaimed)',
st.grantUnlocked().length === 0 && !st.isGranted('leaving_the_nest'));
check('unlock: the starter\u2019s claim arms the follow-up', (() => {
st.claim(starter.id);
return JSON.stringify(st.grantUnlocked()) === JSON.stringify(['leaving_the_nest']);
})());
check('unlock: the follow-up is now held', st.isGranted('leaving_the_nest'));
check('unlock: grantUnlocked() is idempotent', st.grantUnlocked().length === 0);
check('unlock: a pre-follow-up save (no leaving_the_nest) still loads', (() => {
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [starter.id] });
return r.isGranted(starter.id) && r.isClaimed(starter.id) && !r.isGranted('leaving_the_nest');
})());
check('unlock: a save claiming a quest it does not hold drops the claim', (() => {
const r = QuestState.fromJSON({ granted: [starter.id], claimed: ['leaving_the_nest'] });
return r.isGranted(starter.id) && !r.isClaimed('leaving_the_nest') && !r.isClaimed(starter.id);
})());
}
// A corrupted save degrades — never throws, never pays out a quest the
// player does not hold.
{
@ -277,23 +216,22 @@ check('helpers: starterQuestIds() is exactly [the starter]',
return st2.setPriority(starter.id) === false;
})());
}
// Multi-quest behavior — the real data set has the starter + the
// 'Leaving the Nest' follow-up today (side quests are deferred by
// design), so the ordering rules are pinned on a synthetic set: a
// second MAIN + one SIDE (config is a plain singleton — re-init for the
// test, restore after).
// Multi-quest behavior — the real data set has ONE quest today (side
// quests are deferred by design), so the ordering rules are pinned on a
// synthetic set: a second MAIN + one SIDE (config is a plain singleton —
// re-init for the test, restore after).
{
const MAIN2 = { id: 'test_main_b', category: 'main', title: 'Test Main B', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 20, label: 'MINE 20' }], reward: { minerals: 20 } };
const SIDE1 = { id: 'test_side_a', category: 'side', title: 'Test Side A', issuer: 'TEST', description: 'synthetic', icon: 'diamond', checks: [{ type: 'mineralsMined', amount: 10, label: 'MINE 10' }], reward: { minerals: 10 } };
const savedConfig = config.data;
config.init({ ...config.data, quests: { ...quests, quests: [...quests.quests, MAIN2, SIDE1] } });
try {
const order = questDefs().map((q) => q.id); // file order
const order = questDefs().map((q) => q.id); // [starter, MAIN2, SIDE1] — file order
const main = questDefs().filter((q) => q.category === 'main').map((q) => q.id);
const side = questDefs().filter((q) => q.category === 'side').map((q) => q.id);
const display = [...main, ...side];
check('priority: display order is main story first, then side quests',
JSON.stringify(display) === JSON.stringify(order));
JSON.stringify(display) === JSON.stringify([order[0], order[1], order[2]]));
const st = new QuestState();
for (const id of display) st.give(id);

View File

@ -95,10 +95,6 @@ const PhaserStub = {
GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub },
Geom: { Rectangle: class {} },
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
// Filter plumbing (js/visuals/SystemEffects.js — the class declarations
// the GameScene import pulls in; only the base classes are needed):
Filters: { Controller: class {} },
Renderer: { WebGL: { RenderNodes: { BaseFilterShader: class {} } } },
Math: {
Linear: (a, b, t) => a + (b - a) * t,
FloatBetween: (a, b) => a + Math.random() * (b - a),

View File

@ -1,212 +0,0 @@
/**
* System LAYOUT test (dev tool, run with Node — no browser needed):
*
* node dev/system-layout.test.mjs
*
* Asserts the tether-discovery layout rules (data/planets.json →
* solarSystem + data/tether.json, implemented by
* js/galaxy/SystemGenerator.js → layoutSystem) across every system of a
* fresh galaxy:
*
* 1. LEVEL-1 DISCOVERY: every planet and free-space station has at
* least one OTHER object (planet/station) within a level-1 tether —
* clearly inside (d ≤ L1 − edgeMargin). A chain of level-1 tethers
* therefore walks the whole system. (The home world — the bootstrap
* anchor the player starts on — sits minSpacing away, which is the
* level-2 zone by design; its connection is covered by rule 2.)
* 2. LEVEL-2 GUARANTEE: the level-2 adjacency graph over the objects
* (and the home world in the starting system) is CONNECTED — a
* level-2 tether DEFINITELY discovers an object while undiscovered
* objects remain.
* 3. NO LEVEL-3 NEEDED: the level-1 chain above implies level 3 is
* never required for discovery (checked as: the whole system is
* reachable from any object by a path of ≤ level-1 steps).
* 4. EDGE MARGIN: NO anchorable pair (planets, stations, home world)
* sits in the no-man's-land band around a tether rim —
* (L1 ± edgeMargin) or (L2 ± edgeMargin) — objects are clearly
* inside or clearly outside each tether region, never at the edge
* (the scan edge case). The invisible star (non-home systems) is not
* a tether anchor, so its distances are exempt.
* 5. ORBITAL BAND: the center-to-object distance sits in
* [minSpacing, maxSpacing] (6400–15360 px), and in the home system
* additionally ≤ level-2 − edgeMargin (the home world IS an anchor).
*
* and DETERMINISM: same seed ⇒ same layout (the lazy/eager content
* cache returns identical coordinates).
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../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 { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
if (!cond) failures++;
};
// --- The bands (from the same config the generator reads) -------------
const L1 = config.get('tether.level1Radius', 5120);
const L2 = L1 * (config.get('tether.radiusGrowth', 2.0));
const M = config.get('tether.edgeMargin', 512);
const L1in = L1 - M;
const L1out = L1 + M;
const L2in = L2 - M;
const L2out = L2 + M;
const MIN = config.get('planets.solarSystem.minSpacing', 6400);
const MAX = config.get('planets.solarSystem.maxSpacing', 15360);
const HOME_MAX = config.get('planets.solarSystem.homeMaxSpacing', 10240);
const inRim = (d) =>
(d > L1in + 1e-9 && d < L1out - 1e-9) || (d > L2in + 1e-9 && d < L2out - 1e-9);
const SEED = 'layout-test-seed';
const g = Galaxy.create(SEED);
const HOME = g.currentSystemId;
// One anchorable node per object: the planets, the free-space stations,
// and (home only) the home world at the origin.
const nodesOf = (c, isHome) => {
const nodes = c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name }));
for (const s of c.settlements ?? []) {
if (s.anchor?.type === 'space') nodes.push({ x: s.x, y: s.y, name: s.name });
}
if (isHome) nodes.push({ x: 0, y: 0, name: 'home' });
return nodes;
};
let systems = 0;
let withObjects = 0;
let l1Bad = 0, connBad = 0, rimBad = 0, orbBad = 0;
let whyL1 = '', whyConn = '', whyRim = '', whyOrb = '';
let l1Pairs = 0, l2Pairs = 0;
for (const r of g.records) {
const c = g.ensureContent(r.id);
const isHome = r.id === HOME;
const planets = c.planets.length;
const space = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
if (planets === 0 && space === 0) continue; // barren — no anchorable objects
systems++;
withObjects++;
const nodes = nodesOf(c, isHome);
const N = nodes.length;
const dist = (i, j) => Math.hypot(nodes[i].x - nodes[j].x, nodes[i].y - nodes[j].y);
// 1 + 2 + 3: the adjacency graphs (level 1 = the chain, level 2 = the
// guarantee) and BFS connectivity over level 2.
const adj1 = Array.from({ length: N }, () => []);
const adj2 = Array.from({ length: N }, () => []);
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
const d = dist(i, j);
if (d <= L1in + 1e-9) {
adj1[i].push(j);
adj1[j].push(i);
l1Pairs++;
}
if (d <= L2in + 1e-9) {
adj2[i].push(j);
adj2[j].push(i);
l2Pairs++;
}
// 4: the edge margin — no anchorable pair in a rim band.
if (inRim(d)) {
rimBad++;
if (!whyRim) whyRim = `${r.id}: ${nodes[i].name}↔${nodes[j].name} @ ${d.toFixed(1)} px`;
}
}
}
// 1: every planet/station has a level-1 neighbor (the chain walks the
// system). The home world is exempt: it is the BOOTSTRAP anchor —
// the player starts there, and minSpacing (6400 > L1out) puts its
// objects in the level-2 zone by design (the L2 check below covers
// its connection). Once the first planet is reached, L1 finds the
// rest.
if (N >= 2) {
const lonely = nodes
.map((n, i) => (n.name === 'home' ? null : adj1[i].length === 0 ? n.name : null))
.filter(Boolean);
if (lonely.length) {
l1Bad++;
if (!whyL1) whyL1 = `${r.id}: no level-1 neighbor — ${lonely.join(', ')}`;
}
// 2: level-2 graph connected.
const seen = new Set([0]);
const queue = [0];
while (queue.length) {
const i = queue.shift();
for (const j of adj2[i]) if (!seen.has(j)) (seen.add(j), queue.push(j));
}
if (seen.size !== N) {
connBad++;
if (!whyConn) whyConn = `${r.id}: level-2 graph splits ${seen.size}/${N}`;
}
}
// 5: the orbital band (center-to-object distances).
for (const n of nodes) {
if (n.name === 'home') continue; // the home world sits at the origin
const d = Math.hypot(n.x, n.y);
const hi = isHome ? Math.min(HOME_MAX, L2in) : MAX;
if (d < MIN - 1e-9 || d > hi + 1e-9) {
orbBad++;
if (!whyOrb) whyOrb = `${r.id}: ${n.name} @ ${d.toFixed(1)} px (band ${MIN}..${hi.toFixed(0)})`;
}
}
}
check(
`level-1 discovery: every planet/station has a level-1 neighbor — ${withObjects} systems`,
l1Bad === 0,
whyL1,
);
check('level-2 guarantee: the level-2 graph is connected (every system)', connBad === 0, whyConn);
check(
`edge margin: no anchorable pair in (L1±${M}) or (L2±${M})`,
rimBad === 0,
whyRim,
);
check(
`orbital band: center-to-object in [${MIN}, ${MAX}] (home ≤ ${Math.min(HOME_MAX, L2in)})`,
orbBad === 0,
whyOrb,
);
console.log(` ${systems} systems with objects checked (${l1Pairs} level-1 pairs, ${l2Pairs} level-2 pairs)`);
// --- Determinism: same seed ⇒ identical coordinates -------------------
{
const g2 = Galaxy.create(SEED);
let same = true;
let why = '';
for (const r of g.records) {
const a = g.ensureContent(r.id);
const b = g2.ensureContent(r.id);
for (let i = 0; i < a.planets.length && same; i++) {
if (Math.abs(a.planets[i].x - b.planets[i].x) > 1e-9 || Math.abs(a.planets[i].y - b.planets[i].y) > 1e-9) {
same = false;
why = `${r.id}: planet ${i} differs`;
}
}
}
check('same seed ⇒ same layout (two fresh galaxies agree)', same, why);
}
console.log(failures === 0 ? '\nAll system-layout tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -1,29 +1,12 @@
/**
* Audit: do generated galaxies obey the tether-discovery design rules?
* Rule 1 (gates): every jump gate strictly < 5000 px from a planet or
* space station — its ANCHOR sits in the 3500–4608 px
* placement band (data/gates.json → anchorMinDistance /
* anchorMaxDistance — inside the level-1 tether with the
* edgeMargin), the distance varying per gate — unless the
* system is a dead end (a barren system: no planets/stations,
* single gate). Also reports: no gate sits in the rim band
* (± edgeMargin) around ANOTHER object's tether, and the
* gate→anchor distance distribution (the variation).
* Rule 2 (discovery chains — replaces the old "all objects within 2
* tether lengths of each other" rule):
* a) LEVEL-1: every planet/station has another object within
* level 1 — clearly inside (d ≤ L1 − edgeMargin); a chain
* of level-1 tethers walks the whole system;
* b) LEVEL-2: the level-2 adjacency graph over the objects
* (and the home world) is CONNECTED — level 2 DEFINITELY
* discovers an object while undiscovered ones remain;
* c) LEVEL-3: never required (the level-1 chain above);
* d) EDGE MARGIN: no anchorable pair (planets, stations, home
* world) sits in the rim band (L1 ± edgeMargin) or
* (L2 ± edgeMargin) — objects are clearly inside or
* clearly outside each tether region, never at the edge.
*
* Run: node dev/tether-rules.audit.mjs
* One-off audit: do generated galaxies obey the two tether rules?
* Rule 1: every jump gate strictly < 5000 px from a planet or space
* station (its ANCHOR sits in the 3500–4990 px placement band)
* — unless the system is a dead end (a barren system: no
* planets/stations, single gate).
* Rule 2: every planet & space station within 2 tether lengths
* (2 × level1Radius) of at least one other planet/station —
* unless the system holds exactly one such object.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
@ -43,39 +26,28 @@ config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const T1 = config.get('tether.level1Radius', 5120);
const T2 = T1 * (config.get('tether.radiusGrowth', 2.0));
const M = config.get('tether.edgeMargin', 512);
const L1in = T1 - M;
const L1out = T1 + M;
const L2in = T2 - M;
const L2out = T2 + M;
const ANCHOR_MIN = config.get('gates.anchorMinDistance', 3500);
const ANCHOR_MAX = Math.min(T1, config.get('gates.anchorMaxDistance', 4608));
const T2 = T1 * 2;
const EPS = 1;
const inRim = (d) =>
(d > L1in + 1e-9 && d < L1out - 1e-9) || (d > L2in + 1e-9 && d < L2out - 1e-9);
for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
const g = Galaxy.create(seed);
g.generateAll();
let r1Bad = 0, r1Checked = 0;
let r2Bad = 0, r2Checked = 0;
const r1Examples = [];
const anchorDs = []; // gate→anchor distances (to verify the variation)
let gateRimBad = 0;
const gateRimExamples = [];
// Rule 2
let l1Bad = 0, connBad = 0, rimBad = 0, systems = 0;
let whyL1 = '', whyConn = '', whyRim = '';
const r2ByN = new Map(); // N → [l1ok, connOk, rimOk, systems]
const r2ByN = new Map(); // N → [checked, bad]
for (const rec of g.records) {
const c = g.contentOf(rec.id);
const planets = c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name }));
const space = c.settlements.filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name }));
const isHome = rec.id === g.homeSystemId;
const objects = [...planets, ...space];
if (isHome) objects.push({ x: 0, y: 0, name: 'home' }); // the home world (a world — a valid Rule-1 solid)
const objects = [...c.planets.map((p) => ({ x: p.x, y: p.y }))]
.concat(c.settlements.filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y })));
if (isHome) objects.push({ x: 0, y: 0 }); // the home world
const isBarren = objects.length === 0;
// Rule 1 (gates < 5000 px from a planet/station; anchor band 3500–4608; barren exempt)
const anchorSet = isHome
? [...c.planets.map((p) => ({ x: p.x, y: p.y })), { x: 0, y: 0 }]
: c.planets.map((p) => ({ x: p.x, y: p.y }));
// Rule 1 (strictly < 5000 px from a planet/station; anchor band 3500–4990; barren exempt)
for (const j of c.jumps) {
r1Checked++;
if (isBarren) continue;
@ -86,82 +58,32 @@ for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
if (r1Examples.length < 3) r1Examples.push(`${rec.id} gate→${j.to} d=${Math.round(best)}`);
}
let aBand = -1;
const anchors = isHome ? [...planets, { x: 0, y: 0, name: 'home' }] : planets;
for (const a of anchors) {
for (const a of anchorSet) {
const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d >= ANCHOR_MIN - 1e-6 && d <= ANCHOR_MAX + 1e-6) { aBand = d; break; }
if (d >= 3500 - 1e-6 && d <= 4990 + 1e-6) { aBand = d; break; }
}
if (aBand >= 0) anchorDs.push(aBand); // the gate's anchoring planet
// Rim band against the OTHER solids (the anchor's own distance is
// on the step band (≤ L1in — clearly inside, by construction), so
// it can never count here).
const solids = [...planets, ...space];
if (isHome) solids.push({ x: 0, y: 0 });
for (const s of solids) {
const d = Math.hypot(j.x - s.x, j.y - s.y);
if (inRim(d)) {
gateRimBad++;
if (gateRimExamples.length < 3) gateRimExamples.push(`${rec.id} gate→${s.name ?? 'obj'} d=${Math.round(d)}`);
}
}
}
// Rule 2 (discovery chains + edge margin)
// Rule 2
if (objects.length >= 2) {
systems++;
const nodes = isHome ? [...objects, { x: 0, y: 0, name: 'home' }] : objects;
const N = nodes.length;
const adj1 = Array.from({ length: N }, () => []);
const adj2 = Array.from({ length: N }, () => []);
let sysRim = true;
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
const d = Math.hypot(nodes[i].x - nodes[j].x, nodes[i].y - nodes[j].y);
if (d <= L1in + 1e-9) { adj1[i].push(j); adj1[j].push(i); }
if (d <= L2in + 1e-9) { adj2[i].push(j); adj2[j].push(i); }
if (inRim(d)) {
sysRim = false;
if (!whyRim) whyRim = `${rec.id}: ${nodes[i].name}↔${nodes[j].name} @ ${Math.round(d)} px`;
}
for (const o of objects) {
r2Checked++;
let best = Infinity;
for (const q of objects) {
if (q === o) continue;
best = Math.min(best, Math.hypot(o.x - q.x, o.y - q.y));
}
const [cc, bb] = r2ByN.get(objects.length) ?? [0, 0];
r2ByN.set(objects.length, [cc + 1, bb + (best > T2 + EPS ? 1 : 0)]);
if (best > T2 + EPS) r2Bad++;
}
// a) every planet/station (not the home world) has an L1 neighbor
let l1ok = true;
for (let i = 0; i < N; i++) {
if (nodes[i].name === 'home') continue;
if (adj1[i].length === 0) {
l1ok = false;
if (!whyL1) whyL1 = `${rec.id}: ${nodes[i].name} has no level-1 neighbor`;
}
}
// b) level-2 graph connected
let conn = true;
const seen = new Set([0]);
const queue = [0];
while (queue.length) {
const i = queue.shift();
for (const j of adj2[i]) if (!seen.has(j)) (seen.add(j), queue.push(j));
}
if (seen.size !== N) conn = false;
if (!conn && !whyConn) whyConn = `${rec.id}: level-2 graph splits ${seen.size}/${N}`;
if (!l1ok) l1Bad++;
if (!conn) connBad++;
if (!sysRim) rimBad++;
const [c1, c2, c3, cc] = r2ByN.get(N) ?? [0, 0, 0, 0];
r2ByN.set(N, [c1 + (l1ok ? 1 : 0), c2 + (conn ? 1 : 0), c3 + (sysRim ? 1 : 0), cc + 1]);
}
}
console.log(`\nseed="${seed}" systems=${g.records.length} (L1=${T1}px, L2=${T2}px, edgeMargin=${M}px)`);
console.log(` Rule 1 (gates < 5000px from a planet/station, anchor band ${ANCHOR_MIN}–${ANCHOR_MAX}, barren exempt): ${r1Checked - r1Bad}/${r1Checked} OK${r1Bad ? ' — BAD: ' + r1Examples.join(', ') : ''}`);
console.log(` Rule 1b (no gate in the rim band around another solid): ${gateRimBad ? 'BAD — ' + gateRimExamples.join(', ') : 'OK'}`);
console.log(`\nseed="${seed}" systems=${g.records.length} (tether L1=${T1}px, 2×L1=${T2}px)`);
console.log(` Rule 1 (gates < 5000px from a planet/station, anchor band 3500–4990, barren exempt): ${r1Checked - r1Bad}/${r1Checked} OK${r1Bad ? ' — BAD: ' + r1Examples.join(', ') : ''}`);
const byN = [...r2ByN.entries()].map(([n, [cc, bb]]) => `${n}-obj ${cc - bb}/${cc}`).join(', ');
const anchorSorted = [...anchorDs].sort((a, b) => a - b);
if (anchorSorted.length) {
const distinct = new Set(anchorSorted.map((d) => Math.round(d / 50))).size;
console.log(` gate→anchor distances: min=${Math.round(anchorSorted[0])} max=${Math.round(anchorSorted[anchorSorted.length - 1])}, ~${distinct} distinct radii (band ${ANCHOR_MIN}–${ANCHOR_MAX})`);
}
const byN = [...r2ByN.entries()].map(([n, [a, b, cc, tot]]) => `${n}-obj L1 ${a}/${tot}, L2-conn ${b}/${tot}, margin ${cc}/${tot}`).join('; ');
console.log(` Rule 2 (L1 chain + L2 connectivity + edge margin, ${systems} systems): L1 ${systems - l1Bad}/${systems}${l1Bad ? ' — ' + whyL1 : ''}`);
console.log(` L2 connectivity ${systems - connBad}/${systems}${connBad ? ' — ' + whyConn : ''}`);
console.log(` edge margin ${systems - rimBad}/${systems}${rimBad ? ' — ' + whyRim : ''}`);
console.log(` by system size: ${byN}`);
const distinct = new Set(anchorSorted.map((d) => Math.round(d / 50))).size;
console.log(` gate→anchor distances: min=${Math.round(anchorSorted[0])} max=${Math.round(anchorSorted[anchorSorted.length - 1])}, ~${distinct} distinct radii (band 3500–4990)`);
console.log(` Rule 2 (every object ≤ ${T2}px from another, N=1 exempt): ${r2Checked - r2Bad}/${r2Checked} OK — ${byN}`);
}

View File

@ -232,28 +232,22 @@ world** — solid, rendered, flyable-to. Rules and seams:
- **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`)
places each system's planets and free-space stations (the home world at
the origin in the starting system; every other system's center is EMPTY —
its star is invisible flavor, never rendered) on a COMPACT ARC at one
orbital radius, built around TETHER DISCOVERY (data/tether.json):
CONSECUTIVE objects sit a level-1 tether apart (≤ 5120 − edgeMargin — a
level-1 tether from any one of them always discovers a neighbor, so a
chain of level-1 tethers walks the whole system), every other pair sits
clearly inside or outside level 2 (the level-2 graph is connected — level
2 DEFINITELY discovers while undiscovered objects remain), and NO
anchorable pair (planets, stations, home world) sits in the rim band
around a tether boundary (± edgeMargin — data/tether.json →
`edgeMargin`, 512 px — objects are never at the edge of a tether region,
where the scan misses them). Level 3 is never required for discovery.
The orbital radius (center-to-object) sits in
`[minSpacing, maxSpacing]` (6400–15360 px; home additionally ≤
level-2 − edgeMargin = 9728 — the home world IS an anchor), and the
consecutive gap in `[neighborMin, level-1 − edgeMargin]` (2048–4608 px).
The arc's step angle is a deterministic scan that serves the system's
gate target bearings. (The old regular N-gon ring is gone — its chords
put 2- and 3-object systems past level 2 and spread 4- and 5-object
systems too thin.) Planet-bound settlements (colonies, mining stations,
cloud bases) are features of their planet, not layout objects.
places each system's planets and free-space stations on a ring (a
regular polygon) around the system center (the home world at the
origin in the starting system; every other system's center is EMPTY —
its star is invisible flavor, never rendered), enforcing the SOLAR
SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout
objects — planets, stations (and the home world, starting system
only) — sits center-to-center in `[minSpacing, maxSpacing]` (6400–15360 px; the starting system's
band tightens to `[minSpacing, homeMaxSpacing]` = 6400–10240 px). The
ring radius is chosen inside the band (non-home: `maxSpacing` over the
chord of the N-gon; home: the (N+1)-gon side over the band's tight
ratio), and the rotation (ring phase) is a deterministic scan that
serves the system's gate target bearings. The starting system is capped
at 3 objects (2 planets + ≤ 1 station) — five points cannot sit
6400–10240 px apart (the tightest 5-point spacing needs ratio ≥ φ >
1.6). Planet-bound settlements (colonies, mining stations, cloud bases)
are features of their planet, not layout objects.
Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
'layout')` — layout results are seed-deterministic *and* don't perturb
the content stream (lazy === eager is preserved). Class sizes/tints in
@ -311,19 +305,17 @@ exit toward a NEARBY star on the 2-D map. Two layers:
preferring a working network over the cap.
- **The in-system placement** — `SystemGenerator.layoutGates(...)` (pure)
places each gate on one of the radius steps spanning
`anchorMinDistance`–`anchorMaxDistance` (default 3500–4608 px — the
`anchorMinDistance`–`anchorMaxDistance` (default 3500–4990 px — the
distance VARIES per gate, always inside the level-1 tether (5120 px,
`data/tether.json`) and strictly < 5000 px) around one of the
system's ANCHORS (a PLANET — free-space stations are deliberately not
anchors, since the build console lives on a world — or the home world
in the starting system) — so the tether rule is hard by
construction. Candidates are tried in this order: same side of the
anchor as the target FIRST, then OUTSIDE the rim bands (± edgeMargin —
a gate must never sit at the edge of ANOTHER object's tether either),
then the facing tier — the ray-circle intersection (gate exactly on
the system→star ray), the circle point aimed at the star, then a ±75°
scan — so the direction rule is soft (same side of the anchor as the
target, deviation < 90°; in practice ~98% are within 75°). Gates stay `minRadius..maxRadius` (2048–20480
construction. Candidates are tried in facing quality: the ray-circle
intersection (gate exactly on the system→star ray), the circle point
aimed at the star, then a ±75° scan — so the direction rule is soft
(same side of the anchor as the target, deviation < 90°; in practice
~98% are within 75°). Gates stay `minRadius..maxRadius` (2048–20480
px) from the system center, keep `size` + `clearance` from solid discs
(planets, free-space stations, the home world) and
2·`size` + `gateGap` from each other, and get unique names ("Avidy

View File

@ -69,10 +69,6 @@ export class CommsHub {
* the mineral bar's lower-right (the box docks
* below it; null → the standard corner fallback)
* onPause?: (on: boolean) => void, the ack pause's scene-side effects
* onStart?: (comm: object) => void, the comm STARTED playing (its id
* is on the run's played-ledger
* already — scene bookkeeping
* that rides on a comm starting)
* }} [o]
*/
constructor(scene, o = {}) {
@ -82,7 +78,6 @@ export class CommsHub {
this.busy = typeof o.busy === 'function' ? o.busy : null;
this.anchor = typeof o.anchor === 'function' ? o.anchor : null;
this.onPause = typeof o.onPause === 'function' ? o.onPause : null;
this.onStart = typeof o.onStart === 'function' ? o.onStart : null;
this.queue = []; // { def, dueAt, assets, loadingDone }
this.current = null; // the comm in play (its def)
@ -243,17 +238,6 @@ export class CommsHub {
this.current = def;
this._voiceDone = false;
this.played.add(def.id); // the run has heard it (or is hearing it)
// The scene's bookkeeping for the STARTED comm (the 'Leaving the
// Nest' quest's Trestle witnesses flip on exactly this ledger mark —
// the checklist row ticks and the READY moment rides on it). A
// failing hook must not hold the play.
if (this.onStart) {
try {
this.onStart(def);
} catch (e) {
console.warn('[comms] onStart hook failed', e);
}
}
// The ack comm freezes the action FIRST (before the box slides in,
// before the voice) — the ship is already parked as the box lands.
// Visual/audio comms never pause (the agreed contract — they are the

View File

@ -51,40 +51,36 @@ const DEG = Math.PI / 180;
* The STARTING system is special: the player's home world sits at the
* origin (not a generated planet, fixed key 'home'), and the system always
* holds exactly two more planets — a gas giant and a rocky world. With the
* home world, three planets, always. It is capped at ONE free-space
* station by design — the home system stays a small start the player
* expands out of (the arc layout could seat more; the cap keeps the
* starting system readable — see layoutSystem + generateSettlements).
* Its TYPE is also
* home world, three planets, always. And it is capped at ONE free-space
* station: five objects (the home world + four) cannot sit 6400..10240 px
* apart — the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618,
* which the home band 10240/6400 = 1.6 cannot give — so the home system
* stays a ≤ 4-object configuration (see layoutSystem). Its TYPE is also
* fixed: always 'main' (Main Sequence — the player's home is an ordinary
* star; Galaxy.js forces it at roster build, data/galaxy.json →
* startingSystem.type), so its star class rolls from the main-sequence
* table (G/K/M).
*
* LAYOUT (data/planets.json → solarSystem) — built around TETHER
* DISCOVERY (data/tether.json): the layout objects (planets + free-space
* stations — the central body, the home world, sits at the local origin
* of the STARTING system only; every other system has an EMPTY center —
* the star is invisible flavor, generated as `star` but never rendered)
* sit on a COMPACT ARC at one orbital radius: consecutive objects a
* level-1 tether apart (≤ 5120 − edgeMargin — a level-1 tether from any
* one of them ALWAYS discovers a neighbor), every other pair clearly
* inside or outside level 2 — and NO object sits in the rim band around
* any tether boundary (± edgeMargin — the scan edge case). A chain of
* level-1 tethers discovers the whole system, so level 3 is never
* required (the old regular N-gon ring is gone — its chords put 2- and
* 3-object systems PAST level 2 and spread 4- and 5-object systems too
* thin). The rotation is chosen to serve the jump gates — objects bias
* toward the directions the system jumps (see layoutSystem + layoutGates).
* LAYOUT (data/planets.json → solarSystem) — the SOLAR SYSTEM BAND: every
* PAIR of layout objects (planets and free-space stations — the central
* body, the home world, sits at the local origin of the STARTING system
* only; every other system has an EMPTY center — the star is invisible
* flavor, generated as `star` but never rendered) sits 6400..15360 px
* apart, center to center; in the home system the band tightens to
* 6400..10240 px. Normal systems lay out as a regular N-gon ring around
* the (empty) center; the home system as a regular (N+1)-polygon with the
* home world as one vertex. The rotation is chosen to serve the jump
* gates — objects bias toward the directions the system jumps (see
* layoutSystem + layoutGates).
*
* JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js):
* 1–3 gates per system — each placed on the side of the system facing its
* destination star on the 2-D map (an upper-right gate jumps to a star in
* the upper right). An ANCHORED system (a planet, or the home world in
* the starting system) hosts its gates 3500–4608 px from an anchor
* the starting system) hosts its gates 3500–4990 px from an anchor
* (data/gates.json → anchorMinDistance/anchorMaxDistance — the distance
* VARIES per gate, always CLEARLY inside the level-1 tether (≤ 5120 −
* edgeMargin — never at its rim) and strictly < 5000 px) — planets ONLY (free-space stations are
* VARIES per gate, always inside the level-1 tether (5120 px) and
* strictly < 5000 px) — planets ONLY (free-space stations are
* deliberately not anchors: the build console lives on a world, so every
* gate must be tether-reachable from a planet the player can build out
* from). A BARREN
@ -329,75 +325,56 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
/**
* Top-down layout of a system's objects — its planets and its free-space
* stations (the "layout objects"; planet-bound settlements are features of
* their planet and are NOT layout objects). The central body — the home
* world at the local origin in the starting system, an invisible star
* (never rendered) elsewhere — is part of the rule but never rendered as
* a planet.
* stations — under the SOLAR SYSTEM BAND (data/planets.json →
* solarSystem):
*
* DISCOVERY IS TETHER-BASED (data/tether.json): level 1 = 5120 px, level
* 2 = 10240 px, level 3 = 20480 px from an anchor. The layout is built
* around the design rules:
* Every PAIR of layout objects — the planets, the free-space stations,
* and the central body (the star, or the HOME world in the starting
* system — which sits at the local origin) — is at least `minSpacing`
* and at most the band's maximum apart, center to center:
*
* 1. A LEVEL-1 tether from any planet or station discovers a neighbor —
* in fact ALWAYS: CONSECUTIVE layout objects sit a level-1 distance
* apart (≤ 5120 − edgeMargin), so a chain of level-1 tethers walks
* the whole arc. (The player can always build on a discovered world
* or station — every one is an anchor in its own right.)
* 2. A LEVEL-2 tether DEFINITELY discovers an object while undiscovered
* objects remain: the level-2 adjacency graph over the objects (and
* the home world, starting system) is CONNECTED — it contains the
* level-1 chain, so it holds by construction.
* 3. No LEVEL-3 tether is ever REQUIRED to find everything — the
* level-1 chain above makes level 3 a reachability luxury, not a
* discovery need. Far pairs MAY sit beyond level 2 — that is the
* point of "not everything within two tether regions".
* normal systems : minSpacing..maxSpacing (6400..15360 px)
* home system : minSpacing..homeMaxSpacing (6400..10240 px)
*
* EDGE MARGIN (data/tether.json → edgeMargin, 512 px): NO anchorable
* object pair (planets, stations, home world) sits in the no-man's-land
* band around a tether rim — (L1 − margin, L1 + margin) or (L2 − margin,
* L2 + margin). Objects are clearly inside or clearly outside each tether
* region, never at the edge where the scan misses them. The invisible
* star is NOT a tether anchor, so a normal system's star–planet distance
* only needs the plain orbital band.
* (The old "every object within maxNeighbor of some object" rule is
* implied: ≤ 6 objects inside a 15360 px band keeps every object within
* level-2/3 tether of the others.)
*
* THE SHAPE: the N objects sit on ONE orbit (radius r from the center) as
* a compact arc — consecutive objects a level-1 gap apart (chord
* 2·r·sin α, α the half-step) — replacing the old regular N-gon ring,
* which put 2- and 3-object systems past level 2 (chords
* 2·r·sin(π(N−1)/N)) and spread 4- and 5-object systems too thin. The
* step angle α is chosen deterministically (a seeded scan over the SAFE α
* — those where EVERY pair k apart, chord 2·r·|sin kα|, is level-1 for
* k = 1 and clearly outside/inside level 2 for k ≥ 2) to land the
* consecutive gap near a seeded target in [neighborMin, L1 − margin] —
* variety across systems, every rule hard by construction.
*
* Bands (data/planets.json → solarSystem):
* orbital radius r (center-to-object):
* normal systems : minSpacing..maxSpacing (6400..15360 px)
* home system : minSpacing..homeMaxSpacing, additionally ≤
* level-2 − edgeMargin (9728 px — the home world is
* an anchor, so its pairs respect the bands)
* consecutive gap : neighborMin..(level-1 − edgeMargin) (2048..4608 px)
* Shapes that satisfy a full pairwise band exactly:
* N = 1, non-home — a single point at distance R ∈ [min, max]; its only
* pair (with the star) is just R.
* N ≥ 2, non-home — a REGULAR N-GON RING around the star: every pair is
* a chord, the longest being 2·R·sin(⌊N/2⌋·π/N), so
* R ∈ [min, max / (2·sin(⌊N/2⌋·π/N))].
* home system — a REGULAR (N+1)-POLYGON with the home world as ONE
* VERTEX (the origin): every pair is a polygon chord,
* the longest ratio(N+1)·side, so side ∈ [min,
* homeMax / ratio(N+1)]. (The home world + 4 objects —
* 5 points — would need a max/min ratio ≥ φ ≈ 1.618,
* which the home band 10240/6400 = 1.6 cannot give;
* that is why the home system is capped at 3 objects:
* its 2 fixed planets + at most one free-space
* station — see generateSettlements.)
*
* What counts as an object: planets (all of them) and free-space
* settlements (anchor.type 'space' — deep-space stations, waypoints).
* Planet-bound settlements are features OF their planet (a colony sits on
* it) and so are not layout objects of their own. The central body is the
* origin (the game renders the home world there in the starting system;
* elsewhere it is the star — the visual).
* elsewhere it is the star — the visual, but part of the spacing rule).
*
* The ROTATION (arc phase) is chosen to serve the jump gates: it
* minimizes the worst perpendicular distance between a gate's target ray
* and the nearest object, so layoutGates can sit each gate on the true
* target ray while staying tether-reachable. Without targets (gates
* disabled, one-system galaxy) it falls back to a seeded random rotation.
* The ROTATION (ring phase / polygon orientation) is chosen to serve the
* jump gates: it minimizes the worst perpendicular distance between a
* gate's target ray and the nearest object, so layoutGates can sit each
* gate on the true target ray while staying tether-reachable. Without
* targets (gates disabled, one-system galaxy) it falls back to a seeded
* random rotation.
*
* Each planet also gains scale (size multiplier, data/planets.json →
* classScale — the distances are center-to-center, but the rendered discs
* still scale by class).
* classScale — the band is center-to-center, but the rendered discs still
* scale by class).
*
* Determinism: the radius/gap draws come from the dedicated fork
* Determinism: the radius/side draw comes from the dedicated fork
* (seed, 'system', id, 'layout'); the rotation is a pure function of
* (shape, target angles). Same seed ⇒ same layout, and lazy
* (on-arrival) === eager (generateAll).
@ -408,7 +385,7 @@ function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles)
const MIN = Math.max(1, band.minSpacing ?? 6400);
const MAX = Math.max(MIN, isHome ? (band.homeMaxSpacing ?? 10240) : (band.maxSpacing ?? 15360));
// Rendered size per class (visual only — the distances are center-to-center).
// Rendered size per class (visual only — the band is center-to-center).
for (const p of planets) {
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
}
@ -420,52 +397,45 @@ function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles)
if (N === 0) return;
const lay = Rng.derive(seed, 'system', systemId, 'layout');
const B = tetherBands();
// Orbital radius (every object shares the one orbit). The home world is
// a TETHER ANCHOR, so its distances to the objects respect the bands —
// the ceiling falls at level-2 − edgeMargin (9728 px, clearly inside
// level 2, off the rim); the star of a normal system is not an anchor,
// so its distances only need the plain band.
const Rmax = isHome ? Math.min(MAX, B.L2in) : MAX;
if (Rmax < MIN) {
console.warn(
`[orbit] ${systemId}: the orbital radius band [${MIN}, ${Math.round(Rmax)}] px is empty — using the minimum spacing`,
);
}
const r = lay.range(MIN, Math.max(MIN, Rmax));
// N = 1: a single point at distance r — its only anchorable pair is
// with the central body (home world: r ≤ L2in, and 6400 > L1out —
// clearly inside level 2 and clearly outside level 1 ✓).
if (N === 1) {
const single = (phi) => [{ x: r * Math.cos(phi), y: r * Math.sin(phi) }];
const phi = targetAngles ? bestRotation(single, targetAngles) : lay.range(0, TAU);
objects[0].x = single(phi)[0].x;
objects[0].y = single(phi)[0].y;
return;
}
// N ≥ 2: a COMPACT ARC of consecutive objects. The consecutive gap is
// level-1 (clearly inside — discoverable) and disc-clear; every other
// pair sits off both tether rims. A chain of level-1 tethers discovers
// the whole system; level 2 is the guaranteed fallback, level 3 never
// required (see the docstring).
const nMin = Math.max(2, band.neighborMin ?? 2048);
const nMax = Math.max(nMin, B.L1in);
const dTarget = lay.range(nMin, nMax); // desired consecutive gap (seeded variety)
const alpha = chooseHalfStep(N, r, nMin, dTarget, B);
const step = 2 * alpha; // step angle between consecutive objects
// `place(phi)` → the N object positions [{x, y}] for rotation `phi`.
const place = (phi) => {
const out = [];
for (let k = 0; k < N; k++) {
const a = phi + k * step;
out.push({ x: r * Math.cos(a), y: r * Math.sin(a) });
let place;
if (isHome) {
// Regular (N+1)-gon, the home world (origin) as vertex 0: vertex k is
// Rc·(u(φ + 2πk/m) − u(φ)) — a chord of length 2·Rc·sin(πk/m).
const m = N + 1;
const sideMax = MAX / ratioOf(m);
if (sideMax < MIN) {
console.warn(
`[orbit] ${systemId}: ${N} objects cannot fit the home band [${MIN}, ${MAX}] px — using the minimum spacing`,
);
}
return out;
};
const side = lay.range(MIN, Math.max(MIN, sideMax));
const Rc = side / (2 * Math.sin(Math.PI / m));
place = (phi) => {
const out = [];
for (let k = 1; k < m; k++) {
out.push({
x: Rc * (Math.cos(phi + (TAU * k) / m) - Math.cos(phi)),
y: Rc * (Math.sin(phi + (TAU * k) / m) - Math.sin(phi)),
});
}
return out;
};
} else {
// Regular N-gon ring around the star (origin). N = 1: one point at
// distance R — its only pair (with the star) is just R.
const rMax = N === 1 ? MAX : MAX / (2 * Math.sin((Math.floor(N / 2) * Math.PI) / N));
const R = lay.range(MIN, Math.max(MIN, rMax));
place = (phi) => {
const out = [];
for (let k = 0; k < N; k++) {
const a = phi + (TAU * k) / N;
out.push({ x: R * Math.cos(a), y: R * Math.sin(a) });
}
return out;
};
}
const phi = targetAngles ? bestRotation(place, targetAngles) : lay.range(0, TAU);
const slots = place(phi);
@ -475,89 +445,11 @@ function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles)
}
}
/**
* The tether discovery bands (data/tether.json): the level radii the
* player actually uses + the EDGE MARGIN around each rim — the
* no-man's-land bands where no object sits. L1in/L1out = the level-1
* boundary ± edgeMargin (clearly inside / clearly outside level 1);
* likewise L2in/L2out for level 2.
*/
function tetherBands() {
const L1 = Math.max(1, config.get('tether.level1Radius', 5120));
const growth = Math.max(1, config.get('tether.radiusGrowth', 2.0));
const M = Math.max(0, config.get('tether.edgeMargin', 512));
const L2 = L1 * growth;
return {
L1, L2, M,
L1in: L1 - M,
L1out: L1 + M,
L2in: L2 - M,
L2out: L2 + M,
};
}
/**
* The safe half-step angle α for the N-object arc at orbit radius r: with
* step angle 2α, objects k apart sit 2·r·|sin kα| apart (|sin| also covers
* the wrap past a half-circle). α is SAFE when:
* - k = 1: nMin ≤ d ≤ L1in — the consecutive gap is clearly inside
* level 1 (discoverable) and clear of the rendered discs;
* - k ≥ 2: d NOT in (L1in, L1out) nor in (L2in, L2out) — every other
* pair sits clearly inside or clearly outside each tether region,
* never at a rim (the scan edge case).
* A deterministic scan (coarse + local refine, like bestRotation) picks
* the safe α whose consecutive gap is nearest the seeded target — variety
* across systems, every rule hard by construction. If no α passes
* (degenerate config / N too large for this radius), fall back to the
* level-1 cap and warn.
*/
function chooseHalfStep(N, r, nMin, dTarget, B) {
const aMax = Math.asin(Math.min(1, B.L1in / (2 * r)));
const aMin = Math.asin(Math.min(1, Math.min(nMin, B.L1in) / (2 * r)));
const safe = (a) => {
for (let k = 1; k < N; k++) {
const d = 2 * r * Math.abs(Math.sin(k * a));
if (k === 1) {
if (d < nMin - 1e-9 || d > B.L1in + 1e-9) return false;
} else if (
(d > B.L1in + 1e-9 && d < B.L1out - 1e-9) ||
(d > B.L2in + 1e-9 && d < B.L2out - 1e-9)
) {
return false;
}
}
return true;
};
const gap = (a) => 2 * r * Math.abs(Math.sin(a));
const score = (a) => (safe(a) ? Math.abs(gap(a) - dTarget) : Infinity);
let best = null; // [α, score]
const COARSE = 2048;
for (let s = 0; s < COARSE; s++) {
const a = aMin + ((aMax - aMin) * s) / (COARSE - 1);
const sc = score(a);
if (best === null || sc < best[1] - 1e-12) best = [a, sc];
}
if (best !== null && best[1] !== Infinity) {
// Local refine around the coarse winner (the safe set is a union of
// intervals — this pins the gap to well under a pixel).
const w = (2 * (aMax - aMin)) / (COARSE - 1);
for (let s = 0; s < 256; s++) {
const a = best[0] - w + ((2 * w) * s) / 255;
if (a < aMin - 1e-12 || a > aMax + 1e-12) continue;
const sc = score(a);
if (sc < best[1] - 1e-12) best = [a, sc];
}
return best[0];
}
// Degenerate (N too large for this orbit radius, or a config conflict):
// the level-1 cap keeps the consecutive gap safe; a far pair may end up
// in a rim band — flag it for inspection rather than fail.
console.warn(
`[orbit] arc layout: no safe half-step for ${N} objects at r=${r} px — ` +
'using the level-1 cap (a far pair may sit in a tether band)',
);
return aMax;
/** Max-chord / side ratio of a regular `m`-gon (m ≥ 3); 1 for m < 3. */
function ratioOf(m) {
if (m < 3) return 1;
const half = Math.floor(m / 2);
return Math.sin((half * Math.PI) / m) / Math.sin(Math.PI / m);
}
/**
@ -618,10 +510,9 @@ function bestRotation(place, targetAngles) {
* star to the upper right on the map";
* - ANCHORED systems (a planet — or the home world in the starting
* system): the gate sits BETWEEN `anchorMinDistance` and
* `anchorMaxDistance` from an anchor (default 3500–4608 px — the
* distance VARIES per gate, always CLEARLY inside the level-1 tether
* (≤ 5120 − edgeMargin — never at its rim) and strictly < 5000 px) —
* on one of the radius STEPS spanning that
* `anchorMaxDistance` from an anchor (default 3500–4990 px — the
* distance VARIES per gate, always inside the level-1 tether and
* strictly < 5000 px) — on one of the radius STEPS spanning that
* band around the anchor, chosen in order of facing quality: (1) the
* far ray-circle intersection (the gate exactly on the target ray —
* system center, gate, and destination star collinear), (2) the point
@ -636,10 +527,7 @@ function bestRotation(place, targetAngles) {
* tier — so the gate faces its destination FROM THE CENTER (the rule
* the map reads) and is never placed on the far side; an on-ray
* intersection behind the center loses to any destination-side
* aimed/scan point. After the side, RIM-BAND VIOLATIONS order the
* candidates: a gate never sits in the no-man's-land band around
* another object's tether rim (± edgeMargin — the scan edge case) when
* a clean point exists.
* aimed/scan point.
* Anchors are PLANETS
* only (plus the home world at home) — free-space stations are
* deliberately excluded: the build console lives on a world, so every
@ -681,15 +569,13 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
const maxR = Math.max(minR, g.maxRadius ?? 20480);
// The anchor circles: the gate's distance from its anchor VARIES
// across `anchorMinDistance`..`anchorMaxDistance` (data/gates.json —
// default 3500–4608 px: the design rule "strictly < 5000 px from a
// planet or space station" (Rule 1), with the EDGE MARGIN — the gate
// never sits at the rim of the anchor's level-1 tether
// (data/tether.json → edgeMargin)), CAPPED by the anchor's
// default 3500–4990 px: the design rule "strictly < 5000 px from a
// planet or space station", Rule 1), CAPPED by the anchor's
// level-`anchorTetherLevel` tether range (the gate must stay inside
// it). Every candidate below sits exactly on one of these circles, so
// both rules hold by construction.
const tetherRange = anchorTetherRange(g.anchorTetherLevel ?? 1);
const rangeMax = Math.min(tetherRange, Math.max(1, g.anchorMaxDistance ?? 4608));
const rangeMax = Math.min(tetherRange, Math.max(1, g.anchorMaxDistance ?? 4990));
const rangeMin = Math.min(rangeMax, Math.max(1, g.anchorMinDistance ?? 3500));
// Anchors: the PLANETS and (home only) the home world at the origin —
@ -768,48 +654,27 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
// (32 points, ± up to 75° from the target direction) — the
// clearance search for tight systems.
// Every candidate sits EXACTLY `r` from its anchor, `r` one of the
// RADIUS STEPS spanning [rangeMin, rangeMax] (default 3500–4608 px —
// RADIUS STEPS spanning [rangeMin, rangeMax] (default 3500–4990 px —
// the gate's distance from its planet VARIES, always < 5000 and
// clearly inside the level-1 tether — off its rim). The per-gate
// PREFERRED step (a dedicated seeded fork — deterministic, lazy ===
// eager) orders the steps, and clearance/gap pressure pushes the
// gate outward from it (the radius is the LAST tie-break — facing
// quality wins over it). The facing rule (soft — the gate faces its
// destination FROM THE SYSTEM CENTER) is honored by the sort:
// destination-side candidates (side 0) come FIRST — before every
// tier — so a gate is never placed on the far side of the center
// (an on-ray intersection behind the center loses to any
// destination-side aimed/scan point); then the RIM-BAND count
// (a gate never sits in the no-man's-land band around another
// object's tether rim — the scan edge case — when a clean point
// exists); then on-ray → aimed → near-aimed → preferred radius.
// inside the level-1 tether). The per-gate PREFERRED step (a
// dedicated seeded fork — deterministic, lazy === eager) orders the
// steps, and clearance/gap pressure pushes the gate outward from it
// (the radius is the LAST tie-break — facing quality always wins).
// The facing rule (soft — the gate faces its destination FROM THE
// SYSTEM CENTER) is honored by the sort: destination-side candidates
// (side 0) come FIRST — before every tier — so a gate is never
// placed on the far side of the center (an on-ray intersection
// behind the center loses to any destination-side aimed/scan point);
// then on-ray → aimed → near-aimed → preferred radius first.
const STEPS = 8;
const radii = Array.from(
{ length: STEPS },
(_, s) => rangeMin + ((rangeMax - rangeMin) * s) / (STEPS - 1),
);
const radPref = Rng.derive(seed, 'system', record.id, 'gates', i).int(0, STEPS - 1);
// RIM BANDS (data/tether.json → edgeMargin): the no-man's-land
// around each tether rim where no object sits. A gate's distance to
// its OWN anchor is on the step band (≤ L1in — clearly inside, by
// construction), so only the OTHER solids count.
const B = tetherBands();
const inRim = (d) =>
(d > B.L1in + 1e-9 && d < B.L1out - 1e-9) ||
(d > B.L2in + 1e-9 && d < B.L2out - 1e-9);
const bandCount = (px, py, ai) => {
let n = 0;
for (let j = 0; j < discs.length; j++) {
if (j === ai) continue;
const dx = px - discs[j].x;
const dy = py - discs[j].y;
if (inRim(Math.hypot(dx, dy))) n++;
}
return n;
};
const cands = [];
const add = (tier, order, px, py, ai) =>
cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, band: bandCount(px, py, ai), px, py });
const add = (tier, order, px, py) =>
cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, px, py });
anchors.forEach((a, ai) => {
const proj = a.x * ux + a.y * uy; // signed distance along the ray
const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance
@ -817,20 +682,18 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
const rad = (s - radPref + STEPS) % STEPS; // 0 = preferred step
if (h <= r) {
const off = Math.sqrt(Math.max(0, r * r - h * h));
add(1, h * 1e6 + ai * 1000 + rad * 10, (proj + off) * ux, (proj + off) * uy, ai);
add(1, h * 1e6 + ai * 1000 + rad * 10, (proj + off) * ux, (proj + off) * uy);
}
add(2, h * 1e6 + ai * 1000 + rad * 10, a.x + r * ux, a.y + r * uy, ai);
add(2, h * 1e6 + ai * 1000 + rad * 10, a.x + r * ux, a.y + r * uy);
for (let k = 0; k < 32; k++) {
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3 + rad * 1e-4, a.x + r * dx, a.y + r * dy, ai);
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3 + rad * 1e-4, a.x + r * dx, a.y + r * dy);
}
});
});
cands.sort(
(p, q) => p.side - q.side || p.band - q.band || p.tier - q.tier || p.order - q.order,
);
cands.sort((p, q) => p.side - q.side || p.tier - q.tier || p.order - q.order);
const ok = (c) => {
const d2c = c.px * c.px + c.py * c.py;
@ -858,7 +721,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
}
if (!chosen) {
// Every candidate failed clearance (nearly impossible — an anchor's
// circles span 3500–4608 px, the discs under a thousand): take
// circles span 3500–4990 px, the discs under a thousand): take
// the first DESTINATION-SIDE aimed point anyway — the tether and
// facing rules outrank cosmetics.
chosen =
@ -1266,8 +1129,7 @@ function mixTint(hex, strength) {
*
* home system → fixed: 2 planets (gas giant + rocky) beside the home
* world, + at most one free-space station (deepSpace only —
* by design: the home system stays a small start the player
* expands out of; the arc layout could seat more)
* a waypoint would be a 5th point the home band can't hold);
* every other → 1) the FINAL object count N from the configured table
* (0 = barren, then 2/3/4/5 at the configured weights — the
* distribution is on the FINAL count, by design);

View File

@ -9,8 +9,8 @@
* Only the two things the run owns are state:
*
* granted — which quest ids the player HOLDS (the starter quest is
* granted on the run's first boot; the run's chain quests are
* granted by their unlock requirement — see isUnlockMet / grantUnlocked)
* granted on the run's first boot; future quests are granted
* by whatever event issues them)
* claimed — which of the held quests have PAID OUT their reward
* priority — the ONE priority quest the tracker HUD features (title +
* live checklist). The player sets/clears it; the ledger
@ -42,21 +42,6 @@ export function starterQuestIds() {
.map((q) => q.id);
}
/**
* The quest's unlock requirement (data/quests.json → quests[].unlocks):
* claimed: [ids] — the quests whose rewards must be CLAIMED (the
* ledger's paid-out set) before this one is granted.
* Absent/empty = no requirement (the quest grants on first sight — the
* boot's own are flagged starter:true instead). Pure: data + the ledger.
* @returns {boolean}
*/
export function isUnlockMet(state, id) {
const def = questDef(id);
if (!def || !state) return false;
const req = Array.isArray(def?.unlocks?.claimed) ? def.unlocks.claimed : [];
return req.every((x) => typeof x === 'string' && x !== '' && state.claimed.has(x));
}
/**
* A held-quest ledger. The ids come from data/quests.json (validated
* against it on load — a save pointing at a removed quest just drops it).
@ -86,24 +71,6 @@ export class QuestState {
return this.granted.has(id);
}
/**
* Grant every not-yet-held quest whose unlock requirement (unlocks.
* claimed) is met. The starter quests are the boot's own (grant via
* starterQuestIds on a fresh ledger); this is the RUN's chain — the
* claim that pays one quest is what arms the next (the scene calls it
* on the claim + on scene create for loaded saves). give() is
* idempotent, so re-calls are silent no-ops.
* @returns {string[]} the NEWLY granted ids (data order)
*/
grantUnlocked() {
const fresh = [];
for (const def of questDefs()) {
if (!def || this.granted.has(def.id) || def.starter === true) continue;
if (isUnlockMet(this, def.id) && this.give(def.id)) fresh.push(def.id);
}
return fresh;
}
isClaimed(id) {
return this.claimed.has(id);
}

View File

@ -122,10 +122,7 @@ export function captureState(scene, now) {
quests: scene.questState ? scene.questState.toJSON() : null,
// Comms — the run's ALREADY-PLAYED ids (data/comms.json: the `once`
// comms the run has heard, so a load doesn't re-fire them and a new
// game re-plays the welcome — INCLUDING Trestle's 'Leaving the Nest'
// beats: trestle-brief-1/2/3 + trestle-arrive, the witnesses of the
// quest's two social checks, GameScene._trestleCheck). The ledger
// lives in the registry
// game re-plays the welcome). The ledger lives in the registry
// ('commsPlayed' — js/comms/CommsHub.js owns it). A save predating
// comms has no field; the restore stages an empty set (old saves
// load).

View File

@ -36,7 +36,6 @@ import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
import { CommsHub } from '../comms/CommsHub.js';
import { commsForTrigger } from '../comms/CommsModel.js';
import { TetherField } from '../tether/TetherField.js';
import { Mining } from '../mining/Mining.js';
import { MiningPopup } from '../ui/MiningPopup.js';
@ -61,7 +60,6 @@ import {
systemIdOfGatesNode,
buildSystemTree,
isNavComplete,
navChart,
activationKeys,
applyActivation,
} from '../research/SystemCategory.js';
@ -910,16 +908,6 @@ export class GameScene extends Phaser.Scene {
this.ship.stop();
if (this.mining && this.mining.isActive) this.mining.stop();
},
// The comm has STARTED (its id is on the run's played-ledger): the
// 'Leaving the Nest' quest's Trestle witnesses flip on exactly this
// moment — all three briefs in the ledger = 'Talk to Trestle' done,
// the arrival in it = 'Meet Trestle' done (GameScene._trestleCheck
// reads the ledger live). Refresh the quest ledger so the checklist
// row ticks over and, if the quest just completed, the READY moment
// (toast + chime) carries it when the tracker is slid off.
onStart: (def) => {
if (def && def.from === 'trestle') this._questRefresh();
},
});
// The run's FIRST rock field (the 'firstCluster' comm beat — see
@ -929,13 +917,6 @@ export class GameScene extends Phaser.Scene {
// re-play a comm the ledger already holds).
this._firstClusterSeen = false;
this._firstWorldSeen = false; // the first DISCOVERED WORLD (Trestle's intro — the quest's own witness)
// The systems whose NAV chart COMPLETED this scene life (the
// 'systemComplete' milestone — oac-system-complete, see
// celebrateDiscovery): one fire per system. A later asteroid-cluster
// discovery in an already-charted system still passes
// isSystemNavComplete() (clusters aren't NAV points), so the set —
// not the chart state — is the arming gate.
this._systemCompleteSeen = new Set();
// The run's FIRST crossing of the 200-mineral line (the 'minerals200'
// comm beat — see onOre): same one-shot shape; the threshold check
@ -1088,7 +1069,7 @@ export class GameScene extends Phaser.Scene {
this.questState = new QuestState();
this.registry.set('quests', this.questState);
}
this._questAnnounced = new Set(); // the 'QUEST READY' toasts + chime, once each
this._questAnnounced = new Set(); // the 'QUEST READY' toasts, once each
this.questWindow = new QuestWindow(this, {
getSnapshot: () => this.questSnapshot(),
onClaim: (id) => this.claimQuest(id),
@ -1189,13 +1170,6 @@ export class GameScene extends Phaser.Scene {
}
}
}
// The RUN's chain (data/quests.json → quests[].unlocks): the quests
// whose unlock requirement (unlocks.claimed — the reward CLAIMED) is
// met land on the ledger now. On a fresh run the starter is unclaimed
// — a no-op; on a LOADED save whose starter is already paid out,
// 'Leaving the Nest' grants here (a fresh run's later claim grants it
// via claimQuest → the same method; give() is idempotent either way).
this._grantUnlockedQuests();
// The field's native set (home + the activated gates) is above; the
// tethers the player INSTALLED on this system's other worlds are
// world state (built records) — re-anchor them on their worlds, since
@ -1517,15 +1491,6 @@ export class GameScene extends Phaser.Scene {
// run the count is zero so this is a no-op; the hub's ledger /
// in-flight guards keep the pair a one-shot either way.
if ((this.discoveredWorldCount?.() ?? 0) >= 1) this.comms.trigger('firstWorld');
// The 'Leaving the Nest' beats (Trestle's briefing + arrival —
// data/comms.json: trestle-brief-1/2/3 / trestle-arrive): the scene-
// create arm covers the loaded-save case — a save parked in the
// neighboring system (the arrival's witness already earned) or one
// whose home gates were already online (the briefing's) still gets
// its beats. _trestleBeats is the guard (quest held + unclaimed) and
// the comm's once-ledger dedupes the rest (a mid-sequence quit re-
// arms only the unplayed briefs).
this._trestleBeats();
}
/**
@ -1794,7 +1759,6 @@ export class GameScene extends Phaser.Scene {
onClearPriority: () => this.clearPriorityQuest(),
onViewQuest: (id) => this.openQuestAt(id),
onShowMore: (id) => this.openQuestAt(id),
onClaim: (id) => this.claimQuest(id),
});
}
@ -3121,23 +3085,6 @@ export class GameScene extends Phaser.Scene {
this._firstWorldSeen = true;
this.comms?.trigger('firstWorld');
}
// The system-complete milestone (data/comms.json: systemComplete —
// oac-system-complete): the moment this system's NAV chart COMPLETES —
// the discovery that makes its 'Unlock Jumpgates' research AVAILABLE
// (the SYSTEM tree's available hook, js/research/SystemCategory.js —
// the node flips live in the research console), NOT the research's
// completion. OAC reads the milestone; the voice rolls one of the
// three discovery-complete clips (audioPool); delayMs lets the
// discovery chime + toast land first. One fire per system — the
// arming set in create(), because a later cluster discovery in a
// charted system still passes isSystemNavComplete() (clusters aren't
// NAV points).
const sysId = this.systemRecord?.id;
if (sysId && this.isSystemNavComplete() && !this._systemCompleteSeen.has(sysId)) {
this._systemCompleteSeen.add(sysId);
this.comms?.trigger('systemComplete');
}
}
showTargetMarker(x, y) {
@ -3316,88 +3263,9 @@ export class GameScene extends Phaser.Scene {
const done = have >= count;
return { label, done, detail: `${have}/${count}` };
}
if (type === 'systemCharted') {
// EVERY NAV point of the named system ('home' = the home system)
// — the same discoverable set that gates the jumpgate research
// (navChart: planets, stations, gates — the central body is
// implicit, clusters don't count).
const sysId = this._questSystemId(check);
const content = sysId && this.galaxy && typeof this.galaxy.contentOf === 'function'
? (() => { try { return this.galaxy.contentOf(sysId); } catch { return null; } })()
: null;
const chart = sysId && content ? navChart(this.discovery, sysId, content) : null;
const done = !!chart && chart.complete;
return { label, done, detail: chart ? `${chart.discovered}/${chart.total}` : '??' };
}
if (type === 'gatesActivated') {
// The named system's jumpgates are LIVE (the activateGates effect's
// keys — the same set _activateSystemJumpgates writes).
const sysId = this._questSystemId(check);
const keys = sysId ? activationKeys(this.galaxy, sysId) : [];
const have = keys.filter((k) => this.activatedGates?.has?.(k)).length;
const done = keys.length > 0 && have === keys.length;
return { label, done, detail: `${have}/${keys.length}` };
}
if (type === 'trestleTalked') {
// Trestle's THREE-BRIEF sequence (data/comms.json, trigger
// 'trestleBrief' — the gates-online briefing): the run's comms
// played-ledger (registry 'commsPlayed') is the witness — all
// three heard = the player talked to him.
return this._trestleCheck('trestleBrief', label, 'TALKED');
}
if (type === 'trestleMet') {
// His ARRIVAL beat (trigger 'trestleArrival' — the player lands in
// a home-neighbor system) in the ledger = met.
return this._trestleCheck('trestleArrival', label, 'MET');
}
return { label, done: false, detail: '??' };
}
/**
* A Trestle story-beat check (the 'Leaving the Nest' quest's two
* social lines): every comm on `trigger` (data/comms.json) in the
* run's comms played-ledger (registry 'commsPlayed' — CommsHub marks
* it when a comm STARTS; SaveData saves/restores it) = done. Reading
* the trigger's roster data-driven keeps the check and the comms
* from drifting (a beat added to the sequence completes the check
* only once it too has been heard), and a quit mid-sequence can't
* fake it — the unplayed beats re-arm on the next load and the
* ledger knows.
* @param {string} trigger the comm trigger the witness rides on
* @param {string} label the checklist line
* @param {string} doneWord the detail once done
*/
_trestleCheck(trigger, label, doneWord) {
const ids = commsForTrigger(config.section('comms', {}), trigger).map((c) => c.id);
const played = this._commsPlayed();
const have = ids.filter((id) => played.has(id)).length;
const done = ids.length > 0 && have === ids.length;
return { label, done, detail: done ? doneWord : `${have}/${ids.length}` };
}
/**
* The run's comms played-ledger (registry 'commsPlayed' — CommsHub
* owns it; SaveData captures/restores it under record.comms.played).
* Empty set when the run hasn't armed a comm yet (headless harnesses
* included — the checks just read zero).
* @returns {Set<string>}
*/
_commsPlayed() {
const cur = this.registry?.get?.('commsPlayed');
return cur instanceof Set ? cur : new Set();
}
/**
* A quest check's system: 'home' → the home system (the nest); a
* literal id otherwise (future quests may name a specific system).
* @returns {string|null}
*/
_questSystemId(check) {
const sys = check?.system;
if (sys === 'home') return this.galaxy?.homeSystemId ?? null;
return typeof sys === 'string' && sys ? sys : null;
}
/**
* How many DISCOVERED worlds are planets or space stations (the
* 'Discover another planet or space station' requirement). The home
@ -3504,92 +3372,6 @@ export class GameScene extends Phaser.Scene {
},
);
this.questWindow?.refresh();
// The RUN's chain — the claim that pays this quest is what arms the
// next (data/quests.json → leaving_the_nest.unlocks.claimed): the
// follow-up grants ONTO the claim (sfx + NEW QUEST toast + the OAC's
// quest-grant voice), queued after this claim's own toast.
this._grantUnlockedQuests();
}
/**
* Grant every not-yet-held quest whose unlock requirement is met
* (QuestState.grantUnlocked — pure ledger work) and announce each new
* grant in the console's voice: the 'something new is here' SFX, the
* OAC's quest-grant voice (data/comms.json: questNew — once:false,
* re-armed on every grant), and the NEW QUEST toast (the starter's
* own toast style — quests.starterToast, one toast slot: the 2.7 s
* delay lands it after the claim toast that preceded it). A fresh
* run's starter boot and a loaded save's boot both call this (the
* create() arm); claimQuest calls it on the claim. Idempotent —
* give() is, and the announcement is per-NEW-grant.
* @returns {string[]} the newly granted ids
*/
_grantUnlockedQuests() {
if (config.get('quests.enabled', true) === false || !this.questState) return [];
const fresh = this.questState.grantUnlocked();
for (const id of fresh) {
const def = questDef(id);
this.playSfx('discovery'); // the 'something new is here' voice
this.comms?.trigger('questNew'); // the OAC's quest-grant voice (audio-only)
this.time.delayedCall(2700, () => {
this.consoleToast(
String(config.get('quests.starterToast.text', 'NEW QUEST — {title}'))
.replace('{title}', String(def?.title ?? id).toUpperCase()),
{
glyph: config.get('quests.starterToast.glyph', '◆'),
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
});
}
if (fresh.length) {
this._questRefresh(); // a grant may have landed already-complete
this._trestleBeats(); // the gates may already be live (the claim was the last piece)
}
return fresh;
}
/**
* The 'Leaving the Nest' quest's Trestle beats (data/comms.json:
* the trestle-brief-1/2/3 sequence + trestle-arrive) — armed from
* every point where their preconditions could have become true while
* the quest is held and unclaimed:
* - the BRIEFING (three back-to-back ack comms), when the home
* system's jumpgates are LIVE — the moment the player can jump
* out of the nest (the activateGates effect — _applyResearchEffects,
* or already-live when the quest grants / a save loads);
* - the ARRIVAL, when the player is IN a home-neighbor system
* (Trestle's side of the gate — scene create on the jump-in or a
* loaded save parked there).
* The comm's once-ledger + the hub's in-flight guard keep each beat a
* one-shot; the QUEST CHECKS are the authoritative witnesses (the
* played-ledger, read by _trestleCheck), so a beat the player never
* heard can't fake the check — and a quit mid-sequence re-arms only
* the unplayed beats here, on the next load.
*/
_trestleBeats() {
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'leaving_the_nest';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
const homeId = this.galaxy?.homeSystemId;
if (!homeId) return;
// The BRIEFING — home's gates are live (the quest's own 'Activate
// System Jump Gates' check holds): Trestle walks the player to the
// gate (one trigger arms all three comms; the hub's FIFO plays them
// back-to-back).
const homeKeys = activationKeys(this.galaxy, homeId);
if (homeKeys.length && homeKeys.every((k) => this.activatedGates?.has?.(k))) {
this.comms?.trigger('trestleBrief');
}
// The ARRIVAL — the player is IN a home-neighbor system (the gate's
// other side). Only home's gates lead out of the nest, so a neighbor
// is exactly 'the neighboring system'.
const cur = this.systemRecord?.id;
if (cur && cur !== homeId) {
const neighbors = this.galaxy?.jumpNetwork?.gates?.get(homeId) ?? [];
if (neighbors.includes(cur)) this.comms?.trigger('trestleArrival');
}
}
/**
@ -3661,7 +3443,6 @@ export class GameScene extends Phaser.Scene {
for (const q of snap.quests) {
if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) {
this._questAnnounced.add(q.id);
this.playSfx('discovery'); // the READY chime — the 'something new is here' voice, once per quest
const t = config.get('quests.readyToast', {});
this.consoleToast(
String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD')
@ -4295,21 +4076,18 @@ export class GameScene extends Phaser.Scene {
// `id` (passed in) is the source of truth — NOT `node.id`.
const sysId = systemIdOfGatesNode(id);
if (sysId) this._activateSystemJumpgates(sysId);
// (The 'systemComplete' milestone beat moved to
// celebrateDiscovery — it fires when this research BECOMES
// AVAILABLE, the chart-completing discovery, not on completion.)
// The 'Leaving the Nest' quest's BRIEFING (data/comms.json:
// trestle-brief-1/2/3 — the three back-to-back ack comms) — the
// HOME system's gates are the run's door out of the nest: the
// moment the player can jump out, Trestle keeps his starter-beat
// promise ('I'll ping you once you're ready'). It lands in front
// of the generic 'researchComplete' sign-off _completeResearch
// queues after the effects — the hub's FIFO plays the specific
// character beat first, then the system sign-off. _trestleBeats
// guards it (quest held + unclaimed) and the comm's once-ledger
// dedupes.
if (sysId && sysId === this.galaxy?.homeSystemId) {
this._trestleBeats();
// The comms beat (data/comms.json: systemComplete —
// oac-system-complete): the milestone — EVERY planet, space station,
// and jump gate of the system is discovered AND its jumpgate
// research is now unlocked. The gates research was only researchable
// while that full chart held (the SYSTEM tree's available hook), so
// both conditions land together exactly here — once per system.
// OAC's box reads the milestone; the voice rolls one of the
// discovery-complete clips at random (audioPool). Arming it here,
// before the 'researchComplete' sign-off _completeResearch queues
// next, keeps the FIFO order specific → generic.
if (sysId && this.isSystemNavComplete()) {
this.comms?.trigger('systemComplete');
}
} else {
console.warn(`[orbit] research: unknown effect ${type}`, spec, id);

View File

@ -45,18 +45,11 @@ export class Tether {
return l1 * Math.pow(g, n - 1);
}
/**
* Is the point inside this tether's zone (center distance ≤ radius)?
* A 1 px rim tolerance keeps an object whose center sits exactly ON the
* boundary counted as inside — the scan edge case (the layout keeps
* objects off the rims by edgeMargin, data/tether.json — but floats
* don't know that).
*/
/** Is the point inside this tether's zone (center distance ≤ radius)? */
static contains(t, x, y) {
const dx = x - t.x;
const dy = y - t.y;
const r = t.radius + 1; // rim tolerance (px)
return dx * dx + dy * dy <= r * r;
return dx * dx + dy * dy <= t.radius * t.radius;
}
/** Union membership: the ship is allowed wherever ANY tether reaches. */

View File

@ -16,17 +16,6 @@
*
* Or, with nothing tracked: QUESTS / "NONE TRACKED" (grayed out).
*
* A COMPLETE-but-unclaimed quest is READY — the reward is sitting there:
* its row turns AMBER (the console's READY color — amber = claimable, the
* green accent stays the tracker's), the percent reads READY, and the
* plate breathes (a slow ~1.2 s sine on an amber tint — a pulse, never a
* strobe). The flip is a one-shot MOMENT: a bright row-flash + a small ◆
* burst off the row's right edge (when the row is on screen — the
* scene's toast + 'discovery' chime carry it when the tracker is slid
* off), and the row's popup gains a CLAIM REWARD button (the console's
* claim-plate palette) that claims in place (GameScene.claimQuest — the
* console's own path).
*
* Lifecycle (driven by GameScene — see GameScene.updateHud /
* startExpand): the tracker is VISIBLE ⇔ the dossier is FOLDED, armed by
* the dossier's FIRST fold (the one-shot auto-fold, or the player's
@ -68,7 +57,6 @@ const BODY = fontStack('body', FONT_FALLBACK);
/** The tracker's palette — green is the tracker's accent (data/quests.json). */
const C = {
green: themeColor('green', 0x67e863),
amber: themeColor('amber', 0xffc94d), // the console's READY color — claimable
ink: themeColor('ink', 0xeaf6ff),
dim: themeColor('dim', 0x7d92c4),
faint: themeColor('faint', 0x3d4c74),
@ -115,8 +103,6 @@ export class QuestTrackerHud {
* @param {(id: string) => void} [o.onViewQuest] — VIEW QUEST pressed
* @param {(id: string) => void} [o.onShowMore] — SHOW MORE pressed (the
* first hidden quest)
* @param {(id: string) => void} [o.onClaim] — CLAIM REWARD pressed (a
* READY row's popup)
*/
constructor(scene, o = {}) {
this.scene = scene;
@ -126,7 +112,6 @@ export class QuestTrackerHud {
this.onClearPriority = typeof o.onClearPriority === 'function' ? o.onClearPriority : null;
this.onViewQuest = typeof o.onViewQuest === 'function' ? o.onViewQuest : null;
this.onShowMore = typeof o.onShowMore === 'function' ? o.onShowMore : null;
this.onClaim = typeof o.onClaim === 'function' ? o.onClaim : null;
const cfg = config.section('quests.tracker', {});
this.maxVisible = Math.max(1, Math.floor(cfg.maxVisible ?? 5));
@ -172,8 +157,6 @@ export class QuestTrackerHud {
this._lastPopup = null; // { rect, at } — the just-closed popup (the same-frame guard)
this._fp = null; // the last painted fingerprint
this._priorityId = null; // the last resolved priority (for the popup)
this._readyBaseline = null; // the last observed READY set (transitions fire the moment)
this._readyFired = new Set(); // the moment already fired — one celebration per quest, ever
this.state = 'hidden'; // hidden | showing | shown | hiding
this._destroyed = false;
@ -289,101 +272,11 @@ export class QuestTrackerHud {
this._fp = fp;
this._priorityId = priorityId;
this._paint(active, visible, priorityId, overflowFirst);
this._readyTransitions(active); // the one-shot moment for any newly-READY row
}
// ------------------------------------------------------------ the READY state
/**
* A quest just became claimable — the one-shot MOMENT (the scene's
* toast + 'discovery' chime carry it when the row isn't on screen):
* a bright amber row-flash + a small ◆ glyph burst off the row's right
* edge. Runs right after the repaint the flip triggered (a check
* completing changes the fingerprint, so it lands on the SAME frame
* the row repaints READY-styled). The baseline absorbs whatever was
* ALREADY ready on the tracker's first poll (a loaded run), so a scene
* start doesn't re-fire.
*/
_readyTransitions(active) {
const readyIds = active.filter((q) => q.complete === true).map((q) => q.id);
if (this._readyBaseline === null) {
this._readyBaseline = new Set(readyIds); // first poll — establish, don't celebrate
return;
}
const fresh = readyIds.filter((id) => !this._readyBaseline.has(id) && !this._readyFired.has(id));
this._readyBaseline = new Set(readyIds);
for (const id of fresh) {
this._readyFired.add(id); // the run is monotonic, but this is the guard
if (!this.shown) continue; // slid off — the toast + chime carry it
const r = this._rowRectById.get(id);
if (r) this._burstRow({ x: r.x, y: r.y, w: r.w, h: r.h });
}
}
/**
* The row's completion moment: a bright amber flash over the row
* (~420 ms fade) + four ◆ glyphs drifting up-right off its right edge
* and fading (plain tweens, staggered). Transient — NOT in _kids (no
* teardown; each self-destroys) and added to the root AFTER the paint,
* so it renders on top and slides with the tracker if a slide starts.
*/
_burstRow(r) {
const s = this.scene;
const flash = s.add.graphics().setScrollFactor(0);
flash.clear();
flash.fillStyle(C.amber, 1);
flash.fillRect(r.x, r.y, r.w, r.h);
this.root.add(flash);
s.tweens.add({
targets: flash,
alpha: 0,
duration: 420,
ease: 'Sine.easeOut',
onComplete: () => {
try {
flash.destroy();
} catch {
/* already gone */
}
},
});
const n = 4;
for (let i = 0; i < n; i++) {
const g = s.add
.text(r.x + r.w - 10, r.y + r.h / 2, '◆', {
fontFamily: BODY,
fontSize: `${9 + (i % 2) * 4}px`,
color: toCss(C.amber),
})
.setOrigin(0.5)
.setScrollFactor(0);
this.root.add(g);
s.tweens.add({
targets: g,
x: g.x + 16 + i * 9,
y: g.y - (10 + i * 8),
alpha: 0,
duration: 460 + i * 90,
delay: i * 40,
ease: 'Cubic.easeOut',
onComplete: () => {
try {
g.destroy();
} catch {
/* already gone */
}
},
});
}
}
/** Tear down the dynamic content (the title + rule are permanent). */
_teardown() {
for (const k of this._kids) {
try {
this.scene.tweens.killTweensOf(k); // the READY row's breathing tint
} catch {
/* no tweens */
}
try {
k.destroy();
} catch {
@ -425,7 +318,6 @@ export class QuestTrackerHud {
for (const q of visible) {
const isPrio = q.id === priorityId;
const isReady = q.complete === true; // ACTIVE quests only — claimed are filtered out above
const doneN = (q.checks ?? []).filter((c) => c?.done).length;
const total = (q.checks ?? []).length;
const pct = total > 0 ? Math.round((100 * doneN) / total) : 0;
@ -435,13 +327,12 @@ export class QuestTrackerHud {
const rh = 20;
// The row plate (a VISIBLE object — the v4 hit-test rule) + the
// left tick (amber = READY / claimable, green = the priority quest,
// faint = the rest).
// left tick (green = the priority quest, faint = the rest).
const plate = add(s.add.graphics().setScrollFactor(0));
plate.clear();
plate.fillStyle(C.panel, 0.35);
plate.fillRect(rx, ry, rw, rh);
plate.fillStyle(isReady ? C.amber : isPrio ? C.green : C.faint, isReady ? 1 : isPrio ? 0.95 : 0.7);
plate.fillStyle(isPrio ? C.green : C.faint, isPrio ? 0.95 : 0.7);
plate.fillRect(rx, ry + 3, isPrio ? 2 : 1, rh - 6);
plate.setInteractive({
useHandCursor: true,
@ -457,47 +348,25 @@ export class QuestTrackerHud {
this.openPopup(q, { x: rx, y: ry, w: rw, h: rh });
});
// The READY tint — an amber wash OVER the plate, UNDER the text,
// breathing on a slow sine (a pulse, never a strobe).
if (isReady) {
const tint = add(s.add.graphics().setScrollFactor(0));
tint.clear();
tint.fillStyle(C.amber, 1);
tint.fillRect(rx, ry, rw, rh);
tint.setAlpha(0.1);
s.tweens.add({
targets: tint,
alpha: 0.3,
duration: 1200,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
}
add(
s.add
.text(this.X + 8, ry + 3, String(q.title ?? '').toUpperCase(), {
fontFamily: BODY,
fontSize: '12px',
color: toCss(isReady || isPrio ? C.ink : C.dim),
color: toCss(isPrio ? C.ink : C.dim),
letterSpacing: 1,
})
.setOrigin(0, 0)
.setScrollFactor(0),
);
const labelStyle = {
fontFamily: BODY,
fontSize: '11px',
color: toCss(isReady ? C.amber : isPrio ? C.green : C.dim),
letterSpacing: 1,
};
if (isReady) {
labelStyle.fontStyle = 'bold';
labelStyle.letterSpacing = 1.5;
}
add(
s.add.text(this.X + rw, ry + rh / 2, isReady ? 'READY' : `${pct}%`, labelStyle)
s.add
.text(this.X + rw, ry + rh / 2, `${pct}%`, {
fontFamily: BODY,
fontSize: '11px',
color: toCss(isPrio ? C.green : C.dim),
letterSpacing: 1,
})
.setOrigin(1, 0.5)
.setScrollFactor(0),
);
@ -569,10 +438,9 @@ export class QuestTrackerHud {
// ------------------------------------------------------------ the row popup
/**
* The row's context popup (a click on a quest row): a small cut-corner
* panel to the right of the row with the quest's name and its buttons —
* CLAIM REWARD (READY rows only, first — claims in place), SET PRIORITY
* (CLEAR PRIORITY, when this quest IS the priority), and VIEW QUEST
* (the QUESTS console on this quest). A click on a button
* panel to the right of the row with the quest's name and two buttons —
* SET PRIORITY (CLEAR PRIORITY, when this quest IS the priority) and
* VIEW QUEST (the QUESTS console on this quest). A click on a button
* is the button's (MenuButton's own pointerdown); any click ELSEWHERE
* closes the popup and is consumed (GameScene's pointerdown, via
* popupContains() below).
@ -584,7 +452,6 @@ export class QuestTrackerHud {
this.closePopup();
const s = this.scene;
const isPrio = this._priorityId === quest.id;
const isReady = quest.complete === true; // ACTIVE quests only — claimed are out of the list
const W = 212;
const mk = (label, onClick) =>
new MenuButton(s, 0, 0, label, onClick, {
@ -599,23 +466,6 @@ export class QuestTrackerHud {
hoverColor: C.panel,
textColor: toCss(C.ink),
});
const btns = [];
// CLAIM REWARD — READY rows only, FIRST (the popup's primary action):
// the console's claim-plate palette (amber edge, dark amber fill —
// QuestWindow's CLAIM REWARD), claiming in place via the scene's
// claimQuest (the console's own path — idempotent, toast + tick).
if (isReady) {
const bClaim = mk('CLAIM REWARD', () => {
this.onClaim?.(quest.id);
this.closePopup();
});
bClaim.style.fill = 0x2a1f08;
bClaim.style.hoverFill = 0x3d2c0e;
bClaim.style.stroke = C.amber;
bClaim.style.neon = C.amber;
bClaim.paint('base');
btns.push(bClaim);
}
const bPrio = mk(isPrio ? 'CLEAR PRIORITY' : 'SET PRIORITY', () => {
if (isPrio) this.onClearPriority?.();
else this.onSetPriority?.(quest.id);
@ -631,9 +481,10 @@ export class QuestTrackerHud {
bView.style.stroke = C.dim;
bView.style.neon = C.green;
bView.paint('base');
btns.push(bPrio, bView);
const H = 26 + btns.reduce((a, b) => a + b.style.height, 0) + (btns.length - 1) * 6 + 8;
const h1 = bPrio.style.height;
const h2 = bView.style.height;
const H = 26 + 6 + h1 + 6 + h2 + 8;
// The panel plate (a cut-corner plate — CyberShape at its centre).
const g = s.add.graphics().setScrollFactor(0);
g.clear();
@ -641,10 +492,10 @@ export class QuestTrackerHud {
notch: Math.min(12, H * 0.18),
fill: C.panel,
fillAlpha: 0.96,
stroke: isReady ? C.amber : isPrio ? C.faint : C.green,
stroke: isPrio ? C.faint : C.green,
strokeAlpha: 0.85,
lineWidth: 1.5,
glow: isReady ? C.amber : isPrio ? C.dim : C.green,
glow: isPrio ? C.dim : C.green,
glowAlpha: 0.18,
});
g.setPosition(W / 2, H / 2);
@ -652,16 +503,13 @@ export class QuestTrackerHud {
.text(10, 8, String(quest.title ?? '').toUpperCase(), {
fontFamily: BODY,
fontSize: '10px',
color: toCss(isReady ? C.amber : C.dim),
color: toCss(C.dim),
letterSpacing: 1.5,
})
.setOrigin(0, 0)
.setScrollFactor(0);
let by = 26;
for (const b of btns) {
b.setPosition(W / 2, by + b.style.height / 2);
by += b.style.height + 6;
}
bPrio.setPosition(W / 2, 26 + h1 / 2);
bView.setPosition(W / 2, 26 + h1 + 6 + h2 / 2);
// Placement: right of the row (falling back to its left when the
// right side runs off the screen edge).
@ -679,13 +527,14 @@ export class QuestTrackerHud {
cont.setDepth(31); // just above the tracker's own plate (30)
cont.add(g);
cont.add(header);
for (const b of btns) cont.add(b);
cont.add(bPrio);
cont.add(bView);
this._popup = {
questId: quest.id,
rect: { x: px, y: py, w: W, h: H },
sourceRect: { ...rowRect }, // the row that opened it (the scene's same-click test)
openedAt: this.scene.time.now,
btns,
btns: [bPrio, bView],
obj: cont,
};
this.scene.playSfx?.('ui_window');