From 0a76c60ab10043701bdcf522bfa526dd8860ff94 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 12 Mar 2026 16:44:00 -0400 Subject: [PATCH] Refactor ship control layers and update docs --- NEXT-STEPS.md | 130 +++ SESSION.md | 359 +++--- STATES.md | 543 +++++++++ src/game/GameApp.ts | 1753 ++++++++++++++++++++++------ src/game/data/balance.json | 1 + src/game/state/selectionManager.ts | 30 +- src/game/types.ts | 81 +- src/game/ui/hud.ts | 10 + src/game/ui/presenters.ts | 148 ++- src/game/ui/strategicRenderer.ts | 2 - src/game/world/worldFactory.ts | 29 +- src/style.css | 59 + 12 files changed, 2580 insertions(+), 565 deletions(-) create mode 100644 NEXT-STEPS.md create mode 100644 STATES.md diff --git a/NEXT-STEPS.md b/NEXT-STEPS.md new file mode 100644 index 0000000..7d2efb2 --- /dev/null +++ b/NEXT-STEPS.md @@ -0,0 +1,130 @@ +# Next Steps + +## Economic Growth + +The current economy already supports: + +1. mining ore +2. hauling to refining +3. refining / fabricating goods +4. spending those goods on ships and outposts + +The next step is not “invent a use for refined goods.” That use already exists. + +The next step is to make faction growth more intentional and legible. + +Recommended work: + +- make shipbuilding priorities reactive + - build more miners / haulers when ore throughput is low + - build escorts when industrial losses rise + - build warships when frontier pressure rises +- make expansion logic consume the economy more visibly + - use industrial stock to claim and fortify central systems +- expose production pressure in UI + - show ore throughput + - show fabricated goods + - show queued faction priorities + +## Pirate Harassment + +Pirates already exist and can raid, fight, and destroy ships. + +What they are missing is sharper industrial harassment behavior. + +Recommended work: + +- prioritize miners, haulers, and refinery approaches as pirate targets +- add local threat weighting around: + - resource nodes + - refinery docking lanes + - undefended transport routes +- force empires to react by: + - escorting miners + - patrolling refinery systems + - building defensive stations sooner + +This will make the industrial loop produce strategic tension instead of just passive growth. + +## High-Value Gameplay Sequence + +The most useful short-term gameplay loop to solidify is: + +1. miners feed refining +2. refining feeds ship production +3. pirates harass industry +4. empires respond with escorts, patrols, and new outposts +5. stronger economies produce stronger military presence +6. system control shifts based on industrial strength and protection + +That turns the simulation into a real strategy loop. + +## Concrete Implementation Order + +1. Add faction production heuristics based on current economy and losses. +2. Make pirate target selection explicitly prefer economic targets. +3. Surface faction stocks, throughput, and build priorities in the HUD/debug views. +4. Expand the order/behavior set with higher-value RTS actions like `hold-here`, `attack`, and `defend-area`. +5. Break [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) into smaller planning / faction / combat / logistics modules. + +## Migration To .NET + +If the long-term goal is multiplayer, scale, persistence, or server authority, a .NET migration is a sensible next architectural track. + +Recommended direction: + +- keep the current Vite / Three.js client for rendering and input +- move simulation authority into a .NET backend +- treat the browser client as a renderer + command UI + +Suggested migration phases: + +1. Define a shared simulation contract. + - ship state snapshots + - orders + - behaviors + - assignments + - combat / economy events + +2. Extract the pure simulation model from the Three.js runtime. + - separate rendering state from simulation state + - remove direct scene dependencies from game logic + +3. Rebuild the simulation core in .NET. + - `Order` + - `DefaultBehavior` + - `Assignment` + - `ControllerTask` + - faction economy + - combat resolution + +4. Add server-driven ticking. + - authoritative world step on the server + - deterministic or near-deterministic update model + - event stream / snapshot replication to clients + +5. Add persistence and multiplayer infrastructure. + - saves + - world seeds + - reconnect support + - eventually player ownership / fog / permissions + +Suggested .NET stack: + +- ASP.NET Core for API / realtime transport +- SignalR or custom websocket layer for simulation updates +- PostgreSQL for persistence +- background hosted service for world ticks + +Suggested immediate prep before migration: + +- isolate simulation data structures from rendering objects +- isolate faction AI from UI code +- isolate travel / docking / mining / combat systems into separate modules +- make event emission explicit and serializable + +The key rule for the migration is: + +- do not port Three.js-shaped code into .NET +- first separate the simulation from rendering in TypeScript +- then move the pure simulation into .NET cleanly diff --git a/SESSION.md b/SESSION.md index 68340d4..b5f69c6 100644 --- a/SESSION.md +++ b/SESSION.md @@ -1,196 +1,239 @@ # Session Summary -## Project State +## Current State -This repository now contains a playable Three.js/Vite autonomous space-sim prototype that has moved away from a player-command RTS testbed and toward a game-master / observer simulation. +The project is a Three.js/Vite space simulation with: -The codebase is still TypeScript + Three.js on Vite, with authored catalogs under `src/game/data/`, but the runtime now centers on: +- autonomous ships +- orbital travel +- docking and undocking +- mining and refinery delivery +- refining / fabrication +- faction growth through ship and outpost production +- observer-focused debugging tools -- procedural universe generation -- autonomous faction behavior -- direct per-ship faction control -- economic production loops -- pirate harassment -- strategic system control -- observer-oriented HUD and camera controls +The active runtime model now follows the intended layered architecture more closely: -## Current Prototype +- `order` +- `defaultBehavior` +- `assignment` +- `controllerTask` +- `state` -The current build includes: +The previous `captainGoal` layer has been removed. -- a generated universe with a few dozen systems -- 4 empire factions inspired by EVE-style sovereign powers -- multiple pirate factions that raid empire space -- rich central systems that factions contest for control -- faction-owned stations, ships, inventories, and combat stats -- autonomous shipbuilding and limited outpost growth -- observer controls for camera orbit, pan, focus, and inspection +## Ship Runtime Model -## Major Gameplay / Sim Systems +Ships now carry: -### Universe Generation +- `order` + - direct one-shot instruction such as `move-to`, `mine-this`, `dock-at` +- `defaultBehavior` + - standing automation such as `auto-mine`, `patrol`, `escort-assigned`, `idle` +- `assignment` + - contextual ownership / doctrine such as `unassigned`, `commander-subordinate`, `station-based`, `mining-group` +- `controllerTask` + - immediate executable task such as `travel`, `dock`, `extract`, `unload`, `follow`, `undock` +- `state` + - physical ship state such as `spooling-warp`, `warping`, `arriving`, `docking`, `docked`, `undocking`, `transferring` -- Startup no longer uses the fixed two-system authored sandbox. -- `src/game/world/universeGenerator.ts` now generates: - - empire capitals - - empire mining systems - - pirate base systems - - central high-value systems - - frontier filler systems -- The generated scenario also assigns: - - faction definitions - - initial faction-owned stations - - initial ship formations - - central system IDs +Current precedence is: -### Factions +1. `order` +2. `defaultBehavior` +3. assignment-derived fallback behavior +4. idle fallback -- Runtime faction state now exists in `src/game/types.ts`. -- Factions track: - - credits - - ore mined - - goods produced - - ships built - - stations built - - ships lost - - enemy ships destroyed - - raids completed - - stolen cargo - - owned systems -- Empire factions and pirate factions are distinct runtime kinds. +The main loop in [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) is now: -### High-Level AI / Delegation +- `refreshControlLayers()` +- `planControllerTask()` +- `updateControllerTask()` +- `advanceControlState()` -- Faction AI now acts at a strategic level and issues direct orders to ships. -- Empire AI chooses high-level goals such as: - - secure home and mining space - - contest central systems - - send miners to resource systems - - send military ships to rally and patrol targets -- Pirate AI chooses raid targets and moves military ships into hostile space. -- The fleet / wing layer has been removed from both simulation and UI. +## Travel Model -### Economy / Production +Travel is destination-driven and orbital-centric. -- Mining, refining, and fabrication still run through recipe-driven station logic. -- Faction-owned inventories are effectively pooled across faction stations for recipe consumption. -- Factions can build new ships when enough goods exist. -- Empires can build limited defense outposts in central systems they control. +- same-system travel: + - `spooling-warp -> warping -> arriving` +- inter-system travel: + - `spooling-ftl -> ftl -> arriving` +- arrival anchors the ship to the destination orbital when appropriate -### Combat / Control +Destination ownership lives in the `controllerTask`. -- Ships and relevant stations now have combat stats: - - health - - damage - - range - - cooldown -- Combat is lightweight and proximity-based. -- Central systems track control progress and controlling faction. -- Pirate ships can steal cargo from vulnerable civilian ships. +Examples: -## Starting State +- `travel(destination)` +- `dock(host, bay)` +- `extract(node)` +- `unload(station)` +- `undock(host)` -- Empires now start very small for easier debugging and growth observation. -- Each empire currently starts with: - - 1 miner - - 1 manufactory - - 1 refinery -- Each pirate faction currently starts with: - - 1 frigate - - 1 trade hub -- This is a bootstrap-oriented setup: factions mine first, then try to grow from minimal infrastructure. +## Mining / Delivery / Refining -## UI / UX State +Current industrial loop: -### Observer HUD +1. miner travels to node +2. miner extracts ore +3. miner travels to refinery +4. miner docks +5. miner unloads over time +6. miner undocks +7. loop repeats -- The old summary panel is gone. -- The old bottom RTS command bar has been removed. -- The bottom HUD is now a selection dock that shows: - - selection title - - status line - - horizontally scrolling cards for selected entities - - fallback observer details when nothing specific is selected -- A dedicated `Debug` window now contains the `New Universe` button. +Important details: -### Selection / Inspection +- `mine-this` is a one-shot order and currently completes when cargo is full +- `auto-mine` is persistent behavior and includes its own internal phase state +- unloading is time-based through `transferRate` in [src/game/data/balance.json](/home/jbourdon/repos/space-game/src/game/data/balance.json) +- unload state is now `transferring` +- unload completion emits `` -- Selection is no longer limited to ships and stations. -- It is now possible to select: - - systems - - planets - - ships - - stations -- Double-click centers / focuses the clicked target. -- Multiple ship selections render as horizontal cards in the bottom dock. +Refineries and fabricators feed faction production. -### Windows +The faction economy now uses fabricated goods to: -- Generic draggable / resizable app windows still exist. -- Main windows currently in use: - - `Ships` - - `Debug` -- The `Ships` window: - - lists ships grouped by faction - - selects a ship on click - - focuses a ship on double click - - focuses a faction home system when clicking that faction header +- build new ships +- build defense outposts in valuable systems -### Strategic Rendering +Current production behavior lives in: -- Strategic overlay and minimap infrastructure still exist. -- The minimap canvas is still created for renderer use, but it is no longer shown in the visible HUD. -- Fleet link overlays and fleet counters were removed along with the fleet system. +- [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) + - `tryBuildShipForFaction()` + - `tryBuildOutpostForFaction()` -## Controls +## Faction Growth Loop -- `Left Click`: inspect / select systems, planets, ships, or stations -- `Shift + Left Click`: add ships to multi-selection -- `Ctrl/Cmd + Left Click`: toggle ships in multi-selection -- `Left Drag`: marquee-select multiple visible ships -- `Double Click`: center / focus the clicked target -- `Middle Drag`: orbit camera -- `Shift + Middle Drag`: pan camera -- `Mouse Wheel` or `-` / `=`: zoom -- `W A S D`: pan camera using the same motion as `Shift + Middle Drag` -- `Q / E`: rotate camera -- `F`: focus current selection -- `G`: toggle ships window -- `Tab`: jump camera between systems +The active empire growth loop is: -## Technical Notes +1. mine ore +2. refine / fabricate goods +3. spend goods on ships +4. spend goods on military outposts +5. project power into central / contested systems -- Main runtime remains concentrated in `src/game/GameApp.ts` -- World construction and entity instancing: - - `src/game/world/worldFactory.ts` -- Procedural universe generation: - - `src/game/world/universeGenerator.ts` -- Selection state: - - `src/game/state/selectionManager.ts` -- HUD / presentation: - - `src/game/ui/hud.ts` - - `src/game/ui/presenters.ts` - - `src/game/ui/strategicRenderer.ts` -- Production build is currently passing with `npm run build` +This means the simulation is no longer missing a use for refined goods. -## Known Limitations / Caveats +What is still missing is stronger strategic prioritization, for example: -- `GameApp.ts` is still carrying too much simulation responsibility. -- Faction AI is improved, but still fairly heuristic and not yet a deep planning system. -- Combat is lightweight and does not yet model formations, threat evaluation, or target priorities in a sophisticated way. -- Economic logistics are still abstracted heavily. -- Ship construction is recipe-gated but still simplified. -- Stations consume pooled faction stock rather than explicit transport delivery chains. -- Bootstrap progression is still constrained by the current station recipe / stock model. -- The ships window is useful for inspection, but the overall UI is still only partially refit for observer mode. -- There is still no persistence layer for window layouts, saves, or generated universe seeds. +- when to build more miners vs escorts vs warships +- how to react to throughput shortages +- how to react to pirate pressure -## Suggested Next Steps +## Pirates / Threats -- Extract faction strategy into a dedicated AI / planning module -- Separate economic simulation from UI and rendering concerns -- Improve transport logistics so goods physically move through faction supply chains -- Add explicit shipyard construction queues and faction production priorities -- Rework bootstrap progression so factions can genuinely grow from near-zero infrastructure -- Add system-level threat, ownership, and economy views for game-master inspection -- Add save/load support for generated universes and long-running simulations +Pirates already exist as an active faction and can raid / fight. + +Current pirate support includes: + +- pirate faction command logic +- hostile target selection +- ship combat and destruction + +What is still underdeveloped: + +- explicit preference for miners, haulers, and refinery traffic +- clearer harassment behavior around resource chains + +## Debug History + +The debug window is focused on the selected ship and includes: + +- `order` +- `defaultBehavior` +- `assignment` +- `controllerTask` +- `state` +- task target +- anchor + +History is event-oriented plus explicit state lines. + +Current notation includes: + +- controller commands: + - `[travel]`, `[dock]`, `[unload]`, `[undock]` +- state snapshots: + - `state=move-to:.../travel-to-node [travel]/(warping)` +- events: + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + +History remains HTML-escaped before rendering and same-tick changes are still batched. + +Copy-to-clipboard includes: + +- current live summary block +- event history + +## Selection / HUD + +The HUD currently supports selecting: + +- ships +- stations +- systems +- planets +- asteroid field nodes + +Notable UI status: + +- ship cards show cargo and current layered control summary +- station cards show ore stored and refined stock +- Fleet and Debug window toggle buttons exist +- debug history is scrollable and copyable + +## Important Recent Changes + +- removed the old `captainGoal` layer +- planner now derives `controllerTask` directly from `order` and `defaultBehavior` +- moved mining / patrol progress state into `order` and `defaultBehavior` +- updated debug / selection UI to show the active layered model +- removed confirmed dead code found by strict TypeScript unused checks + +## Current Known Limitations + +- [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) is still too large and owns too much simulation responsibility +- order types are still narrow + - currently focused on `move-to`, `mine-this`, `dock-at` +- default behavior set is still narrow + - currently focused on `idle`, `auto-mine`, `patrol`, `escort-assigned` +- pirate harassment exists but is not yet economically targeted enough +- faction production logic is timer-driven and only lightly reactive +- no persistence for saves, seeds, or layouts + +## Important Files + +- [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) + - main simulation loop + - layered planning + - travel, docking, mining, unloading, faction growth, combat, debug history +- [src/game/types.ts](/home/jbourdon/repos/space-game/src/game/types.ts) + - `order` / `defaultBehavior` / `assignment` / `controllerTask` / `state` model +- [src/game/world/worldFactory.ts](/home/jbourdon/repos/space-game/src/game/world/worldFactory.ts) + - ship and station instancing +- [src/game/ui/presenters.ts](/home/jbourdon/repos/space-game/src/game/ui/presenters.ts) + - selection cards + - station cards + - debug history markup +- [src/game/data/balance.json](/home/jbourdon/repos/space-game/src/game/data/balance.json) + - travel, docking, transfer rates + +## Validation + +Validation passing at the end of this session: + +- `npx tsc --noEmit --noUnusedLocals --noUnusedParameters` +- `npm run build` diff --git a/STATES.md b/STATES.md new file mode 100644 index 0000000..d3d1700 --- /dev/null +++ b/STATES.md @@ -0,0 +1,543 @@ +# States, Orders, Behaviours, Assignments + +This document defines the intended gameplay model for the project: a multiplayer X4-inspired RTS with strong automation and strong direct control. + +It is no longer purely aspirational. The current runtime now uses the same top-level control layers described here: + +1. `order` +2. `defaultBehavior` +3. `assignment` +4. `controllerTask` +5. `state` + +What is still incomplete is scope, not structure. + +## Design Goals + +The game should support both: + +- RTS-style direct control: `move-to`, `mine-this`, `hold-here`, `dock-there`, `attack-this` +- X4-style automation: `auto-mine`, `patrol`, `escort`, `trade`, `defend-area` + +The intended player experience is: + +- you can micro units at any time +- automation resumes when direct intervention is over +- fleets, stations, carriers, and sectors can provide reusable command structure + +## Control Layers + +Each ship should be modeled with five layers: + +1. `order` +2. `defaultBehavior` +3. `assignment` +4. `controllerTask` +5. `state` + +### `order` + +A direct player-issued instruction with highest priority. + +Properties: + +- one-shot unless explicitly marked persistent +- can preempt automation +- may complete, fail, cancel, or timeout +- after resolution, ship falls back to `defaultBehavior` + +Current active examples: + +- `move-to(destination)` +- `mine-this(node, refinery)` +- `dock-at(target)` + +### `defaultBehavior` + +Persistent automation the ship should run when no direct order is active. + +Properties: + +- long-lived +- self-replanning +- normally loops or maintains a standing objective +- resumes automatically after one-shot order resolution + +Current active examples: + +- `idle` +- `auto-mine(area, refinery)` +- `patrol(route)` +- `escort-assigned` + +### `assignment` + +The command relationship the ship belongs to. + +Examples: + +- assigned to fleet +- assigned to carrier +- assigned to station +- assigned to sector command +- assigned to mining group + +Assignment does not necessarily tell the ship what exact task to do every second. It provides authority, context, operating area, logistics ownership, and fallback doctrine. + +Current active examples: + +- `unassigned` +- `commander-subordinate(commanderId, role)` +- `station-based(stationId, role)` +- `mining-group(controllerId)` + +### `controllerTask` + +The immediate executable action. + +Examples: + +- travel +- dock +- undock +- mine/extract +- unload +- follow +- hold-position + +Current active examples: + +- `idle` +- `travel(destination, threshold)` +- `dock(host, bay)` +- `undock(host)` +- `extract(node)` +- `unload(target)` +- `follow(target, offset)` + +### `state` + +The physical/runtime state of the unit. + +Examples: + +- idle +- spooling-warp +- warping +- docking +- docked +- mining +- attacking +- holding + +## Precedence Rules + +Control precedence should be: + +1. `order` +2. `defaultBehavior` +3. `assignment doctrine` +4. safety/failsafe logic + +Interpretation: + +- if a one-shot order exists, it drives planning +- if no order exists, the ship executes its default behavior +- if no explicit default behavior exists, assignment may supply one +- if none of the above apply, ship uses idle/safe fallback + +This is the active planner model in the codebase. + +## Order Lifecycle + +One-shot orders are the RTS-facing layer. + +Each order should move through: + +1. `queued` +2. `accepted` +3. `planning` +4. `executing` +5. one of `completed`, `failed`, `cancelled`, `blocked` + +Recommended behavior: + +- `completed`: clear order and resume `defaultBehavior` +- `cancelled`: clear order immediately and resume `defaultBehavior` +- `failed`: clear order or retry depending on order policy +- `blocked`: wait, reroute, or escalate depending on order policy + +Current implementation notes: + +- direct orders already move through `queued`, `accepted`, `planning`, `executing` +- `completed`, `failed`, and `blocked` are already represented +- full queued-order chains do not exist yet + +## Command Categories + +### One-shot direct orders + +These are the core RTS commands. + +| Order | Intent | Status | +| --- | --- | --- | +| `move-to(destination)` | go to a position or object | active | +| `mine-this(node)` | mine this specific node | active | +| `dock-at(target)` | dock at target | active | +| `undock` | leave current host | not first-class yet | +| `hold-here(position)` | stay at current or assigned point | not implemented | +| `attack(target)` | attack a specific unit/object | not implemented as direct order | +| `intercept(target)` | chase and engage moving target | not implemented | +| `escort(target)` | follow and defend a specific target | not implemented as direct one-shot order | +| `transfer-cargo(target, item, amount)` | move cargo between entities | not implemented | +| `build-here(site)` | construct at location | not implemented | +| `salvage-this(target)` | recover specific wreck/resource | not implemented | +| `retreat-to(destination)` | disengage and move to safety | not implemented | + +### Persistent default behaviours + +These are the automation layer. + +| Behavior | Intent | Status | +| --- | --- | --- | +| `idle` | do nothing beyond safety drift / local maintenance | active | +| `auto-mine(area, refinery)` | find nodes, mine, deliver, repeat | active | +| `patrol(route or area)` | move across checkpoints and react to threats | active | +| `escort-assigned` | stay with assigned commander or protected ship | active | +| `defend-area(area)` | hold area and engage threats inside rules | not implemented | +| `trade(route or policy)` | acquire, move, and deliver goods automatically | not implemented | +| `resupply(host or fleet)` | fetch and deliver fuel/ammo/cargo | not implemented | +| `ferry-units(host)` | shuttle units between hosts or waypoints | not implemented | +| `harvest(area)` | collect local resources automatically | not implemented | +| `hold-area(center, radius)` | remain in zone with low autonomy | not implemented | + +### Assignments + +Assignments define command context. + +| Assignment | Meaning | Status | +| --- | --- | --- | +| `unassigned` | independent unit | active | +| `fleet-member(fleetId, role)` | unit belongs to a fleet structure | not implemented | +| `commander-subordinate(commanderId, role)` | subordinate follows commander doctrine | active | +| `carrier-wing(carrierId, role)` | launch/recover/escort under carrier | not implemented as distinct type | +| `station-based(stationId, role)` | station-owned logistics or defense unit | active | +| `sector-command(sectorId, role)` | unit operates inside a sector mandate | not implemented | +| `mining-group(controllerId)` | industrial unit tied to a mining controller | active | + +Assignments can supply default behavior if the unit has none explicitly set. + +## Planner Model + +Planning works like this: + +1. if `order` exists, derive `controllerTask` from it +2. else if `defaultBehavior` exists, derive `controllerTask` from it +3. else if `assignment` implies doctrine, derive `defaultBehavior` +4. else run idle fallback + +This preserves RTS responsiveness while keeping X4-style automation. + +Current implementation detail: + +- mining and patrol progress are stored directly on `order` or `defaultBehavior` +- there is no separate high-level “captain” intent layer anymore + +## Physical States + +These are the intended physical states for ships. + +| State | Meaning | Status | +| --- | --- | --- | +| `idle` | no active movement or operation | active | +| `holding` | maintaining current position/formation/anchor | reserved | +| `spooling-warp` | charging local fast-travel | active | +| `warping` | high-speed in-system travel | active | +| `spooling-ftl` | charging inter-system travel | active | +| `ftl` | inter-system travel | active | +| `arriving` | final approach after warp/ftl | active | +| `approaching` | standard approach to target | reserved | +| `docking-approach` | approach to docking lane or bay | active | +| `docking` | docking procedure in progress | active | +| `docked` | physically docked | active | +| `undocking` | undocking procedure in progress | active | +| `mining-approach` | aligning to resource node | active | +| `mining` | active extraction | active | +| `transferring` | moving cargo, fuel, or units | active | +| `building` | performing construction | reserved | +| `repairing` | performing repair actions | reserved | +| `patrolling` | movement as part of patrol | active | +| `escorting` | movement as part of escort | active | +| `attacking` | active weapons engagement | reserved | +| `retreating` | disengaging toward safety | reserved | +| `disabled` | cannot act | reserved | +| `destroyed` | removed from play | active outcome | + +## Controller Tasks + +These are the low-level tasks the planner can issue. + +| Task | Purpose | Status | +| --- | --- | --- | +| `idle` | no task | active | +| `travel(destination, threshold)` | move to destination | active | +| `hold(position, radius)` | maintain a point | not implemented | +| `dock(host, bay)` | dock at host | active | +| `undock(host)` | undock from host | active | +| `extract(node)` | mine/extract resource | active | +| `unload(target, item, amount?)` | move cargo out | active | +| `load(target, item, amount?)` | move cargo in | not implemented | +| `follow(target, offset)` | maintain formation | active | +| `attack(target)` | engage target | not implemented as controller task | +| `intercept(target)` | chase moving target | not implemented | +| `orbit(target, radius)` | remain in orbit around target | not implemented | +| `build(site)` | perform construction | not implemented | +| `repair(target)` | perform repair | not implemented | +| `scan(target)` | gather intel | not implemented | + +## Events + +Events should be explicit and usable by both gameplay and debug tools. + +### Order events + +| Event | Meaning | Status | +| --- | --- | --- | +| `order-issued` | player or AI created an order | partially implicit | +| `order-accepted` | unit accepted order | represented in order status | +| `order-rejected` | unit cannot accept order | not explicit yet | +| `order-started` | execution began | represented in order status | +| `order-blocked` | execution cannot proceed for now | represented in order status | +| `order-completed` | order finished successfully | represented and logged | +| `order-failed` | order failed | represented and logged | +| `order-cancelled` | order was manually cleared or overridden | reserved | + +### Behavior events + +| Event | Meaning | Status | +| --- | --- | --- | +| `behavior-set` | default behavior assigned | represented in debug history | +| `behavior-cleared` | default behavior removed | represented in debug history | +| `behavior-resumed` | resumed after order completion | implicit | +| `behavior-paused` | temporarily suspended | implicit | +| `behavior-phase-changed` | automation phase updated | represented in debug history | + +### Assignment events + +| Event | Meaning | Status | +| --- | --- | --- | +| `assignment-set` | unit assigned | represented in debug history | +| `assignment-cleared` | assignment removed | represented in debug history | +| `assignment-role-changed` | subordinate role changed | partially represented | +| `commander-lost` | assigned leader unavailable | implicit | + +### Controller/runtime events + +| Event | Meaning | Status | +| --- | --- | --- | +| `arrived` | destination reached | active | +| `docking-clearance-granted` | docking permission accepted | active via history | +| `docking-clearance-denied` | docking permission denied | active via history | +| `docking-begin` | docking procedure started | active | +| `docked` | docking complete | active | +| `undocked` | undocking complete | active | +| `cargo-full` | cargo reached capacity | active via history | +| `cargo-empty` | cargo emptied | active via history | +| `target-lost` | attack/escort target unavailable | not explicit yet | +| `hostile-detected` | threat found | not explicit yet | +| `resource-depleted` | node exhausted | not explicit yet | +| `damaged` | ship took damage | not explicit yet | +| `destroyed` | ship destroyed | active outcome | + +## Transition Rules + +### Core fallback rule + +When a direct order resolves: + +- clear `order` +- emit terminal order event +- resume `defaultBehavior` +- if no explicit `defaultBehavior`, ask `assignment` for fallback +- if none exists, become `idle` + +This is the main rule that keeps automation and RTS control compatible. + +### Example: direct move order + +1. player issues `move-to` +2. ship order becomes `move-to(destination)` +3. planner emits `travel(destination)` +4. ship moves through travel states +5. on `arrived`, order completes +6. ship resumes previous default behavior, such as `patrol` or `auto-mine` + +### Example: direct mine-this order + +1. player issues `mine-this(node)` +2. order overrides `auto-mine` +3. planner travels to node +4. planner extracts until cargo is full +5. order completes +6. unit returns to `auto-mine` + +Current limitation: + +- the one-shot `mine-this` order does not yet auto-deliver once before completing + +### Example: assignment-driven escort + +1. fighter assigned to `commander-subordinate(commanderId, escort)` +2. assignment implies default behavior `escort-assigned` +3. on no direct order, fighter follows commander +4. when direct order ends, fighter resumes escort automatically + +## Blocking and Waiting + +Blocked execution should be explicit, not hidden inside `idle`. + +Common blocked reasons: + +- no docking bay free +- no path to destination +- no cargo space +- no valid mining target +- no ammo/fuel/energy +- target out of command scope +- waiting for commander or carrier + +Recommended blocked substate or status: + +- `blocked(waiting-for-bay)` +- `blocked(waiting-for-target)` +- `blocked(waiting-for-resource)` +- `blocked(waiting-for-order-scope)` + +Current implementation: + +- blocked status exists on `order` +- blocked reason metadata does not yet exist + +## Queue and Override Rules + +For RTS feel, orders should support: + +- immediate replace +- optional shift-queue +- cancel current order +- clear all queued orders + +Recommended semantics: + +- normal click command replaces current direct order +- shift-command appends to order queue +- automation only runs when order queue is empty + +Current implementation: + +- immediate replace behavior exists +- shift queues do not exist yet + +## Suggested Data Shape + +Current runtime shape is effectively: + +```ts +type ShipMind = { + order?: ShipOrder; + defaultBehavior: ShipBehavior; + assignment: ShipAssignment; + controllerTask: ControllerTask; + state: UnitState; +}; +``` + +Future expansion can add: + +```ts +type ShipMindExtensions = { + queuedOrders?: ShipOrder[]; + blockedReason?: BlockedReason; + stance?: CombatStance; +}; +``` + +## Mapping To Current Project + +Current runtime concepts map like this: + +| Runtime concept | Current implementation | +| --- | --- | +| direct move | one-shot `order = move-to` | +| direct mining | one-shot `order = mine-this` | +| direct docking | one-shot `order = dock-at` | +| automation mining | `defaultBehavior = auto-mine` | +| automation patrol | `defaultBehavior = patrol` | +| assignment escort | `assignment = commander-subordinate` plus `defaultBehavior = escort-assigned` | +| execution | `controllerTask` | +| physical movement / interaction | `state` | + +## Recommended Next Scope + +To move the game forward without overbuilding, the highest-value next steps are: + +### Direct orders + +- `hold-here` +- `attack` +- `escort` as a first-class direct order + +### Default behaviors + +- `defend-area` +- `trade` +- `resupply` + +### Assignments + +- `fleet-member` +- `carrier-wing` +- `sector-command` + +### Runtime support + +- blocked reason handling +- optional order queue +- better event emission +- more explicit threat / target events + +### Faction strategy + +- reactive shipbuilding priorities +- better pirate harassment of industrial targets +- clearer economic pressure and throughput visibility + +## Non-Goals + +This model should not force: + +- full economic AI before direct control feels good +- full hierarchy simulation before ship orders work +- perfect X4 parity + +The target remains a practical hybrid: + +- faster and clearer than a pure sim +- deeper and more autonomous than a pure RTS + +## Summary + +The active design is now: + +- one-shot orders for direct RTS control +- default behaviors for persistent automation +- assignments for organizational structure +- controller tasks for immediate execution +- physical states for movement and interaction + +That structure supports the gameplay goal cleanly: + +- players can micro ships directly +- ships remain useful when not microed +- factions can scale through automation +- the game can feel like an RTS without losing simulation depth diff --git a/src/game/GameApp.ts b/src/game/GameApp.ts index bebed4c..ce9a71a 100644 --- a/src/game/GameApp.ts +++ b/src/game/GameApp.ts @@ -16,31 +16,43 @@ import type { ShipInstance, SolarSystemInstance, StationInstance, + TravelDestination, TravelPlan, UnitState, UniverseDefinition, ViewLevel, } from "./types"; import { createHud } from "./ui/hud"; -import { getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle, getShipWindowMarkup } from "./ui/presenters"; +import { getDebugHistoryMarkup, getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle, getShipWindowMarkup } from "./ui/presenters"; import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer"; import { buildInitialWorld, createShipInstance, createStationInstance } from "./world/worldFactory"; import { generateUniverse } from "./world/universeGenerator"; const MOVING_STATES = new Set([ - "moving", + "spooling-warp", + "warping", + "ftl", "mining-approach", - "delivering", + "transferring", "docking-approach", "docking", "undocking", - "leaving-gravity-well", "arriving", "patrolling", "escorting", ]); type DockingHost = StationInstance | ShipInstance; +type ControllerEvent = + | { kind: "none" } + | { kind: "arrived"; destination: TravelDestination } + | { kind: "docking-begin"; portIndex: number } + | { kind: "docked" } + | { kind: "undocked" } + | { kind: "unloaded" }; +type DockingClearance = + | { kind: "accepted"; portIndex: number } + | { kind: "rejected"; reason: "permission-denied" | "no-free-bay" }; export class GameApp { private readonly container: HTMLElement; @@ -50,7 +62,6 @@ export class GameApp { private readonly clock = new THREE.Clock(); private readonly raycaster = new THREE.Raycaster(); private readonly mouse = new THREE.Vector2(); - private readonly movePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -gameBalance.yPlane); private readonly keyState = new Set(); private readonly cameraFocus = new THREE.Vector3(); private readonly selectableTargets = new Map(); @@ -84,7 +95,7 @@ export class GameApp { "fleet-command": true, "ship-designer": false, "station-manager": false, - debug: false, + debug: true, }; private readonly detailsEl: HTMLDivElement; @@ -101,8 +112,16 @@ export class GameApp { private readonly fleetWindowBodyEl: HTMLDivElement; private readonly fleetWindowTitleEl: HTMLHeadingElement; private readonly debugWindowEl: HTMLDivElement; + private readonly debugHistoryEl: HTMLDivElement; + private readonly debugAutoScrollToggleEl: HTMLButtonElement; + private readonly debugCopyHistoryEl: HTMLButtonElement; private readonly sessionActionsEl: HTMLDivElement; private lastShipWindowMarkup = ""; + private lastDebugHistoryMarkup = ""; + private debugHistoryPinnedShipId?: string; + private debugAutoScroll = true; + private readonly shipHistoryById = new Map(); + private readonly shipHistorySnapshotById = new Map(); private universe: UniverseDefinition; constructor(container: HTMLElement) { @@ -141,6 +160,9 @@ export class GameApp { this.fleetWindowBodyEl = hud.fleetWindowBody; this.fleetWindowTitleEl = hud.fleetWindowTitle; this.debugWindowEl = hud.debugWindow; + this.debugHistoryEl = hud.debugHistory; + this.debugAutoScrollToggleEl = hud.debugAutoScrollToggle; + this.debugCopyHistoryEl = hud.debugCopyHistory; this.sessionActionsEl = hud.sessionActions; this.setupScene(); @@ -169,6 +191,10 @@ export class GameApp { return this.selectionManager.getPlanet(); } + private get selectedNode() { + return this.selectionManager.getNode(); + } + private setupScene() { const world = buildInitialWorld(this.scene, this.selectableTargets, this.universe.systems, this.universe.scenario); this.systems.push(...world.systems); @@ -182,6 +208,7 @@ export class GameApp { this.initializeFactions(); this.applyViewLevel(); + this.ships.forEach((ship) => this.trackShipHistory(ship)); } private generateNewUniverse() { @@ -194,6 +221,10 @@ export class GameApp { this.systems.length = 0; this.factions.length = 0; this.factionsById.clear(); + this.shipHistoryById.clear(); + this.shipHistorySnapshotById.clear(); + this.debugHistoryPinnedShipId = undefined; + this.debugAutoScroll = true; this.followShipId = undefined; this.buildMode = false; this.selectedSystemIndex = 0; @@ -451,6 +482,10 @@ export class GameApp { this.selectionManager.setPlanet(target.planet); this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.system.definition.id); } + if (target?.kind === "node") { + this.selectionManager.setNode(target.node); + this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.node.systemId); + } } this.updateHud(); @@ -480,6 +515,9 @@ export class GameApp { const worldPosition = target.planet.mesh.getWorldPosition(new THREE.Vector3()); this.focusPoint(worldPosition, 760); this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.system.definition.id); + } else if (target.kind === "node") { + this.focusPoint(target.node.position, 680); + this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.node.systemId); } this.updateHud(); }; @@ -571,17 +609,666 @@ export class GameApp { } private makePatrolPoints(systemId: string) { + const orbitalDestinations = this.getSystemOrbitalDestinations(systemId); const route = this.universe.scenario.patrolRoutes.find((candidate) => candidate.systemId === systemId); if (!route) { - const system = this.getSystem(systemId); + return orbitalDestinations.slice(0, 4); + } + return route.points.map((point, index) => + this.resolveTravelDestination( + new THREE.Vector3(...point).setY(gameBalance.yPlane), + systemId, + `${this.getSystem(systemId).definition.label} Patrol ${index + 1}`, + orbitalDestinations, + ), + ); + } + + private cloneTravelDestination(destination: TravelDestination): TravelDestination { + return { + ...destination, + position: destination.position.clone(), + orbitalAnchor: destination.orbitalAnchor.clone(), + }; + } + + private sameTravelDestination(left: TravelDestination, right: TravelDestination) { + return ( + left.kind === right.kind && + left.id === right.id && + left.systemId === right.systemId && + left.label === right.label + ); + } + + private sameTravelDestinationList(left: TravelDestination[], right: TravelDestination[]) { + return left.length === right.length && left.every((destination, index) => this.sameTravelDestination(destination, right[index])); + } + + private sameShipOrder(ship: ShipInstance, nextOrder: ShipInstance["order"]) { + const currentOrder = ship.order; + if (!currentOrder || !nextOrder) { + return currentOrder === nextOrder; + } + if (currentOrder.kind !== nextOrder.kind) { + return false; + } + if (nextOrder.kind === "move-to" && currentOrder.kind === "move-to") { + return this.sameTravelDestination(currentOrder.destination, nextOrder.destination); + } + if (nextOrder.kind === "mine-this" && currentOrder.kind === "mine-this") { + return currentOrder.nodeId === nextOrder.nodeId && currentOrder.refineryId === nextOrder.refineryId; + } + if (nextOrder.kind === "dock-at" && currentOrder.kind === "dock-at") { + return currentOrder.hostKind === nextOrder.hostKind && currentOrder.hostId === nextOrder.hostId; + } + return false; + } + + private setShipOrder(ship: ShipInstance, nextOrder: ShipInstance["order"]) { + if (this.sameShipOrder(ship, nextOrder)) { + return; + } + ship.order = nextOrder; + } + + private sameDefaultBehavior(ship: ShipInstance, nextBehavior: ShipInstance["defaultBehavior"]) { + const currentBehavior = ship.defaultBehavior; + if (currentBehavior.kind !== nextBehavior.kind) { + return false; + } + if (nextBehavior.kind === "idle") { + return true; + } + if (nextBehavior.kind === "auto-mine" && currentBehavior.kind === "auto-mine") { + return ( + currentBehavior.areaSystemId === nextBehavior.areaSystemId && + currentBehavior.refineryId === nextBehavior.refineryId && + currentBehavior.nodeId === nextBehavior.nodeId && + currentBehavior.phase === nextBehavior.phase + ); + } + if (nextBehavior.kind === "patrol" && currentBehavior.kind === "patrol") { + return ( + currentBehavior.systemId === nextBehavior.systemId && + currentBehavior.index === nextBehavior.index && + this.sameTravelDestinationList(currentBehavior.points, nextBehavior.points) + ); + } + if (nextBehavior.kind === "escort-assigned" && currentBehavior.kind === "escort-assigned") { + return currentBehavior.offset.distanceToSquared(nextBehavior.offset) < 1; + } + return false; + } + + private setDefaultBehavior(ship: ShipInstance, nextBehavior: ShipInstance["defaultBehavior"]) { + if (this.sameDefaultBehavior(ship, nextBehavior)) { + return; + } + ship.defaultBehavior = nextBehavior; + } + + private sameAssignment(ship: ShipInstance, nextAssignment: ShipInstance["assignment"]) { + const currentAssignment = ship.assignment; + if (currentAssignment.kind !== nextAssignment.kind) { + return false; + } + if (nextAssignment.kind === "unassigned") { + return true; + } + if (nextAssignment.kind === "commander-subordinate" && currentAssignment.kind === "commander-subordinate") { + return currentAssignment.commanderId === nextAssignment.commanderId && currentAssignment.role === nextAssignment.role; + } + if (nextAssignment.kind === "station-based" && currentAssignment.kind === "station-based") { + return currentAssignment.stationId === nextAssignment.stationId && currentAssignment.role === nextAssignment.role; + } + if (nextAssignment.kind === "mining-group" && currentAssignment.kind === "mining-group") { + return currentAssignment.controllerId === nextAssignment.controllerId; + } + return false; + } + + private setAssignment(ship: ShipInstance, nextAssignment: ShipInstance["assignment"]) { + if (this.sameAssignment(ship, nextAssignment)) { + return; + } + ship.assignment = nextAssignment; + } + + private sameControllerTask(ship: ShipInstance, nextTask: ShipInstance["controllerTask"]) { + const currentTask = ship.controllerTask; + if (currentTask.kind !== nextTask.kind) { + return false; + } + if (nextTask.kind === "idle") { + return true; + } + if (nextTask.kind === "travel" && currentTask.kind === "travel") { + return ( + this.sameTravelDestination(currentTask.destination, nextTask.destination) && + currentTask.threshold === nextTask.threshold + ); + } + if (nextTask.kind === "dock" && currentTask.kind === "dock") { + return ( + currentTask.hostKind === nextTask.hostKind && + currentTask.hostId === nextTask.hostId && + currentTask.portIndex === nextTask.portIndex + ); + } + if (nextTask.kind === "extract" && currentTask.kind === "extract") { + return currentTask.nodeId === nextTask.nodeId; + } + if (nextTask.kind === "unload" && currentTask.kind === "unload") { + return currentTask.stationId === nextTask.stationId; + } + if (nextTask.kind === "follow" && currentTask.kind === "follow") { + return currentTask.targetShipId === nextTask.targetShipId && currentTask.threshold === nextTask.threshold && currentTask.offset.distanceToSquared(nextTask.offset) < 1; + } + if (nextTask.kind === "undock" && currentTask.kind === "undock") { + return currentTask.hostKind === nextTask.hostKind && currentTask.hostId === nextTask.hostId; + } + return false; + } + + private setControllerTask(ship: ShipInstance, nextTask: ShipInstance["controllerTask"]) { + if (this.sameControllerTask(ship, nextTask)) { + return; + } + ship.controllerTask = nextTask; + const preserveLandedAnchor = + (nextTask.kind === "dock" && + ((nextTask.hostKind === "station" && + ship.landedDestination?.kind === "station" && + ship.landedDestination.id === nextTask.hostId) || + (nextTask.hostKind === "ship" && + ship.landedDestination?.kind === "ship" && + ship.landedDestination.id === nextTask.hostId))) || + (nextTask.kind === "extract" && + ship.landedDestination?.kind === "resource-node" && + ship.landedDestination.id === nextTask.nodeId) || + (nextTask.kind === "unload" && + ship.landedDestination?.kind === "station" && + ship.landedDestination.id === nextTask.stationId); + if (nextTask.kind !== "idle" && !preserveLandedAnchor) { + this.clearShipLandedDestination(ship); + } + } + + private clearShipLandedDestination(ship: ShipInstance) { + ship.landedDestination = undefined; + ship.landedOffset.set(0, 0, 0); + } + + private setShipLandedDestination(ship: ShipInstance, destination: TravelDestination) { + ship.landedDestination = this.cloneTravelDestination(destination); + const anchorPosition = this.resolveTravelDestinationPosition(destination); + ship.landedOffset.copy(ship.group.position.clone().setY(gameBalance.yPlane).sub(anchorPosition)); + } + + private resolveTravelDestinationPosition(destination: TravelDestination) { + if (destination.kind === "planet") { + const system = this.getSystem(destination.systemId); + const planet = system.planets.find((candidate) => `${system.definition.id}-planet-${candidate.index}` === destination.id); + if (planet) { + return planet.mesh.getWorldPosition(new THREE.Vector3()).setY(gameBalance.yPlane); + } + } + if (destination.kind === "station" && destination.id) { + const station = this.stations.find((candidate) => candidate.id === destination.id); + if (station) { + return station.group.position.clone().setY(gameBalance.yPlane); + } + } + if (destination.kind === "resource-node" && destination.id) { + const node = this.nodes.find((candidate) => candidate.id === destination.id); + if (node) { + return node.mesh.getWorldPosition(new THREE.Vector3()).setY(gameBalance.yPlane); + } + } + if (destination.kind === "ship" && destination.id) { + const ship = this.shipsById.get(destination.id); + if (ship) { + return ship.group.position.clone().setY(gameBalance.yPlane); + } + } + return destination.position.clone().setY(gameBalance.yPlane); + } + + private hydrateTravelDestination(destination: TravelDestination) { + const hydrated = this.cloneTravelDestination(destination); + const livePosition = this.resolveTravelDestinationPosition(destination); + hydrated.position.copy(livePosition); + hydrated.orbitalAnchor.copy(livePosition); + return hydrated; + } + + private updateAnchoredShipPosition(ship: ShipInstance) { + if (!ship.landedDestination) { + return false; + } + const anchor = this.resolveTravelDestinationPosition(ship.landedDestination); + const nextPosition = anchor.add(ship.landedOffset).setY(gameBalance.yPlane); + ship.group.position.lerp(nextPosition, 0.2); + if (ship.landedOffset.lengthSq() > 1) { + ship.group.lookAt(ship.group.position.clone().add(ship.landedOffset)); + } + return true; + } + + private formatHistoryTime() { + return this.clock.elapsedTime.toFixed(1).padStart(6, "0"); + } + + private recordShipHistoryEvent(ship: ShipInstance, message: string) { + this.recordShipHistoryEvents(ship, [message]); + } + + private recordShipHistoryEvents(ship: ShipInstance, messages: string[]) { + if (messages.length === 0) { + return; + } + const entries = this.shipHistoryById.get(ship.id) ?? []; + const stampedMessages = messages.map((message) => `${this.formatHistoryTime()} ${message}`); + this.shipHistoryById.set(ship.id, [...stampedMessages, ...entries].slice(0, 16)); + } + + private formatControllerEvent(event: ControllerEvent) { + if (event.kind === "none") { + return ""; + } + if (event.kind === "arrived") { + return ``; + } + if (event.kind === "docking-begin") { + return ``; + } + if (event.kind === "docked") { + return ""; + } + if (event.kind === "unloaded") { + return ""; + } + return ""; + } + + private getControllerTaskDestination(ship: ShipInstance) { + const task = ship.controllerTask; + if (task.kind === "travel") { + return this.hydrateTravelDestination(task.destination); + } + if (task.kind === "dock" || task.kind === "undock") { + const host = task.hostKind === "ship" + ? this.shipsById.get(task.hostId) + : this.stations.find((candidate) => candidate.id === task.hostId); + if (!host) { + return undefined; + } + return task.hostKind === "ship" + ? this.createShipTravelDestination(host as ShipInstance) + : this.createStationTravelDestination(host as StationInstance); + } + if (task.kind === "extract") { + const node = this.nodes.find((candidate) => candidate.id === task.nodeId); + return node ? this.createNodeTravelDestination(node) : undefined; + } + if (task.kind === "unload") { + const station = this.stations.find((candidate) => candidate.id === task.stationId); + return station ? this.createStationTravelDestination(station) : undefined; + } + if (task.kind === "follow") { + const targetShip = this.shipsById.get(task.targetShipId); + return targetShip ? this.createShipTravelDestination(targetShip) : undefined; + } + return undefined; + } + + private getDockingReservationLabel(ship: ShipInstance) { + if (ship.dockingPortIndex === undefined) { + return "none"; + } + const host = this.getDockingHostForShip(ship); + if (!host) { + return `port-${ship.dockingPortIndex}`; + } + const hostLabel = this.isCarrierHost(host) ? `${host.definition.label} ${host.id}` : `${host.definition.label} ${host.id}`; + return `${hostLabel} bay-${ship.dockingPortIndex}`; + } + + private getDockingWaitLabel(ship: ShipInstance) { + return ship.dockingClearanceStatus ?? "none"; + } + + private getCargoStateLabel(ship: ShipInstance) { + const cargo = getShipCargoAmount(ship); + if (cargo <= 0) { + return "empty"; + } + if (cargo >= ship.definition.cargoCapacity) { + return "full"; + } + return "partial"; + } + + private getOrderLabel(ship: ShipInstance) { + if (!ship.order) { + return "none"; + } + if (ship.order.kind === "move-to") { + return `move-to:${ship.order.destination.label}:${ship.order.status}`; + } + if (ship.order.kind === "mine-this") { + return `mine-this:${ship.order.nodeId}:${ship.order.status}`; + } + return `dock-at:${ship.order.hostKind}:${ship.order.hostId}:${ship.order.status}`; + } + + private getOrderPhaseLabel(ship: ShipInstance) { + if (!ship.order || ship.order.kind !== "mine-this") { + return "n/a"; + } + return ship.order.phase; + } + + private getDefaultBehaviorLabel(ship: ShipInstance) { + const behavior = ship.defaultBehavior; + if (behavior.kind === "auto-mine") { + return `auto-mine:${behavior.areaSystemId}:${behavior.refineryId}`; + } + if (behavior.kind === "patrol") { + return `patrol:${behavior.systemId}`; + } + if (behavior.kind === "escort-assigned") { + return "escort-assigned"; + } + return "idle"; + } + + private getAssignmentLabel(ship: ShipInstance) { + const assignment = ship.assignment; + if (assignment.kind === "commander-subordinate") { + return `commander:${assignment.commanderId}:${assignment.role}`; + } + if (assignment.kind === "station-based") { + return `station:${assignment.stationId}:${assignment.role}`; + } + if (assignment.kind === "mining-group") { + return `mining-group:${assignment.controllerId}`; + } + return "unassigned"; + } + + private getBehaviorPhaseLabel(ship: ShipInstance) { + const behavior = ship.defaultBehavior; + if (behavior.kind === "auto-mine") { + return behavior.phase; + } + if (behavior.kind === "patrol") { + return `point-${behavior.index + 1}`; + } + return "n/a"; + } + + private formatStateLine(ship: ShipInstance) { + return `state=${this.getOrderLabel(ship)}/${this.getBehaviorPhaseLabel(ship)} [${ship.controllerTask.kind}]/(${ship.state})`; + } + + private trackShipHistory(ship: ShipInstance) { + const controllerDestination = this.getControllerTaskDestination(ship); + const destination = controllerDestination ? `${controllerDestination.label} @ ${controllerDestination.systemId}` : "none"; + const landed = ship.landedDestination ? `${ship.landedDestination.label} @ ${ship.landedDestination.systemId}` : "free"; + const reservation = this.getDockingReservationLabel(ship); + const dockingWait = this.getDockingWaitLabel(ship); + const cargoState = this.getCargoStateLabel(ship); + const order = this.getOrderLabel(ship); + const orderPhase = this.getOrderPhaseLabel(ship); + const behavior = this.getDefaultBehaviorLabel(ship); + const behaviorPhase = this.getBehaviorPhaseLabel(ship); + const assignment = this.getAssignmentLabel(ship); + const snapshot = `state:${ship.state}|order:${order}|orderPhase:${orderPhase}|behavior:${behavior}|behaviorPhase:${behaviorPhase}|assignment:${assignment}|controller:${ship.controllerTask.kind}|destination:${destination}|system:${ship.systemId}|landed:${landed}|reservation:${reservation}|dockwait:${dockingWait}|cargo:${cargoState}`; + const previous = this.shipHistorySnapshotById.get(ship.id); + if (!previous) { + this.shipHistorySnapshotById.set(ship.id, snapshot); return [ - system.center.clone().add(new THREE.Vector3(180, 0, 120)), - system.center.clone().add(new THREE.Vector3(360, 0, -140)), - system.center.clone().add(new THREE.Vector3(620, 0, 210)), - system.center.clone().add(new THREE.Vector3(260, 0, 320)), + ``, ]; } - return route.points.map((point) => new THREE.Vector3(...point).setY(gameBalance.yPlane)); + if (previous === snapshot) { + return []; + } + + const previousParts = new Map(previous.split("|").map((part) => { + const [key, value] = part.split(":"); + return [key, value]; + })); + const nextParts = new Map(snapshot.split("|").map((part) => { + const [key, value] = part.split(":"); + return [key, value]; + })); + const changes = [...nextParts.entries()] + .filter(([key, value]) => previousParts.get(key) !== value) + .map(([key, value]) => { + const before = previousParts.get(key) ?? "none"; + if (key === "state") { + return this.formatStateLine(ship); + } + if (key === "controller") { + return `[${value}]`; + } + if (key === "order") { + return ` ${value}>`; + } + if (key === "orderPhase") { + return ` ${value}>`; + } + if (key === "behavior") { + return ` ${value}>`; + } + if (key === "behaviorPhase") { + return ` ${value}>`; + } + if (key === "assignment") { + return ` ${value}>`; + } + if (key === "destination") { + return ` ${value}>`; + } + if (key === "landed") { + return ` ${value}>`; + } + if (key === "system") { + return ` ${value}>`; + } + if (key === "reservation") { + return ` ${value}>`; + } + if (key === "dockwait") { + return ` ${value}>`; + } + if (key === "cargo") { + if (value === "full") { + return ""; + } + if (value === "empty") { + return ""; + } + return ""; + } + return `<${key} ${before} -> ${value}>`; + }) + .filter((change) => change.length > 0); + + this.shipHistorySnapshotById.set(ship.id, snapshot); + return changes; + } + + private getDebugShip() { + const selectedShip = this.selection[0]; + if (selectedShip) { + this.debugHistoryPinnedShipId = selectedShip.id; + return selectedShip; + } + if (this.debugHistoryPinnedShipId) { + return this.shipsById.get(this.debugHistoryPinnedShipId); + } + return undefined; + } + + private copyDebugHistory() { + const ship = this.getDebugShip(); + if (!ship) { + return; + } + const entries = this.shipHistoryById.get(ship.id) ?? []; + const controllerDestination = this.getControllerTaskDestination(ship); + const destination = controllerDestination ? `${controllerDestination.label} @ ${controllerDestination.systemId}` : "none"; + const anchor = ship.landedDestination ? `${ship.landedDestination.label} @ ${ship.landedDestination.systemId}` : "free"; + const payload = [ + `${ship.definition.label} • ${ship.id}`, + `Order: ${this.getOrderLabel(ship)}`, + `Order phase: ${this.getOrderPhaseLabel(ship)}`, + `Default behavior: ${this.getDefaultBehaviorLabel(ship)}`, + `Behavior phase: ${this.getBehaviorPhaseLabel(ship)}`, + `Assignment: ${this.getAssignmentLabel(ship)}`, + `Controller task: ${ship.controllerTask.kind}`, + `Flight state: ${ship.state}`, + `Task target: ${destination}`, + `Anchor: ${anchor}`, + "", + ...entries, + ].join("\n"); + void this.writeTextToClipboard(payload); + } + + private async writeTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back to a direct selection-based copy for browsers that reject clipboard API calls. + } + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + document.body.append(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + document.execCommand("copy"); + textarea.remove(); + } + + private createOrbitTravelDestination(systemId: string, label: string, position: THREE.Vector3, id?: string): TravelDestination { + const normalizedPosition = position.clone().setY(gameBalance.yPlane); + return { + kind: "orbit", + id, + systemId, + label, + position: normalizedPosition, + orbitalAnchor: normalizedPosition.clone(), + }; + } + + private createSystemTravelDestination(systemId: string): TravelDestination { + const system = this.getSystem(systemId); + const radius = Math.max(system.gravityWellRadius + 120, system.definition.starSize * 6); + return this.createOrbitTravelDestination( + systemId, + `${system.definition.label} Primary Orbit`, + system.center.clone().add(new THREE.Vector3(radius, 0, 0)), + system.definition.id, + ); + } + + private createPlanetTravelDestination(system: SolarSystemInstance, planetIndex: number): TravelDestination { + const planet = system.planets[planetIndex]; + const position = planet.mesh.getWorldPosition(new THREE.Vector3()).setY(gameBalance.yPlane); + return { + kind: "planet", + id: `${system.definition.id}-planet-${planet.index}`, + systemId: system.definition.id, + label: planet.definition.label, + position: position.clone(), + orbitalAnchor: position, + }; + } + + private createStationTravelDestination(station: StationInstance): TravelDestination { + const position = station.group.position.clone().setY(gameBalance.yPlane); + return { + kind: "station", + id: station.id, + systemId: station.systemId, + label: station.definition.label, + position: position.clone(), + orbitalAnchor: position, + }; + } + + private createNodeTravelDestination(node: ResourceNode): TravelDestination { + const position = node.position.clone().setY(gameBalance.yPlane); + return { + kind: "resource-node", + id: node.id, + systemId: node.systemId, + label: `Asteroid Field ${node.id}`, + position: position.clone(), + orbitalAnchor: position, + }; + } + + private createShipTravelDestination(ship: ShipInstance): TravelDestination { + const position = ship.group.position.clone().setY(gameBalance.yPlane); + return { + kind: "ship", + id: ship.id, + systemId: ship.systemId, + label: ship.definition.label, + position: position.clone(), + orbitalAnchor: position, + }; + } + + private getSystemOrbitalDestinations(systemId: string) { + const system = this.getSystem(systemId); + const stationDestinations = this.stations + .filter((station) => station.systemId === systemId) + .map((station) => this.createStationTravelDestination(station)); + const planetDestinations = system.planets.map((_, index) => this.createPlanetTravelDestination(system, index)); + const destinations = [...stationDestinations, ...planetDestinations]; + if (destinations.length === 0) { + destinations.push(this.createSystemTravelDestination(systemId)); + } + return destinations; + } + + private resolveTravelDestination( + position: THREE.Vector3, + systemId = this.findNearestSystem(position).definition.id, + fallbackLabel?: string, + candidates = this.getSystemOrbitalDestinations(systemId), + ) { + const normalizedPosition = position.clone().setY(gameBalance.yPlane); + const nearestCandidate = [...candidates].sort( + (left, right) => + normalizedPosition.distanceToSquared(left.position) - normalizedPosition.distanceToSquared(right.position), + )[0]; + if (nearestCandidate && normalizedPosition.distanceTo(nearestCandidate.position) < 240) { + return nearestCandidate; + } + const system = this.getSystem(systemId); + return this.createOrbitTravelDestination( + systemId, + fallbackLabel ?? `${system.definition.label} Orbit`, + normalizedPosition, + `${system.definition.id}-orbit-${Math.round(normalizedPosition.x)}-${Math.round(normalizedPosition.z)}`, + ); } private getFactionShips(factionId: string) { @@ -662,7 +1349,14 @@ export class GameApp { const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120)); this.getFactionMilitaryShips(faction.definition.id).forEach((ship, index) => { if (ship.systemId !== militaryTargetSystemId) { - this.issueMoveOrder(ship, rally.clone().add(this.makeEscortOffset(index, 18))); + this.issueMoveOrder( + ship, + this.resolveTravelDestination( + rally.clone().add(this.makeEscortOffset(index, 18)), + militaryTargetSystemId, + `${targetSystem.definition.label} Rally`, + ), + ); return; } this.setPatrolOrder(ship, this.makePatrolPoints(militaryTargetSystemId), index, militaryTargetSystemId); @@ -675,7 +1369,14 @@ export class GameApp { const raidPoint = targetSystem.center.clone().add(new THREE.Vector3(120, 0, 160)); this.getFactionMilitaryShips(faction.definition.id).forEach((ship, index) => { if (ship.systemId !== targetSystemId) { - this.issueMoveOrder(ship, raidPoint.clone().add(this.makeEscortOffset(index, 20))); + this.issueMoveOrder( + ship, + this.resolveTravelDestination( + raidPoint.clone().add(this.makeEscortOffset(index, 20)), + targetSystemId, + `${targetSystem.definition.label} Raid Orbit`, + ), + ); return; } this.setPatrolOrder(ship, this.makePatrolPoints(targetSystemId), index, targetSystemId); @@ -805,7 +1506,7 @@ export class GameApp { this.updateCombat(delta); this.updateSystems(delta); this.applyViewLevel(); - if (this.selection.length > 0 || this.selectedStation || this.selectedSystem || this.selectedPlanet || this.followShipId) { + if (this.selection.length > 0 || this.selectedStation || this.selectedSystem || this.selectedPlanet || this.selectedNode || this.followShipId) { this.updateHud(); } this.renderHudCanvases(); @@ -947,50 +1648,415 @@ export class GameApp { return THREE.MathUtils.clamp(distance / 240, 1.5, 4.2); } + private refreshControlLayers(ship: ShipInstance) { + if (ship.order?.status === "queued") { + ship.order = { ...ship.order, status: "accepted" }; + } + if (ship.defaultBehavior.kind === "idle") { + const derivedBehavior = this.deriveBehaviorFromAssignment(ship); + if (derivedBehavior.kind !== "idle") { + ship.defaultBehavior = derivedBehavior; + } + } + } + + private deriveBehaviorFromAssignment(ship: ShipInstance): ShipInstance["defaultBehavior"] { + const { assignment } = ship; + if (assignment.kind === "commander-subordinate") { + return { kind: "escort-assigned", offset: this.makeEscortOffset(this.ships.indexOf(ship)) }; + } + if (assignment.kind === "mining-group") { + const controller = this.stations.find((station) => station.id === assignment.controllerId); + return controller + ? { + kind: "auto-mine", + areaSystemId: controller.systemId, + refineryId: controller.id, + nodeId: undefined, + phase: getShipCargoAmount(ship) >= ship.definition.cargoCapacity ? "travel-to-station" : "travel-to-node", + } + : { kind: "idle" }; + } + if (assignment.kind === "station-based") { + const station = this.stations.find((candidate) => candidate.id === assignment.stationId); + return station + ? { kind: "patrol", systemId: station.systemId, points: this.makePatrolPoints(station.systemId), index: 0 } + : { kind: "idle" }; + } + return { kind: "idle" }; + } + + private clearShipOrder(ship: ShipInstance, status: "completed" | "failed" | "cancelled" | "blocked") { + if (!ship.order) { + return; + } + ship.order = undefined; + this.recordShipHistoryEvent(ship, ``); + } + + private planControllerTask(ship: ShipInstance) { + ship.dockingClearanceStatus = undefined; + if (ship.order && ["queued", "accepted", "planning"].includes(ship.order.status)) { + ship.order = { ...ship.order, status: ship.controllerTask.kind === "idle" ? "planning" : "executing" }; + } + if (ship.order) { + this.planTaskFromOrder(ship, ship.order); + return; + } + if (ship.defaultBehavior.kind !== "idle") { + this.planTaskFromBehavior(ship, ship.defaultBehavior); + return; + } + this.setControllerTask(ship, { kind: "idle" }); + } + + private planTaskFromOrder(ship: ShipInstance, order: NonNullable) { + if (order.kind === "move-to") { + this.setControllerTask(ship, { + kind: "travel", + destination: this.cloneTravelDestination(order.destination), + threshold: gameBalance.arrivalThreshold, + }); + return; + } + if (order.kind === "mine-this") { + const node = this.nodes.find((candidate) => candidate.id === order.nodeId); + const refinery = this.stations.find((candidate) => candidate.id === order.refineryId); + if (!node || !refinery) { + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; + this.clearShipOrder(ship, "failed"); + return; + } + if (order.phase === "travel-to-node") { + this.setControllerTask(ship, { + kind: "travel", + destination: this.createNodeTravelDestination(node), + threshold: 26, + }); + return; + } + this.setControllerTask(ship, { kind: "extract", nodeId: node.id }); + return; + } + const host = order.hostKind === "ship" + ? this.shipsById.get(order.hostId) + : this.stations.find((candidate) => candidate.id === order.hostId); + if (!host || !this.canShipDockAtHost(ship, host)) { + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; + this.clearShipOrder(ship, "failed"); + return; + } + const radius = this.isCarrierHost(host) ? host.definition.size + 28 : host.definition.radius + 30; + if (ship.systemId !== host.systemId) { + this.setControllerTask(ship, { + kind: "travel", + destination: this.isCarrierHost(host) + ? this.createShipTravelDestination(host) + : this.createStationTravelDestination(host), + threshold: radius, + }); + return; + } + const clearance = this.requestDockingClearance(host, ship); + ship.dockingClearanceStatus = this.formatDockingClearance(host, clearance); + if (clearance.kind === "rejected") { + this.setControllerTask(ship, { kind: "idle" }); + ship.order = { ...order, status: "blocked" }; + return; + } + this.setControllerTask(ship, { kind: "dock", hostKind: order.hostKind, hostId: host.id, portIndex: clearance.portIndex }); + } + + private planTaskFromBehavior(ship: ShipInstance, behavior: ShipInstance["defaultBehavior"]) { + if (behavior.kind === "auto-mine") { + const refinery = this.stations.find((candidate) => candidate.id === behavior.refineryId); + const node = behavior.nodeId + ? this.nodes.find((candidate) => candidate.id === behavior.nodeId) + : this.findBestMiningNode(behavior.areaSystemId); + if (!node || !refinery) { + this.setDefaultBehavior(ship, { kind: "idle" }); + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; + return; + } + if (behavior.nodeId !== node.id) { + this.setDefaultBehavior(ship, { ...behavior, nodeId: node.id }); + } + if (behavior.phase === "travel-to-node") { + this.setControllerTask(ship, { + kind: "travel", + destination: this.createNodeTravelDestination(node), + threshold: 26, + }); + return; + } + if (behavior.phase === "extract") { + this.setControllerTask(ship, { kind: "extract", nodeId: node.id }); + return; + } + if (behavior.phase === "travel-to-station") { + this.setControllerTask(ship, { + kind: "travel", + destination: this.createStationTravelDestination(refinery), + threshold: refinery.definition.radius + 30, + }); + return; + } + if (behavior.phase === "dock") { + const clearance = this.requestDockingClearance(refinery, ship); + ship.dockingClearanceStatus = this.formatDockingClearance(refinery, clearance); + if (clearance.kind === "rejected") { + this.setControllerTask(ship, { kind: "idle" }); + return; + } + this.setControllerTask(ship, { kind: "dock", hostKind: "station", hostId: refinery.id, portIndex: clearance.portIndex }); + return; + } + if (behavior.phase === "unload") { + this.setControllerTask(ship, { kind: "unload", stationId: refinery.id }); + return; + } + if (behavior.phase === "undock") { + this.setControllerTask(ship, { kind: "undock", hostKind: "station", hostId: refinery.id }); + } + return; + } + if (behavior.kind === "patrol") { + const target = behavior.points[behavior.index]; + this.setControllerTask(ship, { + kind: "travel", + destination: this.cloneTravelDestination(target), + threshold: 20, + }); + return; + } + if (behavior.kind !== "escort-assigned") { + this.setControllerTask(ship, { kind: "idle" }); + return; + } + const commanderId = ship.assignment.kind === "commander-subordinate" ? ship.assignment.commanderId : undefined; + const commander = commanderId ? this.shipsById.get(commanderId) : undefined; + if (!commander) { + this.setDefaultBehavior(ship, { kind: "idle" }); + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; + return; + } + if (commander.systemId !== ship.systemId) { + this.setControllerTask(ship, { + kind: "travel", + destination: this.createShipTravelDestination(commander), + threshold: commander.definition.size + 28, + }); + return; + } + this.setControllerTask(ship, { + kind: "follow", + targetShipId: commander.id, + offset: behavior.offset.clone(), + threshold: 18, + }); + } + + private updateControllerTask(ship: ShipInstance, delta: number): ControllerEvent { + const task = ship.controllerTask; + if (task.kind === "idle") { + if (ship.state !== "docked" && ship.state !== "undocking") { + ship.state = "idle"; + ship.velocity.multiplyScalar(0.9); + if (!this.updateAnchoredShipPosition(ship)) { + this.updateIdleOrbit(ship, delta); + } + } + return { kind: "none" }; + } + if (task.kind === "travel") { + return this.updateTravelState(ship, task.destination, delta, task.threshold, task.suppliedPlan) + ? { kind: "arrived", destination: this.hydrateTravelDestination(task.destination) } + : { kind: "none" }; + } + if (task.kind === "dock") { + const host = task.hostKind === "ship" ? this.shipsById.get(task.hostId) : this.stations.find((candidate) => candidate.id === task.hostId); + if (!host) { + ship.state = "idle"; + return { kind: "none" }; + } + const previousState = ship.state; + const docked = this.updateDockingState(ship, host, task.portIndex, delta); + if (previousState !== ship.state && ship.state === "docking") { + return { kind: "docking-begin", portIndex: task.portIndex }; + } + return docked ? { kind: "docked" } : { kind: "none" }; + } + if (task.kind === "extract") { + const node = this.nodes.find((candidate) => candidate.id === task.nodeId); + if (!node) { + ship.state = "idle"; + return { kind: "none" }; + } + ship.state = "mining"; + ship.actionTimer += delta; + ship.velocity.multiplyScalar(0.75); + if (ship.actionTimer >= 1) { + const cargo = getShipCargoAmount(ship); + const mined = Math.min(gameBalance.miningRate, ship.definition.cargoCapacity - cargo, node.oreRemaining); + addShipCargo(ship, mined); + node.oreRemaining = Math.max(0, node.oreRemaining - mined); + ship.actionTimer = 0; + if (node.oreRemaining <= 0) { + node.oreRemaining = node.maxOre; + } + } + return { kind: "none" }; + } + if (task.kind === "unload") { + const station = this.stations.find((candidate) => candidate.id === task.stationId); + if (!station) { + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; + return { kind: "none" }; + } + ship.state = "transferring"; + const cargo = getShipCargoAmount(ship); + const transferred = removeShipCargo(ship, Math.min(cargo, gameBalance.transferRate * delta)); + this.addStationItem(station, "ore", transferred); + const faction = this.factionsById.get(ship.factionId); + if (faction) { + faction.oreMined += transferred; + faction.credits += transferred * 0.4; + } + if (getShipCargoAmount(ship) <= 0) { + ship.state = "docked"; + return { kind: "unloaded" }; + } + return { kind: "none" }; + } + if (task.kind === "follow") { + const targetShip = this.shipsById.get(task.targetShipId); + if (!targetShip) { + ship.state = "idle"; + return { kind: "none" }; + } + ship.state = "escorting"; + const anchor = targetShip.group.position.clone().add(task.offset); + this.moveShipToward(ship, anchor, ship.definition.speed * 1.05, delta, task.threshold); + return { kind: "none" }; + } + if (task.kind === "undock") { + const host = task.hostKind === "ship" ? this.shipsById.get(task.hostId) : this.stations.find((candidate) => candidate.id === task.hostId); + if (!host) { + ship.state = "idle"; + return { kind: "none" }; + } + if (ship.state === "docked") { + this.beginUndock(ship, host); + } + if (ship.state === "undocking" && this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8, true)) { + ship.state = "idle"; + return { kind: "undocked" }; + } + return { kind: "none" }; + } + return { kind: "none" }; + } + + private advanceControlState(ship: ShipInstance, controllerEvent: ControllerEvent) { + if (controllerEvent.kind === "arrived") { + this.setShipLandedDestination(ship, controllerEvent.destination); + } + if (ship.order?.kind === "move-to" && controllerEvent.kind === "arrived") { + this.setControllerTask(ship, { kind: "idle" }); + this.clearShipOrder(ship, "completed"); + return true; + } + if (ship.order?.kind === "mine-this") { + const cargo = getShipCargoAmount(ship); + if (ship.order.phase === "travel-to-node" && controllerEvent.kind === "arrived") { + ship.order = { ...ship.order, phase: "extract" }; + return true; + } + if (ship.order.phase === "extract" && cargo >= ship.definition.cargoCapacity) { + this.clearShipOrder(ship, "completed"); + this.setControllerTask(ship, { kind: "idle" }); + return true; + } + return false; + } + if (ship.order?.kind === "dock-at" && controllerEvent.kind === "docked") { + this.setControllerTask(ship, { kind: "idle" }); + this.clearShipOrder(ship, "completed"); + return true; + } + + const behavior = ship.defaultBehavior; + if (behavior.kind === "auto-mine") { + const cargo = getShipCargoAmount(ship); + if (behavior.phase === "travel-to-node" && controllerEvent.kind === "arrived") { + this.setDefaultBehavior(ship, { + ...behavior, + phase: cargo >= ship.definition.cargoCapacity ? "travel-to-station" : "extract", + }); + return true; + } + if (behavior.phase === "extract" && cargo >= ship.definition.cargoCapacity) { + this.setDefaultBehavior(ship, { ...behavior, phase: "travel-to-station" }); + return true; + } + if (behavior.phase === "travel-to-station" && controllerEvent.kind === "arrived") { + this.setDefaultBehavior(ship, { ...behavior, phase: "dock" }); + return true; + } + if (behavior.phase === "dock" && controllerEvent.kind === "docked") { + this.setDefaultBehavior(ship, { ...behavior, phase: "unload" }); + return true; + } + if (behavior.phase === "unload" && controllerEvent.kind === "unloaded") { + this.setDefaultBehavior(ship, { ...behavior, phase: "undock" }); + return true; + } + if (behavior.phase === "undock" && controllerEvent.kind === "undocked") { + this.setDefaultBehavior(ship, { ...behavior, phase: "travel-to-node", nodeId: undefined }); + return true; + } + return false; + } + if (behavior.kind === "patrol" && controllerEvent.kind === "arrived") { + this.setDefaultBehavior(ship, { + ...behavior, + index: (behavior.index + 1) % behavior.points.length, + }); + return true; + } + return false; + } + private updateShips(delta: number, elapsed: number) { this.ships.forEach((ship, index) => { this.consumeShipResources(ship, delta); - - if (ship.state === "undocking" && this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8, true)) { - ship.state = "idle"; - } - - switch (ship.order.kind) { - case "idle": - if (ship.state !== "docked" && ship.state !== "undocking") { - ship.state = "idle"; - ship.velocity.multiplyScalar(0.9); - this.updateIdleOrbit(ship, delta); - } - break; - case "move": - if (this.updateTravelState(ship, ship.order.destination, ship.order.systemId, delta, gameBalance.arrivalThreshold)) { - ship.order = { kind: "idle" }; - ship.travelPlan = undefined; - } - break; - case "transfer": - this.updateTransferOrder(ship, delta); - break; - case "mine": - this.updateMiningOrder(ship, delta); - break; - case "patrol": - this.updatePatrolOrder(ship, delta); - break; - case "escort": - this.updateEscortOrder(ship, delta); - break; - case "dock": - this.updateDockOrder(ship, delta); + this.refreshControlLayers(ship); + this.planControllerTask(ship); + const tickHistoryEntries: string[] = []; + for (let iteration = 0; iteration < 3; iteration += 1) { + const controllerEvent = this.updateControllerTask(ship, delta); + const eventLabel = this.formatControllerEvent(controllerEvent); + if (eventLabel) { + tickHistoryEntries.push(eventLabel); + } + const controlChanged = this.advanceControlState(ship, controllerEvent); + if (!controlChanged) { break; + } + this.refreshControlLayers(ship); + this.planControllerTask(ship); } if (ship.state === "docked") { this.updateDockedShipTransform(ship); ship.group.rotation.z = 0; ship.energy = Math.min(ship.maxEnergy, ship.energy + gameBalance.energy.shipRechargeRate * delta); - } else if (ship.state !== "warping") { + } else if (ship.state !== "warping" && ship.state !== "ftl") { ship.group.position.y = gameBalance.yPlane + Math.sin(elapsed * 1.2 + index) * 0.7; ship.group.rotation.z = Math.sin(elapsed * 2 + index) * 0.04; } else { @@ -998,151 +2064,20 @@ export class GameApp { ship.group.rotation.z = 0; } - ship.warpFx.visible = ship.state === "warping" || ship.state === "spooling-ftl"; - ship.warpFx.scale.x = ship.state === "warping" ? 2.4 : 1.2; + ship.warpFx.visible = + ship.state === "spooling-warp" || ship.state === "warping" || ship.state === "spooling-ftl" || ship.state === "ftl"; + ship.warpFx.scale.x = ship.state === "ftl" ? 3.2 : ship.state === "warping" ? 2.4 : 1.2; + ship.warpFx.traverse((child) => { + if ("material" in child && child.material instanceof THREE.MeshBasicMaterial) { + child.material.opacity = + ship.state === "ftl" ? 0.65 : ship.state === "warping" ? 0.5 : ship.state === "spooling-warp" || ship.state === "spooling-ftl" ? 0.28 : 0.22; + } + }); + tickHistoryEntries.push(...this.trackShipHistory(ship)); + this.recordShipHistoryEvents(ship, tickHistoryEntries); }); } - private updateTransferOrder(ship: ShipInstance, delta: number) { - const order = ship.order; - if (order.kind !== "transfer") { - return; - } - if (this.updateTravelState(ship, order.destination, order.destinationSystemId, delta, gameBalance.arrivalThreshold, order)) { - ship.order = { kind: "idle" }; - ship.travelPlan = undefined; - } - } - - private updateMiningOrder(ship: ShipInstance, delta: number) { - const order = ship.order; - if (order.kind !== "mine") { - return; - } - - const node = this.nodes.find((candidate) => candidate.id === order.nodeId); - const refinery = this.stations.find((candidate) => candidate.id === order.refineryId); - if (!node || !refinery) { - ship.order = { kind: "idle" }; - ship.state = "idle"; - return; - } - - const cargo = getShipCargoAmount(ship); - if (cargo >= ship.definition.cargoCapacity) { - order.phase = "to-refinery"; - } - - if (order.phase === "to-node") { - if (this.updateTravelState(ship, node.position, node.systemId, delta, 26)) { - order.phase = "mining"; - } - return; - } - - if (order.phase === "mining") { - ship.state = "mining"; - ship.actionTimer += delta; - ship.velocity.multiplyScalar(0.75); - if (ship.actionTimer >= 1) { - const mined = Math.min(gameBalance.miningRate, ship.definition.cargoCapacity - cargo, node.oreRemaining); - addShipCargo(ship, mined); - node.oreRemaining = Math.max(0, node.oreRemaining - mined); - ship.actionTimer = 0; - if (getShipCargoAmount(ship) >= ship.definition.cargoCapacity) { - order.phase = "to-refinery"; - } - if (node.oreRemaining <= 0) { - node.oreRemaining = node.maxOre; - } - } - return; - } - - if (order.phase === "to-refinery") { - if (this.updateTravelState(ship, refinery.group.position, refinery.systemId, delta, refinery.definition.radius + 30)) { - order.phase = "transfer"; - } - return; - } - - if (order.phase === "transfer" && this.updateDockingState(ship, refinery, delta)) { - const transferred = removeShipCargo(ship, getShipCargoAmount(ship)); - this.addStationItem(refinery, "ore", transferred); - const faction = this.factionsById.get(ship.factionId); - if (faction) { - faction.oreMined += transferred; - faction.credits += transferred * 0.4; - } - order.phase = "to-node"; - this.beginUndock(ship, refinery); - } - } - - private updatePatrolOrder(ship: ShipInstance, delta: number) { - const order = ship.order; - if (order.kind !== "patrol") { - return; - } - - ship.state = "patrolling"; - const target = order.points[order.index]; - if (ship.systemId !== order.systemId) { - this.issueMoveOrder(ship, target.clone()); - return; - } - - if (this.moveShipToward(ship, target, ship.definition.speed, delta, 20)) { - order.index = (order.index + 1) % order.points.length; - } - } - - private updateEscortOrder(ship: ShipInstance, delta: number) { - const order = ship.order; - if (order.kind !== "escort") { - return; - } - - const targetShip = this.shipsById.get(order.targetShipId); - if (!targetShip) { - ship.order = { kind: "idle" }; - ship.state = "idle"; - return; - } - - ship.state = "escorting"; - const anchor = targetShip.group.position.clone().add(order.offset); - if (targetShip.systemId !== ship.systemId) { - this.issueMoveOrder(ship, targetShip.group.position.clone()); - return; - } - - this.moveShipToward(ship, anchor, ship.definition.speed * 1.05, delta, 18); - } - - private updateDockOrder(ship: ShipInstance, delta: number) { - const order = ship.order; - if (order.kind !== "dock") { - return; - } - - const carrier = this.shipsById.get(order.carrierShipId); - if (!carrier || !this.canDockShipAtCarrier(ship, carrier)) { - ship.order = { kind: "idle" }; - ship.state = "idle"; - return; - } - - if (ship.systemId !== carrier.systemId) { - this.updateTravelState(ship, carrier.group.position.clone(), carrier.systemId, delta, carrier.definition.size + 28); - return; - } - - if (this.updateDockingState(ship, carrier, delta)) { - ship.order = { kind: "idle" }; - } - } - private updateDockedShipTransform(ship: ShipInstance) { const host = this.getDockingHostForShip(ship); if (!host || ship.dockingPortIndex === undefined) { @@ -1229,10 +2164,6 @@ export class GameApp { return categoryMatches && modulesMatch && inputsMatch; } - private getStationItemAmount(station: StationInstance, itemId: string) { - return station.itemStocks[itemId] ?? 0; - } - private addStationItem(station: StationInstance, itemId: string, amount: number) { if (amount <= 0) { return; @@ -1271,7 +2202,7 @@ export class GameApp { } private consumeShipResources(ship: ShipInstance, delta: number) { - if (ship.state === "warping" || ship.state === "spooling-ftl") { + if (ship.state === "spooling-warp" || ship.state === "warping" || ship.state === "spooling-ftl" || ship.state === "ftl") { ship.energy = Math.max(0, ship.energy - gameBalance.energy.warpDrain * delta); ship.fuel = Math.max(0, ship.fuel - gameBalance.fuel.warpDrain * delta); return; @@ -1302,60 +2233,44 @@ export class GameApp { ship.group.lookAt(ship.group.position.clone().add(tangent)); } - private ensureTravelPlan( - ship: ShipInstance, - destination: THREE.Vector3, - destinationSystemId: string, - suppliedPlan?: TravelPlan, - ) { - if ( - ship.travelPlan && - ship.travelPlan.destinationSystemId === destinationSystemId && - ship.travelPlan.destination.distanceToSquared(destination) < 1 - ) { + private ensureTravelPlan(ship: ShipInstance, destination: TravelDestination, suppliedPlan?: TravelPlan) { + const liveDestination = this.hydrateTravelDestination(destination); + if (ship.travelPlan && this.sameTravelDestination(ship.travelPlan.destination, liveDestination)) { + ship.travelPlan.destination = liveDestination; return ship.travelPlan; } if (suppliedPlan) { ship.travelPlan = { - destination: suppliedPlan.destination.clone(), - destinationSystemId: suppliedPlan.destinationSystemId, - exitPoint: suppliedPlan.exitPoint.clone().setY(gameBalance.yPlane), + destination: this.hydrateTravelDestination(suppliedPlan.destination), arrivalPoint: suppliedPlan.arrivalPoint.clone().setY(gameBalance.yPlane), + interSystem: suppliedPlan.interSystem, }; return ship.travelPlan; } const currentSystem = this.getSystem(ship.systemId); - const destinationSystem = this.getSystem(destinationSystemId); - const exitDirection = ship.group.position.clone().sub(currentSystem.center).setY(0).normalize(); - if (exitDirection.lengthSq() === 0) { - exitDirection.copy(destinationSystem.center.clone().sub(currentSystem.center).setY(0).normalize()); - } - const arrivalDirection = destination.clone().sub(destinationSystem.center).setY(0).normalize(); + const destinationSystem = this.getSystem(liveDestination.systemId); + const arrivalDirection = liveDestination.orbitalAnchor.clone().sub(destinationSystem.center).setY(0).normalize(); if (arrivalDirection.lengthSq() === 0) { arrivalDirection.copy(currentSystem.center.clone().sub(destinationSystem.center).setY(0).normalize()); } + const starArrivalRadius = Math.max(destinationSystem.definition.starSize * 6, destinationSystem.gravityWellRadius * 0.35, 120); ship.travelPlan = { - destination: destination.clone().setY(gameBalance.yPlane), - destinationSystemId, - exitPoint: currentSystem.center - .clone() - .add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230)) - .setY(gameBalance.yPlane), + destination: liveDestination, arrivalPoint: destinationSystem.center .clone() - .add(arrivalDirection.multiplyScalar(destinationSystem.gravityWellRadius + 150)) + .add(arrivalDirection.multiplyScalar(starArrivalRadius)) .setY(gameBalance.yPlane), + interSystem: currentSystem.definition.id !== destination.systemId, }; return ship.travelPlan; } private updateTravelState( ship: ShipInstance, - destination: THREE.Vector3, - destinationSystemId: string, + destination: TravelDestination, delta: number, threshold: number, suppliedPlan?: TravelPlan, @@ -1367,41 +2282,35 @@ export class GameApp { } } - const plan = this.ensureTravelPlan(ship, destination, destinationSystemId, suppliedPlan); + const plan = this.ensureTravelPlan(ship, destination, suppliedPlan); if (ship.state === "undocking") { return false; } + if (ship.systemId === plan.destination.systemId && ship.group.position.distanceTo(plan.destination.position) <= threshold) { + const systemCenter = this.getSystem(plan.destination.systemId).center; + ship.idleOrbitRadius = plan.destination.position.clone().setY(0).distanceTo(systemCenter); + ship.idleOrbitAngle = Math.atan2( + plan.destination.position.z - systemCenter.z, + plan.destination.position.x - systemCenter.x, + ); + this.setShipLandedDestination(ship, plan.destination); + return true; + } + if ( - ship.systemId === destinationSystemId && - ship.state !== "leaving-gravity-well" && + !plan.interSystem && + ship.state !== "spooling-warp" && ship.state !== "spooling-ftl" && + ship.state !== "ftl" && ship.state !== "warping" && ship.state !== "arriving" ) { - ship.state = "moving"; - return this.moveShipToward(ship, destination, ship.definition.speed, delta, threshold); + ship.state = "spooling-warp"; + ship.actionTimer = ship.definition.spoolTime * 0.7; } - if ( - ship.state === "idle" || - ship.state === "moving" || - ship.state === "mining-approach" || - ship.state === "mining" || - ship.state === "delivering" - ) { - ship.state = "leaving-gravity-well"; - } - - if (ship.state === "leaving-gravity-well") { - if (this.moveShipToward(ship, plan.exitPoint, ship.definition.speed, delta, 28)) { - ship.state = "spooling-ftl"; - ship.actionTimer = ship.definition.spoolTime; - } - return false; - } - - if (ship.state === "spooling-ftl") { + if (!plan.interSystem && ship.state === "spooling-warp") { ship.actionTimer -= delta; ship.velocity.multiplyScalar(0.8); if (ship.actionTimer <= 0) { @@ -1410,20 +2319,67 @@ export class GameApp { return false; } - if (ship.state === "warping") { + if (!plan.interSystem && ship.state === "warping") { + if ( + this.moveShipToward( + ship, + plan.destination.orbitalAnchor, + Math.max(ship.definition.speed * 4, ship.definition.ftlSpeed * 0.18), + delta, + Math.max(42, threshold * 2), + true, + ) + ) { + ship.state = "arriving"; + } + return false; + } + + if (!plan.interSystem && ship.state === "arriving") { + if (this.moveShipToward(ship, plan.destination.position, ship.definition.speed, delta, threshold, true)) { + const systemCenter = this.getSystem(plan.destination.systemId).center; + ship.idleOrbitRadius = plan.destination.position.clone().setY(0).distanceTo(systemCenter); + ship.idleOrbitAngle = Math.atan2( + plan.destination.position.z - systemCenter.z, + plan.destination.position.x - systemCenter.x, + ); + this.setShipLandedDestination(ship, plan.destination); + return true; + } + return false; + } + + if (ship.state !== "spooling-ftl" && ship.state !== "ftl" && ship.state !== "arriving") { + ship.state = "spooling-ftl"; + ship.actionTimer = ship.definition.spoolTime; + } + + if (ship.state === "spooling-ftl") { + ship.actionTimer -= delta; + ship.velocity.multiplyScalar(0.8); + if (ship.actionTimer <= 0) { + ship.state = "ftl"; + } + return false; + } + + if (ship.state === "ftl") { if (this.moveShipToward(ship, plan.arrivalPoint, ship.definition.ftlSpeed, delta, 50)) { - ship.systemId = destinationSystemId; + ship.systemId = plan.destination.systemId; ship.state = "arriving"; } return false; } if (ship.state === "arriving") { - if (this.moveShipToward(ship, destination, ship.definition.speed, delta, threshold)) { - ship.state = "moving"; - const systemCenter = this.getSystem(destinationSystemId).center; - ship.idleOrbitRadius = destination.clone().setY(0).distanceTo(systemCenter); - ship.idleOrbitAngle = Math.atan2(destination.z - systemCenter.z, destination.x - systemCenter.x); + if (this.moveShipToward(ship, plan.destination.position, ship.definition.speed, delta, threshold)) { + const systemCenter = this.getSystem(plan.destination.systemId).center; + ship.idleOrbitRadius = plan.destination.position.clone().setY(0).distanceTo(systemCenter); + ship.idleOrbitAngle = Math.atan2( + plan.destination.position.z - systemCenter.z, + plan.destination.position.x - systemCenter.x, + ); + this.setShipLandedDestination(ship, plan.destination); return true; } return false; @@ -1432,21 +2388,14 @@ export class GameApp { return false; } - private updateDockingState(ship: ShipInstance, host: DockingHost, delta: number) { - const portIndex = this.reserveDockingPort(host, ship); - if (portIndex < 0) { - ship.state = "docking-approach"; - ship.velocity.multiplyScalar(0.7); - return false; - } - + private updateDockingState(ship: ShipInstance, host: DockingHost, portIndex: number, delta: number) { const portPosition = host.group.localToWorld(host.dockingPorts[portIndex].clone()); if (ship.state !== "docking" && ship.state !== "docked") { ship.state = "docking-approach"; } if (ship.state === "docking-approach") { - if (this.moveShipToward(ship, portPosition, ship.definition.speed * 0.75, delta, 8, true)) { + if (this.moveShipTowardDockingPort(ship, host, portIndex, delta, ship.definition.speed * 0.75, 8)) { ship.state = "docking"; ship.actionTimer = gameBalance.dockingDuration; } @@ -1472,12 +2421,47 @@ export class GameApp { return false; } + private moveShipTowardDockingPort( + ship: ShipInstance, + host: DockingHost, + portIndex: number, + delta: number, + speed: number, + threshold: number, + ) { + const targetLocal = host.dockingPorts[portIndex].clone(); + const currentLocal = host.group.worldToLocal(ship.group.position.clone()); + currentLocal.y = gameBalance.yPlane; + targetLocal.y = gameBalance.yPlane; + + const toTargetLocal = targetLocal.clone().sub(currentLocal); + const distance = toTargetLocal.length(); + if (distance <= threshold) { + ship.group.position.copy(host.group.localToWorld(targetLocal.clone())); + ship.velocity.setScalar(0); + return true; + } + + const step = Math.min(distance, speed * delta); + const nextLocal = currentLocal.add(toTargetLocal.normalize().multiplyScalar(step)); + const worldPosition = host.group.localToWorld(nextLocal.clone()); + ship.target.copy(host.group.localToWorld(targetLocal.clone())); + ship.velocity.copy(worldPosition.clone().sub(ship.group.position).divideScalar(Math.max(delta, 0.0001))); + ship.group.position.copy(worldPosition.setY(gameBalance.yPlane)); + + if (ship.velocity.lengthSq() > 1) { + ship.group.lookAt(ship.group.position.clone().add(ship.velocity)); + } + return false; + } + private beginUndock(ship: ShipInstance, host: DockingHost) { if (ship.state === "undocking") { return; } ship.state = "undocking"; ship.actionTimer = gameBalance.dockingDuration * 0.75; + this.clearShipLandedDestination(ship); const portIndex = ship.dockingPortIndex ?? 0; const port = host.group.localToWorld(host.dockingPorts[portIndex].clone()); const direction = port.clone().sub(host.group.position).setY(0).normalize(); @@ -1485,28 +2469,37 @@ export class GameApp { this.releaseDockingPort(host, ship); } - private reserveDockingPort(host: DockingHost, ship: ShipInstance) { + private formatDockingClearance(host: DockingHost, clearance: DockingClearance) { + const hostLabel = this.isCarrierHost(host) ? `${host.definition.label} ${host.id}` : `${host.definition.label} ${host.id}`; + if (clearance.kind === "accepted") { + return `accepted ${hostLabel} bay-${clearance.portIndex}`; + } + return `rejected ${hostLabel} ${clearance.reason}`; + } + + private requestDockingClearance(host: DockingHost, ship: ShipInstance): DockingClearance { if (this.getAssignedDockingHostId(ship) === host.id && ship.dockingPortIndex !== undefined) { - return ship.dockingPortIndex; + return { kind: "accepted", portIndex: ship.dockingPortIndex }; } if (!this.canShipDockAtHost(ship, host)) { - return -1; + return { kind: "rejected", reason: "permission-denied" }; } - if (host.dockedShipIds.size >= host.dockingPorts.length) { - return -1; - } - const usedPorts = new Set( - this.ships - .filter((candidate) => this.getAssignedDockingHostId(candidate) === host.id && candidate.dockingPortIndex !== undefined) - .map((candidate) => candidate.dockingPortIndex as number), + const assignedShips = this.ships.filter( + (candidate) => + this.getAssignedDockingHostId(candidate) === host.id && + candidate.dockingPortIndex !== undefined, ); + host.dockedShipIds.clear(); + assignedShips.forEach((candidate) => host.dockedShipIds.add(candidate.id)); + const usedPorts = new Set(assignedShips.map((candidate) => candidate.dockingPortIndex as number)); const freePort = host.dockingPorts.findIndex((_, index) => !usedPorts.has(index)); if (freePort >= 0) { host.dockedShipIds.add(ship.id); this.assignDockingHost(ship, host); ship.dockingPortIndex = freePort; + return { kind: "accepted", portIndex: freePort }; } - return freePort; + return { kind: "rejected", reason: "no-free-bay" }; } private releaseDockingPort(host: DockingHost, ship: ShipInstance) { @@ -1542,7 +2535,7 @@ export class GameApp { } private isCarrierHost(host: DockingHost): host is ShipInstance { - return "definition" in host; + return "warpFx" in host; } private canDockShipAtCarrier(ship: ShipInstance, carrier: ShipInstance) { @@ -1558,7 +2551,13 @@ export class GameApp { if (this.isCarrierHost(host)) { return this.canDockShipAtCarrier(ship, host); } - return host.factionId === ship.factionId; + return ( + host.factionId === ship.factionId || + host.factionId === "neutral" || + ship.factionId === "neutral" || + (ship.defaultBehavior.kind === "auto-mine" && ship.defaultBehavior.refineryId === host.id) || + (ship.order?.kind === "mine-this" && ship.order.refineryId === host.id) + ); } private findNearestFriendlyCarrier(ship: ShipInstance) { @@ -1580,8 +2579,9 @@ export class GameApp { return; } ship.travelPlan = undefined; - ship.order = { kind: "dock", carrierShipId: carrier.id }; - ship.state = "docking-approach"; + this.setShipOrder(ship, { kind: "dock-at", status: "queued", hostKind: "ship", hostId: carrier.id }); + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; } private moveShipToward( @@ -1603,7 +2603,7 @@ export class GameApp { } let desiredDirection = toTarget.normalize(); - if (!directApproach && ship.state !== "warping" && ship.state !== "spooling-ftl") { + if (!directApproach && ship.state !== "warping" && ship.state !== "spooling-ftl" && ship.state !== "ftl") { const systemCenter = this.getSystem(ship.systemId).center; const radial = ship.group.position.clone().sub(systemCenter).setY(0); const targetRadial = target.clone().sub(systemCenter).setY(0); @@ -1617,7 +2617,7 @@ export class GameApp { } const desiredVelocity = desiredDirection.multiplyScalar(speed); - const steering = ship.state === "warping" ? delta * 4.2 : delta * 1.8; + const steering = ship.state === "warping" || ship.state === "ftl" ? delta * 4.2 : delta * 1.8; ship.velocity.lerp(desiredVelocity, steering); ship.group.position.addScaledVector(ship.velocity, delta); @@ -1627,75 +2627,79 @@ export class GameApp { return false; } - private issueMoveOrder(ship: ShipInstance, destination: THREE.Vector3) { - const system = this.findNearestSystem(destination); - destination.y = gameBalance.yPlane; - ship.travelPlan = undefined; - - if (ship.systemId === system.definition.id) { - ship.order = { kind: "move", destination, systemId: system.definition.id }; - ship.state = "moving"; + private issueMoveOrder(ship: ShipInstance, destination: TravelDestination) { + const normalizedDestination = this.cloneTravelDestination(destination); + if (ship.order?.kind === "move-to" && this.sameTravelDestination(ship.order.destination, normalizedDestination)) { return; } - - const currentSystem = this.getSystem(ship.systemId); - const exitDirection = ship.group.position.clone().sub(currentSystem.center).setY(0).normalize(); - if (exitDirection.lengthSq() === 0) { - exitDirection.copy(system.center.clone().sub(currentSystem.center).setY(0).normalize()); - } - - const arrivalDirection = destination.clone().sub(system.center).setY(0).normalize(); - if (arrivalDirection.lengthSq() === 0) { - arrivalDirection.copy(currentSystem.center.clone().sub(system.center).setY(0).normalize()); - } - - ship.order = { - kind: "transfer", - destination, - destinationSystemId: system.definition.id, - exitPoint: currentSystem.center - .clone() - .add(exitDirection.multiplyScalar(currentSystem.gravityWellRadius + 230)) - .setY(gameBalance.yPlane), - arrivalPoint: system.center - .clone() - .add(arrivalDirection.multiplyScalar(system.gravityWellRadius + 150)) - .setY(gameBalance.yPlane), - }; - ship.state = "leaving-gravity-well"; ship.travelPlan = undefined; + this.clearShipLandedDestination(ship); + this.setShipOrder(ship, { kind: "move-to", status: "queued", destination: normalizedDestination }); + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; } private assignMineOrder(ship: ShipInstance, node: ResourceNode | undefined, refinery: StationInstance | undefined) { if (!node || !refinery) { - ship.order = { kind: "idle" }; + this.setShipOrder(ship, undefined); + this.setDefaultBehavior(ship, { kind: "idle" }); + this.setAssignment(ship, { kind: "unassigned" }); + this.setControllerTask(ship, { kind: "idle" }); ship.state = "idle"; + this.clearShipLandedDestination(ship); return; } - ship.order = { kind: "mine", nodeId: node.id, refineryId: refinery.id, phase: "to-node" }; - ship.state = "mining-approach"; + if (ship.defaultBehavior.kind === "auto-mine" && ship.defaultBehavior.areaSystemId === node.systemId && ship.defaultBehavior.refineryId === refinery.id) { + return; + } + this.setShipOrder(ship, undefined); + this.setAssignment(ship, { kind: "mining-group", controllerId: refinery.id }); + this.setDefaultBehavior(ship, { + kind: "auto-mine", + areaSystemId: node.systemId, + refineryId: refinery.id, + nodeId: node.id, + phase: getShipCargoAmount(ship) >= ship.definition.cargoCapacity ? "travel-to-station" : "travel-to-node", + }); + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; + this.clearShipLandedDestination(ship); } - private setPatrolOrder(ship: ShipInstance, points: THREE.Vector3[], startIndex: number, systemId = ship.systemId) { - ship.order = { + private setPatrolOrder(ship: ShipInstance, points: TravelDestination[], startIndex: number, systemId = ship.systemId) { + const normalizedPoints = points.length > 0 ? points : [this.createSystemTravelDestination(systemId)]; + if (ship.defaultBehavior.kind === "patrol" && ship.defaultBehavior.systemId === systemId && this.sameTravelDestinationList(ship.defaultBehavior.points, normalizedPoints)) { + return; + } + this.setShipOrder(ship, undefined); + this.setAssignment(ship, { kind: "unassigned" }); + this.setDefaultBehavior(ship, { kind: "patrol", - points: points.map((point) => point.clone().setY(gameBalance.yPlane)), + points: normalizedPoints.map((point) => this.cloneTravelDestination(point)), systemId, - index: startIndex, - }; - ship.state = "patrolling"; + index: startIndex % normalizedPoints.length, + }); + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; + this.clearShipLandedDestination(ship); } private setEscortOrder(ship: ShipInstance, target: ShipInstance, offset = new THREE.Vector3()) { const angle = (this.ships.indexOf(ship) % 6) * (Math.PI / 3); const formationOffset = offset.lengthSq() > 0 ? offset : new THREE.Vector3(Math.cos(angle) * 32, 0, Math.sin(angle) * 32); - ship.order = { - kind: "escort", - targetShipId: target.id, + if (ship.assignment.kind === "commander-subordinate" && ship.assignment.commanderId === target.id && ship.defaultBehavior.kind === "escort-assigned" && ship.defaultBehavior.offset.distanceToSquared(formationOffset) < 1) { + return; + } + this.setShipOrder(ship, undefined); + this.setAssignment(ship, { kind: "commander-subordinate", commanderId: target.id, role: "escort" }); + this.setDefaultBehavior(ship, { + kind: "escort-assigned", offset: formationOffset, - }; - ship.state = "escorting"; + }); + this.setControllerTask(ship, { kind: "idle" }); + ship.state = "idle"; + this.clearShipLandedDestination(ship); } private findFactionStations(factionId: string) { @@ -1733,13 +2737,6 @@ export class GameApp { return this.nodes.filter((node) => node.systemId === systemId).sort((left, right) => right.oreRemaining - left.oreRemaining)[0]; } - private findBestMiningNodeForFaction(factionId: string, systemIds: string[]) { - const allowedSystems = new Set(systemIds); - return this.nodes - .filter((node) => allowedSystems.has(node.systemId)) - .sort((left, right) => right.oreRemaining - left.oreRemaining)[0]; - } - private findRefinery(systemId: string, factionId?: string) { return this.stations.find( (station) => @@ -1850,6 +2847,7 @@ export class GameApp { this.scene.add(ship.group); this.ships.push(ship); this.shipsById.set(ship.id, ship); + this.trackShipHistory(ship); faction.shipsBuilt += 1; faction.credits -= 60; } @@ -1925,7 +2923,14 @@ export class GameApp { } private focusSelection() { - if (this.selection.length === 0 && !this.selectedStation && !this.selectedSystem && !this.selectedPlanet) { + if (this.selection.length === 0 && !this.selectedStation && !this.selectedSystem && !this.selectedPlanet && !this.selectedNode) { + return; + } + if (this.selectedNode) { + this.followShipId = undefined; + this.focusPoint(this.selectedNode.position, 680); + this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === this.selectedNode?.systemId); + this.updateHud(); return; } if (this.selectedPlanet) { @@ -1980,22 +2985,28 @@ export class GameApp { this.applyViewLevel(); } - private handleOrderAction(action: string) { - if (action === "focus") { - this.focusSelection(); - } - this.updateHud(); - } - private handleWindowAction(action: string) { if (action === "new-universe") { this.generateNewUniverse(); return; } + if (action === "toggle-fleet-command") { + this.toggleWindow("fleet-command"); + return; + } if (action === "toggle-debug") { this.toggleWindow("debug"); return; } + if (action === "toggle-debug-autoscroll") { + this.debugAutoScroll = !this.debugAutoScroll; + this.updateHud(); + return; + } + if (action === "copy-debug-history") { + this.copyDebugHistory(); + return; + } if (action === "toggle-ship-designer") { this.toggleWindow("ship-designer"); return; @@ -2077,7 +3088,6 @@ export class GameApp { ships: this.ships, selection: this.selection, selectedStation: this.selectedStation, - selectedSystemIndex: this.selectedSystemIndex, viewLevel: this.viewLevel, }); } @@ -2086,20 +3096,22 @@ export class GameApp { const selectedDefinition = constructibleDefinitions[this.selectedConstructible]; const system = this.systems[this.selectedSystemIndex] ?? this.systems[0]; const selectedCount = - this.selection.length + (this.selectedStation ? 1 : 0) + (this.selectedSystem ? 1 : 0) + (this.selectedPlanet ? 1 : 0); + this.selection.length + (this.selectedStation ? 1 : 0) + (this.selectedSystem ? 1 : 0) + (this.selectedPlanet ? 1 : 0) + (this.selectedNode ? 1 : 0); this.selectionTitleEl.textContent = getSelectionTitle( this.selection, this.selectedStation, this.selectedSystem, this.selectedPlanet, + this.selectedNode, ); this.selectionStripEl.innerHTML = getSelectionCardsMarkup( this.selection, this.selectedStation, this.selectedSystem, this.selectedPlanet, + this.selectedNode, ); - const hasExplicitSelection = Boolean(this.selectedStation || this.selectedSystem || this.selectedPlanet || this.selection.length > 0); + const hasExplicitSelection = Boolean(this.selectedStation || this.selectedSystem || this.selectedPlanet || this.selectedNode || this.selection.length > 0); this.detailsEl.textContent = hasExplicitSelection ? "" : getSelectionDetails( @@ -2107,6 +3119,7 @@ export class GameApp { this.selectedStation, this.selectedSystem, this.selectedPlanet, + this.selectedNode, this.systems, this.viewLevel, this.ships, @@ -2130,25 +3143,25 @@ export class GameApp { this.lastShipWindowMarkup = shipWindowMarkup; } this.debugWindowEl.dataset.open = this.windowState.debug ? "true" : "false"; - } - - private placeStation(definition: StationInstance["definition"], position: THREE.Vector3, systemId: string) { - const faction = this.factions.find((candidate) => candidate.definition.kind === "empire"); - if (!faction) { - return; + this.debugAutoScrollToggleEl.textContent = this.debugAutoScroll ? "Pause Scroll" : "Resume Scroll"; + const debugShip = this.getDebugShip(); + this.debugCopyHistoryEl.disabled = !debugShip; + const previousScrollTop = this.debugHistoryEl.scrollTop; + const previousScrollHeight = this.debugHistoryEl.scrollHeight; + const debugHistoryMarkup = getDebugHistoryMarkup(debugShip, this.shipHistoryById); + if (debugHistoryMarkup !== this.lastDebugHistoryMarkup) { + this.debugHistoryEl.innerHTML = debugHistoryMarkup; + this.lastDebugHistoryMarkup = debugHistoryMarkup; + if (this.debugAutoScroll) { + this.debugHistoryEl.scrollTop = 0; + } else { + this.debugHistoryEl.scrollTop = previousScrollTop + (this.debugHistoryEl.scrollHeight - previousScrollHeight); + } + } else if (this.debugAutoScroll) { + this.debugHistoryEl.scrollTop = 0; + } else { + this.debugHistoryEl.scrollTop = previousScrollTop; } - const station = createStationInstance({ - id: `station-${++this.stationIdCounter}`, - scene: this.scene, - definition, - systemId, - position, - factionId: faction.definition.id, - factionColor: faction.definition.color, - selectableTargets: this.selectableTargets, - }); - this.stations.push(station); - this.updateHud(); } private updateMouse(clientX: number, clientY: number) { @@ -2160,14 +3173,6 @@ export class GameApp { this.selectionManager.clear(); } - private addShipToSelection(ship: ShipInstance) { - this.selectionManager.addShip(ship); - } - - private removeShipFromSelection(ship: ShipInstance) { - this.selectionManager.removeShip(ship); - } - private updateMarqueeBox(clientX: number, clientY: number) { if (!this.marqueeStart) { return; diff --git a/src/game/data/balance.json b/src/game/data/balance.json index ce444d5..998d3e0 100644 --- a/src/game/data/balance.json +++ b/src/game/data/balance.json @@ -2,6 +2,7 @@ "yPlane": 4, "arrivalThreshold": 16, "miningRate": 28, + "transferRate": 56, "dockingDuration": 1.2, "undockDistance": 42, "energy": { diff --git a/src/game/state/selectionManager.ts b/src/game/state/selectionManager.ts index aa2f880..e47e710 100644 --- a/src/game/state/selectionManager.ts +++ b/src/game/state/selectionManager.ts @@ -1,11 +1,12 @@ import * as THREE from "three"; -import type { PlanetInstance, ShipInstance, SolarSystemInstance, StationInstance } from "../types"; +import type { PlanetInstance, ResourceNode, ShipInstance, SolarSystemInstance, StationInstance } from "../types"; export class SelectionManager { private shipSelection: ShipInstance[] = []; private stationSelection?: StationInstance; private systemSelection?: SolarSystemInstance; private planetSelection?: PlanetInstance; + private nodeSelection?: ResourceNode; getShips() { return this.shipSelection; @@ -23,6 +24,10 @@ export class SelectionManager { return this.planetSelection; } + getNode() { + return this.nodeSelection; + } + clear() { this.shipSelection.forEach((ship) => this.setShipVisual(ship, false)); this.shipSelection = []; @@ -38,6 +43,10 @@ export class SelectionManager { this.setPlanetVisual(this.planetSelection, false); this.planetSelection = undefined; } + if (this.nodeSelection) { + this.setNodeVisual(this.nodeSelection, false); + this.nodeSelection = undefined; + } } replaceShips(ships: ShipInstance[]) { @@ -72,6 +81,15 @@ export class SelectionManager { this.setPlanetVisual(planet, true); } + setNode(node?: ResourceNode) { + this.clear(); + if (!node) { + return; + } + this.nodeSelection = node; + this.setNodeVisual(node, true); + } + addShip(ship: ShipInstance) { if (this.shipSelection.includes(ship)) { return; @@ -88,6 +106,10 @@ export class SelectionManager { this.setPlanetVisual(this.planetSelection, false); this.planetSelection = undefined; } + if (this.nodeSelection) { + this.setNodeVisual(this.nodeSelection, false); + this.nodeSelection = undefined; + } this.shipSelection.push(ship); this.setShipVisual(ship, true); } @@ -133,4 +155,10 @@ export class SelectionManager { (planet.selectionRing.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0; } } + + private setNodeVisual(node: ResourceNode, selected: boolean) { + if (node.selectionRing) { + (node.selectionRing.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0; + } + } } diff --git a/src/game/types.ts b/src/game/types.ts index 00dcf52..98a9212 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -13,14 +13,16 @@ export type ConstructibleCategory = | "gate"; export type UnitState = | "idle" - | "moving" - | "leaving-gravity-well" + | "holding" + | "spooling-warp" | "spooling-ftl" + | "ftl" | "warping" | "arriving" + | "approaching" | "mining-approach" | "mining" - | "delivering" + | "transferring" | "docking-approach" | "docking" | "docked" @@ -28,7 +30,8 @@ export type UnitState = | "patrolling" | "escorting" | "forming"; -export type UnitOrderKind = "idle" | "move" | "transfer" | "mine" | "patrol" | "escort" | "dock"; +export type ControllerTaskKind = "idle" | "travel" | "dock" | "extract" | "follow" | "undock"; +export type OrderStatus = "queued" | "accepted" | "planning" | "executing" | "completed" | "failed" | "cancelled" | "blocked"; export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured"; export type ModuleCategory = | "bridge" @@ -43,6 +46,7 @@ export type ModuleCategory = | "habitat" | "production"; export type ViewLevel = "local" | "solar" | "universe"; +export type TravelDestinationKind = "system" | "planet" | "station" | "resource-node" | "ship" | "orbit"; export interface ModuleDefinition { id: string; @@ -219,6 +223,7 @@ export interface GameBalance { yPlane: number; arrivalThreshold: number; miningRate: number; + transferRate: number; dockingDuration: number; undockDistance: number; energy: { @@ -233,20 +238,37 @@ export interface GameBalance { }; } -export type UnitOrder = +export type ShipOrder = + | { kind: "move-to"; status: OrderStatus; destination: TravelDestination } + | { kind: "mine-this"; status: OrderStatus; nodeId: string; refineryId: string; phase: "travel-to-node" | "extract" } + | { kind: "dock-at"; status: OrderStatus; hostKind: "station" | "ship"; hostId: string }; + +export type DefaultBehavior = | { kind: "idle" } - | { kind: "move"; destination: THREE.Vector3; systemId: string } | { - kind: "transfer"; - destination: THREE.Vector3; - destinationSystemId: string; - exitPoint: THREE.Vector3; - arrivalPoint: THREE.Vector3; + kind: "auto-mine"; + areaSystemId: string; + refineryId: string; + nodeId?: string; + phase: "travel-to-node" | "extract" | "travel-to-station" | "dock" | "unload" | "undock"; } - | { kind: "mine"; nodeId: string; refineryId: string; phase: "to-node" | "mining" | "to-refinery" | "transfer" } - | { kind: "patrol"; points: THREE.Vector3[]; systemId: string; index: number } - | { kind: "escort"; targetShipId: string; offset: THREE.Vector3 } - | { kind: "dock"; carrierShipId: string }; + | { kind: "patrol"; points: TravelDestination[]; systemId: string; index: number } + | { kind: "escort-assigned"; offset: THREE.Vector3 }; + +export type Assignment = + | { kind: "unassigned" } + | { kind: "commander-subordinate"; commanderId: string; role: string } + | { kind: "station-based"; stationId: string; role: string } + | { kind: "mining-group"; controllerId: string }; + +export type ControllerTask = + | { kind: "idle" } + | { kind: "travel"; destination: TravelDestination; threshold: number; suppliedPlan?: TravelPlan } + | { kind: "dock"; hostKind: "station" | "ship"; hostId: string; portIndex: number } + | { kind: "extract"; nodeId: string } + | { kind: "unload"; stationId: string } + | { kind: "follow"; targetShipId: string; offset: THREE.Vector3; threshold: number } + | { kind: "undock"; hostKind: "station" | "ship"; hostId: string }; export interface InventoryState { "bulk-solid": number; @@ -256,11 +278,19 @@ export interface InventoryState { manufactured: number; } +export interface TravelDestination { + kind: TravelDestinationKind; + systemId: string; + label: string; + position: THREE.Vector3; + orbitalAnchor: THREE.Vector3; + id?: string; +} + export interface TravelPlan { - destination: THREE.Vector3; - destinationSystemId: string; - exitPoint: THREE.Vector3; + destination: TravelDestination; arrivalPoint: THREE.Vector3; + interSystem: boolean; } export interface ShipInstance { @@ -273,11 +303,17 @@ export interface ShipInstance { ring: THREE.Mesh; systemId: string; state: UnitState; - order: UnitOrder; + order?: ShipOrder; + defaultBehavior: DefaultBehavior; + assignment: Assignment; + controllerTask: ControllerTask; inventory: InventoryState; cargoItemId?: string; actionTimer: number; travelPlan?: TravelPlan; + landedDestination?: TravelDestination; + landedOffset: THREE.Vector3; + dockingClearanceStatus?: string; dockedStationId?: string; dockedCarrierId?: string; dockingPortIndex?: number; @@ -347,6 +383,7 @@ export interface ResourceNode { systemId: string; position: THREE.Vector3; mesh: THREE.Object3D; + selectionRing?: THREE.Mesh; oreRemaining: number; maxOre: number; itemId: string; @@ -372,7 +409,8 @@ export type SelectableTarget = | { kind: "ship"; ship: ShipInstance } | { kind: "station"; station: StationInstance } | { kind: "system"; system: SolarSystemInstance } - | { kind: "planet"; system: SolarSystemInstance; planet: PlanetInstance }; + | { kind: "planet"; system: SolarSystemInstance; planet: PlanetInstance } + | { kind: "node"; node: ResourceNode }; export interface HudElements { details: HTMLDivElement; @@ -390,4 +428,7 @@ export interface HudElements { fleetWindowBody: HTMLDivElement; fleetWindowTitle: HTMLHeadingElement; debugWindow: HTMLDivElement; + debugHistory: HTMLDivElement; + debugAutoScrollToggle: HTMLButtonElement; + debugCopyHistory: HTMLButtonElement; } diff --git a/src/game/ui/hud.ts b/src/game/ui/hud.ts index 6f03258..0a2203e 100644 --- a/src/game/ui/hud.ts +++ b/src/game/ui/hud.ts @@ -14,6 +14,10 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle

