Automation
Overview
Server-side rule engine that executes player-defined automation rules every tick. Rules consist of trigger conditions and actions that fire when conditions are met. Rules execute regardless of player connection status.
Data Model
Rule
Stored as a Redis hash at automation:{ship_id}:{rule_id}.
| Field | Type | Description |
|---|---|---|
| rule_id | uuid | Unique rule identifier |
| ship_id | uuid | Owning ship |
| name | string | Display name (max 64 chars) |
| enabled | string | "true" or "false" |
| mode | string | "once" or "continuous" |
| priority | integer | 0-99, lower executes first |
| trigger_json | string | JSON-encoded trigger object |
| actions_json | string | JSON-encoded action array |
| created_at | string | ISO 8601 timestamp |
Index
Redis set at automation:{ship_id}:rules containing rule_id strings.
Limits
| Parameter | Value |
|---|---|
| Max name length | 64 characters |
| Max conditions per trigger | 5 |
| Max actions per rule | 5 |
Trigger Format
{
"conditions": [
{"field": "ship.fuel", "op": "<", "value": 0.05},
{"field": "ship.distance_to", "op": "<", "value": 1000000, "args": ["Earth"]}
],
"logic": "AND"
}
Trigger Fields
| Field | Type | Range | Description |
|---|---|---|---|
| ship.fuel | float | 0.0-1.0 | Fuel fraction (fuel / fuel_capacity) |
| ship.thrust | float | 0.0-1.0 | Current thrust level |
| ship.speed | float | 0+ m/s | Speed relative to reference body (SOI/Hill sphere), matching HUD display |
| ship.surface_speed | float | 0+ m/s | Speed relative to the surface of the reference body, accounting for body rotation. Useful for landing triggers. |
| ship.agl | float | 0+ m | Altitude Above Ground Level. Uses terrain elevation when available (e.g. Luna), otherwise altitude above body radius. Computed by physics service from nearest body. 0 when landed. |
| ship.distance_to | float | 0+ m | Distance to named body (requires args: ["BodyName"]) |
| orbit.apoapsis | float | 0+ m | Apoapsis altitude above surface (optional args: ["BodyName"], defaults to reference body). Returns false for escape trajectories. |
| orbit.periapsis | float | m | Periapsis altitude above surface (optional args: ["BodyName"], defaults to reference body). Can be negative (impact trajectory). |
| orbit.eccentricity | float | 0+ | Orbital eccentricity (optional args: ["BodyName"], defaults to reference body). 0=circular, <1=elliptical, ≥1=escape. |
| orbit.inclination | float | 0-180° | Orbital inclination relative to body’s equatorial plane (optional args: ["BodyName"], defaults to reference body). |
| orbit.period | float | 0+ s | Orbital period in seconds (optional args: ["BodyName"], defaults to reference body). Returns false for escape trajectories. |
| orbit.true_anomaly | float | 0-360° | True anomaly in degrees (optional args: ["BodyName"], defaults to reference body). |
| orbit.angle_to_pe | float | 0-180° | Angular distance to periapsis in degrees (optional args: ["BodyName"], defaults to reference body). Returns false for near-circular orbits (e < 0.001). |
| orbit.angle_to_ap | float | 0-180° | Angular distance to apoapsis in degrees (optional args: ["BodyName"], defaults to reference body). Returns false for escape trajectories or near-circular orbits (e < 0.001). |
| orbit.angle_to_an | float | 0-180° | Angular distance to ascending node in degrees (optional args: ["BodyName"], defaults to reference body). Returns false when inclination ≤ 0.5° or ≥ 179.5°. |
| orbit.angle_to_dn | float | 0-180° | Angular distance to descending node in degrees (optional args: ["BodyName"], defaults to reference body). Returns false when inclination ≤ 0.5° or ≥ 179.5°. |
| ship.fuel_pct | float | 0-100 | Fuel as percentage of capacity (fuel / fuel_capacity × 100) |
| ship.docked | integer | 0 or 1 | Whether ship is currently docked (1) or not (0) |
| ship.docked_station | string | name or “” | Station name the ship is docked at (e.g. “Gateway Station”). String field — only == and != operators. Value must be a string, not a number. Empty string when not docked. |
| game.tick | integer | 0+ | Current game tick number |
| immediate | integer | 1 | Always returns 1. Use with once mode to fire a rule on the next tick. |
All fields are read from Redis ship and body hashes. ship.speed is computed relative to the reference body determined by SOI (Hill sphere) containment — the same algorithm used by the HUD and physics engine. This ensures automation rules trigger at the orbital speed players see, not the absolute ICRF velocity. Falls back to absolute speed if no reference body is found.
Orbital element fields (orbit.*) are computed lazily — only when a rule references them. Results are cached per body name within a single tick evaluation to avoid recomputation when multiple conditions reference the same body. Orbital elements use the same Keplerian math as the HUD display (Python port of calculateOrbitalElements).
Comparators
| Operator | Description |
|---|---|
< |
Less than |
> |
Greater than |
<= |
Less than or equal |
>= |
Greater than or equal |
== |
Equal (exact float comparison) |
!= |
Not equal |
Logic
Only AND is supported in MVP. All conditions must be true for the trigger to fire.
Action Format
[
{"action": "set_thrust", "value": 0},
{"action": "set_attitude", "value": "prograde"},
{"action": "alert", "message": "Fuel critical!"}
]
Actions
| Action | Parameters | Description |
|---|---|---|
| set_thrust | value (float 0.0-1.0) |
Set ship thrust level via gRPC ApplyControl |
| set_attitude | value (string: prograde, retrograde, normal, antinormal, radial, antiradial, hold, none) |
Set attitude mode via gRPC SetAttitudeMode/SetAttitudeHold. Hold mode uses PD tracking toward attitude_target (not just rate damping). When activated by player (H key), current attitude is saved as target so the ship holds its current orientation. When set externally with a target quaternion (e.g., by rendezvous), the ship steers toward that direction. |
| circularize | (none) | Start autonomous circularization maneuver relative to current reference body |
| set_inclination | value (float, target degrees), optional args: ["BodyName"] |
Start autonomous inclination change maneuver. Defaults to current reference body if no args. |
| rendezvous | target_id (uuid or lagrange key), target_type (“ship”, “station”, “jumpgate”, or “lagrange”), optional strategy, optional dock_on_arrival (bool), optional max_fuel_fraction (float 0.0–1.0), optional max_transfer_time (number, seconds), optional budget_mode (string) |
Start autonomous rendezvous maneuver with target entity in same SOI. Strategy selects transfer approach (see below). Default: "hohmann". When dock_on_arrival is true (default for stations), automatically docks at the target upon completion. max_fuel_fraction limits fuel usage to a fraction of fuel capacity (brachistochrone only). max_transfer_time specifies maximum transfer time in seconds; the solver finds the minimum fuel needed (brachistochrone only). budget_mode selects an automatic budget computation: "efficient" computes the Hohmann transfer time from current orbital geometry and uses it as max_transfer_time, yielding the minimum fuel for the most time-efficient ballistic-equivalent transfer (brachistochrone only). For target_type: "lagrange", target_id is a key string like "Sun-Earth-L1" (not a UUID). Position and velocity are computed on-demand from body data using Hill’s approximation; velocity is the co-rotating velocity at the Lagrange point. |
| station_keep | target_id (uuid, lagrange key, or body name), target_type (“ship”, “station”, “jumpgate”, “lagrange”, or “body”) |
Start autonomous station-keeping maneuver. Captures current distance to target as hold_distance_m at activation. Holds position within adaptive deadband of the hold distance. Never completes — must be aborted. |
| dock | target_id (uuid) |
Dock with specified station. Calls physics RequestService(SERVICE_DOCK). Fails silently if ship is already docked, out of range, or too fast. |
| undock | (none) | Undock from current station. Calls physics RequestService(SERVICE_UNDOCK). Fails silently if ship is not docked. |
| set_raan | value (float, target degrees), optional args: ["BodyName"] |
Start autonomous RAAN change maneuver. Defaults to current reference body if no args. |
| set_orbit_plane | target_inclination (float, degrees), target_raan (float, degrees) |
Start combined inclination + RAAN change maneuver using Q-law OOP steering. |
| land | target_body (string, body name), optional target_lat (float, degrees), optional target_lon (float, degrees) |
Start autonomous landing maneuver on target body. With lat/lon, performs targeted landing with plane change + precision descent (0.5m accuracy). Ship must be in the body’s SOI with eccentricity < 1. |
| shuttle | station_a (uuid), station_b (uuid), optional strategy (“efficient”, “balanced”, “fastest”) |
Repeating dock-to-dock delivery between two stations. Undocks, transits, docks, refuels to 100%, then returns. Runs until cancelled. Default strategy: “balanced”. |
| alert | message (string, max 128 chars) |
Send status message to player via automation_triggered event |
Rule Modes
| Mode | Behavior |
|---|---|
| once | Rule auto-disables (enabled=”false”) after first successful firing |
| continuous | Rule fires every tick while conditions are met |
Copying Rules
Players can copy automation rules between ships they own.
WebSocket API
Request: automation_copy
| Field | Type | Description |
|---|---|---|
| source_ship_id | uuid | Ship to copy rules from |
| target_ship_id | uuid | Ship to copy rules to |
| rule_ids | array of uuid | Rule IDs to copy (empty array = all rules) |
Response: automation_copied
| Field | Type | Description |
|---|---|---|
| source_ship_id | uuid | Source ship |
| target_ship_id | uuid | Target ship |
| rules | array | Newly created rule objects |
| count | integer | Number of rules copied |
Behavior
- Both source and target ships must be owned by the player
- Copied rules get new UUIDs
- Copied rules are created with
enabled: falseso they don’t fire immediately - Name conflicts: if target ship has a rule with the same name, append “ (2)”, “ (3)”, etc.
Evaluation Algorithm
Evaluation runs once per tick in the tick-engine, after ProcessTick() completes:
- Discover ships with rules: scan Redis for
automation:*:ruleskeys - For each ship with rules:
a. Load ship hash from Redis
b. Load all enabled rules, sorted by priority (ascending)
c. Build evaluation context (fuel fraction, speed, thrust, etc.)
d. For each rule:
- Evaluate all conditions against context
- If all conditions true (AND logic):
- Execute actions in order
- If mode is
once, disable the rule - Publish
automation.triggeredevent togalaxy:automationsstream
- Body positions are loaded once at the start of evaluation (cached across all ships)
Performance
- Only ships with non-empty rule sets are evaluated
- Body positions loaded once per tick, not per-ship
- No recursive triggers: actions from automation do not re-trigger rules in the same tick
Maneuvers
Maneuvers are autonomous multi-tick operations that execute attitude and thrust commands each tick until a completion condition is met. Only one maneuver per ship at a time — starting a new maneuver cancels any active one.
Maneuver State
Stored as a Redis hash at maneuver:{ship_id}:
| Field | Type | Description |
|---|---|---|
| type | string | "circularize", "set_inclination", "rendezvous", "station_keep", or "land" |
| ref_body | string | Reference body name |
| rule_id | string | Rule that started this maneuver |
| rule_name | string | Rule display name |
| started_tick | integer | Tick when maneuver began |
| started_game_time | string | ISO 8601 game time when maneuver began |
| target_inclination | float | Target inclination in degrees (set_inclination only) |
| target_id | string | Target entity ID: UUID for ship/station/jumpgate, key string for lagrange (e.g. "Sun-Earth-L1"), or body name (rendezvous/station_keep) |
| target_type | string | "ship", "station", "jumpgate", "lagrange", or "body" (rendezvous/station_keep) |
| dock_on_arrival | string | "true" or "false" (rendezvous only). When true, automatically sends a dock request to the target station upon rendezvous completion. Defaults to "true" for dockable targets (stations), "false" otherwise. |
| strategy | string | Transfer strategy: "hohmann", "fast", "express", "parabolic", or "brachistochrone" (rendezvous only). Legacy value "manual" maps to "hohmann". |
| phase | string | Current phase: rendezvous uses "plane_change", "transfer_plan", "departure_wait", "transfer_burn", "transfer_coast", "circularize", "phase_coast", "phase", "brachistochrone", "capture", or "approach"; station_keep uses "approach" or "hold" |
| hold_distance_m | float | Distance to maintain from target in meters (station_keep only). Captured from current distance at activation time. |
| status_text | string | Human-readable description of current maneuver activity, updated each tick. Examples: "Circularizing — prograde, e=0.042", "Plane change — oop, eff 0.85". Empty string when unavailable. |
| element_errors | string | JSON array of element error objects for client display. Each object: {"label": "Δi", "value": "2.1°", "ok": true/false}. ok is true when the element is within convergence tolerance. The client renders converged elements in green and out-of-tolerance elements in red. Empty string when unavailable. |
| abort | string | "true" when abort requested (set by API gateway) |
| paused | string | "true" or "false" (default "false") — when true, tick-engine skips maneuver logic but preserves all state |
| paused_at_tick | integer | Tick when maneuver was paused (cleared on resume) |
Maneuver Lifecycle
- Start: Rule action creates maneuver state in Redis, sets initial attitude + thrust
- Running: Each tick after rule evaluation, active maneuvers are checked and advanced:
- Check for abort flag first — if
abort == "true", abort immediately (see below) - Check for paused flag — if
paused == "true", skip maneuver logic entirely (state preserved, ship coasts) - Early completion check for landing: If maneuver type is
landandlanded_body_nameis set, complete immediately — landed ships have zero relative velocity which makes orbital elements undefined - Compute orbital elements relative to reference body (returns null for degenerate orbits, e.g., zero velocity)
- If orbital elements are unavailable and not already handled by early checks, skip this tick
- Adjust attitude (prograde/retrograde for circularize, normal/antinormal for inclination)
- Thrust only when attitude is aligned with desired direction (see Alignment Check)
- Check for abort flag first — if
- Complete: When completion threshold is reached:
- Set thrust to 0
- Set attitude hold
- If
dock_on_arrival == "true"andtarget_type == "station": send dock request to physics service viaRequestService(SERVICE_DOCK). Dock failure is non-fatal — maneuver still completes and player is notified viaautomation_triggeredevent. - Clear maneuver from Redis
- Publish
automation.maneuver_completeevent
- Abort: When
abortfield is"true"(set by API gateway viamaneuver_abortmessage):- Set thrust to 0
- Set attitude hold
- Clear maneuver from Redis
- Publish
automation.maneuver_abortedevent
Alignment Check
All maneuvers use a binary alignment gate before thrusting: if the ship’s forward vector (attitude quaternion applied to +Z) is more than ALIGNMENT_GATE_DEG (5°) from the desired thrust direction, thrust is set to zero. When aligned within the gate, full commanded thrust is applied. This eliminates the fuel efficiency losses from the previous cosine scaling approach, where thrust *= cos(angle) caused continuous fuel waste during reorientation — the effective exhaust velocity dropped to v_e × cos(angle), a single-cosine loss that the BCB planner couldn’t budget for.
With the alignment gate, the cost of reorientation is time-based (dead time during slews) rather than efficiency-based, and is analytically predictable:
T_settle = 4/ω_nper direction change (8s for fast_frigate at ω_n=0.5, 27s for cargo_hauler at ω_n=0.15)- The BCB planner deducts dead time from effective burn time (see Brachistochrone section)
- At 5°, cos(5°) = 0.996 — effectively zero efficiency loss when thrusting
status_text indicates the aligning state when outside the gate (e.g., "Circularizing — aligning (15°)"). This prevents off-axis burns that waste fuel and push the orbit in the wrong direction — especially critical when circularize switches between prograde and retrograde. This applies to all maneuver thrusting including the periapsis guard.
| Constant | Value | Description |
|---|---|---|
ALIGNMENT_GATE_DEG |
5° | Zero thrust beyond this angle; full thrust within — replaces cosine scaling |
Completion Thresholds
| Maneuver | Threshold | Description |
|---|---|---|
| circularize | eccentricity < 0.001 | Near-circular orbit achieved |
| set_inclination | |inclination - target| < 0.5° | Target inclination reached |
| rendezvous | distance < 500 m AND relative velocity < 1 m/s | Proximity and velocity match achieved |
| station_keep | (never completes) | Holds distance until aborted |
| land | landed_body_name != "" |
Ship has touched down on surface |
Circularize Algorithm
Uses vector-based delta-v targeting. Computes the desired circular velocity vector (tangential, at v_circ = sqrt(GM / r)) and burns toward the required delta-v direction to correct both speed magnitude and direction simultaneously.
Each tick:
- Compute relative position and velocity vectors to reference body
- Decompose velocity into radial (
v_r) and tangential (v_t) components - Compute desired circular velocity:
v_circ = sqrt(GM / r), directed tangentially (in the current orbital plane, perpendicular to radius, in the direction of angular momentum) - Compute delta-v vector:
dv = v_desired - v_current(kills radial velocity and corrects tangential tov_circ) - Set attitude to burn in the delta-v direction using
ATTITUDE_DIRECTIONmode - Compute proportional throttle:
thrust = min(1.0, |dv| / dv_per_tick)wheredv_per_tick = max_thrust / total_mass * effective_dt - Apply alignment gate: if
alignment_angle > ALIGNMENT_GATE_DEG(5°), set thrust to 0
This replaces the prior scalar speed comparison (|v| vs v_circ) which failed when speed magnitude matched circular velocity but direction was wrong (e.g., mostly radial velocity).
Set Inclination Algorithm
Each tick:
- Compute orbital inclination relative to reference body’s equatorial plane
- If inclination > target: set attitude antinormal (decrease inclination)
- If inclination < target: set attitude normal (increase inclination)
- Set thrust to 1.0
Rendezvous Algorithm
State machine: PLANE_CHANGE → TRANSFER_PLAN → DEPARTURE_WAIT → TRANSFER_BURN → TRANSFER_COAST → CIRCULARIZE → [PHASE_COAST → TRANSFER_PLAN] → PHASE → APPROACH → COMPLETE. See Q-law spec for steering math details (GVE, periapsis barrier, effectivity).
Transfer Strategies
| Strategy | Factor | Description |
|---|---|---|
hohmann |
1× | Iterative Hohmann burns at apsides. Lowest Δv, longest transfer. Default. |
fast |
2× | Two-burn transfer. Ascending: 2× apogee. Descending: perigee at r_target/2. Moderate Δv increase, ~50% time reduction. |
express |
5× | Two-burn transfer. Ascending: 5× apogee. Descending: perigee at r_target/5. High Δv, ~80% time reduction. |
parabolic |
∞ (escape) | Two-burn transfer via parabolic trajectory (e=1). Maximum Δv, fastest transfer. |
lambert |
— | Lambert solver: direct point-to-point transfer using position vectors. Computes optimal velocity vectors for given time of flight. |
brachistochrone |
— | Direct continuous-thrust trajectory. Skips orbit matching entirely — accelerates toward target, flips, decelerates to arrive. Fastest for high-thrust ships at close range. |
Backward compatibility: The legacy value "manual" is mapped to "hohmann" at maneuver start. Existing rules with strategy: "manual" continue to work unchanged.
UI dropdown options (rendezvous strategy selector):
| Value | Label | Notes |
|---|---|---|
hohmann |
Hohmann (efficient) | Default. Shows Δv/time estimate. |
fast |
Fast (2× apogee) | Shows Δv/time estimate. |
express |
Express (5× apogee) | Shows Δv/time estimate. |
parabolic |
Parabolic (fastest) | Shows Δv/time estimate. |
brachistochrone |
Brachistochrone (direct) | Shows coast ratio, fuel budget rows. No Δv estimate (depends on thrust profile). |
Coast ratio and fuel budget rows are shown only when brachistochrone is selected.
Budget mode dropdown (brachistochrone only):
| Value | Label | Notes |
|---|---|---|
none |
None | Uses all available fuel, no constraints. |
fuel |
Fuel % | Shows fuel budget input (% of capacity). |
time |
Max time | Shows max transfer time input (hours). |
efficient |
Efficient | Auto-computes Hohmann transfer time as max_transfer_time, minimizing fuel for equivalent speed. |
fastest |
Fastest | Optimizes for minimum transfer time. Cross-SOI: scans aggressive departure windows. Same-SOI: uses all fuel for maximum burn. |
Optimal Departure Timing
For Hohmann transfers from eccentric orbits (e > 0.01), an optional departure scan finds the optimal departure position to minimize total Δv (Oberth effect). Scans 12 candidate true anomaly positions over 2 orbits and selects the minimum-Δv departure if it saves > 5% over immediate departure. This is most beneficial when departing from eccentric parking orbits where periapsis gives significantly lower Δv.
Bi-Elliptic Transfers
For very large altitude changes (radius ratio > 11.94:1), a bi-elliptic transfer with an intermediate apoapsis at BI_ELLIPTIC_FACTOR × max(r1, r2) can save > 5% Δv over a standard Hohmann. Implemented as two sequential Hohmann transfers through the existing state machine (cycle 1: r1 → r_intermediate, cycle 2: r_intermediate → r2), requiring no new sub-states. The should_use_bi_elliptic() function evaluates whether the savings threshold is met.
Unified Transfer State Machine
Architecture: Phase Handler Extraction
The _maneuver_rendezvous_manual_tick method is structured as a dispatcher + phase handlers pattern:
-
Dispatcher (
_maneuver_rendezvous_manual_tick): ~200 lines. Loads target data, computes shared state (relative position/velocity, orbital elements, tolerances, GVE coefficients), performs distance-based approach transition, then delegates to the appropriate phase handler. -
Shared context (
_RvContextdataclass): Groups all shared state computed by the dispatcher — ship/target/body positions and velocities, orbital elements, tolerances, GVE coefficients, element diffs, periapsis barrier params, Q-law class params, physics stub, tick, and helper closures (_build_element_errors,_apply_steering). Passed as a single argument to each handler. -
Phase handlers: Each phase is an
asyncmethod onAutomationEngine:
| Method | Phase(s) | Typical Lines |
|---|---|---|
_phase_plane_change |
plane_change |
~130 |
_phase_transfer_plan |
transfer_plan |
~260 |
_phase_departure_wait |
departure_wait |
~190 |
_phase_transfer_burn |
transfer_burn |
~105 |
_phase_transfer_coast |
transfer_coast |
~180 |
_phase_circularize |
circularize |
~390 |
_phase_phase_coast |
phase_coast |
~150 |
_phase_phase |
phase |
~450 |
_phase_brachistochrone |
brachistochrone |
delegate |
_phase_approach |
approach |
delegate |
Phase dispatch uses a dict mapping phase names to handler methods. Each handler receives (self, ship_id, maneuver, ctx) where ctx is the _RvContext. Handlers may mutate maneuver and call _transition_phase for phase transitions. Some handlers (e.g., plane_change) fall through by returning "continue" to let the dispatcher invoke the next phase in the same tick.
Phase Transition Helper
All phase transitions go through a single helper method to ensure consistent persistence and logging:
async def _transition_phase(
self, ship_id: str, maneuver: dict, new_phase: str, tick: int,
status_text: str | None = None, **updates,
) -> None:
The helper:
- Sets
maneuver["phase"] = new_phase - Sets
maneuver["status_text"]ifstatus_textis provided - Applies any additional field updates from
**updatesviamaneuver.update() - Calls
await self._tick_state.set_active_maneuver(ship_id, maneuver)to persist
Phase handlers call _transition_phase instead of directly mutating maneuver["phase"] and calling set_active_maneuver separately. This eliminates the class of bugs where persistence or field updates are forgotten after a phase assignment.
The centralized phase transition telemetry in _process_target_sync_maneuver (which detects pre_phase != post_phase and logs to PostgreSQL + Redis stream) remains unchanged — it provides the canonical event recording for all transitions regardless of how they occur.
Guard Clause Style
Phase handlers and action dispatch use guard clauses (early returns) to keep the main logic path at low nesting depth (target: max 3 levels inside a method body). Conditions that would push nesting deeper are inverted and returned early:
# Instead of:
if complex_condition:
if another_condition:
[main logic at depth 3+]
# Use:
if not complex_condition:
return
if not another_condition:
return
[main logic at depth 1]
This applies to: parameter validation in _execute_actions, orbital condition checks in phase handlers (_phase_transfer_plan, _phase_phase, _phase_circularize), and fuel/thrust cascading guards in _brachistochrone_tick.
Position/Velocity Extraction Helpers
Module-level helper functions eliminate duplicated Redis hash unpacking throughout automation.py:
def _extract_pos_vel(data: dict) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Extract (position, velocity) tuples from a Redis hash dict."""
pos = (float(data.get("position_x", 0)),
float(data.get("position_y", 0)),
float(data.get("position_z", 0)))
vel = (float(data.get("velocity_x", 0)),
float(data.get("velocity_y", 0)),
float(data.get("velocity_z", 0)))
return pos, vel
def _extract_pos(data: dict) -> tuple[float, float, float]:
"""Extract position tuple from a Redis hash dict."""
return (float(data.get("position_x", 0)),
float(data.get("position_y", 0)),
float(data.get("position_z", 0)))
Used wherever ship, body, or target data is read from Redis hashes (ship:{id}, body:{name}, station:{id}). Replaces the repeated 3-line or 6-line float(data.get("position_x", 0)) pattern. Applied in: compute_soi_radius, _process_target_sync_maneuver, _maneuver_rendezvous_manual_tick (dispatcher), cross-body transfer handling, _find_reference_body, _build_context, _get_orbital_elements, _evaluate_condition.
Module-Level Constants
All tunable parameters and repeated literals are defined as module-level constants at the top of automation.py. This provides single-point-of-change for gameplay balancing and self-documenting code.
Ship defaults (fallback values when ship class lookup fails or context is unavailable):
| Constant | Value | Description |
|---|---|---|
DEFAULT_SHIP_CLASS |
"fast_frigate" |
Default ship class for thrust/ISP/RCS lookups |
DEFAULT_SHIP_DRY_MASS |
20,000 kg | Default dry mass when context unavailable |
DEFAULT_MAX_THRUST |
500,000.0 N | Fallback max thrust for SHIP_CLASS_THRUST.get() |
DEFAULT_ISP |
20,000.0 s | Fallback specific impulse for SHIP_CLASS_ISP.get() |
DEFAULT_RCS_THRUST |
4,000.0 N | Fallback RCS thrust for SHIP_CLASS_RCS_THRUST.get() |
Physical constants:
| Constant | Value | Description |
|---|---|---|
_G |
6.6743e-11 | Gravitational constant (m³ kg⁻¹ s⁻²) |
STANDARD_GRAVITY |
9.80665 m/s² | Standard gravitational acceleration (for Tsiolkovsky equation) |
Unit conversion and communication:
| Constant | Value | Description |
|---|---|---|
METERS_PER_KM |
1,000 | Meters-to-kilometers conversion factor |
GRPC_TIMEOUT_S |
2 | gRPC call timeout (seconds) for physics service calls |
Telemetry and diagnostics:
| Constant | Value | Description |
|---|---|---|
TELEMETRY_LOG_INTERVAL_TICKS |
300 | Periodic telemetry logging interval (ticks) |
Time-scale invariance: All transfer states must produce correct results regardless of the game time scale. Burns that depend on orbital position (e.g., Hohmann burn at perigee, circularize at apoapsis) must gate on true anomaly windows rather than burning continuously. This prevents high time scales from causing burns at incorrect orbital positions.
All transfer strategies (Hohmann, fast, express, parabolic) use a single explicit state machine for orbit-to-orbit transfers. The flow is:
plane_change → transfer_plan → departure_wait → transfer_burn →
transfer_coast → circularize → [phase_coast → transfer_plan] →
phase → approach → complete
Transfer States
| State | Purpose | Exit Condition |
|---|---|---|
transfer_plan |
Compute transfer orbit, departure angle, burn Δv values | Immediate (one tick) → departure_wait |
departure_wait |
Coast until phase angle matches ideal departure | \|phase_err\| ≤ 2° → transfer_burn |
transfer_burn |
Execute burn 1 (prograde). Hohmann waits for perigee; high-energy burns immediately | dv_rem < 0.1 m/s → transfer_coast |
transfer_coast |
Coast on transfer ellipse. OOP corrections during coast | Hohmann: near apoapsis (ν ≈ π). High-energy: radius crosses target → circularize |
circularize |
Execute burn 2. Hohmann: prograde at apoapsis. High-energy: combined tangential+radial | dv_rem < 5 m/s → evaluate next state |
phase_coast |
Coast in phasing orbit while differential mean motion closes phase angle | \|Δφ\| < 2° → transfer_plan (re-enter for real target) |
The plane_change, phase, and approach states are unchanged from the original design.
Burn Calculations (compute_full_transfer)
Unified function compute_full_transfer(r_departure, r_target, mu, strategy, a_ship, e_ship) parameterizes burn geometry for all strategies:
Hohmann (apogee factor = 1×):
transfer_a = (r_departure + r_target) / 2dv1 = √(μ(2/r_dep - 1/transfer_a)) - √(μ(2/r_dep - 1/a_ship))(prograde at perigee)dv2 = √(μ/r_target) - √(μ(2/r_target - 1/transfer_a))(prograde at apoapsis)transfer_time = π × √(transfer_a³/μ)burn_1_point = "perigee",nu_arrival = π
Fast (factor = 2×), Express (5×):
- Direction-aware orbit geometry:
- Ascending (
r_departure ≤ r_target):r_perigee = r_departure,r_apogee = factor × r_target. Ship departs at periapsis, crosses target on outbound leg. - Descending (
r_departure > r_target):r_apogee = r_departure,r_perigee = r_target / factor, clamped tobody_radius × 1.01(defaults tor_target × 0.5when body_radius unavailable). This prevents periapsis below the body surface. Near-Hohmann fallback: if the clamped periapsis is within 5% of target (r_perigee > 0.95 × r_target), the orbit is essentially Hohmann — falls back tocompute_full_transfer(..., "hohmann")which uses apse-based burns and detection (more robust for near-surface targets). Ship departs at apoapsis, crosses target on inbound leg.dv1 < 0(retrograde).
- Ascending (
transfer_a = (r_perigee + r_apogee) / 2burn_1_point = "any"(burn immediately)dv2= vector difference at r_target (tangential + radial components)- Transfer time: ascending = time from ν=0 to ν₁; descending = time from ν=π to ν₂ (by symmetry:
(π - M) / n)
Parabolic (e=1):
- Departure at escape velocity,
burn_1_point = "any" dv2= vector difference at r_target (tangential + radial components)
transfer_plan State
On first entry (cycle 1):
- Check for pre-computed phasing orbit: if
transfer_is_phasing == "true"andtransfer_target_ris already set (by thephasehandler’s computed-phasing logic), use that target directly. The phase handler uses multi-orbit phasing with altitude-scaled max_deg, which handles phase angles > 60° that transfer_plan’s single-orbit limit cannot. -
Same-altitude detection: if Δa < a_tol (ship and target at same orbit) AND Δφ > 2°, compute a multi-orbit phasing orbit using altitude-scaled max_deg ( n_orbits = ceil(angle/max_deg), wheremax_deg = min(120, 30 × max(1, T_target/5500))) instead of a degenerate same-altitude Hohmann (which would have dv ≈ 0 and never close the phase angle). This prevents the same-altitude routing loop. The altitude scaling matches the phase handler — at LEO 30°/orbit, at high orbits up to 120°/orbit. - Otherwise, compute phase angle to target and ideal departure phase for direct transfer
-
If ship is near the target orbit ( |Δa| < 5 × a_tol, ensuring only same-altitude or near-same-altitude triggers phasing) AND phase error is moderate (5° <err < 60°) AND Δφ > 2°: compute phasing orbit SMA, set transfer_is_phasing = "true", target phasing orbit radius. For errors > 60°, waiting for the departure window is cheaper than a large phasing orbit. For different-altitude transfers (ship far from target orbit), departure timing alone handles the phase — the Hohmann transfer time provides natural phase drift, anddeparture_waitwaits for the correct departure window. - Otherwise: target real orbit directly
On re-entry after phase_coast (cycle 2):
- Always target real orbit,
transfer_is_phasing = "false"
Calls compute_full_transfer(), stores all transfer parameters, transitions to departure_wait.
Departure radius: For phasing transfers (is_phasing = true), the departure radius is the ship’s SMA (not instantaneous radius). The ship just circularized and is near-circular, but r_mag oscillates around the SMA due to residual eccentricity. When the phasing orbit SMA is close to the ship’s SMA (typical for small phase angles), a momentary r_mag > r_target_orbit flips the Hohmann from ascent to descent. Descent arrival at perigee (ν≈0) instead of apoapsis (ν≈π) causes the circularize to converge at the wrong altitude, leaving a highly eccentric orbit with correct SMA but wrong apsides. Using SMA as the departure radius is stable across the orbit.
departure_wait State
Each tick:
- Compute current phase angle and phase error vs ideal departure
- Immediate departure: depart immediately (skip departure wait) when any of these conditions hold:
transfer_is_phasing == "true"— phasing orbit transfer. The exact arrival phase is unimportant becausephase_coastcloses the remaining angle. This avoids both the same-altitude stall (zero differential drift means the ideal departure window never naturally arrives) and the active-burn drift problem (prograde burns during departure_wait change the orbit enough to drift the phase angle ~90°, making the subsequent coast much longer).transfer_cycle == "2"— return from phasing orbit to target orbit. The phase coast already closed the angle to within 2°. Waiting for the ideal departure is counterproductive because active burns during the wait change the orbit (e.g. raising from 200km to 1200km), which causes the return Hohmann to arrive with a large phase offset. Immediate departure preserves the phase coast’s work.departure_immediate == "true"— orbit correction transfer from the phase handler. The correction is fixing orbit shape (not phase timing), so departure timing doesn’t matter. Active burns would change the orbit further, defeating the correction’s purpose.
- For non-immediate transfers: If
|phase_err| ≤ 2°OR phase error crossed zero since last tick (handles high time scales where phase_err jumps over the window): recompute transfer plan at current radius (includingtransfer_burn_pointfrom the plan’sburn_1_point), transition totransfer_burn. Storesdeparture_phase_prevfor zero-crossing detection. - Otherwise compute ETA from differential mean motion:
- If
eta > min(DEPARTURE_ACTIVE_WAIT_ORBITS × T_target, DEPARTURE_ACTIVE_WAIT_MAX_S): active departure wait — apply gentle pro/retrograde burns (same phasing logic as thephasestate: proportional throttle, per-tick Δv cap, SMA headroom limiter) to create a temporary SMA offset that accelerates phase drift. Themin()ensures high orbits (where 3 periods may exceed 70h) still activate within 2h game time. This prevents multi-hour stalls when ship and target orbits are nearly identical (e.g. during Hohmann correction cycles). - Orbit-relative SMA headroom:
phase_headroom = max(a_tol × PHASE_SMA_HEADROOM_FACTOR, r_target × DEPARTURE_HEADROOM_MIN_FRACTION). Thea_tol-based headroom (500 km at GEO) is only 1.2% of orbit radius at high altitudes, producing a phase drift of ~6.4°/day — too slow for large phase angles. The orbit-relative floor (5% of target orbit radius = 2,108 km at GEO) gives ~27°/day drift, closing 150° in ~960s at 500× time scale. At LEO thea_tol-based value dominates, so behavior is unchanged. - Otherwise: passive wait — zero thrust, pre-orient prograde
- If
transfer_burn State
Orbit-targeted burn — recomputes needed Δv from current state each tick (vis-viva), making the burn time-scale independent. Finite-burn effects (thrust applied in a fixed inertial direction while sweeping an orbital arc) are automatically corrected because each tick steers toward the target orbit shape.
Each tick:
- Compute
v_target = sqrt(μ * (2/r - 1/a_transfer))— vis-viva target speed for the transfer orbit at current radius - Compute
dv_needed = v_target - v_tangential(positive = prograde, negative = retrograde) - Exit condition:
|dv_needed| < 0.5 m/sOR orbit shape reached target:- High-energy (
burn_point = "any"): SMA convergence —|a_current - a_transfer| / a_transfer < 0.02(2%). For parabolic transfers wherea_transferis non-finite, falls back to|a_current - transfer_target_r| / transfer_target_r < 0.02. This handles same-altitude fast/express transfers where the ascent/descent checks would immediately pass (apoapsis already at target) without any burn occurring. - Ascent (
burn_point = "perigee"): apoapsis ≥ 99% oftransfer_target_r - Descent (
burn_point = "apoapsis"): periapsis ≤ 101% oftransfer_target_r - If
transfer_burn_pointis missing, derive fromtransfer_dv1: negative = descent (apoapsis), positive = ascent (perigee)
- High-energy (
- Burn prograde/retrograde toward target speed:
thrust_fraction = min(1.0, |dv_needed| / dv_per_tick) - On exit: transition to
transfer_coast, storetransfer_r_prev
Ascent vs Descent Transfers
compute_full_transfer() distinguishes transfer direction for Hohmann:
- Ascent (
r_departure < r_target): departure at perigee of transfer orbit (ν=0), arrival at apoapsis (ν=π).burn_1_point = "perigee". - Descent (
r_departure ≥ r_target): departure at apoapsis of transfer orbit (ν=π), arrival at perigee (ν=0).burn_1_point = "apoapsis".
This affects transfer_burn exit checks, transfer_coast arrival detection, and ETA computation.
transfer_coast State
Each tick:
- Mid-course SMA corrections, no OOP. Purely tangential (prograde/retrograde) burns maintain the planned transfer orbit SMA against N-body perturbations (e.g. Jupiter tidal forces at Callisto). When
|a_ship - a_transfer| > 0.5% × a_transfer, compute vis-viva Δv correction and apply with per-tick cap. No cross-track thrust — OOP is handled by the plane_change phase before departure and after circularize. - Pre-orient for circularization burn direction
- Detect arrival:
- Hohmann ascent (
burn_point = "perigee"):angle_near_pi(ν, window_half)— arrive at apoapsis - Hohmann descent (
burn_point = "apoapsis"):angle_near_zero(ν, window_half)— arrive at periapsis - Radius-crossing fallback (#1066): For low-eccentricity transfer orbits (same-altitude phasing), ω drifts numerically and ν jumps unpredictably, so the anomaly-based check may never trigger. Fallback: if
r_prevcrossestransfer_target_r(descent:r_prev > target ≥ r_mag; ascent:r_prev < target ≤ r_mag), consider arrived. Only applies to Hohmann transfers. - High-energy:
r_prevcrossedtransfer_target_r(ascending or descending). Same-altitude fallback: for same-altitude fast/express transfers, the transfer orbit periapsis equals the target radius. Finite burn imprecision can leave periapsis above target, so exact crossing may never be detected. Fallback: compute orbit periapsisr_pe = a × (1 - e)from orbital elements; if|r_pe - r_target| / r_target < 10%AND near periapsis (ν within ±30° of 0), consider arrived. The 10% tolerance handles thetransfer_target_rdiscrepancy between computed orbital elements and actual position (~200km for LEO). For different-altitude transfers (e.g., 400km→35000km), periapsis-vs-target difference is ~84% — no false trigger.
- Hohmann ascent (
- On arrival: recompute dv2 from current state (vis-viva for Hohmann, vector diff for high-energy), store
transfer_dv_rem, transition tocircularize - Update ETA: time to target anomaly (apoapsis for ascent, periapsis for descent), store
transfer_r_prev = current_radius
circularize State
Each tick:
All strategies use the same unified approach. The correction method depends on time scale:
Low time scale (dt_game ≤ CIRC_GAIN_DT_REF, i.e., ≤50s):
- Compute velocity target (two modes):
- Vis-viva mode (default, when
|a_ship - a_target| ≥ CIRC_CIRCULAR_BLEND_THRESHOLD × a_tol):v_target = sqrt(μ * (2/r - 1/a_target)). Targets the correct velocity for the target orbit at the current radius — converges the SMA efficiently. - Circular velocity mode (when
|a_ship - a_target| < CIRC_CIRCULAR_BLEND_THRESHOLD × a_tol):v_target = sqrt(μ / a_target). Targets the circular velocity at the target orbit radius, regardless of current radius. This actively corrects eccentricity by burning prograde at apoapsis and retrograde at periapsis. Uses moderate gain (CIRC_CIRCULAR_GAIN = 0.5) to ensure convergence outpaces per-orbit velocity oscillation.CIRC_CIRCULAR_BLEND_THRESHOLD = 3.0(3× the SMA tolerance).
- Vis-viva mode (default, when
- Tangential-only corrections with total-speed comparison (
dv_r = 0,dv_t = v_target - v_mag,dv_rem = |dv_t|). Radial velocity is natural Keplerian motion — zeroing it wastes fuel and amplifies eccentricity. Total-speed comparison (same in both modes) avoids prograde bias from comparing against v_tangential and prevents mode-switching discontinuity at the vis-viva/circular boundary. In circular mode, this still circularizes correctly: retrograde at periapsis (v_mag > v_circ), prograde at apoapsis (v_mag < v_circ). - Burn direction: pure prograde/retrograde
(0, sign(dv_t), 0)in RTN. Thrust fraction scaled bydv_rem / dv_per_tick. - Per-step dv cap:
thrust_fraction = min(thrust_fraction, PHASE_MAX_DV_PER_TICK / dv_per_tick)whendv_per_tick > PHASE_MAX_DV_PER_TICK. Caps actual Δv per integration sub-step to 10 m/s regardless of time scale or ship acceleration. At 1× (frigate dv_per_tick=25 m/s): throttle ≤ 0.4. At 50× with sub_dt=10s (dv_per_tick=250 m/s): throttle ≤ 0.04. Without this cap, high-TWR ships at high time scales apply 15%+ of circular velocity per sub-step, causing Q-law to overshoot the target orbit and diverge.
High time scale (dt_game > CIRC_GAIN_DT_REF, i.e., >50s) — apse-targeted tangential burns:
At high time scales, per-tick corrections span many degrees of orbit. Continuous vis-viva velocity matching causes oscillation because the target velocity changes significantly between ticks, and radial velocity zeroing fights natural Keplerian dynamics. Instead, burns are restricted to apse windows where tangential corrections efficiently change the opposite apse:
- Determine orbital position using true anomaly
νfrom orbital elements. - Near periapsis (
|ν| < CIRC_APSE_HALF_WIDTHor|ν - 2π| < CIRC_APSE_HALF_WIDTH): Compute tangential Δv to correct apoapsis:a_adj = (r_ship + r_ap_target) / 2wherer_ap_target = a_target × (1 + e_target)v_needed = sqrt(μ × (2/r_ship - 1/a_adj))dv_t = v_needed - v_tangential
- Near apoapsis (
|ν - π| < CIRC_APSE_HALF_WIDTH): Compute tangential Δv to correct periapsis:a_adj = (r_ship + r_pe_target) / 2wherer_pe_target = a_target × (1 - e_target)v_needed = sqrt(μ × (2/r_ship - 1/a_adj))dv_t = v_needed - v_tangential
- Between apsides: Coast (zero thrust), maintain prograde attitude.
dv_t = 0. - Angular momentum mode is disabled (
CIRC_APSE_E_MIN = 0.0). Apse-targeted mode handles all eccentricities includinge → 0. When the orbit is already converged (correct SMA and low e), the vis-viva formula givesdv_t ≈ 0at all apsides — no fuel waste. Angular momentum mode was found to drain 20%+ fuel without converging eccentricity (symmetric corrections cannot reduce e effectively; only asymmetric apse burns can). dv_rem = sqrt(dv_t² + dv_r²). In apse-targeted mode,dv_r = 0(tangential only). Burn direction: prograde/retrograde at apsides.- Per-step dv cap with dynamic scaling (#725): When
sma_ratio > CIRC_WIDE_WINDOW_THRESHOLD(3×),scaled_cap = PHASE_MAX_DV_PER_TICK × min(sma_ratio, CIRC_DV_CAP_MAX_SCALE). Otherwisescaled_cap = PHASE_MAX_DV_PER_TICK(base 10 m/s). When the ship’s SMA is far from the target (e.g., 10× for a 24,466 km orbit targeting 2,548 km), the cap scales up to 100 m/s per tick (10× the base 10 m/s). Near convergence (sma_ratio ≤ 3), the cap stays at 10 m/s to prevent overshooting.thrust_fraction = min(thrust_fraction, scaled_cap / dv_per_tick)whendv_per_tick > scaled_cap. Apse window widening was attempted but reverted — the vis-viva apse formula gives wildly incorrect Δv at positions far from the actual apse, causing orbit destruction for high-e orbits. Only the dv cap scales dynamically.
This converges eccentricity directly — each apse burn targets the specific orbit element that needs correction. At CIRC_APSE_HALF_WIDTH = 30°, each apse window spans ~17% of the orbit.
- Exit conditions (either triggers
_circularize_complete):ap_pe_convergedwith period-scaled tolerance: apoapsis and periapsis each withincirc_exit_tolof target, wherecirc_exit_tol = a_tol × max(1, √(T_orbit / T_ref)).T_orbitis the ship’s orbital period,T_ref = PHASE_MAX_DEG_REF_PERIOD(~5500s, LEO reference). At LEO: factor ≈ 1 (no change from base a_tol = 100 km). At 35,000 km altitude (T ≈ 83,600s): factor ≈ 3.9, giving ~390 km tolerance. This is necessary because per-orbit convergence rate is ~50%/orbit regardless of altitude, but the orbital period (and thus wall-clock time per orbit) scales with altitude. Without period scaling, high-orbit circularize takes 4+ orbits (56+ minutes at 35,000 km) to reach the LEO-calibrated 100 km tolerance, exceeding test timeouts. Subsequent phases (phase matching, approach) handle residual orbit error through additional burns.- Stall detection (two conditions, either triggers exit). Uses minimum tracking over dynamic-length windows: tracks
min(dv_rem)andmin(orbit_err)(whereorbit_err = max(ap_err, pe_err)) across each window and compares between windows to eliminate orbital oscillation artifacts. For phasing orbits (transfer_is_phasing == "true"), orbit_err is computed against the phasing orbit (circular atr_target_orbit), not the rendezvous target — the ship is intentionally at a different altitude, and using the rendezvous target gives a structural orbit_err of ~15,000 km that never improves, causing false stalls after 180 ticks. Window size =max(60, orbit_period_ticks)whereorbit_period_ticks = ceil(2π√(a_target³/μ) / time_scale)using the target orbit SMA (not the ship’s current SMA, which changes each tick during convergence and can be degenerate/hyperbolic):- Circular-mode stall: Disabled (
CIRC_APSE_E_MIN = 0.0). Angular momentum mode is no longer used. All stall detection uses the apse-targeted conditions below. - Oscillation stall:
min(dv_rem)hasn’t improved by >10% compared to previous window’s minimum ANDmin(orbit_err)hasn’t improved by >5% either. Both conditions must hold — if the orbit shape is converging (ap/pe errors shrinking), circularize continues even if dv_rem oscillates. The orbit_err threshold is 5% (more lenient than dv_rem’s 10%) because linear gain scaling at high time scales produces slower convergence rates — orbit_err may improve only ~8% per window at 100× time scale, which is genuine convergence that should not trigger a stall. Trackscircularize_stall_orbit_err_baselineandcircularize_stall_orbit_err_minalongside the dv_rem fields. Near-zero baseline: when bothmin(dv_rem)and baseline are < 1.0 m/s, the orbit has converged to the circularize target (possibly a phasing orbit, not the final target) — dv_stalled is forced True. This prevents infinite loops when circularizing to a phasing orbit that doesn’t match the final target ship (orbit_err vs target is always large, but dv_rem vs phasing target is ~0). dv_rem tracking skips coast:dv_remis only tracked whenburn_point != "coast"to prevent coast dv_rem=0 from poisoning the minimum. On window reset during coast, the minimum is initialized to a sentinel (1e9) so the first apse-burn tick sets the real minimum. - Wrong-orbit convergence: both
min(dv_rem)and the previous minimum are < 5.0 m/s ANDorbit_err < 5 × a_tol(guard against false-trigger during apse-targeted coasting where dv_rem=0 between apsides). The vis-viva circularization converges to a velocity near the target but the orbit shape (ap/pe) doesn’t match due to eccentricity or altitude offsets. The 5.0 m/s threshold catches cases where dv_rem bottoms out at 1-3 m/s. - Regression guard (continuous): If
dv_rem > circ_stall_baseline × CIRC_STALL_REGRESSION_FACTOR(2.0) and baseline is established, declare stall immediately with type “regression”. Catches catastrophic divergence where a transient minimum masks that current conditions are far worse than baseline. Only checked whenburn_point != "coast". - Both conditions are time-scale independent. The
_circularize_completehandler routes based on stall type (see Phasing Loop routing below). - Stall field reset: When entering circularize from
transfer_coast, all stall tracking fields (circularize_stall_baseline,circularize_stall_min,circularize_stall_orbit_err_baseline,circularize_stall_orbit_err_min,circularize_stall_ticks) are cleared. This ensures each circularize attempt evaluates convergence independently — stale baselines from prior attempts would cause falsewrong_orbitstalls (e.g., baseline=0.4 from cycle 1 persisting into cycle 2 where the orbit is actually converging).
- Circular-mode stall: Disabled (
- Thrust distribution (apse-targeted mode only): The vis-viva formula recalculates
dv_tfrom current orbital elements each tick. After one tick applies the correction, the orbit changes so that subsequent ticks seedv_t ≈ 0— the formula is self-correcting. No window budget or remaining-tick tracking is needed.window_fraction = 2 × CIRC_APSE_HALF_WIDTH / 360T_orbit = 2π√(a_ship³/μ)ticks_in_window = max(1, floor(T_orbit × window_fraction / dt_game))- If
ticks_in_window < 3: conservative mode — budget only 50% of Δv per pass (dv_budget = dv_rem × CIRC_CONSERVATIVE_PASS_FRACTION). Prevents overcorrection when burn time is a large fraction of the orbital period and the ship passes through the apse window too quickly for multi-tick settling. - If
ticks_in_window ≥ 3: full correction — no gain cap. The first few ticks at each apse correct the orbit, and remaining ticks seedv ≈ 0. Convergence in 1-2 apse passes (periapsis + apoapsis) regardless of orbital period. - (Disabled) Angular momentum mode was removed. Apse-targeted handles all eccentricities.
- ETA =
dv_rem / accel -
Periapsis impact protection (after apse-targeted burn/coast decision): When the orbit has periapsis below the body surface (
rp < body_radius), the apse-targeted coast/burn decision is overridden with an emergency burn to raise periapsis. Without this, a ship coasting between apse windows on an impact trajectory will crash before reaching the next apse burn window. (#720)Detection:
rp = a × (1 - e)from current orbital elements. Ifrp < body_radius, the orbit intersects the surface.Emergency burn direction: Prograde tangential + radial outward (combined):
- Tangential component:
dv_t = v_circular - v_tangential(at minimum match circular velocity — prograde thrust near apoapsis raises periapsis) - Radial component:
dv_r = |v_radial|when falling inward (v_r_signed < 0) — arrests inward velocity to prevent surface impact. When outbound (v_r_signed >= 0),dv_r = 0. burn_point = "emergency"- Full throttle:
thrust_fraction = 1.0
Exit: Normal apse-targeted logic resumes once periapsis rises above
body_radius. The emergency burn raises periapsis rapidly (TWR » 1 for most ships at moon-scale bodies), typically within a few ticks.Why not reuse brachistochrone periapsis protection: The brachistochrone guard blends outward-radial thrust into an existing ZEM/ZEV direction. Circularize has no ZEM/ZEV — it uses apse-targeted burns with explicit coast periods. The emergency override must replace the coast decision entirely, not blend into it.
- Tangential component:
This converges at any time scale. Apse-targeted mode uses tangential-only corrections at periapsis/apoapsis windows — each burn directly targets the opposite apse, providing the asymmetric corrections needed for eccentricity reduction (retrograde at periapsis lowers apoapsis, prograde at apoapsis raises periapsis). The self-correcting vis-viva formula ensures convergence in 1-2 apse passes for eccentric orbits. Conservative mode prevents overcorrection at low resolution. Period-scaled exit tolerance ensures timely convergence at all altitudes.
Phasing Loop
Circularize exit conditions:
ap_pe_converged(oe_ship, oe_target): ship orbit matches rendezvous target. Skipped for phasing orbits (transfer_is_phasing == "true") — the ship is intentionally at a different altitude than the rendezvous target. Without this guard, the check can pass immediately (before or during the transfer) because the ship’s orbit still matches the rendezvous target from the previous phase, causing the phasing orbit to never actually execute. This results in near-zero differential mean motion in phase_coast (the ship stays at the target orbit instead of the computed phasing orbit).- Near-converged fast-path: when standard
ap_pe_convergedfails butorbit_err < 2× circ_exit_a_tol(orbit within twice the period-scaled tolerance), treat as converged afterCIRC_STALL_MIN_WINDOW(60) ticks. This handles high-orbit residual eccentricity where Q-law converges to dv_rem ≈ 0 but orbit error stays just above the standard tolerance. Without this, the stall detection window (proportional to orbital period) takes too long for high orbits — e.g., at 35,000 km the stall detection needs ~1400 real seconds, exceeding test timeouts. The 2× tolerance is safe because_circularize_completeroutes tophasefor fine corrections. circ_stalled: stall detection triggered (oscillation or wrong-orbit)- Phasing orbit convergence: when
transfer_is_phasing == "true", check|Δa| < a_tolagainst the phasing SMA (not the rendezvous target) ANDe < 0.02. This allows phasing orbits to exit circularize quickly — the standardap_pe_convergedcheck would never pass because the ship targets a different altitude than the rendezvous target.
After circularize completes (either ap_pe_converged, stall detected, or phasing converged), routing is checked in _circularize_complete in this priority order:
- If circularize stalled (any stall type) AND
transfer_is_phasing == "true"AND|Δφ| > 2°→phase_coast(for phasing orbits, any stall means the orbit is close enough — at high time scales the vis-viva oscillation floor can be ~30 m/s but the SMA is within ~1% of target, close enough for phasing coast) - If circularize stalled on a non-phasing orbit AND
|Δφ| > 2°→phase_coastwithtransfer_target_rset to the ship’s current SMA. The ship drifts on its current (imperfect) orbit until the phase gap closes, then the next transfer cycle corrects the orbit. Without this coast, thephasehandler immediately re-enterstransfer_plan, creating rapid cycling that never accumulates enough drift to close the gap (#1066). 2b. If circularize stalled on a non-phasing orbit AND|Δφ| ≤ 2°→phase(gentle corrections; thephasehandler will trigger a corrective transfer when the orbit doesn’t match) - If
transfer_is_phasing == "true"AND|Δφ| > 2°→phase_coast - If
|Δφ| ≤ 2°→phase - If orbit converged (
ap_pe_convergedwith exit tolerances) AND|Δφ| > 2°→phase_coastwithtransfer_target_rset to the ship’s current SMA. Same reasoning as case 2: prevents thephasehandler from immediately cycling totransfer_plan. Exit tolerance propagation:_circularize_completeaccepts anexit_tolerancesparameter (the period-scaled tolerances used by circularize exit). At this check, usesexit_tolerancesif provided, otherwise falls back to standardtolerances. This prevents a mismatch where circularize exits using relaxed (period-scaled) tolerance but_circularize_completechecks with standard tolerance. 5b. If orbit converged AND|Δφ| ≤ 2°→phase(gentle corrections to close the final gap). - Otherwise →
transfer_plan(re-enter for correction transfer)
phase_coast State
Each tick:
- Compute phase angle to target
- Sign mismatch (overshoot) detection: only for small phase angles (
|Δφ| < 60°) and significant altitude offset (|r_phasing − a_target| > 0.1% a_target). The 0.1% tolerance prevents false sign mismatch from floating-point noise in OE computation for same-altitude rendezvous. Large phase angles (e.g. 179°) arise from phase drift during the transfer and need continued coasting — the ship will lap the target. - Drift-compensated transition: Compute the Hohmann phase drift from the current orbit to the target orbit:
drift = |π − n_target × T_hohmann/2|whereT_hohmann/2 = π × √(a_transfer³/μ)anda_transfer = (a_ship + a_target)/2. The transition threshold ismax(2°, drift + 0.5°). This ensures the cycle 2 Hohmann arrives with phase ≈ 0° — the phase drift during the half-orbit transfer exactly closes the remaining phase angle.- If
|Δφ|≤ threshold OR sign mismatch detected OR diverging:- Sign mismatch: route to
phase(the Hohmann drift would make things worse for overshot cases) - Normal closure:
transfer_plan(cycle 2) — the Hohmann brings the ship to the target orbit while the transit drift closes the remaining phase angle
- Sign mismatch: route to
- If
- Divergence detection: Compute signed drift rate
dphi_dt_signed = n_ship - n_target. Sinced(phase)/dt = -(n_ship - n_target) = -dphi_dt_signed(phase angle decreases when ship orbits faster), the coast is diverging whenphase_angle × dphi_dt_signed < 0— the drift is increasing the phase angle magnitude instead of closing it. This happens when the ship circularized at the wrong altitude for its phase sign — e.g., ship behind target (phase > 0) but in a higher orbit (slower, dphi_dt < 0, falling further behind). On divergence, transition totransfer_planto re-plan the transfer. (#1066: previously the sign was reversed, ejecting convergent coasts and causing the phasing limit cycle.) - Otherwise: zero thrust, pre-orient prograde
- Compute ETA:
remaining_angle / |n_ship - n_target| - Coast safety valve (dynamic limit): compute
n_target_orbits_neededfrom actual differential mean motion (not a fixed constant). Max coast =max(3, n_needed × 1.5)target orbits. This accommodates min_a clamping which reduces closure rate below the designed 30°/orbit. When recomputing, use altitude-scaled max_deg (n_orbits = ceil(|Δφ°| / max_deg), wheremax_deg = min(120, 30 × max(1, T_target/5500))) to match the phase handler. At LEO this gives 30°/orbit; at high orbits up to 120°/orbit, preventing excessive n_orbits that would produce unnecessarily close phasing orbits.
OOP Corrections During Transfer
Throughout transfer_coast and circularize (coast periods):
- Apply
oop_coast_steering()when i/Ω exceed tolerance and effectivity ≥ 0.15 - OOP thrust is in H direction (orbit normal), orthogonal to in-plane GVE rows → no interference
Backward Compatibility
Old phase names map on entry:
adjust_orbit→transfer_plan- Old
transfer_phasesub-states →transfer_plan(restart transfer) - Old fields (
hohmann_departure_computed,hohmann_wait_departure,hohmann_depart_phase,phasing_target_a,transfer_phase,transfer_arrival_dv_t,transfer_arrival_dv_r,transfer_depart_phase) are cleared and replaced with new fields
Maneuver Hash Fields
| Field | Type | Description |
|---|---|---|
transfer_target_r |
string (meters) | Target radius for this transfer leg |
transfer_dv1 |
string (m/s) | Planned Δv for burn 1 |
transfer_dv2 |
string (m/s) | Planned Δv for burn 2 |
transfer_dv_rem |
string (m/s) | Remaining Δv for current burn |
transfer_a |
string (meters) | SMA of transfer ellipse |
transfer_time |
string (seconds) | Estimated coast time |
transfer_is_phasing |
"true"/"false" |
Whether targeting phasing orbit |
transfer_burn_point |
"perigee"/"apoapsis" |
Where burn 1 should occur (derived from transfer_dv1 sign if missing) |
departure_phase_target |
string (radians) | Ideal departure phase angle |
transfer_r_prev |
string (meters) | Previous radius (for crossing detection) |
transfer_cycle |
"1"/"2" |
Which transfer cycle (phasing=1, real=2) |
transfer_nu_arrival |
string (radians) | Expected true anomaly at arrival |
transfer_v_arrival |
string (m/s) | Expected speed at arrival |
transfer_gamma |
string (radians) | Flight path angle at arrival |
departure_immediate |
"true"/"false" |
Skip departure_wait active burns, depart immediately. Set for cycle 2 returns and phase correction transfers. |
phase_correction_count |
string (int) | Number of phase → transfer_plan corrective transfers completed (max PHASE_CORRECTION_MAX = 3) |
phase_active_count |
string (int) | Number of computed phasing orbit cycles used (max PHASE_ACTIVE_MAX_CYCLES = 3) |
Status Text Format
| State | Format |
|---|---|
departure_wait |
"[Strategy] waiting for departure, Δφ=X°, ETA Yh Zm" (passive) or "[Strategy] active departure wait (pro/retrograde), Δφ=X°, ETA Yh Zm" (active) |
transfer_burn |
"[Strategy] burn 1 [coast→pe\|burning], Δv_rem=Xm/s, ETA Ym" |
transfer_coast |
"[Strategy] coast→target, alt=Xkm, ETA Ym" |
circularize |
"[Strategy] circularize, dv_rem Xm/s" (near apoapsis) or "[Strategy] coast→apo, dv_rem Xm/s, ETA Ym" (coasting) |
phase_coast |
"Phasing coast Δφ=X°, ETA Yh" |
ETA Computation
| State | ETA = |
|---|---|
departure_wait |
remaining_phase / |phase_rate| |
transfer_burn |
time_to_perigee + burn_time (Hohmann); burn_time (high-energy) |
transfer_coast |
time_to_arrival + circularize_burn_time |
circularize |
time_to_apoapsis + burn_time (Hohmann coast); burn_time (near apoapsis or high-energy) |
phase_coast |
remaining_phase / |Δn| + transfer_2_estimate |
Constants
| Constant | Value | Description |
|---|---|---|
HOHMANN_BURN_WINDOW_MARGIN_DEG |
10.0 | Degrees margin beyond computed burn arc |
HOHMANN_MIN_WINDOW_DEG |
15.0 | Minimum burn window half-width (degrees) |
HOHMANN_MAX_WINDOW_DEG |
90.0 | Maximum burn window half-width (degrees) |
HOHMANN_OOP_EFF_GATE |
0.15 | Minimum OOP effectivity during coast |
HOHMANN_CIRCULAR_E_THRESHOLD |
0.01 | Below this eccentricity, burn immediately (skip angle check) |
PHASING_HOHMANN_ANGLE_THRESHOLD_DEG |
2.0 | Below this phase angle, target exact orbit |
PHASING_HOHMANN_N_ORBITS |
1.0 | Close phase in N target orbits |
PHASING_RECOMPUTE_RATIO |
2.0 | Recompute SMA when |Δφ| > ratio × designed closure |
PHASING_COAST_MAX_ORBITS |
3.0 | Recompute SMA when coast ETA > this × target period |
DEPARTURE_ACTIVE_WAIT_ORBITS |
3 | Switch to active phasing in departure_wait when ETA > this × target orbital period |
DEPARTURE_ACTIVE_WAIT_MAX_S |
7200 | Hard cap (game seconds): switch to active phasing if ETA > this, regardless of orbital period. Prevents long passive waits on high orbits where 3 periods exceeds the cap. |
DEPARTURE_HEADROOM_MIN_FRACTION |
0.05 | Minimum SMA headroom as fraction of target orbit radius. Ensures departure_wait active phasing creates sufficient SMA offset at high altitudes where a_tol × 5 is too small relative to orbit size. At GEO (42,164 km): max(500 km, 2,108 km) = 2,108 km. At LEO (7,000 km): max(500 km, 350 km) = 500 km (unchanged). |
CIRCULARIZE_GAIN |
0.3 | (Unused) Was proportional gain for circular fallback mode. Circular mode is disabled. |
CIRC_GAIN_DT_REF |
50.0 | (Unused) Was reference dt for circular fallback gain normalization. |
CIRC_CONSERVATIVE_PASS_FRACTION |
0.5 | When ticks_in_window < 3, budget only 50% of Δv per apse pass to prevent overcorrection |
CIRC_APSE_HALF_WIDTH |
30.0° | Half-width of apse windows for apse-targeted circularize. Burns only within ±30° of periapsis/apoapsis. Passed in degrees to angle_near_zero/angle_near_pi |
CIRC_APSE_MAX_HALF_WIDTH |
90.0° | (Unused) Dynamic apse window widening was reverted — vis-viva formula gives incorrect Δv far from apsides. |
CIRC_WIDE_WINDOW_THRESHOLD |
3.0 | Threshold for dynamic dv cap scaling: scale when ship SMA > this × target radius. (#725) |
CIRC_DV_CAP_MAX_SCALE |
10.0 | Maximum multiplier for per-step dv cap during circularize. At sma_ratio = 10, cap = 100 m/s (10 × base 10 m/s). Prevents slow convergence for extreme orbit mismatches (e.g., post-SOI-capture e=0.92). (#725) |
CIRC_APSE_E_MIN |
0.0 | Disabled. Apse-targeted mode handles all eccentricities. Angular momentum mode (circular fallback) drained 20%+ fuel without converging eccentricity — symmetric corrections cannot reduce e. At e → 0, apse-targeted vis-viva self-corrects: dv ≈ 0 when orbit is converged, no fuel waste. |
PHASE Phase (Close Phase Angle)
Phasing uses gentle prograde/retrograde burns to create an SMA offset that causes differential mean motion, closing the phase angle to the target. Key parameters:
- Per-step dv cap:
dv_budget = PHASE_MAX_DV_PER_TICK × v_scalewherev_scale = min(1, √(μ/a_target) / PHASE_DV_CAP_V_CIRC_REF). The base cap is 10 m/s, calibrated for LEO (v_circ ≈ 7670 m/s, so 10 m/s = 0.13% of orbital velocity). At bodies with lower orbital velocity (e.g. Callisto v_circ ≈ 1691 m/s), the cap scales down to maintain the same fractional perturbation — without this, the 10 m/s cap is 0.6% of v_circ, causing eccentricity to diverge within ~17 ticks (#1066).PHASE_DV_CAP_V_CIRC_REF = 7670 m/s(LEO 400 km reference). Caps actual Δv per integration sub-step independent of time scale. Same cap applies todeparture_waitactive phasing throttle and the circularize per-step cap. - Dynamic SMA headroom: Phasing throttle is limited by an SMA headroom factor that scales linearly with phase angle:
phase_headroom_factor = clamp(1.0 + (PHASE_SMA_HEADROOM_FACTOR - 1.0) × |phase_angle| / PHASE_HEADROOM_SCALE_ANGLE, 1.0, PHASE_SMA_HEADROOM_FACTOR)- At 30°+ Δφ: 5× a_tol headroom (unchanged). At 3° Δφ: ~1.4×. At 0°: 1×.
- Orbit-relative floor:
phase_headroom = max(a_tol × phase_headroom_factor, r_target × DEPARTURE_HEADROOM_MIN_FRACTION). At high altitudes (GEO+), thea_tol-based headroom is too small relative to orbit radius (1.2% at GEO), causing very slow phase drift. The orbit-relative floor (5% of target radius) ensures sufficient drift rate at all altitudes. - Effect: ship orbit stays close to target orbit as phase angle closes, reducing transfer work on re-entry. Eliminates phase↔transfer cycling.
- Eccentricity catastrophic fallback: Triggers at
PHASE_ECCENTRICITY_FALLBACK_FACTOR(10×) × e_tol instead of 5×. Relaxed because dynamic headroom reduces phasing duration, limiting eccentricity growth. - RAAN equatorial guard: RAAN (Ω) is undefined for equatorial orbits — at i ≈ 0°, numerical noise can produce RAAN errors of ~180° that represent negligible actual plane separation. All RAAN error checks (convergence, element_errors “ok”, catastrophic OOP fallback) weight the raw RAAN error by
sin(max(i_ship, i_target)). At i = 0.1°, a 177° raw RAAN error becomes 0.31° effective — well within the 2° tolerance. This prevents spuriousplane_changefallback from destroying near-equatorial orbits. - SOI guard: Retrograde thrust if apoapsis approaches Hill sphere boundary.
- Corrective orbit transfer: When the phase angle is small but the orbit doesn’t match (
not ap_pe_converged), the phase mode transitions totransfer_planfor a corrective Hohmann transfer withdeparture_immediate = "true". Drift-compensated threshold: the corrective transfer uses the same drift-compensated logic as phase_coast — it computes the Hohmann phase drift|π − n_target × T_hohmann/2|and only fires when|Δφ| ≤ max(2°, drift + 0.5°). This prevents the corrective Hohmann from introducing more phase drift than the remaining phase angle, which causes cycling (corrective Hohmann → phase drift overshoot → rephasing → corrective Hohmann → …). Favorable drift guard: additionally, the corrective transfer is skipped when the current SMA offset is actively closing the phase angle (favorable drift:phase_angle × (a_ship - a_target) < 0). In this case, the differential mean motion is already closing the gap, and a corrective Hohmann would disrupt the natural convergence. Guarded by aphase_correction_countcounter (maxPHASE_CORRECTION_MAX = 3) to prevent infinite loops. Thedeparture_immediateflag preventsdeparture_waitactive burns from changing the orbit further. Period-scaled tolerance: theap_pe_convergedcheck uses period-scaled tolerance (same formula as circularize exit:a_tol × max(1, √(T_orbit / T_ref))), not standard tolerance. -
Computed phasing orbit: When passive phasing ETA exceeds PHASE_ACTIVE_THRESHOLD_ORBITS × T_target(orbit-scaled, no game-time cap), or when the ship is at the same altitude as the target (Δa ≤ 1m, meaning zero differential mean motion and infinite manual-phasing ETA), the phase mode switches to a computed phasing orbit to accelerate phase closure. The threshold scales purely with orbital period — 2 orbits at LEO (~11,000 game seconds) or at GEO (~170,000 game seconds). A fixed game-time cap would be too aggressive at high orbits (1,800s = 0.02 orbits at 35,000 km), triggering computed phasing for tiny phase angles. ETA estimation uses expected drift rate: the drift rate is computed using max(current_SMA_offset, phase_headroom), not the current offset alone. Right after circularize, the SMA offset is near-zero, giving enormous ETA even for small phase angles (~7° at 35,000 km). Using phase_headroom (the SMA offset that manual phasing will build through pro/retro burns) gives a realistic estimate of how long manual phasing will actually take. Computes the phasing orbit SMA viacompute_phasing_orbit_sma()and re-enterstransfer_planwithtransfer_is_phasing = "true". Transfer-time compensation (#1066): whenmuis provided,compute_phasing_orbit_smaiteratively accounts for the phase shift during the return Hohmann transfer. During the half-ellipse from phasing orbit to target orbit, the target advancesn_target × T_hohmannradians while the ship sweeps π radians. The net offset(n_target × T_hohmann − π)is added to the required coast phase closure, and the SMA is recomputed until convergence (≤3 iterations). This prevents the limit cycle where phasing coast closes the gap but the return transfer re-opens it by the same amount (e.g., ~77° at Callisto). Withoutmu, the function falls back to the coast-only formula (backward compatible). The max phase per orbit scales with altitude:max_deg = min(PHASE_MAX_DEG_LIMIT, MAX_PHASE_PER_ORBIT_DEG × max(1, T_target / PHASE_MAX_DEG_REF_PERIOD)). At LEO (T≈5,500s) this gives 30°/orbit; at GEO/high orbits (T≈84,000s) this scales to 120°/orbit. This prevents multi-orbit phasing at high altitudes from exceeding practical timeouts (e.g., 4 orbits at 35,000 km altitude = 50+ minutes at 100×). This reuses the entire transfer state machine (departure_wait → transfer_burn → transfer_coast → circularize → phase_coast) instead of adding a new sub-state machine. Guarded byphase_active_count(maxPHASE_ACTIVE_MAX_CYCLES = 3) to prevent infinite phasing loops. The same-altitude case prevents routing loops where a degenerate Hohmann transfer (dv≈0) repeatedly circularizes to the same orbit without closing the phase angle.
| Constant | Value | Description |
|---|---|---|
PHASE_HEADROOM_SCALE_ANGLE |
30° | Full headroom at this phase angle or above |
PHASE_SMA_HEADROOM_FACTOR |
5.0 | Maximum SMA headroom (×a_tol) |
PHASE_ECCENTRICITY_FALLBACK_FACTOR |
10.0 | Fallback to transfer_plan when e_err > this × e_tol |
PHASE_ACTIVE_THRESHOLD_ORBITS |
2.0 | Switch to computed phasing if passive ETA > this × target orbital period (no game-time cap) |
MAX_PHASE_PER_ORBIT_DEG |
30.0 | Base max phase angle to close per orbit at LEO (limits SMA offset for stability) |
PHASE_MAX_DEG_LIMIT |
120.0 | Absolute max phase per orbit regardless of altitude |
PHASE_MAX_DEG_REF_PERIOD |
5500.0 | LEO reference orbital period (seconds) for scaling max_deg |
PHASE_ACTIVE_MAX_CYCLES |
3 | Max computed phasing cycles before passive fallback |
APPROACH Phase (Dual-Mode: Closing + Braking)
Time-scale-independent dual-mode approach. All control uses the alignment gate (ALIGNMENT_GATE_DEG = 5°) — zero thrust when misaligned, full thrust when aligned. Physics substep integration ensures continuous force application regardless of tick rate.
Both modes use body-frame forward RCS ([0, 0, 1], +Z = ship nose). Since the attitude controller steers body-forward toward the desired direction (target or retrograde), commanding body-forward naturally tracks the correct ICRF direction through substep integration. No ICRF→body conversion needed — eliminates stale body-frame commands when the ship rotates mid-tick.
Combined Δv budget: Both modes use a combined main+RCS Δv budget for proportional throttling: combined_dv_per_tick = dv_per_tick + rcs_dv_per_tick. A single brake_fraction = min(1.0, desired_dv / combined_dv_per_tick) is applied to both main engine and RCS. This prevents over-braking — without this, each thruster independently tries to cancel the full velocity, causing 2× Δv and oscillation (especially at high time scales where each tick spans many game-seconds).
Closing mode (rel_vel < CLOSING_VEL_THRESHOLD AND distance > 100 m):
- Compute closing velocity (projection of relative velocity toward target)
- Deceleration profile:
target_closing = √(2 × a × d × safety), clamped to[0.5, MAX_CLOSING_VEL] - Set attitude mode TARGET (point toward target)
- If closing_vel < target × 0.8: fire engines proportionally using combined budget (gated by alignment)
brake_fraction = min(1.0, vel_deficit / combined_dv_per_tick)if aligned within gate, else 0- Main engine:
brake_fraction, RCS body-forward:brake_fraction - If closing_vel ≥ target: coast (thrust = 0, RCS = 0)
Braking mode (rel_vel ≥ CLOSING_VEL_THRESHOLD OR distance ≤ 100 m):
- Set attitude mode TARGET_RETROGRADE (point opposite relative velocity)
brake_fraction = min(1.0, rel_vel / combined_dv_per_tick)if aligned within gate, else 0- Main engine:
brake_fraction, RCS body-forward:brake_fraction
Why body-forward RCS works: The attitude controller settles in ~8 seconds regardless of time scale. Body-forward ([0, 0, 1], +Z = ship nose) is always correct once aligned. The alignment gate ensures zero thrust during reorientation (angle > 5°), preventing off-axis burns. Once aligned, full thrust is applied along the correct direction. The physics engine transforms body-frame to ICRF each substep using the current attitude, so the effective ICRF thrust direction tracks the ship’s rotation continuously.
RCS thrust per axis (from ship class config): Cargo Hauler 10 kN, Fast Frigate 4 kN, Long-Range Explorer 6 kN. RCS acceleration is small relative to main engines but sufficient for fine maneuvering at < 2 m/s relative velocity.
Completion: distance < 100 m AND relative velocity < 1 m/s. On completion, RCS translation is zeroed.
| Constant | Value | Description |
|---|---|---|
APPROACH_DECEL_BUDGET |
0.1 m/s² | Deceleration budget for closing profile |
MAX_CLOSING_VEL |
50.0 m/s | Cap on closing velocity |
CLOSING_VEL_THRESHOLD |
20.0 m/s | Switch to closing mode when Δv below this |
APPROACH_STOPPING_SAFETY |
0.5 | Fraction of distance reserved as stopping margin |
Brachistochrone Strategy (Direct Continuous-Thrust)
Brachistochrone skips the orbit-matching pipeline entirely. The ship flies a direct trajectory to the target using ZEM/ZEV (Zero-Effort-Miss / Zero-Effort-Velocity) feedback guidance — the standard closed-loop guidance law for powered rendezvous.
Phase Flow
_start_maneuver → transfer_plan (brachistochrone branch) → brachistochrone → approach → complete
For cross-body transfers where the ship cannot decelerate within the target body’s SOI (low-TWR ships):
_start_maneuver → transfer_plan → brachistochrone → capture → circularize → phase → approach → complete
No plane_change, no departure_wait, no transfer_burn/coast. The transfer_plan phase computes fuel/time estimate and initializes brach_tgo, then immediately transitions to brachistochrone. The brachistochrone phase runs ZEM/ZEV guidance each tick. For same-body or high-TWR cross-body transfers, transitions directly to approach when within approach distance and velocity. For low-TWR cross-body transfers, transitions to capture upon SOI entry (see Capture-First Arrival).
Guidance Law: ZEM/ZEV Feedback Guidance (E-Guidance)
Each tick, the guidance law computes a commanded acceleration vector:
a_cmd = 6 × ZEM / tgo² + 2 × ZEV / tgo
Where:
- ZEM (Zero-Effort-Miss) = position error if both ships coast from now to
tgounder gravity only (Kepler propagation) - ZEV (Zero-Effort-Velocity) = velocity error if both ships coast from now to
tgounder gravity only - tgo = time-to-go (estimated remaining transfer time, decremented each tick)
Algorithm each tick:
- Compute achievable flight-time estimate:
t_est = brachistochrone_time_estimate(distance, accel) - Decrement
tgobydt_game, floor atmax(t_est, 1.0)(adaptive floor prevents oscillation) - Kepler-propagate ship and target positions/velocities forward by
tgo(gravity only, no thrust) - Compute
ZEM = target_coast_pos - ship_coast_posandZEV = target_coast_vel - ship_coast_vel - Compute
a_cmd = 6 × ZEM / tgo² + 2 × ZEV / tgo - Set thrust direction to
normalize(a_cmd)viaATTITUDE_DIRECTIONmode - Set throttle to
clamp(|a_cmd| / max_accel, 0, 1)
Key properties:
- No explicit flip logic — ZEM/ZEV naturally reverses thrust direction as the ship approaches intercept
- Handles gravity correctly via Kepler propagation (no Hohmann paradox)
- Converges to matched position AND velocity at
tgo → 0 - Throttle varies continuously between 0 and 1
ATTITUDE_DIRECTION Mode
ZEM/ZEV computes an arbitrary thrust direction each tick. A new attitude mode ATTITUDE_DIRECTION accepts a Vec3 direction and holds it until updated. Added to the physics proto as enum value 14 with an optional direction field on SetAttitudeModeRequest.
Kepler Propagation
ZEM/ZEV requires predicting where ship and target will be at t + tgo under gravity only. Uses a universal-variable Kepler propagator (Curtis Algorithm 3.4) with existing Stumpff functions (_stumpff_c2, _stumpff_c3).
tgo Management
- Initialized to
brachistochrone_time_estimate(distance, accel) × 1.5(50% margin) - Decremented by
dt_gameeach tick - Adaptive floor:
tgonever drops below0.5 × brachistochrone_time_estimate(distance, accel)— this prevents tgo from falling far below the achievable flight time, which would cause enormous clamped acceleration commands, overshoots, and oscillation. The 0.5 factor balances stability (higher floor) vs convergence speed (lower floor) - Absolute minimum: 1.0 second (prevents divide-by-zero)
Approach Handoff
When distance < BRACH_APPROACH_DIST, transition to existing approach phase if either:
rel_vel < BRACH_APPROACH_VEL(velocity low enough), ORstopping_dist < distance × 0.8wherestopping_dist = rel_vel² / (2 × max_accel)(ship can stop within 80% of remaining distance)
The stopping-distance check allows high-thrust ships arriving fast (e.g., 200 m/s at 1 km) to hand off early — the approach phase handles any velocity correctly with retrograde braking and proportional throttle.
Stall Detection
When brach_elapsed > brach_total_time × 2 and distance < BRACH_APPROACH_DIST × 10, the brachistochrone has been oscillating near the target far longer than planned without reaching the normal approach handoff distance. Force transition to approach phase — its retrograde braking mode will kill the oscillating velocity, then closing mode covers the remaining distance.
Fuel Depletion
Checked BEFORE the normal approach handoff because with no fuel, the approach phase cannot decelerate (it requires rel_vel < 1.0 m/s for completion, stricter than the dock tolerance).
When fuel < 1.0 kg, three-tier fallback:
- Dock: If
distance < 500 mANDrel_vel < 10 m/sANDdock_on_arrival == true→ force-complete maneuver (triggers auto-dock via_complete_maneuver) - Approach: If
distance < BRACH_APPROACH_DIST(1 km) → transition to approach phase (approach can complete/dock if velocity is low enough) - Coast: Otherwise, thrust set to 0, attitude set to TARGET_RETROGRADE — normal approach handoff still possible if relative velocity decays
Constants
| Constant | Value | Description |
|---|---|---|
BRACH_APPROACH_DIST |
1,000 m | Hand off to approach phase (1 km) |
BRACH_APPROACH_VEL |
50.0 m/s | Max velocity for approach handoff |
Maneuver Hash Fields
| Field | Type | Description |
|---|---|---|
brach_t_estimate |
string (seconds) | Estimated transfer time |
brach_tgo |
string (seconds) | Current time-to-go |
Status Text Format
| State | Format |
|---|---|
| guidance | "Brachistochrone (Xkm, tgo Ys)" |
| approach | "Brachistochrone — approach" |
| no fuel | "Brachistochrone — no fuel (Xkm, Ym/s)" |
| periapsis safety | "Brachistochrone (Xkm, tgo Ys) [periapsis safety]" |
Periapsis Protection
Low-TWR ships (e.g., Cargo Hauler at TWR ~0.28) can sink into a planet during brachistochrone rendezvous. When the ZEM/ZEV commanded acceleration exceeds the ship’s maximum (throttle saturates at 1.0), the orbit can decay because the ship can’t overcome gravity while following the guidance direction.
After computing the ZEM/ZEV a_cmd direction, the guidance checks the ship’s current orbital periapsis relative to the reference body. If periapsis falls below the safe altitude, the command direction is smoothly blended toward radial-outward.
Parameters (from existing compute_periapsis_barrier_params()):
min_rp: body radius + safe orbit altitude (e.g., Earth surface + 200 km)rp_safe:min_rp+ margin (scaled by body radius)
Blend behavior:
| Condition | Behavior |
|---|---|
rp >= rp_safe |
Pure ZEM/ZEV guidance (no modification) |
rp_safe > rp > min_rp |
Remove inward-radial component from a_cmd direction; add outward-radial component proportional to alpha = (rp_safe - rp) / (rp_safe - min_rp); force throttle >= alpha |
rp <= min_rp |
Full radial-outward thrust at maximum throttle (survival mode) |
Near-target exception: When distance < 5000 m (5 km from the rendezvous target), periapsis protection is skipped entirely. The target is in a stable orbit — flying near it is inherently safe. Without this exception, braking near the station temporarily lowers the computed periapsis (less orbital velocity → lower periapsis), triggering radial-outward safety thrust that pushes the ship away from the station, causing oscillation.
Outbound-past-periapsis exception: On hyperbolic or high-eccentricity orbits where the ship is outbound (radial velocity > 0) and above periapsis (r > rp), the computed periapsis is in the past — the ship will never revisit it. In this case, periapsis protection is skipped entirely. This prevents the safety override from fighting ZEM/ZEV deceleration during brachistochrone rendezvous on escape trajectories.
Stopping-distance exception: On unbound (hyperbolic) orbits only, when a ship is moving inward (radial velocity < 0), the guard computes the radial stopping distance: d_stop = v_radial² / (2 × max_accel). If d_stop < 0.5 × (r - min_rp), the guard is skipped — the ship can decelerate before reaching the danger zone. This only applies to unbound orbits because bound (elliptic) orbits will always reach periapsis regardless of current radial velocity; gravity accelerates the ship as it falls inward. Example: a Fast Frigate on a hyperbolic approach to Luna at 33 km/s from 55,000 km needs ~15,000 km to stop (vs 26,000 km threshold) — guard skipped. A ship in a bound Mars orbit with low current radial velocity — guard remains active (bound orbit, will reach periapsis).
Rendezvous-completes-first exception: On bound (elliptic) orbits, if the time to periapsis exceeds the brachistochrone time-to-go (tgo), the rendezvous maneuver will alter the orbit before periapsis is reached — the projected impact will never occur. In this case, periapsis protection is skipped. This prevents the guard from wasting Δv on survival thrust against a trajectory the ship will never fly, which is especially costly when chaser and target are in different orbital planes (e.g., retrograde vs prograde), where the guard diverts thrust away from the velocity reversal needed to match the target. Computed via Kepler’s equation: t_periapsis = time_to_anomaly(nu_current, 0, a, e, mu). A 20% safety margin is applied: protection is only skipped when tgo × 1.2 < t_periapsis.
Algorithm:
- Compute periapsis
rpfrom ship’s body-relative state vector - Compute
min_rpandrp_safefrom reference body parameters - If
rp < rp_safe: a. Compute radial velocityv_r = dot(v_rel, r_hat)and current radiusrb. Ifrp < randv_r > 0: skip protection (periapsis is in the past) c. Ifv_r < 0and orbit is unbound (specific energy > 0): computed_stop = v_r² / (2 × max_accel). Ifd_stop < 0.5 × (r - min_rp): skip protection (ship can decelerate in time) c2. If orbit is bound (specific energy < 0) andtgo > 0: computet_periapsisvia Kepler’s equation. Iftgo × 1.2 < t_periapsis: skip protection (rendezvous completes before periapsis) d. Compute blend factoralpha = clamp((rp_safe - rp) / (rp_safe - min_rp), 0, 1)e. Compute radial-outward unit vectorr_hatfrom body center to ship f. Remove any inward-radial component from guidance direction (project out negative radial) g. Addalpha × r_hatto guidance direction h. Renormalize direction i. Forcethrottle >= alpha - Status text appends
" [periapsis safety]"when active
Body Obstruction Avoidance
When the ZEM/ZEV guidance direction passes through a celestial body (e.g., the departure body is between the ship and the target), the burn direction must be deflected to avoid impact. This applies to:
- Interplanetary phase: The departure body (first element of SOI chain) may obstruct the path to the target body when the ship escapes on the far side.
- Brachistochrone phase: On cross-body transfers, the reference body may obstruct the path to the target.
Algorithm (deflect_around_body()):
- Compute clearance radius:
r_clear = body_radius + safe_orbit_altitude(fromSAFE_ORBIT_ALTITUDES) - Compute ray-sphere closest approach:
d_closest = |ship_rel × dir|(cross product magnitude;diris the unit burn direction,ship_relis ship position relative to body center) - Check body is ahead:
dot(dir, -ship_rel_hat) > 0(direction has a component toward the body) - If
d_closest < r_clearAND body is ahead: a. Remove the inward-radial component from the direction (project out the component toward the body) b. If the remaining perpendicular magnitude is too small (< 0.1), use the prograde fallback (ship_vrelnormalized) or orbit-normal (ship_rel × ship_vrel) as a tiebreaker c. Renormalize the direction vector d. Forcethrottle >= 0.5to actively steer clear - Otherwise, return the original direction unchanged
Interplanetary phase: Applied after ZEM/ZEV direction normalization. Uses the departure body (chain[0]) position/velocity from body_positions. Body-relative vectors computed from ctx.ship_pos/vel minus departure body pos/vel.
Brachistochrone phase: Applied after the prograde-escape block and before periapsis protection. Only active when cross_body=True and not prograde_escape. Uses the existing ship_rel and ship_vrel body-relative vectors.
Status text: Appends " [obstruction avoidance]" when active.
Bang-Coast-Bang Mode
For long-range transfers where pure brachistochrone requires more Δv than the ship has available, a bang-coast-bang profile splits the transfer into three sub-phases: burn1 (accelerate), coast (engine off), burn2 (brake). This reduces total Δv at the cost of longer transfer time.
Coast ratio c (0–1): fraction of distance spent coasting. c=0 = pure bang-bang (burn1 → burn2 with no coast phase).
Auto-compute: When coast_ratio is "auto" (default), the tick-engine computes the minimum coast ratio from the ship’s Δv budget (with 10% safety reserve):
- Compute Δv available via Tsiolkovsky:
Δv_avail = Isp × g₀ × ln(m_total / m_dry) - Compute pure brachistochrone Δv:
Δv_brach = 2 × √(a × d) - If
Δv_brach ≤ Δv_avail × 0.9: usec = 0(pure brachistochrone) - Otherwise:
k = (Δv_avail × 0.9 / 2)² / (a × d), thenc = (1 - k) / (1 + k), clamped to [0, 0.9999]
Manual override: Player can set an explicit coast ratio (0–0.9999) via the rule editor UI.
Fuel budget mode: When max_fuel_fraction is set (0.0–1.0 exclusive/inclusive), the BCB planner limits fuel usage to max_fuel_fraction × fuel_capacity, capped at current fuel level. This allows players to conserve fuel at the cost of longer transfer time (higher coast ratio). Mutually exclusive with explicit coast_ratio — if both are specified, max_fuel_fraction takes precedence. If the fuel budget is too small for the transfer (planner returns None), the maneuver is rejected immediately with an infeasibility status message and aborted.
Max transfer time mode: When max_transfer_time is set (positive number, seconds), a binary search solver finds the minimum fuel needed to complete the BCB transfer within the time budget. The solver exploits monotonicity: more fuel → shorter total time. It binary-searches on fuel amount (0 to available fuel) for 60 iterations to find the minimum where total_time ≤ max_time. m0 stays constant (ship carries all fuel; budget just limits burns). If the time budget cannot be met even with all available fuel, the maneuver is rejected as infeasible. When both max_fuel_fraction and max_transfer_time are set, the solver computes fuel from each constraint independently and uses the lower (more restrictive) value.
Efficient budget mode: When budget_mode is "efficient", the planner automatically computes the Hohmann transfer time and uses it as max_transfer_time, so the minimum-fuel solver finds the least fuel needed for a transfer that takes no longer than a Hohmann transfer would. This gives the most fuel-efficient brachistochrone transfer. The computation happens at maneuver execution time (not rule creation time) so it reflects current orbital geometry. For same-SOI transfers, uses local semi-major axes: hohmann_transfer_time(a_ship, a_target, mu_local). For cross-SOI transfers (ship and target in different SOIs), finds the common parent body via find_common_parent(), computes each object’s distance from the common parent using absolute ICRF positions, and uses the common parent’s mu: hohmann_transfer_time(r_ship_to_parent, r_target_to_parent, mu_parent). Example: Luna → Earth transfer uses Earth as common parent with r1 ≈ 384,400 km → Hohmann time ≈ 5 days. Falls back to no max_transfer_time (full fuel) if body positions or parent mass are unavailable.
Fastest budget mode: When budget_mode is "fastest", the trajectory planner optimizes for minimum transfer time instead of minimum Δv. The departure window scan uses aggressive TOF multipliers [0.3, 0.4, 0.5, 0.6, 0.7] × Hohmann TOF and sorts candidates by TOF (shortest first). For cross-SOI transfers, "fastest" uses all available fuel (Lambert Δv serves as a floor to ensure enough for escape and capture, but not a cap). For same-SOI transfers, "fastest" uses all available fuel with no max_transfer_time constraint, giving the brachistochrone planner maximum burn capability for minimum transfer time.
Lambert-based fuel budget (cross-SOI): For cross-SOI brachistochrone transfers (ship and target in different SOIs), the fuel budget is derived from the Lambert-computed escape_dv + capture_dv via _compute_cross_soi_plan(), which is called before the BCB planner so that the Δv data is available for budgeting. The total Δv escape_dv + capture_dv represents the full departure and arrival cost. The corresponding fuel is computed via Tsiolkovsky: lambert_fuel = m₀ × (1 - exp(-(escape_dv + capture_dv) / v_e)), capped at available fuel. The Lambert solver uses the Hohmann TOF estimate, so the resulting Δv represents the minimum-energy transfer cost. Efficient mode: fuel_budget is set to lambert_fuel — the Lambert Δv with Hohmann TOF IS the efficient budget, replacing the same-SOI _min_fuel_for_time solver which is physically meaningless across gravity wells. Fastest mode: fuel_budget keeps all available fuel; lambert_fuel acts as a floor only (raised if below Lambert, never lowered). Other modes (e.g., max_fuel_fraction): lambert_fuel acts as a floor — the budget is raised to at least lambert_fuel to ensure enough fuel for escape, but never lowered. No 2× multiplier is needed because escape_dv + capture_dv is the total Δv (not half), and cross-SOI execution uses escape→interplanetary→capture phases rather than the BCB burn1/coast/burn2 split. No 1.1× gravity loss margin is needed because escape_delta_v() uses vis-viva which correctly accounts for the hyperbolic excess velocity. The Lambert budget is stored as lambert_dv_budget in the maneuver hash for diagnostics.
Lambert parent-child fix: When the departure or arrival body IS the common parent (e.g., Earth→Luna where common parent = Earth), the body’s position relative to itself is (0,0,0) — a degenerate Lambert input. Fix: when the departure body equals the common parent, use the ship’s actual position (not body center) as r1; when the target body equals the common parent, use the target’s actual position as r2. This correctly represents the ship/target’s distance from the common parent’s center. Body velocities are similarly adjusted.
Efficient mode skip for cross-SOI: The _min_fuel_for_time distance-based minimum fuel solver is skipped for cross-SOI transfers in efficient mode. The straight-line distance solver is physically meaningless for cross-SOI transfers where the ship must traverse a gravity well. The Lambert-based fuel budget provides the correct minimum.
Fallback: When Lambert fails (solver returns None), a simple v_esc - v_ship fallback is used (no 1.1× margin, no 2× multiplier). This should rarely trigger after the parent-child fix.
Gravity-corrected distance estimation: The BCB planner replaces straight-line Euclidean distance with a Kepler-propagated miss distance for computing burn times, coast duration, and fuel budgets. The ship and target are Kepler-propagated forward by t_est (the constant-acceleration brachistochrone time estimate) using the same frame selection as ZEM/ZEV guidance: same-SOI → reference body frame with local mu; cross-SOI → common parent frame with parent mu (via find_common_parent). The propagated separation replaces ctx.distance in all downstream BCB planner inputs (_tsiolkovsky_bcb_plan, _min_fuel_for_time, status text). If propagation fails or returns non-finite values, the planner falls back to straight-line distance. The corrected distance is stored as brach_gravity_corrected_dist in the maneuver hash for diagnostics.
Tsiolkovsky BCB planner: The transfer plan uses Tsiolkovsky rocket dynamics (variable acceleration due to mass change) to compute asymmetric burn durations. burn1 is longer than burn2 because the ship is heavier during acceleration (more fuel) and lighter during braking. The planner uses binary search to find the burn1 duration where d_burn1 + d_coast + d_burn2 = distance, accounting for the exponential velocity profile v(t) = v_e × ln(m₀/(m₀ - ṁt)) and corresponding distance integrals. Fuel is split equally by mass between burn1 and burn2. The result is stored as separate brach_burn_time (burn1) and brach_burn2_time (burn2) fields.
Alignment dead-time budget: The BCB planner accepts an optional dead_time parameter representing the total time lost to attitude slews during which the alignment gate prevents thrusting. Dead time is split equally across burn1 and burn2: max_burn1_time = fuel / (2 × ṁ) - dead_time / 2. Dead time is computed at the call site:
T_settle = 4/ω_nper direction change (from attitude controller natural frequency)n_changes = max(1, t_rough / step_hold_interval)wherestep_hold_interval = 60sdead_time = n_changes × T_settle
This ensures the fuel budget accounts for the time cost of the alignment gate, closing the gap between planned and actual fuel consumption.
Dynamic braking trigger: Instead of using pre-computed timing for the coast-to-burn2 transition, the tick engine continuously monitors remaining Δv capacity during coast. When dv_remaining ≤ rel_vel × 1.15, burn2 begins immediately. The 15% margin accounts for gravity acceleration during the braking burn itself. This replaces the previous time-based transition and dynamic fuel replan, which couldn’t account for gravitational perturbations that alter coast velocity. A fallback time-based transition (elapsed >= burn_time + coast_time) fires if the fuel trigger never activates.
Burn2 abort conditions: During burn2, the maneuver is aborted if any of these conditions hold:
- Time overrun:
elapsed > total_time × 3.0— the plan has been tripled, indicating a failed trajectory - Fuel exhaustion with large Δv deficit:
fuel < 1.0is already handled (coasts with retrograde attitude), but ifremaining_dv < rel_vel × 0.15anddistance > BRACH_APPROACH_DIST, the trajectory is infeasible — abort rather than waste remaining fuel - Divergence: If
distanceis increasing while in burn2 andelapsed > total_time × 1.5, the ship has overshot — abort
On abort, the maneuver is deactivated, thrust is set to zero, attitude is set to retrograde (relative to target), and a status message is sent to the client.
Timing formulas: The Tsiolkovsky planner computes asymmetric burn durations via binary search. Approximate relationships (given coast ratio c, distance d, acceleration a):
| Parameter | Formula |
|---|---|
| Burn1 time | Computed by Tsiolkovsky planner (binary search) |
| Burn2 time | Shorter than burn1 (ship is lighter, decelerates faster) |
| Coast time | d - d_burn1 - d_burn2 at coast velocity |
| Total time | t_burn1 + t_coast + t_burn2 |
| Total Δv | v_e × ln(m₀/m_f1) + v_e × ln(m₀₂/m_f2) |
Sub-phase flow:
- burn1 (
elapsed < burn_time): Base throttle = 1.0, gated by alignment: ifalignment_angle > ALIGNMENT_GATE_DEG(5°), throttle = 0. This prevents misaligned thrust from perturbing the trajectory when the attitude controller hasn’t yet converged (guidance-attitude decoupling). Dynamic braking guard: ifdv_remaining ≤ rel_vel × 1.5(andrel_vel > 100 m/s,elapsed > 2 × dt_game), transition directly to burn2, skipping coast. This prevents burn1 from building excessive velocity in a gravity well where the BCB straight-line plan underestimates gravity’s contribution. The 1.5× margin (vs coast’s 1.15×) provides extra headroom since burn1 is still accelerating. Direction depends on orbital state:- Bound orbit (specific orbital energy < 0): Override direction to prograde (velocity direction relative to reference body). ZEM/ZEV points toward the interplanetary target, which is a fixed direction in the Sun frame. In a parking orbit this alternates between local prograde and retrograde as the ship orbits, producing near-zero net escape Δv and degrading the orbit. Prograde maximizes the Oberth effect and efficiently escapes the gravity well. During prograde escape, step-and-hold is bypassed — direction tracks velocity every tick since the orbital velocity direction changes continuously.
- Escape hysteresis: Once prograde escape activates, it stays active until the ship exceeds 1.2× escape velocity (
v² > 1.44 × 2μ/r). Without hysteresis, the ship oscillates between bound/unbound states near escape velocity — ZEM/ZEV commands a non-prograde direction, the ship decelerates back to bound, prograde escape re-engages, etc. The 20% margin ensures the ship is firmly on an escape trajectory before ZEM/ZEV takes over. State tracked viabrach_prograde_escapein the maneuver hash. - Unbound orbit (specific orbital energy ≥ 0, past hysteresis threshold): Use ZEM/ZEV direction. The ship has escaped the local body and ZEM/ZEV steers toward the interplanetary intercept point.
- coast (after burn1 completes): Throttle = 0. Before coast midpoint: hold current direction. After midpoint: command
ATTITUDE_TARGET_RETROGRADEto prepare for braking. Transition to burn2 via dynamic braking trigger (dv_remaining ≤ rel_vel × 1.15), with time-based fallback. - burn2 (triggered dynamically or by time fallback): ZEM/ZEV guidance for both direction and throttle magnitude. Throttle is
min(a_cmd / max_accel, 1.0), gated by alignment (zero if > 5°). Unlike burn1, throttle is NOT forced to 1.0. Additionally, a velocity overshoot guard caps throttle so the applied Δv per tick never exceeds the current relative velocity:throttle = min(throttle, rel_vel / (max_accel × dt_game)). This prevents the ship from overshooting zero velocity and oscillating at high time scales (e.g. at 50x, dv_per_tick=3750 m/s would vastly overshoot a 6 m/s relative velocity without the guard).
Time-scale-aware tgo management: The tgo (time-to-go) floor includes dt_game to ensure ZEM/ZEV never plans for less than one guidance update cycle: tgo = max(tgo - dt_game, t_est × 0.5, dt_game, 1.0). At high time scales, this prevents wildly aggressive acceleration commands from small tgo values that the ship can’t correct until the next tick.
Guidance-attitude decoupling (two mechanisms):
-
Alignment gate: Without alignment gating, misaligned thrust perturbs the trajectory, which changes the ZEM/ZEV direction, which causes the attitude controller to chase a moving target — a positive feedback loop that causes persistent spin. The alignment gate breaks this loop: when the ship is misaligned beyond 5°, thrust is zero, trajectory perturbation is eliminated, and the attitude controller can settle before thrust resumes. This also eliminates the fuel efficiency losses of the previous cosine scaling approach — fuel is only burned when thrust is useful. This is consistent with all other guidance modes (Q-law, circularize, capture) which use the same alignment gate pattern.
-
Step-and-hold direction updates: Instead of updating the ZEM/ZEV direction every tick (which forces continuous rotation), the direction is held fixed and updated periodically. The update interval scales with tgo:
clamp(tgo/100, 10, 60)seconds. Between updates the ship aligns and thrusts at full efficiency. When spinning (|ω| > ω_n/2), the direction is frozen so the controller can damp the spin. The direction and its age are stored in the maneuver hash (brach_dir_x/y/z,brach_dir_age).
Escape velocity guard (same-SOI only): After computing the BCB plan, if BOTH conditions are met, the brachistochrone strategy is abandoned for express orbital transfer (5× apogee factor):
v_coast > 0.8 × v_escape— the BCB would go hyperbolicBCB_Δv > 20 × orbital_Δv_estimate— the BCB is wildly disproportionate to the actual orbit change needed (whereorbital_Δv_estimate = 2 × |v_circ(r_ship) - v_circ(r_target)|)
Both criteria prevent false triggers: condition 1 alone would fire on large orbit changes (LEO→GEO) where brachistochrone works fine despite high v_coast; condition 2 alone would fire on cross-SOI transfers or deep-space maneuvers. Together they identify same-body transfers where the straight-line BCB distance (dominated by orbital phase difference) vastly overestimates the orbital Δv needed. Express keeps the transfer orbit bound while being fast (~80% time reduction vs Hohmann). The fallback is logged as a warning. Does not apply to cross-SOI transfers.
Approach handoff: Same criteria as pure brachistochrone — applies regardless of sub-phase.
Ship class ISP (seconds, used for Δv computation):
| Ship Class | Isp (s) |
|---|---|
cargo_hauler |
15,000 |
fast_frigate |
20,000 |
long_range_explorer |
50,000 |
Maneuver hash fields (when c > 0):
| Field | Type | Description |
|---|---|---|
brach_coast_ratio |
string (float) | Coast ratio (0–0.9999) |
brach_prograde_escape |
string (“0”/”1”) | Prograde escape hysteresis flag |
brach_burn_time |
string (seconds) | Duration of burn1 phase |
brach_burn2_time |
string (seconds) | Duration of burn2 phase (shorter, ship is lighter) |
brach_coast_time |
string (seconds) | Duration of coast phase |
brach_total_time |
string (seconds) | Total transfer time |
brach_elapsed |
string (seconds) | Time elapsed since transfer start |
brach_sub_phase |
string | Current sub-phase: "burn1", "coast", or "burn2" |
Status text format:
| Sub-phase | Format |
|---|---|
| burn1 | "Brachistochrone BURN 1 (Xkm, tgo Ys)" |
| coast | "Brachistochrone COAST (Xkm, tgo Ys)" |
| burn2 | "Brachistochrone BURN 2 (Xkm, tgo Ys)" |
Timeline UI: Horizontal bar showing BURN1/COAST/BURN2 segments with widths proportional to burn_time, coast_time, burn_time. White vertical line indicates progress (elapsed / total_time). Phase label and remaining time displayed below.
Cross-Body Transfers
When the ship and target orbit different reference bodies (e.g., ship at Earth, target at Luna), ZEM/ZEV Kepler propagation uses their lowest common ancestor (LCA) body’s frame and gravitational parameter instead of the ship’s current reference body.
Common parent resolution (find_common_parent(body_a, body_b)):
- Walks the
BODY_PARENTShierarchy from each body to the root (Sun) - Returns the first ancestor shared by both bodies
- Examples: Earth↔Luna → Earth. Earth↔Mars → Sun. Io↔Europa → Jupiter.
Propagation logic each tick:
- Determine target’s reference body via
_find_reference_body(target_pos, body_positions) - If target ref body differs from ship ref body:
a. Find common parent via
find_common_parent()b. Compute both ship and target positions/velocities relative to common parent c. Use common parent’s μ for Kepler propagation - If same body: use existing body-relative propagation (unchanged)
ship_rel/ship_vrelremain relative to ship’s current ref body for periapsis protection
SOI transition handling (#494): The central maneuver dispatcher (_execute_maneuver_tick) detects SOI transitions by comparing the ship’s current reference body (from _ref_body in context, computed by _find_reference_body()) against the maneuver’s stored ref_body. When they differ:
- Update
maneuver["ref_body"]to the new body - Reload body position, velocity, mass, radius, and μ from the new body’s data
- Recompute orbital elements relative to the new body
- Log the transition with old and new body names
This runs before any phase-specific handler, so all maneuver types (circularize, set_inclination, rendezvous/brachistochrone) automatically get SOI transition support. Same-SOI maneuvers are unaffected — the comparison never triggers.
Status text: Cross-body transfers display "Brachistochrone → {target_ref_body} (distance, tgo)".
Why this works: ZEM/ZEV recomputes every tick, so inaccuracies self-correct. The common parent’s μ is the dominant gravitational influence during the transit phase. Periapsis protection remains body-relative and handles safety near departure/arrival bodies.
SOI Chain Computation
soi_chain(source_body, target_body) in automation_orbital.py computes the ordered sequence of SOI transitions a ship must traverse to reach a target in a different SOI.
Algorithm:
- Walk
BODY_PARENTSfromsource_bodyto the root (Sun), collecting ancestors - Walk
BODY_PARENTSfromtarget_bodyto the root, collecting ancestors - Find the lowest common ancestor (LCA)
- Build chain:
source_body→ ancestors up to LCA → ancestors down totarget_body
Examples:
soi_chain("Earth", "Mars")→["Earth", "Sun", "Mars"]soi_chain("Luna", "Mars")→["Luna", "Earth", "Sun", "Mars"]soi_chain("Io", "Europa")→["Io", "Jupiter", "Europa"]soi_chain("Earth", "Luna")→["Earth", "Luna"](parent→child, no intermediate)soi_chain("Earth", "Earth")→["Earth"](same body)
Returns: List of body names from source to target, inclusive.
Cross-SOI Transfer Planning (#495)
When a rendezvous maneuver starts and the ship and target are in different SOIs, the _phase_transfer_plan() handler detects the mismatch and computes a multi-phase transfer plan using patched-conic approximation. This detection is strategy-agnostic — all strategies (hohmann, fast, express, parabolic, lambert, brachistochrone) use the same cross-SOI planning logic and route through the same escape → interplanetary → capture pipeline.
Detection (in _phase_transfer_plan(), before strategy-specific logic):
- Determine target’s reference body via
_find_reference_body(target_pos, body_positions) - Compare with ship’s
ref_body_name - If same SOI → proceed with existing single-body logic (no change)
- If different SOI → compute cross-SOI plan via
_compute_cross_soi_plan()
Planning (for 2-body chains like Earth → Sun → Mars):
The shared helper _compute_cross_soi_plan(ctx, maneuver, body_positions) computes:
- Find common parent via
find_common_parent(ship_ref_body, target_ref_body) - Compute positions/velocities of both ship’s ref body and target’s ref body relative to common parent
- Call
plan_transfer()fromtrajectory_planner.pyfor optimized departure window search with forward integration verification (see Trajectory Planning spec) - If planner succeeds, use planner results for escape/capture Δv; otherwise fall back to single Lambert
- escape_delta_v bypass: When
ref_body == common, Lambert Δv IS the departure Δv —escape_delta_v()is skipped (avoids double-counting). Same for capture whentarget_ref == common. - Solve Lambert in common parent frame:
lambert_solve(r1_body, r2_body, tof, mu_parent) - Compute departure v_infinity:
v_inf_dep = |v_lambert_dep - v_ship_body|(ship’s body velocity relative to parent) - Compute arrival v_infinity:
v_inf_arr = |v_lambert_arr - v_target_body|(target’s body velocity relative to parent) - Compute escape delta-v (if ref_body ≠ common):
escape_delta_v(r_parking, v_inf_dep, mu_ship_body) - Compute capture delta-v (if target_ref ≠ common):
escape_delta_v(r_target_parking, v_inf_arr, mu_target_body)
Returns True if cross-SOI plan was stored, False if not cross-SOI or planning failed.
Maneuver hash fields stored:
| Field | Type | Description |
|---|---|---|
transfer_type |
string | "cross_soi" for cross-SOI transfers |
soi_chain |
string | Comma-separated body names, e.g. "Earth,Sun,Mars" |
current_phase_index |
string (int) | Index into SOI chain (0 = at source) |
escape_v_inf |
string (m/s) | Required v_infinity to match interplanetary transfer |
escape_v_inf_vec |
string (JSON) | Departure v_infinity vector in parent frame |
capture_v_inf |
string (m/s) | Arrival v_infinity at target body |
capture_v_inf_vec |
string (JSON) | Arrival v_infinity vector in parent frame |
interplanetary_tof |
string (seconds) | Planned interplanetary time of flight |
escape_dv |
string (m/s) | Required Δv for escape burn |
capture_dv |
string (m/s) | Required Δv for capture burn |
planner_verified |
string (“True”/”False”) | Whether forward integration confirmed SOI entry (#653) |
planner_departure_offset |
string (seconds) | Optimal departure delay from current time (#653) |
Phase flow (cross-SOI, all strategies):
transfer_plan → escape → interplanetary → capture → circularize → phase → approach → complete
After capture and circularize, the existing same-SOI rendezvous pipeline (phase, approach) handles final rendezvous regardless of the original strategy.
The cross-SOI plan enriches the existing brachistochrone phase with escape/capture energy data. The brachistochrone phase already handles cross-body propagation via ZEM/ZEV in the common parent frame (#494). The stored v_infinity data enables:
- Pre-flight feasibility checks (enough fuel for escape + transfer + capture)
- Status text showing interplanetary context
- Future ESCAPE phase implementation (#496)
Feasibility check: After computing the plan, verify total Δv budget:
dv_escape = escape_delta_v(r_parking, v_inf_dep, mu_ship_body)dv_capture = escape_delta_v(r_target_parking, v_inf_arr, mu_target_body)dv_available = Isp × g₀ × ln(m_total / m_dry)- If
dv_escape + dv_capture > dv_available: abort with status “Transfer infeasible” (ship cannot cover minimum escape + capture Δv)
Capture fuel reservation (cross-SOI): Before computing the BCB plan, reserve fuel for the capture burn at the destination. capture_fuel = m_dry × (e^(capture_dv / ve) - 1). The BCB planner receives fuel_budget = min(fuel_budget, fuel - capture_fuel), ensuring the escape/transit portion never consumes the capture budget. The capture_fuel_reserved field is stored in the maneuver for status display.
Same-SOI transfers: Completely unchanged — the detection check gates all cross-SOI logic.
Escape Energy Computations
Utility functions in orbital.py for computing hyperbolic escape parameters. Used by cross-SOI transfer planning (#495) and the ESCAPE phase (#496).
compute_v_infinity(semi_major_axis, mu): Hyperbolic excess velocity — the speed a ship retains at infinity (SOI boundary) on an escape trajectory.
- Formula:
v_inf = √(μ / |a|)whereais the (negative) semi-major axis of a hyperbolic orbit - Returns 0.0 for non-escape orbits (
a ≥ 0)
compute_c3(v_infinity): Characteristic energy — the square of v_infinity. Standard measure of escape energy in astrodynamics.
- Formula:
C3 = v_inf² - Equivalent:
C3 = -μ / a(for hyperbolic orbits) - Units: m²/s²
escape_delta_v(r_parking, v_inf_required, mu): Delta-v to transition from a circular parking orbit to a hyperbolic escape with target v_infinity.
- Parking orbit velocity:
v_circ = √(μ / r) - Required velocity at periapsis (vis-viva):
v_depart = √(2μ/r + v_inf²) - Delta-v:
Δv = v_depart - v_circ - This is the minimum (tangential) burn; real burns may differ due to plane changes
v_infinity field in calculate_orbital_elements(): When an escape trajectory is detected (e ≥ 1 or ε ≥ 0), the returned dict includes v_infinity computed from the hyperbolic semi-major axis. For bound orbits, v_infinity is None.
ESCAPE Phase (#496)
When a cross-SOI transfer is detected (#495), the ship must first escape its departure body’s SOI. The ESCAPE phase burns prograde (relative to the departure body) until achieving escape energy, then transitions to the interplanetary transfer phase.
Phase flow (cross-SOI with escape):
transfer_plan → escape → interplanetary → [capture] → [circularize] → [phase] → approach → complete
Pre-flight Δv feasibility (#737): Before entering the ESCAPE phase, all strategies (brachistochrone, hohmann, fast, express, parabolic, lambert) perform a Δv abort gate: if escape_dv + capture_dv > dv_available (computed from the Tsiolkovsky rocket equation), the maneuver is aborted with status "Transfer infeasible — need X m/s, have Y m/s". This prevents ships with insufficient fuel from entering escape burns they cannot complete.
Entry condition: transfer_type == "cross_soi" in maneuver hash. The _phase_transfer_plan() handler transitions to "escape" instead of "brachistochrone" when a cross-SOI plan is present.
Guidance (each tick):
- Compute velocity relative to departure body:
v_rel = v_ship - v_body - Compute specific orbital energy:
E = 0.5 × |v_rel|² − μ/r - Set thrust direction to prograde (relative to departure body):
dir = normalize(v_rel)viaATTITUDE_DIRECTION - Alignment gate: if
alignment_angle > ALIGNMENT_GATE_DEG(5°), throttle = 0 — prevents full-power burns before the attitude controller has settled - Periapsis protection remains active (prevents crashing during low-altitude escape burns)
Exit condition (v∞-aware, #738):
The exit condition depends on whether escape_v_inf is available in the maneuver hash (set by _compute_cross_soi_plan):
When escape_v_inf is present:
- Nested SOI, sub-escape target (
escape_nested_soi == "1"andescape_v_inf² < 2μ/r): Transfer orbit is elliptical — energy will never reach zero. Exit when|v_rel| ≥ escape_v_inf. Theescape_v_infvalue is the total departure velocity in the parent frame. - Sibling SOI (
escape_nested_soi == "0"): Transfer orbit is hyperbolic. Exit whenorbit_energy ≥ 0.5 × escape_v_inf² × 1.05(5% margin). Theescape_v_infvalue is v∞ at infinity. - Nested SOI, above-escape target (
escape_nested_soi == "1"andescape_v_inf² ≥ 2μ/r): Fast brachistochrone transfer where planned velocity exceeds escape. Falls through to standard energy exit:E ≥ 0withv² ≥ 1.2 × 2μ/r.
Fallback (no escape_v_inf): E ≥ 0 with hysteresis v² ≥ 1.2 × 2μ/r (original behavior for backward compatibility).
Post-escape transition:
When escape completes:
- Transition to
"interplanetary"phase (whentransfer_type == "cross_soi") - Dynamic ref_body tracking (#494) will automatically update
ref_bodywhen the ship crosses the SOI boundary
SOI guard interaction: The existing brach_prograde_escape hysteresis in the brachistochrone phase handles escape from bound orbits during burn1. The explicit ESCAPE phase provides a dedicated, cleaner mechanism for cross-SOI transfers with proper phase accounting and status text.
Maneuver hash fields:
| Field | Type | Description |
|---|---|---|
escape_start_energy |
string (J/kg) | Specific orbital energy at escape phase entry |
escape_v_inf |
string (m/s) | Target departure velocity (nested) or v∞ (sibling) — set by _compute_cross_soi_plan |
escape_nested_soi |
string (“1”/”0”) | Whether departure body is the common parent (nested SOI transfer) |
Status text:
| State | Format |
|---|---|
| escape (bound) | "ESCAPE {body}: burning to leave SOI (E={X.X} MJ/kg)" |
| escape (unbound) | "ESCAPE {body}: achieved escape velocity" (transitions next tick) |
INTERPLANETARY Phase (#497)
After escaping the departure body’s SOI, the ship enters the INTERPLANETARY phase — a parent-body-relative (typically heliocentric) transfer that uses ZEM/ZEV guidance to fly toward the target body and detect arrival at the target’s SOI.
Entry condition: Transition from ESCAPE phase when transfer_type == "cross_soi". The ship is now on a heliocentric trajectory with ref_body = common parent (e.g., Sun).
Guidance (each tick):
- Identify target body: Extract from
soi_chain(e.g.,"Earth,Sun,Mars"→ target body ="Mars") - Pre-departure coast (#921): For non-brachistochrone strategies, if
interplanetary_tgo > interplanetary_tof × 1.1(ship is still waiting for the departure window, with 10% margin), coast with zero thrust in prograde attitude. ZEM/ZEV guidance should NOT run during the parking orbit wait — even tiny commanded accelerations waste fuel (0.38% throttle × 12 days = 17% of fuel) and perturb the orbit. - Compute target body position/velocity from
_body_positionscontext - Common parent frame: Determine common parent via
find_common_parent(chain[0], chain[-1])— for Earth↔Luna transfers this returns Earth (not Sun). Compute ship and target body positions/velocities relative to the common parent body - ZEM/ZEV guidance: Use the same Kepler-propagated ZEM/ZEV feedback law as brachistochrone:
- Propagate ship and target body by
tgousingkepler_propagate()in common parent frame a_cmd = 6×ZEM/tgo² + 2×ZEV/tgo-
Direction = normalize(a_cmd), throttle = min( a_cmd /max_accel, 1.0)
- Propagate ship and target body by
- Time-to-go management: Initialize from
brach_t_estimate × 1.5when available (brachistochrone flight-time estimate), fall back tointerplanetary_tof × 1.5(Lambert solution). Decay bydt_gameeach tick. Adaptive distance-based floor: When the target body position is known,tgofloors atbrachistochrone_time_estimate(dist_to_target_body, max_accel) × 0.5— this adapts to the remaining distance rather than the full-transfer estimate, preventing massive tgo overestimation near the target (e.g., 12,400s floor when only 100s remain at 60,000 km from Luna). When far from target or target body data unavailable, falls back tomax(brach_t_estimate × 0.5, 60s). Absolute minimum:max(dt_game, 1.0)(prevents divide-by-zero and ensures at least one guidance cycle) - Alignment gate: Zero thrust when misaligned beyond 5° (same as brachistochrone)
Exit condition: Ship enters target body’s SOI:
- Compute distance from ship to target body
- Compute SOI radius of target body via
compute_soi_radius() - Departure SOI guard (sibling transfers only): For sibling transfers (e.g., Earth→Mars, both orbiting Sun), the target SOI check is suppressed while the ship is still inside the departure body’s SOI. This prevents premature capture transition — without this guard, a ship leaving Earth’s SOI would spuriously match when crossing distant SOI boundaries. The guard uses
soi_chain[0]as the departure body and checksdistance_to_departure < departure_soi_radius. The guard is skipped for parent↔child transfers (e.g., Earth→Luna, Luna→Earth, Jupiter→Io) because the target SOI is nested inside the departure SOI and the ship can never “leave” the outer SOI (#720). - Capture trigger evaluation (at SOI entry): Evaluate specific orbital energy and strategy to determine next phase:
- Compute
v_rel, specific orbital energyE = 0.5 × v_rel² − μ/r - If
E < 0and apoapsis within SOI:strategy == "brachistochrone"→ transition tobrachistochronephase (ZEM/ZEV in target body frame)- Otherwise → transition to
stabilizephase
- If
E < 0but apoapsis beyond SOI:strategy == "brachistochrone"→ replan BCB from remaining distance, fuel, and relative velocity, then transition tobrachistochronephase (ZEM/ZEV in target body frame). The BCB replan uses_tsiolkovsky_bcb_plan()with the current distance to target and remaining fuel, producing correctbrach_burn_time,brach_coast_time,brach_coast_ratio, andbrach_tgo. This preserves the BCB state machine (burn1→coast→burn2 transitions) instead of bypassing it withbrach_coast_ratio=0. Velocity-aware time estimate (#919): When the ship has significant relative velocity at SOI entry, the BCB time estimate must account for the deceleration phase. Thebrach_total_timeis set tomax(bcb_plan_time, velocity_adjusted_time)where the velocity-adjusted time includes: (a) deceleration timev₀/a, (b) if stopping distancev₀²/(2a) > dist: additional turnaround time2×√(overshoot/a), else: BCB time for remaining distance after deceleration. Thebrach_burn_time(burn1) is set to 0 when initial velocity exceeds the BCB coast velocity, since the ship is already moving fast and needs to decelerate (not accelerate).- Otherwise → enter
capturephase (retrograde braking)
- If
E ≥ 0:strategy == "brachistochrone"→ replan BCB (same as case 3), then transition tobrachistochronephase- Otherwise → enter
capturephase (retrograde braking)
- Compute
- Update maneuver
ref_bodyto target body name
Why brachistochrone skips capture: Brachistochrone transfers use continuous-thrust ZEM/ZEV guidance that actively decelerates throughout approach. Unlike Hohmann/Lambert transfers that arrive on a ballistic trajectory with uncontrolled v_inf, brachistochrone ships are already managing their velocity. Routing them through the capture phase (retrograde burn using Lambert-computed capture_v_inf) discards ZEM/ZEV guidance state and uses incorrect velocity targets, causing surface impacts. On SOI entry, the BCB plan is recomputed from the remaining distance, fuel, and relative velocity via _replan_brachistochrone(), preserving the burn1→coast→burn2 state machine. The relative velocity is critical (#919): a ship entering Luna’s SOI at 8.8 km/s needs ~15,000 km to decelerate at 2.5 m/s², but a rest-to-rest BCB plan for 10,000 km distance only budgets ~3,900s — leading to a time overrun abort when the ship inevitably overshoots.
Why non-brachistochrone uses capture (#723): ZEM/ZEV guidance for Hohmann/Lambert transfers operates in the parent frame (e.g., Earth frame for Earth→Luna) and does not model the target body’s local gravity well. Continuing ZEM/ZEV inside the target SOI with hyperbolic excess velocity causes the ship to accelerate toward the body surface faster than the guidance can correct, resulting in crashes. The capture phase uses body-relative retrograde braking, which is the correct algorithm for decelerating within a gravity well regardless of v_inf magnitude.
Approach distance exclusion: The interplanetary phase is excluded from the generic approach distance check (like escape and brachistochrone), since approach distance thresholds are inappropriate at interplanetary scale.
Maneuver hash fields (read from transfer plan):
| Field | Type | Description |
|---|---|---|
soi_chain |
string | Comma-separated SOI chain (e.g., "Earth,Sun,Mars") |
interplanetary_tof |
string (seconds) | Estimated time of flight from Lambert solution |
interplanetary_tgo |
string (seconds) | Current time-to-go (decays each tick) |
brach_t_estimate |
string (seconds) | Brachistochrone flight-time estimate (used for tgo init/floor) |
Status text:
| State | Format |
|---|---|
| cruising | "INTERPLANETARY: {from} → {to} ({dist} AU, tgo {eta})" |
| SOI entry | "INTERPLANETARY: entering {body} SOI" (transitions next tick) |
CAPTURE Phase (#498)
When a ship arrives at the target body’s sphere of influence on a hyperbolic trajectory (from the INTERPLANETARY phase), it must brake into a bound orbit before rendezvous. High-TWR ships (e.g., Fast Frigate at 36 m/s²) can decelerate from interplanetary velocities within ~15,000 km. Low-TWR ships (e.g., Cargo Hauler at 2.5 m/s², Long-Range Explorer at 1.18 m/s²) require hundreds of thousands of km — exceeding typical SOI radii (Luna: ~59,000 km).
The CAPTURE phase inserts an orbit-capture burn between the interplanetary transfer and the final approach. Instead of flying directly at the target, the ship:
- Burns retrograde (relative to the arrival body) to shed hyperbolic excess velocity
- Achieves a bound orbit (specific orbital energy < 0)
- Circularizes to a parking orbit near the target’s altitude
- Performs standard phase/approach rendezvous
This mirrors real spacecraft orbit insertion (Mars Orbit Insertion, Lunar Orbit Insertion).
Updated phase flow (cross-SOI transfers with capture):
transfer_plan → escape → interplanetary → capture → circularize → phase → approach → complete
Without capture (same-body or high-TWR cross-body):
transfer_plan → brachistochrone → approach → complete
Trigger criteria:
When the ship’s reference body transitions to the target’s reference body (SOI entry), the guidance evaluates specific orbital energy:
- Compute speed relative to body:
v_rel = |v_ship - v_body| - Compute specific orbital energy:
E = 0.5 × v_rel² − μ/r - Check strategy:
strategy = maneuver.get("strategy", "hohmann") - If
E < 0: a. Compute apoapsis radiusrafrom position/velocity vectors b. Ifra ≤ SOI_radius: stable bound orbit — route by strategy (brachistochrone→ brachistochrone phase, otherwise →stabilize) c. Ifra > SOI_radius: marginally bound, orbit extends beyond SOI — route by strategy (brachistochrone→ brachistochrone phase withbrach_coast_ratio=0,brach_tgo=0; otherwise → capture phase) - If
E ≥ 0: route by strategy (brachistochrone→ brachistochrone phase withbrach_coast_ratio=0,brach_tgo=0; otherwise → capture phase)
Non-brachistochrone ships with positive orbital energy at SOI entry must use the capture phase for retrograde braking. Brachistochrone ships continue ZEM/ZEV guidance in the target body’s frame, with BCB disabled (brach_coast_ratio=0) and tgo recomputed from remaining distance (brach_tgo=0).
Capture phase behavior:
Each tick during capture:
- Compute velocity relative to reference body:
v_rel = v_ship - v_body - Set thrust direction to retrograde:
dir = -normalize(v_rel)viaATTITUDE_DIRECTION - Alignment gate: if
alignment_angle > ALIGNMENT_GATE_DEG(5°), throttle = 0 — prevents full-power burns before the attitude controller has settled - Compute specific orbital energy:
E = 0.5 × |v_rel|² − μ/r - Periapsis protection remains active (prevents crashing during capture)
Exit condition: E < 0 AND apoapsis ≤ SOI_radius (specific orbital energy negative AND the resulting orbit fits within the sphere of influence). A marginally bound orbit (e ≈ 1) can have an apoapsis extending far beyond the SOI, making it unstable under multi-body perturbations and unsuitable as input for the circularize phase. The capture burn continues retrograde until the apoapsis is pulled within the SOI.
SOI escape fallback: If the ship exits the target body’s SOI during capture (velocity too high to arrest before reaching the SOI boundary), the capture phase detects r_rel > SOI_radius and reverts to the interplanetary phase. This handles edge cases where the arrival velocity exceeds what the ship can brake within the SOI, allowing the interplanetary ZEM/ZEV guidance to re-approach.
Post-capture transition:
When capture completes (bound orbit achieved):
- Compute the target station’s orbital elements (SMA, eccentricity)
- Set
transfer_target_rto the target’s SMA - Transition to
circularizephase - After circularization, transition to
phase→approach→completevia existing rendezvous state machine
The circularize phase uses the existing Q-law algorithm to match the target orbit altitude. The phase/approach pipeline handles phasing and final docking.
Fuel considerations:
The capture burn Δv equals the hyperbolic excess velocity: Δv_capture ≈ v_inf. The BCB transfer_plan should account for this when computing the coast ratio:
- Compute expected arrival v_inf from the planned transfer trajectory
- Add
Δv_captureto the required Δv budget - If total Δv (burn1 + burn2 + capture) exceeds available Δv: increase coast ratio or abort
Maneuver hash fields:
| Field | Type | Description |
|---|---|---|
capture_active |
string ("true"/"false") |
Whether capture phase is active |
capture_v_inf |
string (m/s) | Hyperbolic excess velocity at capture entry |
capture_energy |
string (J/kg) | Current specific orbital energy |
Status text:
| State | Format |
|---|---|
| capture (unbound) | "Capture burn at {body} (v_excess X.Xkm/s, E=Y.Y MJ/kg)" |
| capture (bound, ra > SOI) | "Capture burn at {body} — lowering apoapsis (ra Xkm, SOI Ykm, E=Z.Z MJ/kg)" |
| capture (bound, ra ≤ SOI) | "Captured — circularizing" (transitions next tick) |
Periapsis guard interaction: During capture, the standard periapsis guard remains active. The stopping-distance exception does NOT apply during capture (the ship is intentionally decelerating, not overshooting). If periapsis drops below safe altitude during capture, the guard blends thrust toward radial-outward — this is correct behavior, preventing the capture burn from driving the ship into the body.
Prograde escape interaction: The prograde_escape flag (used during BCB burn1 for bound orbits) is mutually exclusive with capture. Prograde escape applies when leaving a body; capture applies when arriving. The SOI transition from departure body to arrival body naturally separates these phases.
Station-Keeping Algorithm
Station-keeping maintains a fixed distance from a target entity. Two phases: approach and hold.
Phases
| Phase | Entry Condition | Behavior |
|---|---|---|
| approach | abs(distance - hold_distance_m) > SK_APPROACH_THRESHOLD_M |
Approach/retreat to hold distance using braking-curve speed profile |
| hold | abs(distance - hold_distance_m) ≤ deadband AND rel_vel < SK_VEL_DEADBAND_MS |
Maintain position within adaptive deadband |
Adaptive Deadband
The deadband scales with hold distance:
deadband = clamp(hold_distance_m × 0.05, 10m, 500m)
- Position deadband: ±
deadbandmeters fromhold_distance_m - Velocity deadband: 0.5 m/s relative velocity
Constants
| Constant | Value | Description |
|---|---|---|
SK_DEADBAND_FRACTION |
0.05 | ±5% of hold distance |
SK_DEADBAND_MIN_M |
10.0 | Minimum deadband (meters) |
SK_DEADBAND_MAX_M |
500.0 | Maximum deadband (meters) |
SK_VEL_DEADBAND_MS |
0.5 | Velocity deadband (m/s) |
SK_APPROACH_THRESHOLD_M |
1000.0 | Switch from approach to hold when within this distance of hold_distance |
SK_APPROACH_DECEL |
1.0 m/s² | Deceleration budget for approach braking curve (10× rendezvous to outpace tidal drift) |
Approach Phase Algorithm
The approach phase uses a braking-curve speed profile rather than a fixed velocity threshold:
- Compute target speed from constant-deceleration braking curve:
target_speed = clamp(√(2 × SK_APPROACH_DECEL × dist_error × APPROACH_STOPPING_SAFETY), 0.5, MAX_CLOSING_VEL)
- Compare total relative velocity (
rel_vel) againsttarget_speed:- Brake if
rel_vel > target_speed × 1.1: thrust retrograde (oppose all relative velocity — both radial and tangential). Proportional to excess only. This prevents orbiting around the target due to unchecked tangential drift. - Accelerate if closing speed <
target_speed × 0.9OR ship is separating: thrust toward target, proportional to velocity deficit. - Coast if within ±10% of target speed: no thrust commands.
- Brake if
- Transition to hold phase when
abs(dist_error) < SK_APPROACH_THRESHOLD_MANDrel_vel < max(rcs_dv_per_tick × 60, 15.0)— approach decelerates to RCS-manageable speeds before handoff.
The braking check uses total relative velocity (not just radial) because tangential velocity is equally dangerous — it causes the ship to orbit the target rather than approach it. The 10% hysteresis band prevents oscillation.
For retreat (too close, dist_error < 0): thrust away from target.
Hold Phase Algorithm
The hold phase uses RCS-only corrections — no main engine. This prevents overshoot from high-thrust ships and provides fine-grained control.
Each tick:
- Compute relative position and velocity to target
- Compute position error:
dist_error = distance - hold_distance_m - If
abs(dist_error) < deadband AND rel_vel < SK_VEL_DEADBAND_MS: coast (no thrust commands) - If
abs(dist_error) > SK_APPROACH_THRESHOLD_MORrel_vel > max(rcs_dv_per_tick × 60, 15.0): revert to approach phase (main engine handles large corrections) - Otherwise, 3D RCS velocity matching + braking-curve position correction:
- Decompose relative velocity into radial (along line of sight) and tangential components
- Radial: braking-curve target speed =
min(√(2 × rcs_accel × overshoot), rcs_dv × 10, 5.0) - Tangential: target = 0 (kill all tangential drift to prevent orbital divergence)
- Compute desired ΔV in world frame: radial correction + tangential kill
- Construct body frame from d_hat (toward target): Y=d_hat, X=cross(d_hat, ecliptic_pole), Z=cross(X, Y)
- Project ΔV onto body axes →
rcs_x,rcs_y,rcs_z(each clamped to [-1, 1]) - Attitude: ATTITUDE_TARGET, main engine off
- This 3D approach prevents tangential drift from causing distance oscillation between ships in different orbits
Target Types
Station-keeping supports all rendezvous target types plus celestial bodies:
"ship","station","jumpgate","lagrange": resolved same as rendezvous"body": resolved frombody_positionscontext by body name (e.g.,"Earth","Moon")
Hold Distance Capture
hold_distance_m is computed at activation time from the current distance between ship and target. Stored in the maneuver hash and remains constant for the duration of the maneuver.
Fuel Behavior
Station-keeping has no fuel safety — it stops naturally when fuel runs out. The ship will coast without correction until aborted or fuel is restored (e.g., by refueling).
Landing Algorithm
The landing autopilot autonomously lands a ship on a celestial body. It assumes the ship is in the target body’s SOI with eccentricity < 1 (bound orbit). The autopilot handles deorbit, braking, descent, and touchdown using a hover-descent approach.
Phases
| Phase | Entry Condition | Behavior |
|---|---|---|
deorbit |
Initial phase | Retrograde burn to lower periapsis to ~2 km AGL |
braking |
Periapsis < target altitude | Surface retrograde burn to kill horizontal velocity (immediate, no coast) |
vertical_descent |
Horizontal speed < 5 m/s | LOCAL_VERTICAL attitude, PID-controlled descent rate |
terminal |
AGL < 500 m | Reduce target descent rate as altitude decreases |
touchdown |
AGL < 10 m | Gentle constant-thrust final contact |
Phase Details
Deorbit Phase
Lower periapsis to bring the ship close to the surface.
- Compute current periapsis altitude AGL:
pe_agl = periapsis_radius - body_radius - If
pe_agl > DEORBIT_TARGET_ALT(2000 m):- Set attitude to retrograde
- Compute alignment angle between ship forward and retrograde direction
- Apply alignment gate: zero thrust/RCS if
angle > ALIGNMENT_GATE_DEG(5°) - Standard mode (
hover_thrust >= 0.01): Main engine at 100% thrust - Micro-gravity mode (
hover_thrust < 0.01): RCS retrograde instead of main engine. Main engine at any throttle massively overshoots on bodies with TWR > 2000 (e.g. Deimos TWR ~12,800). RCS +Z proportional to periapsis error:rcs_z = clamp((pe_agl - target) / pe_agl × 2, 0, 1) status_text:"Deorbit — pe {pe_agl}m, target 2km"
- When
pe_agl <= DEORBIT_TARGET_ALT:- Set attitude to surface retrograde (pre-orient for braking)
- Set thrust to 0
- Transition directly to
braking(no coast phase — start braking immediately)
Braking Phase
Kill horizontal (surface-relative) velocity using surface retrograde attitude.
- Compute surface-relative velocity by subtracting body rotation:
omega_body = (2π / rotation_period) × spin_axis spin_axis = [0, sin(axial_tilt), cos(axial_tilt)] # rotate ecliptic north by tilt v_surface = v_orbital - cross(omega_body, r_rel) - Decompose into radial and horizontal components
- Dynamic braking threshold: On micro-gravity bodies, orbital velocity is low enough that the fixed 5 m/s threshold leaves significant orbital speed unbraked. The threshold scales with local gravity:
dynamic_threshold = min(BRAKING_COMPLETE_HSPD, max(0.5, sqrt(g_local × 200)))Examples: Phobos (g=0.004) → 0.89 m/s, Luna (g=1.6) → 5.0 m/s, Mars (g=3.7) → 5.0 m/s
- If
horizontal_speed > dynamic_threshold:- Standard mode (
hover_thrust >= 0.01): Surface retrograde attitude + proportional main engine- Set attitude to surface retrograde (
ATTITUDE_SURFACE_RETROGRADE) - Proportional throttle:
thrust = clamp(horizontal_speed / 50.0, 0.1, 1.0)
- Set attitude to surface retrograde (
- Micro-gravity mode (
hover_thrust < 0.01): LOCAL_VERTICAL attitude + RCS lateral control- Set attitude to local vertical (
ATTITUDE_LOCAL_VERTICAL) - Main engine thrust = 0
- RCS x/y: cancel horizontal velocity via
_horizontal_to_body_rcs() - RCS z: descent rate control (proportional to rate error)
- Set attitude to local vertical (
status_text:"Braking — hspd {hspd} m/s"or"RCS braking — hspd {hspd} m/s"
- Standard mode (
- When
horizontal_speed <= dynamic_threshold:- Transition to
vertical_descent
- Transition to
Phase regression: If vertical_descent detects high horizontal speed, regress to braking. Threshold: dynamic_threshold × 2 for untargeted landings, dynamic_threshold × 10 (~50 m/s) for targeted landings — small h_speed may be intentional RCS steering toward the target, but large h_speed means braking exited early via safety override. hover_steer also regresses to braking when h_speed > dynamic_threshold × 10. Only on normal-gravity bodies (hover_thrust >= 0.01).
Vertical Descent Phase
Controlled descent at a target rate. Two control modes depending on local gravity:
Standard mode (hover_thrust >= 0.01): SURFACE_RETROGRADE attitude with PID throttle. Surface retrograde naturally corrects horizontal drift from gravity gradient and Coriolis effects during descent.
- Set attitude to SURFACE_RETROGRADE (thrust opposes velocity vector relative to surface)
- Compute local gravity:
g_local = G × body_mass / r² - Compute hover thrust fraction:
hover_thrust = (total_mass × g_local) / max_thrust - Target descent rate scales with altitude:
target_rate = min(MAX_DESCENT_RATE, max(DESCENT_TARGET_RATE, sqrt(2 × DESCENT_DECEL_BUDGET × AGL)))— fast at high altitude, tapering to 5.0 m/s near surface. Constants:DESCENT_DECEL_BUDGET= 2.0 m/s²,MAX_DESCENT_RATE= 2000 m/s - PID controller on descent rate:
- Error:
e = target_rate - actual_descent_rate(positive when descending too slow) thrust = hover_thrust - Kp × e - Ki × integral(e) - Kd × d(e)/dt- Clamp thrust to [0.0, 1.0]
- Error:
status_text:"Descent — AGL {agl}, vspd {vspd} m/s"- When AGL < 500 m: transition to
terminal
Micro-gravity mode (hover_thrust < 0.01): On micro-gravity bodies, gravity is too weak to build descent speed — waiting for free-fall is impractical (e.g., 21 minutes to reach 5 m/s on Phobos). The main engine is also far too powerful (TWR >2000), so micro-gravity descent uses RCS translation for fine velocity control:
- Attitude: LOCAL_VERTICAL (ship +Z away from body center = upright)
- Main engine: thrust = 0 (too powerful for precision descent)
- Cap
target_rateby gravity-limited speed:min(target_rate, max(DESCENT_TARGET_RATE, sqrt(2 × g_local × AGL))) - RCS translation in body frame:
- Radial control (z-axis):
rcs_z = -clamp((target_rate - descent_rate) × 0.2, -1.0, 1.0)— negative Z pushes ship toward surface - Lateral drift correction (x/y axes): proportional to horizontal speed components in body frame
- Radial control (z-axis):
status_text:"Descent (RCS) — AGL {agl}, vspd {vspd} m/s"
RCS linear thrust is typically 2,500 N (planetary lander) giving ~0.5 m/s² — orders of magnitude more controllable than the 50 kN main engine. This matches how real micro-gravity operations (asteroid proximity, station docking) use RCS rather than main propulsion.
PID constants:
| Constant | Value | Description |
|---|---|---|
DESCENT_KP |
0.1 | Proportional gain |
DESCENT_KI |
0.01 | Integral gain |
DESCENT_KD |
0.05 | Derivative gain |
DESCENT_I_MAX |
5.0 | Integral windup limit |
Terminal Phase
Reduce descent rate as altitude decreases, approaching a soft touchdown.
- Attitude: LOCAL_VERTICAL (maintained from vertical descent)
- Target descent rate:
max(min_rate, sqrt(2 × 0.1 × AGL))- Standard mode:
min_rate = 1.0 m/s - Micro-gravity mode:
min_rate = 0.3 m/s(slower minimum to avoid surface penetration — RCS can’t decelerate quickly enough at higher rates with negligible gravity assist) - AGL ≤ 10 m: transition to
touchdown
- Standard mode:
- Same PID controller as vertical descent (standard mode) or RCS z-axis control (micro-gravity mode)
status_text:"Terminal — AGL {agl}, vspd {vspd} m/s"
Touchdown Phase
Final contact with surface.
Standard mode (hover_thrust ≥ 0.01):
- Attitude: LOCAL_VERTICAL
- Thrust:
hover_thrust × 0.95— slightly less than hover to allow gentle descent - RCS translation zeroed — prevents stale translation inputs from combining with hover thrust to exceed local gravity, which would repeatedly launch and re-land the ship
- Below 3m AGL: thrust cut to zero to let ship settle under gravity
Micro-gravity mode (hover_thrust < 0.01):
- Attitude: LOCAL_VERTICAL
- Main engine off, RCS-controlled descent at 0.2 m/s target rate
- Cannot cut thrust and rely on gravity — g is negligible (e.g. 0.004 m/s² on Phobos); cutting thrust would leave the ship drifting at whatever speed it had
- RCS z-axis: proportional control with gain 0.5 on rate error
Both modes:
- Physics engine handles surface contact detection and landing state transition
- When
landed_body_name != "": maneuver complete status_text:"Touchdown — AGL {agl}"
Constants
| Constant | Value | Description |
|---|---|---|
DEORBIT_TARGET_ALT |
2000 m | Target periapsis AGL for deorbit burn |
BRAKING_COMPLETE_HSPD |
5.0 m/s | Horizontal speed threshold to end braking |
DESCENT_TARGET_RATE |
5.0 m/s | Target descent rate during vertical descent |
TOUCHDOWN_THRUST_FRAC |
0.95 | Fraction of hover thrust during touchdown |
ALIGNMENT_GATE_DEG |
5° | Shared with other maneuvers |
Maneuver State Fields
Additional fields stored in maneuver:{ship_id} for landing:
| Field | Type | Description |
|---|---|---|
target_body |
string | Body to land on |
phase |
string | Current phase name |
pid_integral |
float | PID integral accumulator |
pid_last_error |
float | PID previous error for derivative |
Fuel Behavior
No fuel safety — the autopilot runs until fuel is exhausted. If fuel runs out during descent, the ship will crash unless it’s already slow enough for a safe landing.
Implementation Files
services/tick-engine/src/maneuver_landing.py— Phase handlers (new file)services/tick-engine/src/automation.py— Registerlandaction, dispatch to phase handlersservices/tick-engine/src/automation_helpers.py— Add"land"toVALID_ACTIONSservices/web-client/src/automationHelpers.js— Addlandaction to UI
Events
automation.maneuver_complete published to galaxy:automations stream:
event: automation.maneuver_complete
ship_id: <uuid>
rule_id: <uuid>
rule_name: <string>
maneuver_type: <string>
tick: <int>
automation.maneuver_aborted published to galaxy:automations stream:
event: automation.maneuver_aborted
ship_id: <uuid>
rule_id: <uuid>
rule_name: <string>
maneuver_type: <string>
tick: <int>
WebSocket Protocol
Cross-Ship Targeting (ship_id parameter)
All automation messages (automation_create, automation_update, automation_delete, automation_list) accept an optional ship_id field. When omitted, the message targets the player’s currently active ship (default behavior). When provided, the server validates that the target ship is owned by the player via player:{player_id}:ships set membership. If the ship is not owned, the server responds with error code E034 (“Ship not owned”).
Response messages (automation_created, automation_updated, automation_deleted, automation_rules) include a ship_id field identifying which ship the rules belong to. The client uses this to route responses to either the automation view (active ship) or the fleet orders panel (other ships).
Client → Server
automation_create
{
"type": "automation_create",
"ship_id": "...",
"name": "Fuel cutoff",
"mode": "once",
"priority": 50,
"trigger": {
"conditions": [{"field": "ship.fuel", "op": "<", "value": 0.05}],
"logic": "AND"
},
"actions": [
{"action": "set_thrust", "value": 0},
{"action": "alert", "message": "Fuel critical — thrust cut"}
]
}
ship_id is optional — defaults to active ship if omitted. Server validates, generates UUID, stores in Redis with enabled: false, responds with automation_created. New rules are always created disabled so the player can review configuration before activating.
automation_update
{
"type": "automation_update",
"ship_id": "...",
"rule_id": "...",
"name": "Updated name",
"enabled": true,
"mode": "continuous",
"priority": 30,
"trigger": { ... },
"actions": [ ... ]
}
ship_id is optional — defaults to active ship if omitted. Only provided fields are updated. Server verifies ship ownership, responds with automation_updated.
automation_delete
{
"type": "automation_delete",
"ship_id": "...",
"rule_id": "..."
}
ship_id is optional — defaults to active ship if omitted. Server verifies ownership, deletes hash and removes from index set, responds with automation_deleted.
automation_list
{
"type": "automation_list",
"ship_id": "..."
}
ship_id is optional — defaults to active ship if omitted. Server reads all rules for the target ship, responds with automation_rules.
maneuver_query
{
"type": "maneuver_query"
}
Server reads maneuver:{ship_id} from Redis. Responds with maneuver_status.
maneuver_abort
{
"type": "maneuver_abort"
}
Server checks if maneuver:{ship_id} exists. If yes, sets abort=true on the hash and responds with maneuver_aborted. If no active maneuver, responds with error E028.
maneuver_pause
{
"type": "maneuver_pause"
}
Server checks if maneuver:{ship_id} exists. If yes, sets paused="true" and paused_at_tick on the hash and responds with maneuver_paused. If no active maneuver, responds with error E028. If already paused, responds with error E029.
maneuver_resume
{
"type": "maneuver_resume"
}
Server checks if maneuver:{ship_id} exists. If yes and paused, sets paused="false" and clears paused_at_tick on the hash and responds with maneuver_resumed. If no active maneuver, responds with error E028. If not paused, responds with error E030.
Server → Client
automation_created
{
"type": "automation_created",
"ship_id": "...",
"rule": {
"rule_id": "...",
"name": "Fuel cutoff",
"enabled": true,
"mode": "once",
"priority": 50,
"trigger": { ... },
"actions": [ ... ],
"created_at": "2025-01-15T10:30:00Z"
}
}
automation_updated
{
"type": "automation_updated",
"ship_id": "...",
"rule": { ... }
}
Full rule object after update. ship_id identifies which ship the rule belongs to.
automation_deleted
{
"type": "automation_deleted",
"ship_id": "...",
"rule_id": "..."
}
automation_rules
{
"type": "automation_rules",
"ship_id": "...",
"rules": [ ... ]
}
Array of rule objects sorted by priority (ascending). ship_id identifies which ship the rules belong to.
automation_triggered
{
"type": "automation_triggered",
"rule_id": "...",
"rule_name": "Fuel cutoff",
"tick": 12345,
"actions_executed": ["set_thrust(0)", "alert(Fuel critical — thrust cut)"]
}
Sent when a rule fires. Delivered only if the player is connected; lost if offline (acceptable for MVP).
maneuver_status
{
"type": "maneuver_status",
"active": true,
"maneuver": {
"type": "circularize",
"ref_body": "Earth",
"rule_name": "Auto-circ",
"started_tick": 12345,
"phase": null,
"sub_phase": null,
"target_inclination": null,
"target_id": null,
"target_type": null,
"dock_on_arrival": null,
"status_text": null,
"element_errors": null,
"paused": false
}
}
Response to maneuver_query. If no active maneuver: { "type": "maneuver_status", "active": false, "maneuver": null }. The paused field indicates whether the maneuver is currently paused. The dock_on_arrival field is a boolean (true/false) for rendezvous maneuvers, null otherwise.
Automation target injection (client-side): When maneuver_status includes target_id and target_type (non-null), the web client automatically adds the target to the multi-target set with role 'automation'. If the player has no focused target, the automation target becomes focused. When a maneuver completes (active=false) or is aborted, all automation-role targets are removed from the target set. On reconnect, maneuver_status is re-queried automatically, so the automation target is re-injected without special logic.
maneuver_aborted
{
"type": "maneuver_aborted",
"maneuver_type": "circularize"
}
Sent as immediate acknowledgment when maneuver_abort succeeds. Also published via galaxy:automations stream for offline delivery.
maneuver_paused
{
"type": "maneuver_paused",
"maneuver_type": "circularize"
}
Sent as immediate acknowledgment when maneuver_pause succeeds.
maneuver_resumed
{
"type": "maneuver_resumed",
"maneuver_type": "circularize"
}
Sent as immediate acknowledgment when maneuver_resume succeeds.
Redis Stream
Tick-engine publishes to galaxy:automations stream:
event: automation.triggered
ship_id: <uuid>
rule_id: <uuid>
rule_name: <string>
tick: <int>
actions_executed: <json-array-string>
Api-gateway subscribes with consumer group api-gateway-automations, forwards to connected player by ship_id lookup.
Error Handling Strategy (#777)
The automation engine uses context-specific exception handling to balance fault isolation with debuggability:
Per-Ship Isolation
The outermost ship evaluation loop catches Exception broadly to prevent one ship’s error from crashing the entire tick loop. This is intentional — a rule parsing error or unexpected state on one ship must not affect other ships’ automation.
Narrowed Exception Types
Within specific operations, exceptions are narrowed to expected types:
| Context | Caught Exceptions | Rationale |
|---|---|---|
| Rule JSON parsing | json.JSONDecodeError, KeyError, TypeError |
Malformed rule data |
| gRPC calls (physics) | grpc.aio.AioRpcError |
Network/service errors |
| Redis operations | OSError, asyncio.TimeoutError, RuntimeError |
Connection/timeout |
| Kepler propagation | ValueError, ArithmeticError, OverflowError |
Degenerate orbits |
Broad except Exception is only used for the per-ship isolation wrapper, with a logged traceback for debugging.
Error Codes
| Code | Message | Description |
|---|---|---|
| E025 | Rule limit reached | Maximum 10 rules per ship |
| E026 | Invalid automation rule | Validation failed (invalid field, operator, action, etc.) |
| E027 | Rule not found | Rule does not exist or belongs to different ship |
| E028 | No active maneuver | No maneuver is currently running for this ship |
| E029 | Maneuver already paused | Maneuver is already in paused state |
| E030 | Maneuver not paused | Cannot resume a maneuver that is not paused |
Web Client UI
Automation Window
Floatable window opened via View menu or Shift+A shortcut. Same drag/close pattern as Chat window.
On WebSocket connect, the client sends automation_list to load existing rules. This ensures rules persist across page refreshes and reconnections.
Rule List
- Each rule displays: name, enabled toggle, mode badge, priority, human-readable condition summary, action summary
- Delete button per rule
- “Create Rule” button at bottom
Create/Edit Form
- Name: text input (max 64 chars)
- Mode: dropdown (Once / Continuous)
- Priority: number input (0-99)
- Conditions: dynamic list of (field dropdown, operator dropdown, value input, optional args)
- Field options: ship.fuel, ship.thrust, ship.speed, ship.distance_to, orbit.apoapsis, orbit.periapsis, orbit.eccentricity, orbit.inclination, orbit.period, orbit.true_anomaly, orbit.angle_to_pe, orbit.angle_to_ap, orbit.angle_to_an, orbit.angle_to_dn, game.tick
- Operator options: <, >, <=, >=, ==, !=
- Value: number input
- Args: body name text input (shown for ship.distance_to and orbit.* fields)
- Actions: dynamic list of (action dropdown, value input)
- Action options: set_thrust, set_attitude, circularize, set_inclination, rendezvous, alert
- Value: number (thrust), dropdown (attitude mode), number (inclination degrees), or text (alert message)
- circularize requires no value input
- set_inclination requires target degrees input
- rendezvous requires a combined target dropdown listing all stations, jumpgates, and other ships (excluding the player’s own ship); the dropdown value format is
{type}:{id}(e.g.,ship:uuid), parsed on save intotarget_typeandtarget_id; auto-selects from current target selection if set. The dropdown must useObject.entries(state.shipStates)(notObject.values) since ship state values are keyed by ship_id but do not contain aship_idproperty. - rendezvous with a dockable target (currently stations) shows a “Dock on arrival” checkbox, checked by default. The checkbox value is sent as
dock_on_arrival: true/falsein the action object. Hidden for non-dockable targets (ships, jumpgates).
- Save / Cancel buttons
Maneuver Event History
Maneuver lifecycle events are persisted to PostgreSQL maneuver_events table for post-hoc diagnosis. These are low-volume events (not per-tick) that survive pod restarts.
Recorded Events
| Event Type | When | Extra Fields |
|---|---|---|
maneuver_started |
Maneuver begins | ref_body, target_id |
phase_transition |
Phase changes (rendezvous) | from_phase, phase, element_errors |
maneuver_completed |
Maneuver finishes successfully | duration_ticks |
maneuver_aborted |
Maneuver aborted by user or system | reason, duration_ticks |
All writes are wrapped in try/except — database failure must not break maneuver execution.
Retention
1000 rows per ship. Oldest rows beyond this limit are pruned on insert.
Maneuver History
When a maneuver completes or is aborted, a full snapshot of the maneuver state is archived to a Redis list before the maneuver:{ship_id} hash is cleared. This preserves completed maneuver data for later inspection.
Redis Key
maneuver_history:{ship_id} — a Redis list (LPUSH, most recent first).
Snapshot Fields
All fields from the maneuver:{ship_id} hash at the time of completion/abort, plus:
| Field | Type | Description |
|---|---|---|
| completed_tick | integer | Tick when maneuver ended |
| duration_ticks | integer | Total ticks elapsed (completed_tick - started_tick) |
| outcome | string | "completed" or "aborted" |
Retention
Maximum 20 entries per ship. Oldest entries beyond this limit are trimmed on insert (LTRIM).
Diagnostic Tool
kubectl exec -n galaxy-dev deployment/tick-engine -- python /app/tools/maneuver_history.py --ship Demon
Shows table of recent maneuvers with type, strategy, target, duration, outcome, started/completed ticks.
Diagnostic Logging
Per-tick verbose logging to stdout for maneuver debugging. Does not write to Redis or PostgreSQL.
Activation
- Per-ship: Set Redis key
maneuver:{ship_id}:debugto"true"via admin API - Global: Set
game:maneuver_loggingto"verbose"(existing mechanism) - Diagnostics emit when either per-ship debug is enabled OR global level is
"verbose"
Logger
Dedicated structlog logger src.automation.debug for filtering:
kubectl logs -f deployment/tick-engine -n galaxy-dev | grep maneuver_diag
Output Fields
| Field | Description |
|---|---|
| ship_id | Ship UUID |
| tick | Current tick |
| phase | Current maneuver phase |
| da_km | Semi-major axis error (km) |
| de | Eccentricity error |
| di_deg | Inclination error (degrees) |
| draan_deg | RAAN error (degrees) |
| f_rtn | RTN thrust direction |
| eff | Effectivity value |
| thrust | Actual thrust applied |
| angle | Alignment angle (degrees) |
| decision | BURN or COAST |
| distance | Distance to target (km, rendezvous only) |
Additional per-phase fields: error_m, max_gve, pro_retro (transfer states); phase_deg, mode (phase).
Shuttle Route Action (#906)
Overview
The shuttle action sends a ship back and forth between two stations indefinitely, docking and refueling at each end.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| station_a | string | yes | Station ID for first endpoint |
| station_b | string | yes | Station ID for second endpoint |
| strategy | string | no | Rendezvous strategy: efficient, balanced, fastest (default: balanced) |
| refuel_threshold | number | no | Fuel percentage to depart (default: 100) |
State Machine
depart → transit → dock → refuel → depart → transit → dock → refuel → ...
(to B) (at B) (at B) (to A) (at A) (at A)
Phases:
depart: Undock from current station (skip if not docked)transit: Rendezvous with target station — delegates to existing rendezvous maneuver phases (plane_change, transfer_plan, brachistochrone, approach, etc.)dock: Auto-dock at arrival station (viadock_on_arrival)refuel: Wait forship.fuel_pct >= refuel_threshold, then request SERVICE_FUEL
Completion
The shuttle never self-completes. It runs until:
- The user disables or deletes the automation rule
- A station is destroyed or unreachable (abort with alert)
Maneuver Fields
| Field | Description |
|---|---|
| type | shuttle |
| station_a | Station A ID |
| station_b | Station B ID |
| current_target | Which station the ship is heading toward (ID) |
| strategy | Rendezvous strategy |
| refuel_threshold | Fuel % to depart |
| leg_count | Number of completed one-way legs |
| shuttle_phase | Current phase: depart, transit, dock, refuel |
Implementation Files
services/tick-engine/src/automation_shuttle.py— Shuttle state machine (shuttle_tickfunction, shuttle intercept incomplete_maneuver_shuttle)services/tick-engine/src/automation.py— Registersshuttleaction, dispatches toautomation_shuttle
Surface Pins (#829)
Overview
Players can place persistent lat/lon markers (“pins”) on body surfaces by clicking in cockpit view. Pins are visible as markers that rotate with the body and can be referenced by the landing automation.
Placement
- Player clicks “Place Pin” toggle button (in the cockpit HUD or a toolbar)
- Cursor changes to crosshair mode
- Player clicks on a body surface (body mesh or terrain)
- Raycaster intersects the surface → world position → body-local unit vector → lat/lon
- Pin is created with
{ bodyName, lat, lon, label }and saved to localStorage
Rendering
- Each pin is a CSS2DObject marker positioned at the body surface
- Pins rotate with the body (recalculated from lat/lon each frame using body quaternion)
- Occluded pins (far side of body) are hidden via dot product check
- Label shows name, lat/lon coordinates
Persistence
- Stored in
settings.surfacePins[]array in localStorage (player-wide) - Loaded on cockpit view startup
- Pins are not per-ship — all ships see the same pins
Automation Integration
- Land action UI shows a “Use Pin” dropdown listing pins on the current ref body
- Selecting a pin auto-fills lat/lon fields
Pin Management
- Toggle mode button to place new pins
- Click existing pin marker to delete it
- No drag-to-move (delete + re-place)