Files
settled-reach/client/scripts/autoloads/sim_bridge.gd
T
jpmschweitzerandClaude Fable 5 90c562a6a3 feat(ui): T-1133 data browser — implant/browser app, six index+detail screens (D-254 SS4)
New implant app (app_path implant/browser, key B, fullscreen), sibling
to the Atlas per Jeroen's IA ruling: kind picker -> generic filterable
index (live search-mode typing) -> generic detail, parameterized per
kind, composed entirely from D-169 components. available_in_companion
left unset (default true) — the app appears in the companion shell
automatically via the generic-host seam, zero companion-side wiring.

browser_adapter.gd is the sole home of literal wire field names: maps
Oscar's BrowseResponse contract ({id, primary, secondary} index rows;
BrowseDetail enum-as-single-key-map) to view models for all six kinds,
folding join partners (system economy/factions/culture, corporation
presence, commodity production chains with nested Leontief inputs).
browse_protocol.gd split out of protocol.gd (max-file-lines);
sim_bridge gains browse_response_received + request_browse_index/detail.

Live-data catch: a present-but-NULL key (unnamed asteroid belt
proper_name) bypasses Dictionary.get fallbacks and rendered '<null>' —
_display_or() null-vs-absent helper applied across all six detail
mappers, 4 regression tests distinct from the absent-key cases.

43 gdUnit adapter cases; full suite 3074 green. Live-verified against
a real server + real systems.db: all six kinds Ready with real row
counts (301/3240/466/165/36/28), detail drill-down, NotFound on bogus
ids. Spawn-mode DB resolution issue found during verification is
pre-existing (cwd-relative data/systems.db) — server-side fix follows
separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:24:39 +02:00

698 lines
25 KiB
GDScript