No Selection

+
+ + +
@@ -38,7 +42,10 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle
+ +
+
@@ -89,6 +96,9 @@ export function createHud(container: HTMLElement, handlers: HudHandlers): HudEle fleetWindowBody: fleetWindowBody as HTMLDivElement, fleetWindowTitle: root.querySelector(".fleet-window h2") as HTMLHeadingElement, debugWindow: root.querySelector(".debug-window") as HTMLDivElement, + debugHistory: root.querySelector(".debug-history") as HTMLDivElement, + debugAutoScrollToggle: root.querySelector('[data-window-action="toggle-debug-autoscroll"]') as HTMLButtonElement, + debugCopyHistory: root.querySelector('[data-window-action="copy-debug-history"]') as HTMLButtonElement, }; } diff --git a/src/game/ui/presenters.ts b/src/game/ui/presenters.ts index 00445e5..d8ca55e 100644 --- a/src/game/ui/presenters.ts +++ b/src/game/ui/presenters.ts @@ -7,6 +7,7 @@ import { getShipCargoAmount } from "../state/inventory"; import type { FactionInstance, PlanetInstance, + ResourceNode, ShipInstance, SolarSystemInstance, StationInstance, @@ -18,7 +19,11 @@ export function getSelectionTitle( selectedStation?: StationInstance, selectedSystem?: SolarSystemInstance, selectedPlanet?: PlanetInstance, + selectedNode?: ResourceNode, ) { + if (selectedNode) { + return `Asteroid Field ${selectedNode.id}`; + } if (selectedPlanet) { return selectedPlanet.definition.label; } @@ -42,7 +47,11 @@ export function getSelectionStripLabels( selectedStation?: StationInstance, selectedSystem?: SolarSystemInstance, selectedPlanet?: PlanetInstance, + selectedNode?: ResourceNode, ) { + if (selectedNode) { + return [`Asteroid Field ${selectedNode.id}`]; + } if (selectedPlanet) { return [selectedPlanet.definition.label]; } @@ -63,7 +72,15 @@ export function getSelectionCardsMarkup( selectedStation: StationInstance | undefined, selectedSystem: SolarSystemInstance | undefined, selectedPlanet: PlanetInstance | undefined, + selectedNode?: ResourceNode, ) { + if (selectedNode) { + return renderCard(`Asteroid Field ${selectedNode.id}`, [ + selectedNode.systemId, + getItemLabel(selectedNode.itemId), + `Ore ${Math.round(selectedNode.oreRemaining)}/${selectedNode.maxOre}`, + ]); + } if (selectedPlanet) { return renderCard( selectedPlanet.definition.label, @@ -92,6 +109,8 @@ export function getSelectionCardsMarkup( [ selectedStation.factionId, selectedStation.definition.category, + `Ore ${Math.round(selectedStation.oreStored)}`, + `Refined ${Math.round(selectedStation.refinedStock)}`, `HP ${Math.round(selectedStation.health)}/${selectedStation.maxHealth}`, `Dock ${selectedStation.dockedShipIds.size}/${selectedStation.definition.dockingCapacity}`, ], @@ -105,7 +124,8 @@ export function getSelectionCardsMarkup( renderCard(ship.definition.label, [ ship.factionId, ship.state, - ship.order.kind, + `${getOrderSummary(ship)} / ${getBehaviorSummary(ship)} / ${ship.controllerTask.kind}`, + `Cargo ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}`, `HP ${Math.round(ship.health)}/${ship.maxHealth}`, ]), ) @@ -117,11 +137,15 @@ export function getSelectionDetails( selectedStation: StationInstance | undefined, selectedSystem: SolarSystemInstance | undefined, selectedPlanet: PlanetInstance | undefined, + selectedNode: ResourceNode | undefined, systems: SolarSystemInstance[], viewLevel: ViewLevel, ships: ShipInstance[], factions: FactionInstance[], ) { + if (selectedNode) { + return `Asteroid Field ${selectedNode.id} • ${selectedNode.systemId}\nResource: ${getItemLabel(selectedNode.itemId)}\nOre Remaining: ${Math.round(selectedNode.oreRemaining)}/${selectedNode.maxOre}`; + } if (selectedPlanet) { return `${selectedPlanet.definition.label} • ${selectedPlanet.systemId}\nOrbit Radius: ${Math.round(selectedPlanet.definition.orbitRadius)}\nSize: ${selectedPlanet.definition.size}\nOrbit Speed: ${selectedPlanet.definition.orbitSpeed.toFixed(2)}\nTilt: ${selectedPlanet.definition.tilt.toFixed(2)}\nRing: ${selectedPlanet.definition.hasRing ? "Yes" : "No"}`; } @@ -154,16 +178,20 @@ export function getSelectionDetails( ship.definition.dockingCapacity && ship.definition.dockingCapacity > 0 ? `\nHangar: ${ship.dockedShipIds.size}/${ship.definition.dockingCapacity} for ${(ship.definition.dockingClasses ?? []).join(", ")}` : ""; - return `${ship.definition.label} • ${ship.systemId}\nFaction: ${ship.factionId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}\nOrder: ${ship.order.kind}\nHealth: ${Math.round(ship.health)}/${ship.maxHealth}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`; + const controllerDestination = getControllerTaskDestinationLabel(ship); + const destination = controllerDestination + ? `\nTask Target: ${controllerDestination}` + : ""; + return `${ship.definition.label} • ${ship.systemId}\nFaction: ${ship.factionId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}${destination}\nOrder: ${getOrderSummary(ship)}\nDefault: ${getBehaviorSummary(ship)}\nAssignment: ${getAssignmentSummary(ship)}\nController: ${ship.controllerTask.kind}\nHealth: ${Math.round(ship.health)}/${ship.maxHealth}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`; }, ) .join("\n\n"); } export function describeStation(station: StationInstance, ships: ShipInstance[]) { - const miners = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "mine").length; - const escorts = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "escort").length; - const patrols = ships.filter((ship) => ship.systemId === station.systemId && ship.order.kind === "patrol").length; + const miners = ships.filter((ship) => ship.systemId === station.systemId && ship.defaultBehavior.kind === "auto-mine").length; + const escorts = ships.filter((ship) => ship.systemId === station.systemId && ship.defaultBehavior.kind === "escort-assigned").length; + const patrols = ships.filter((ship) => ship.systemId === station.systemId && ship.defaultBehavior.kind === "patrol").length; const localShips = ships.filter((ship) => ship.systemId === station.systemId).length; const activeRecipe = station.activeRecipeId ? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId) @@ -238,8 +266,75 @@ export function getShipWindowMarkup(ships: ShipInstance[], selection: ShipInstan .join(""); } +export function getDebugHistoryMarkup( + selectedShip: ShipInstance | undefined, + historyByShipId: Map, +) { + if (!selectedShip) { + return `
Select a ship to inspect its history.
`; + } + const entries = historyByShipId.get(selectedShip.id); + if (!entries || entries.length === 0) { + return ` +
+

${escapeHtml(selectedShip.definition.label)} • ${escapeHtml(selectedShip.id)}

+
No ship history recorded yet.
+
+ `; + } + const destination = getControllerTaskDestinationLabel(selectedShip) ?? "none"; + const anchor = selectedShip.landedDestination + ? `${selectedShip.landedDestination.label} @ ${selectedShip.landedDestination.systemId}` + : "free"; + return ` +
+

${escapeHtml(selectedShip.definition.label)} • ${escapeHtml(selectedShip.id)}

+
+
Order: ${escapeHtml(getOrderSummary(selectedShip))}
+
Default behavior: ${escapeHtml(getBehaviorSummary(selectedShip))}
+
Assignment: ${escapeHtml(getAssignmentSummary(selectedShip))}
+
Controller task: ${escapeHtml(selectedShip.controllerTask.kind)}
+
Flight state: ${escapeHtml(selectedShip.state)}
+
Task target: ${escapeHtml(destination)}
+
Anchor: ${escapeHtml(anchor)}
+
+ ${entries.map((entry) => `
${escapeHtml(entry)}
`).join("")} +
+ `; +} + function describeShipNode(ship: ShipInstance): string { - return `${ship.state} - ${ship.systemId}`; + const intent = `${getOrderSummary(ship)} / ${getBehaviorSummary(ship)} / ${ship.controllerTask.kind}`; + const controllerDestination = getControllerTaskDestinationLabel(ship); + return controllerDestination + ? `${intent} • ${ship.state} -> ${controllerDestination}` + : `${intent} • ${ship.state} - ${ship.systemId}`; +} + +function getControllerTaskDestinationLabel(ship: ShipInstance) { + const task = ship.controllerTask; + if (task.kind === "travel") { + return `${task.destination.label} @ ${task.destination.systemId}`; + } + if (task.kind === "dock" || task.kind === "undock") { + return `${task.hostKind}:${task.hostId}`; + } + if (task.kind === "extract") { + return `Asteroid Field ${task.nodeId}`; + } + if (task.kind === "follow") { + return `ship:${task.targetShipId}`; + } + return undefined; +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } function renderCard(title: string, lines: string[]) { @@ -251,6 +346,47 @@ function renderCard(title: string, lines: string[]) { `; } +function getOrderSummary(ship: ShipInstance) { + if (!ship.order) { + return "none"; + } + if (ship.order.kind === "move-to") { + return `move-to (${ship.order.status})`; + } + if (ship.order.kind === "mine-this") { + return `mine-this:${ship.order.phase} (${ship.order.status})`; + } + return `dock-at (${ship.order.status})`; +} + +function getBehaviorSummary(ship: ShipInstance) { + const behavior = ship.defaultBehavior; + if (behavior.kind === "auto-mine") { + return `auto-mine:${behavior.phase} ${behavior.areaSystemId}`; + } + if (behavior.kind === "patrol") { + return `patrol:${behavior.index + 1} ${behavior.systemId}`; + } + if (behavior.kind === "escort-assigned") { + return "escort-assigned"; + } + return "idle"; +} + +function getAssignmentSummary(ship: ShipInstance) { + const assignment = ship.assignment; + if (assignment.kind === "commander-subordinate") { + return `${assignment.kind} ${assignment.commanderId}`; + } + if (assignment.kind === "station-based") { + return `${assignment.kind} ${assignment.stationId}`; + } + if (assignment.kind === "mining-group") { + return `${assignment.kind} ${assignment.controllerId}`; + } + return "unassigned"; +} + export function getItemLabel(itemId?: string) { return itemId ? itemDefinitionsById.get(itemId)?.label ?? itemId : "None"; } diff --git a/src/game/ui/strategicRenderer.ts b/src/game/ui/strategicRenderer.ts index 43daf74..fd9759e 100644 --- a/src/game/ui/strategicRenderer.ts +++ b/src/game/ui/strategicRenderer.ts @@ -23,7 +23,6 @@ interface RenderOverlayOptions { ships: ShipInstance[]; selection: ShipInstance[]; selectedStation?: StationInstance; - selectedSystemIndex: number; viewLevel: ViewLevel; } @@ -97,7 +96,6 @@ export function drawStrategicOverlay({ ships, selection, selectedStation, - selectedSystemIndex, viewLevel, }: RenderOverlayOptions) { context.clearRect(0, 0, width, height); diff --git a/src/game/world/worldFactory.ts b/src/game/world/worldFactory.ts index 69877cb..5851284 100644 --- a/src/game/world/worldFactory.ts +++ b/src/game/world/worldFactory.ts @@ -226,7 +226,7 @@ function createSolarSystem( return orbitLine; }); - const asteroidDecorations = createAsteroidField(definition, root, nodes, nextNodeId); + const asteroidDecorations = createAsteroidField(definition, root, nodes, nextNodeId, selectableTargets); const strategicMarker = createStrategicMarker(scene, definition); const system = { definition, @@ -263,6 +263,7 @@ function createAsteroidField( root: THREE.Group, nodes: ResourceNode[], nextNodeId: () => string, + selectableTargets?: Map, ) { const rockGeometry = new THREE.IcosahedronGeometry(1, 0); const rockMaterial = new THREE.MeshStandardMaterial({ @@ -309,17 +310,32 @@ function createAsteroidField( shard.position.set((Math.random() - 0.5) * 18, (Math.random() - 0.5) * 12, (Math.random() - 0.5) * 18); cluster.add(shard); } + const selectionRing = new THREE.Mesh( + new THREE.RingGeometry(14, 19, 32), + new THREE.MeshBasicMaterial({ + color: 0xffdd75, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + }), + ); + selectionRing.rotation.x = -Math.PI / 2; + selectionRing.position.y = -6; + cluster.add(selectionRing); root.add(cluster); decorations.push(cluster); - nodes.push({ + const node = { id: nextNodeId(), systemId: definition.id, position: cluster.getWorldPosition(new THREE.Vector3()), mesh: cluster, + selectionRing, oreRemaining: resourceNode.oreAmount, maxOre: resourceNode.oreAmount, itemId: resourceNode.itemId, - }); + }; + nodes.push(node); + cluster.traverse((child) => selectableTargets?.set(child, { kind: "node", node })); }); return decorations; @@ -693,10 +709,14 @@ export function createShipInstance({ ring, systemId, state: "idle", - order: { kind: "idle" }, + order: undefined, + defaultBehavior: { kind: "idle" }, + assignment: { kind: "unassigned" }, + controllerTask: { kind: "idle" }, inventory: createEmptyInventory(), cargoItemId: definition.cargoItemId, actionTimer: 0, + dockingClearanceStatus: undefined, factionId, factionColor, health: definition.maxHealth, @@ -711,6 +731,7 @@ export function createShipInstance({ energy: 260, maxFuel: 220, maxEnergy: 260, + landedOffset: new THREE.Vector3(), idleOrbitRadius: Math.max(120, group.position.length()), idleOrbitAngle: 0, warpFx, diff --git a/src/style.css b/src/style.css index 8a40783..c1174af 100644 --- a/src/style.css +++ b/src/style.css @@ -331,6 +331,65 @@ button:disabled { color: var(--muted); } +.debug-history { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding-right: 4px; +} + +.debug-history-ship { + border: 1px solid rgba(126, 212, 255, 0.12); + border-radius: 14px; + padding: 10px 12px; + background: rgba(8, 17, 33, 0.55); +} + +.debug-history-ship[data-selected="true"] { + border-color: rgba(255, 191, 105, 0.4); +} + +.debug-history-title { + margin: 0 0 8px; + color: var(--text); + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.debug-history-summary { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + margin-bottom: 10px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(126, 212, 255, 0.08); + color: var(--muted); + font-size: 0.78rem; + line-height: 1.4; +} + +.debug-history-summary strong { + color: var(--text); + font-weight: 600; +} + +.debug-history-entry { + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; + white-space: pre-wrap; +} + +.debug-history-empty { + color: var(--muted); + font-size: 0.82rem; +} + .window-resize-handle { position: absolute; right: 10px;