extends Node
# Signals
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
signal snapshot_received(snapshot: Dictionary)
signal atlas_layers_received(response: Dictionary)
signal star_map_received(response: Dictionary) # T-949: StarMapResponse
signal city_names_received(response: Dictionary) # T-949: CityNamesResponse
signal browse_response_received(response: Dictionary) # T-1131/T-1133: BrowseResponse
signal handshake_complete
signal handshake_failed(reason: String)
# Connection states
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
# Connection retry state — handles server startup delay (Critical fix #1)
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
# Handshake state (#556)
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
# Type is TestHarness — untyped to avoid autoload parse-order issue.
var harness = null # Test simulation (D-020: game logic lives outside production client)
var server_port: int = 9876 # Default matches server's default bind address
var server_path: String = "" # Path to server binary — set before connect_to_sim()
# D-254 §2/§3: ConnectionRole wire value for the next StartupMessage — "" (default)
# omits the "role" key (server defaults to Player, byte-identical to pre-D-254
# behavior). atlas_standalone.gd sets this to "Reader" before connect_to_sim();
# every existing caller (main.gd, locomotion_sandbox.gd, tests) leaves it unset.
var connection_role: String = ""
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
# Transport layer (non-test mode)
var _bridge = null # LocalBridge
var _server = null # ServerProcess
var _connect_retries: int = 0
var _retry_timer: float = 0.0
var _handshake_start_usec: int = 0
# T-949: true once any caller has asked for the star map. The Atlas/economics
# screens can call request_star_map() before the bridge finishes its
# handshake — the request is remembered and replayed automatically the
# moment _set_state reaches CONNECTED, instead of silently going nowhere.
var _star_map_wanted: bool = false
var _test_tick: int:
get:
return harness.tick if harness else 0
set(v):
if harness:
harness.tick = v
var _test_player_pos: Vector2i:
get:
return harness.player_pos if harness else Vector2i.ZERO
set(v):
if harness:
harness.player_pos = v
var _test_facing: String:
get:
return harness.facing if harness else "North"
set(v):
if harness:
harness.facing = v
var _test_in_dialogue: bool:
get:
return harness.in_dialogue if harness else false
set(v):
if harness:
harness.in_dialogue = v
var _test_gauntlet_mode: bool:
get:
return harness.gauntlet_mode if harness else false
set(v):
if harness:
harness.gauntlet_mode = v
var _test_npc_relationship: String:
get:
return harness.npc_relationship if harness else "Unknown"
set(v):
if harness:
harness.npc_relationship = v
var _test_input_queue: Array:
get:
return harness.input_queue if harness else []
func _ready() -> void:
if test_mode:
harness = load("res://scripts/protocol/test_harness.gd").new()
print("SimBridge: Running in test mode (dynamic snapshot)")
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
func reset_test_state() -> void:
if harness:
harness.reset()
# T-949: don't leak a star-map request across tests (autoload state).
_star_map_wanted = false
# SystemIndex's static cache is the same cross-suite leak class (PR #176
# review H2/T1) — reset it here so the dozens of suites already calling
# SimBridge.reset_test_state() cover both. load() inline per the autoload
# parse-order rule (CLAUDE.md).
var SI := load("res://ui/implant/widgets/system_index.gd")
SI.reset_test_state()
func _test_snapshot() -> Dictionary:
return harness.snapshot()
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
return harness.has_los(from, to)
# -- Connection lifecycle ------------------------------------------------------
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
if state != new_state:
var old_state = state
state = new_state
connection_state_changed.emit(old_state, new_state)
# T-949: fire (or re-fire, on reconnect) any star-map request that was
# asked for before we were connected.
if new_state == ConnectionState.CONNECTED and _star_map_wanted:
_send_star_map_request()
# Connect to simulation server.
# In test mode, immediately transitions to CONNECTED.
# In live mode, spawns server subprocess and defers TCP connection to _process()
# to allow the server time to bind its port.
func connect_to_sim() -> void:
_set_state(ConnectionState.CONNECTING)
if test_mode:
_set_state(ConnectionState.CONNECTED)
return
# Spawn server subprocess
if not server_path.is_empty():
var SP := load("res://scripts/protocol/server_process.gd")
_server = SP.new()
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
var args := ["127.0.0.1:" + str(server_port)]
var game_id: String = GameState.current_game_id
if not game_id.is_empty():
args.append_array(["--game-id", game_id])
var pid: int = _server.start(server_path, args)
if pid <= 0:
push_error("SimBridge: failed to start server")
_set_state(ConnectionState.ERROR)
return
# Defer TCP connection to _process() — server needs time to bind its port
_connect_retries = 0
_retry_timer = 0.0
_bridge = null
# Disconnect from simulation server
func disconnect_from_sim() -> void:
if _bridge != null:
_bridge.disconnect_from_server()
_bridge = null
if _server != null:
_server.stop()
_server = null
_connect_retries = 0
_set_state(ConnectionState.DISCONNECTED)
# Attempt TCP connection. Called from _process() during CONNECTING state.
func _try_connect() -> void:
var LB := load("res://scripts/protocol/local_bridge.gd")
_bridge = LB.new()
var err: int = _bridge.connect_to_server("127.0.0.1", server_port)
if err != OK:
push_warning(
(
"SimBridge: TCP connect attempt %d/%d failed: %s"
% [_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)]
)
)
_bridge = null
# Poll transport layer every frame (non-test mode only)
func _process(delta: float) -> void: # gdlint:disable=max-returns
if test_mode:
return
# CONNECTING state: retry TCP connection until server is ready
if state == ConnectionState.CONNECTING:
if _bridge == null:
_retry_timer += delta
if _retry_timer >= CONNECT_RETRY_INTERVAL or _connect_retries == 0:
_retry_timer = 0.0
_connect_retries += 1
if _connect_retries > MAX_CONNECT_RETRIES:
push_error(
"SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES
)
_set_state(ConnectionState.ERROR)
return
_try_connect()
return
# Bridge exists — poll for connection completion
_bridge.poll()
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
_handshake_start_usec = Time.get_ticks_usec()
_set_state(ConnectionState.HANDSHAKING)
StreamPeerTCP.STATUS_CONNECTING:
pass # Still connecting, wait
StreamPeerTCP.STATUS_ERROR:
# Connection attempt failed — retry
_bridge = null
if _connect_retries >= MAX_CONNECT_RETRIES:
push_error(
"SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES
)
_set_state(ConnectionState.ERROR)
StreamPeerTCP.STATUS_NONE:
_bridge = null # Reset and retry
return
# HANDSHAKING state: read first framed message, validate HandshakeMessage (#556)
if state == ConnectionState.HANDSHAKING:
if _bridge == null:
_set_state(ConnectionState.ERROR)
return
_bridge.poll()
# Check connection dropped during handshake
var bridge_status: int = _bridge.get_status()
if (
bridge_status == StreamPeerTCP.STATUS_ERROR
or bridge_status == StreamPeerTCP.STATUS_NONE
):
var reason := "Connection dropped during handshake"
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge = null
_set_state(ConnectionState.ERROR)
return
# Check timeout
if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC:
var reason := "Handshake timeout: no message received within 5 seconds"
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
# Try to read first message
var msg: PackedByteArray = _bridge.poll_message()
if msg.is_empty():
return # Not ready yet, continue polling
# Decode HandshakeMessage — D-192 (#875): protocol_version field dropped.
# Server sends {} or a minimal dict; only structural validity is required.
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
var reason := "Handshake decode failed: malformed HandshakeMessage"
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
# Server blocks waiting for this before entering the tick loop.
# D-254 §2: connection_role threads "Reader" for the standalone companion;
# every other caller leaves it "" (omitted key, decodes as Player).
var startup_bytes := Protocol.encode_startup_message(
GameState.world_seed, GameState.character_visual_descriptor, connection_role
)
if startup_bytes.size() > 0:
var send_err: int = _bridge.send_message(startup_bytes)
if send_err != OK:
var reason := "Failed to send startup message: %s" % error_string(send_err)
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
else:
var reason := "Failed to encode startup message"
push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason)
_bridge.disconnect_from_server()
_set_state(ConnectionState.ERROR)
return
handshake_complete.emit()
_set_state(ConnectionState.CONNECTED)
# #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled
# from server SQLite so the client reflects the authoritative persisted state (D-138).
# D-254 §2: this travels as a Vec<PlayerInput> entry (same pipeline as
# MoveNorth/Interact), which the permission matrix marks "no" for Reader —
# a Reader has no settings to hydrate (no character, no per-player state),
# so skip the send rather than have the server log-and-drop it on every
# companion connection once role enforcement lands server-side.
if connection_role == "Reader":
return
(
_outbound_buffer
. append(
{
"tick": 0,
"action_name": "RequestAllSettings",
}
)
)
return
if _bridge == null:
return
_bridge.poll()
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
# Receive: drain all complete messages from the bridge
var msg: PackedByteArray = _bridge.poll_message()
while msg.size() > 0:
receive_bytes(msg)
msg = _bridge.poll_message()
# Send: batch-encode and flush outbound buffer as one frame (Vec<PlayerInput>).
# Inputs are drained before encoding. On encode failure the inputs are
# intentionally dropped — re-queuing would retry the same bad data and
# the server tick has already advanced, making stale inputs invalid.
var outbound := drain_outbound()
if outbound.size() > 0:
var encoded := Protocol.encode_player_inputs(outbound)
if encoded.size() > 0:
var err: int = _bridge.send_message(encoded)
if err != OK:
push_error("SimBridge: failed to send message: %s" % error_string(err))
else:
push_error(
"SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size()
)
StreamPeerTCP.STATUS_CONNECTING:
pass # Should not happen in CONNECTED state
StreamPeerTCP.STATUS_ERROR:
if state != ConnectionState.ERROR:
push_error("SimBridge: TCP connection error")
_set_state(ConnectionState.ERROR)
StreamPeerTCP.STATUS_NONE:
if state == ConnectionState.CONNECTED:
push_warning("SimBridge: connection lost")
_set_state(ConnectionState.DISCONNECTED)
# -- Input / snapshot ----------------------------------------------------------
# Send input to simulation server.
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
# In test mode, inputs are delegated to the test harness.
# In live mode, encoded and buffered for transport.
# Returns OK on success, or an error code on failure.
func send_input(player_input: Dictionary) -> Error:
if state != ConnectionState.CONNECTED:
return ERR_CONNECTION_ERROR
if test_mode:
var action: int = player_input.get("action", -1)
var wire_name: String = action_enum_to_wire(action)
if not wire_name.is_empty():
if wire_name == "SetFacing":
var facing: String = ""
var action_data: Variant = player_input.get("action_data")
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
if not facing.is_empty():
harness.process_facing(facing)
else:
harness.process_input(wire_name)
return OK
var action_name := action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
return ERR_INVALID_PARAMETER
var tick: int = GameState.current_tick
var entry: Dictionary = {"tick": tick, "action_name": action_name}
var action_data: Variant = player_input.get("action_data")
if action_data != null:
entry["action_data"] = action_data
_outbound_buffer.append(entry)
return OK
## Queue a named PlayerAction by wire string (e.g. "RequestBookmarkCatalog").
## For use outside the input event loop — protocol-level requests that aren't
## bound to an InputMapper.Action enum value.
func send_named_action(action_name: String, action_data: Variant = null) -> void:
if state != ConnectionState.CONNECTED:
push_warning("SimBridge.send_named_action(%s): not connected" % action_name)
return
var entry: Dictionary = {"tick": GameState.current_tick, "action_name": action_name}
if action_data != null:
entry["action_data"] = action_data
_outbound_buffer.append(entry)
## Request a body's generation-cascade layers from the server (#960, D-225).
## Live mode only — sends an AtlasLayerRequest frame; the response arrives via the
## atlas_layers_received signal. No-op in test mode (no server connection).
func request_atlas_layers(body_id: String, up_to: String = "Topography") -> void:
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_atlas_layer_request(body_id, up_to)
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error(
(
"SimBridge: failed to send atlas layer request for %s: %s"
% [body_id, error_string(err)]
)
)
## Request the Reach-level star map / system list from the server (T-949,
## D-010 — replaces the client's direct star_map_data.json file read). Live
## mode only; the response arrives via the star_map_received signal. Safe to
## call before the handshake completes — the request is remembered
## (_star_map_wanted) and replayed automatically once CONNECTED (see
## _set_state), so callers don't need to poll or retry themselves.
func request_star_map() -> void:
_star_map_wanted = true
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
_send_star_map_request()
func _send_star_map_request() -> void:
# Same guard as request_star_map(): _star_map_wanted persists on this
# autoload across gdUnit suites, so the replay-on-CONNECTED path in
# _set_state() can fire in test mode where _bridge is null — an unguarded
# send crashed every later suite that called connect_to_sim() (8 tests,
# 2026-07-14). Live mode: _bridge exists before CONNECTED, guard passes.
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_star_map_request()
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error("SimBridge: failed to send star map request: %s" % error_string(err))
## Request one body's atlas city-name pool from the server (T-949, D-223/
## D-236). Live mode only — sends a CityNamesRequest frame; the response
## arrives via the city_names_received signal. No-op in test mode. Never
## called for Sol bodies (system GJ-0) — atlas_viewer.gd keeps the legacy
## markers.json geometry read for those (D-236, T-1073).
func request_city_names(body_id: String) -> void:
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_city_names_request(body_id)
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error(
(
"SimBridge: failed to send city names request for %s: %s"
% [body_id, error_string(err)]
)
)
## Request one entity kind's index list from the browser proxy (T-1131/
## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Index}
## frame; the response arrives via browse_response_received. filter_system_id
## only means anything for kind == "Body" (bodies filter by containing
## system) — pass "" (default) for every other kind or for an unfiltered
## Body index. No "wanted before connect" replay bookkeeping like
## request_star_map(): the browser screens call this on-demand while the app
## is open and connected (a live drill-down request, not a session-scoped
## dataset fetched once at boot), so a request issued before CONNECTED is
## simply not sent — the screen re-requests on its own enter()/refresh path
## the next time it's shown.
func request_browse_index(kind: String, filter_system_id: String = "") -> void:
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_browse_request(kind, "Index", filter_system_id)
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error(
"SimBridge: failed to send browse index request for %s: %s" % [kind, error_string(err)]
)
## Request one entity's full detail row from the browser proxy (T-1131/
## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Detail}
## frame; the response arrives via browse_response_received. entity_id is the
## same "id" field an Index row returned for this kind.
func request_browse_detail(kind: String, entity_id: String) -> void:
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_browse_request(kind, "Detail", entity_id)
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error(
(
"SimBridge: failed to send browse detail request for %s/%s: %s"
% [kind, entity_id, error_string(err)]
)
)
# Poll for snapshot from simulation.
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
func poll_snapshot() -> Variant:
if state != ConnectionState.CONNECTED:
return null
if test_mode:
var snapshot = harness.snapshot()
snapshot_received.emit(snapshot)
return snapshot
if _last_snapshot != null:
var snapshot = _last_snapshot
_last_snapshot = null
snapshot_received.emit(snapshot)
return snapshot
return null
# Called by transport layer when raw bytes arrive from the server.
# Latest-wins for positional state (stale frames are worthless), but one-shot
# events (monologue, dialogue) are carried forward from overwritten snapshots
# so they aren't silently dropped when server ticks faster than client consumes.
func receive_bytes(bytes: PackedByteArray) -> void:
# Decode once, branch by frame shape (#960, D-225; T-949 adds starmap/
# citynames; T-1131/T-1133 adds browse): all response kinds and
# snapshots are msgpack maps, told apart by field.
var inbound := Protocol.decode_inbound(bytes)
if inbound.kind == "atlas":
atlas_layers_received.emit(inbound.value)
return
if inbound.kind == "starmap":
star_map_received.emit(inbound.value)
return
if inbound.kind == "citynames":
city_names_received.emit(inbound.value)
return
if inbound.kind == "browse":
browse_response_received.emit(inbound.value)
return
if inbound.kind != "snapshot":
push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size())
return
var snapshot = inbound.value
if snapshot == null:
push_warning("SimBridge: snapshot decode returned null for %d bytes" % bytes.size())
return
if _last_snapshot != null:
# Carry forward one-shot events the client hasn't consumed yet.
if (
snapshot.get("current_monologue") == null
and _last_snapshot.get("current_monologue") != null
):
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
if (
snapshot.get("current_dialogue") == null
and _last_snapshot.get("current_dialogue") != null
):
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through)
if (
snapshot.get("dialogue_response") == null
and _last_snapshot.get("dialogue_response") != null
):
snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
if old_conv_events.size() > 0:
var new_conv_events: Array = snapshot.get("conversation_events", [])
snapshot["conversation_events"] = old_conv_events + new_conv_events
var old_conv_ended: Array = _last_snapshot.get("conversation_ended", [])
if old_conv_ended.size() > 0:
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
# #554: Carry forward save/load result (one-shot, consumed by main.gd)
if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
snapshot["save_result"] = _last_snapshot["save_result"]
# #646: Carry forward settings_response (one-shot, consumed by game_state apply_snapshot)
if (
snapshot.get("settings_response") == null
and _last_snapshot.get("settings_response") != null
):
snapshot["settings_response"] = _last_snapshot["settings_response"]
# #872: Carry forward bookmark_catalog (one-shot, consumed by main_menu._on_snapshot_received_for_catalog).
# Server sends catalog on tick 0 and after RequestBookmarkCatalog. If tick 0 and tick 1
# arrive in the same TCP batch, the inner receive loop overwrites _last_snapshot and the
# catalog is silently lost — this carry-forward prevents that race.
if (
snapshot.get("bookmark_catalog") == null
and _last_snapshot.get("bookmark_catalog") != null
):
snapshot["bookmark_catalog"] = _last_snapshot["bookmark_catalog"]
_last_snapshot = snapshot
# Drain the outbound buffer. Returns raw input entries for batch encoding.
func drain_outbound() -> Array[Dictionary]:
var inputs = _outbound_buffer.duplicate()
_outbound_buffer.clear()
return inputs
# -- Wire protocol mapping -----------------------------------------------------
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
static func action_enum_to_wire(action: int) -> String:
match action:
InputMapper.Action.MOVE_NORTH:
return "MoveNorth"
InputMapper.Action.MOVE_NORTHEAST:
return "MoveNortheast"
InputMapper.Action.MOVE_EAST:
return "MoveEast"
InputMapper.Action.MOVE_SOUTHEAST:
return "MoveSoutheast"
InputMapper.Action.MOVE_SOUTH:
return "MoveSouth"
InputMapper.Action.MOVE_SOUTHWEST:
return "MoveSouthwest"
InputMapper.Action.MOVE_WEST:
return "MoveWest"
InputMapper.Action.MOVE_NORTHWEST:
return "MoveNorthwest"
InputMapper.Action.INTERACT:
return "Interact"
InputMapper.Action.USE_PERCEPTION_MODE:
return "UsePerceptionMode"
InputMapper.Action.PAUSE:
return "Pause"
InputMapper.Action.UNPAUSE:
return "Unpause"
InputMapper.Action.TOGGLE_STANCE_UP:
return "ToggleStanceUp"
InputMapper.Action.TOGGLE_STANCE_DOWN:
return "ToggleStanceDown"
InputMapper.Action.OPEN_MENU:
return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT:
return "" # Client-only action (#495), not part of wire protocol
InputMapper.Action.SET_FACING:
return "SetFacing" # D-054: facing octant update (no movement)
InputMapper.Action.TELEPORT_HUB:
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
InputMapper.Action.SAVE_GAME:
return "SaveGame" # #554: F5 quicksave (D-085)
InputMapper.Action.LOAD_GAME:
return "LoadGame" # #554: F6 quickload (D-085)
InputMapper.Action.DEBUG_COMMAND:
return "DebugCommand" # #581: debug console command dispatch
InputMapper.Action.CHANGE_SETTINGS:
return "ChangeSettings" # #646: persist setting to server SQLite (D-138)
InputMapper.Action.REQUEST_ALL_SETTINGS:
return "RequestAllSettings" # #646: unit variant — server sends full settings dump
InputMapper.Action.DELETE_SETTING:
return "DeleteSetting" # #646: struct variant — delete setting by key
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""