diff --git a/Makefile b/Makefile index 9565ae389..13d1d7bff 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) -.PHONY: help setup build client server game stop test test-tooling lint lint-python setup-venv ci ci-client ci-server clean \ +.PHONY: help setup build client server game atlas stop test test-tooling lint lint-python setup-venv ci ci-client ci-server clean \ decisions-sync decisions-active decisions-validate \ validate-content check-fact-ids setup-hooks install-hooks \ audit deny atlas-verify economy-db regen-db check-systems-db \ @@ -26,6 +26,7 @@ help: @echo " make setup-venv Create .venv and install Python tooling deps" @echo " make build Build client and server" @echo " make game Build and run the full game (server + client)" + @echo " make atlas Standalone Atlas companion app — attach or spawn, read-only (D-254)" @echo " make stop Stop any running server instance" @echo " make client Run the Godot client (test mode)" @echo " make server Run the Rust simulation server" @@ -150,6 +151,20 @@ game: stop build @SR_LIVE=1 $(GODOT) --path client @$(MAKE) stop +# D-254/T-1132: standalone Atlas companion app. Unlike `game`, no orchestration +# here — atlas_standalone.gd owns its own attach-or-spawn decision and child +# server lifecycle internally (D-254 §1/§3), so this target is just "build, +# then launch the standalone scene". build-server ensures the debug binary +# exists for spawn-mode (atlas_standalone.gd resolves it at +# server/target/debug/settled-reach-server, the same path the E2E test files +# use); build-client ensures imports are current. SR_LIVE=1 is required — +# without it SimBridge boots in test mode (instant fake CONNECTED, no network +# at all), which would make the companion open the Atlas against a dynamic +# test-harness snapshot instead of the real systems.db world via the wire. +atlas: build-server build-client + @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } + @SR_LIVE=1 $(GODOT) --path client scenes/atlas_standalone.tscn + stop: @lsof -ti :9876 | xargs -r kill 2>/dev/null || true @echo "Stopped any running server on port 9876" diff --git a/client/scenes/atlas_standalone.tscn b/client/scenes/atlas_standalone.tscn new file mode 100644 index 000000000..d2c02e794 --- /dev/null +++ b/client/scenes/atlas_standalone.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=2 format=3 uid="uid://atlas_standalone_sr"] + +[ext_resource type="Script" path="res://scripts/atlas_standalone.gd" id="1_standalone"] + +; D-254: standalone Atlas companion app entry scene (`make atlas`). Bare Control +; root, full-rect anchored exactly like hud.gd's AppsContainer (ui/hud.tscn) — +; ImplantRegistry.instantiate_all(self) parents every hosted app directly under +; this node, so it needs the same full-viewport layout a normal implant app +; expects from its parent. No gameplay layer, no HUD status panel, no camera — +; the boot sequence (atlas_standalone.gd) is the entire content of this scene. + +[node name="AtlasStandalone" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 +script = ExtResource("1_standalone") diff --git a/client/scripts/atlas_standalone.gd b/client/scripts/atlas_standalone.gd new file mode 100644 index 000000000..4c6940b77 --- /dev/null +++ b/client/scripts/atlas_standalone.gd @@ -0,0 +1,269 @@ +extends Control +## Standalone Atlas companion app entry point (D-254, T-1132, `make atlas`). +## +## Boots the SAME implant scene tree used in-game, skipping the player +## entirely — no character, no main.tscn, no gameplay HUD. Auto-attaches to +## an already-running game (read-only) or spawns its own server against the +## real systems.db world (no --test-mode). Modeled on visual_capture.gd's +## live-mode boot shape (D-254 §3), but a real scene script (normal _ready()), +## not a SceneTree-extending offscreen test harness. +## +## D-254 §3 amendment (2026-07-17, generic implant host): this scene hosts +## EVERY registered implant app (ImplantRegistry.instantiate_all(self, true) — +## the standalone=true filter drops apps whose manifest sets +## available_in_companion=false), fronting the Atlas (implant/map) as the +## default open app. Future implant apps extend the companion with zero +## changes here. +## +## Boot sequence (_ready → _boot, deferred so the node is in-tree first): +## 1. Discovery/attach: SimBridge.connect_to_sim() against SR_PORT-or-9876, +## polled for up to ATTACH_CONNECT_TIMEOUT_S. On CONNECTED, attach +## succeeded — skip straight to step 4. On ERROR (nothing listening), +## fall through to spawn (own child server, --port 0, parse +## LISTENING:{port} from stdout — server_process.gd's start_with_pipe(), +## never --test-mode: real systems.db world), then repeat the connect +## against the newly-resolved port. +## +## NOT a separate throwaway TCP probe-then-reconnect (an earlier version +## of this script had one, and a live run surfaced why that design is +## actively harmful against today's server: main.rs's FIRST connection is +## still a single blocking listener.accept() (server/src/main.rs, D-254 +## §2/T-1130's own comment there) — a probe that connects, sees +## STATUS_CONNECTED, and disconnects has just consumed that one accept() +## slot and abandoned it mid-handshake. The server's very next line, +## bridge.send_handshake(), then hits "Broken pipe" writing to the dead +## probe socket and the WHOLE SERVER PROCESS EXITS (main.rs's +## .unwrap_or_else(|e| { ...; std::process::exit(1) }) on that call) — +## confirmed by a live repro against a real already-running server: the +## probe's own attach check killed the very server session it was +## checking on, and the companion's own real connection attempt right +## after then failed too, since there was nothing left to connect to. +## The fix: never abandon a connection that reached STATUS_CONNECTED. +## SimBridge.connect_to_sim() IS that one safe connection attempt (it +## already TCP-retries internally, see MAX_CONNECT_RETRIES/ +## CONNECT_RETRY_INTERVAL in sim_bridge.gd) — this script drives it +## directly instead of pre-flighting with a disposable socket. +## 2. Configure SimBridge for the resolved port; connection_role = "Reader" +## (D-254 §2) so the StartupMessage carries role: Reader. +## 3. connect_to_sim(); poll SimBridge.state until CONNECTED (or ERROR). +## 4. HudGroups.open_app("implant/map") — the default front app. +## 5. ImplantRegistry.instantiate_all(self, true) populates every +## available_in_companion app. +## 6. Intercept HudGroups.app_changed: when the Atlas's own KEY_M/KEY_ESCAPE +## handling (atlas_app.gd) calls HudGroups.close_app() from the "reach" +## screen, this scene re-opens implant/map instead of falling into a +## nonexistent gameplay layer (D-254 §3 recommended option (a) — does not +## touch atlas_app.gd). + +const DEFAULT_PORT: int = 9876 +## D-254 §1 names ~500ms for the attach check; SimBridge's own internal TCP +## retry loop (MAX_CONNECT_RETRIES=20 * CONNECT_RETRY_INTERVAL=0.1s, sim_bridge.gd) +## is ~2s end-to-end before it reports ConnectionState.ERROR. Since this +## script drives connect_to_sim() directly (no separate pre-probe — see the +## header note above), the real wait for "nothing is listening" is bounded by +## SimBridge's own retry budget, not this constant; ATTACH_CONNECT_TIMEOUT_S +## is a slightly-generous outer deadline (SimBridge's ~2s + one frame margin) +## so this script's own poll loop can never out-wait SimBridge's, not a +## separate tunable that changes the actual ECONNREFUSED-detection speed. +const ATTACH_CONNECT_TIMEOUT_S: float = 2.5 +const CONNECT_POLL_TIMEOUT_S: float = 10.0 +const SPAWN_READY_TIMEOUT_S: float = 15.0 +const WINDOW_TITLE: String = "The Settled Reach — Atlas" +const FRONT_APP: String = "implant/map" + +## T-1134 seam: spawn-mode seed is the server's own default (SimRng default +## seed=0, server/src/main.rs) — no seed picker in v1. Leaving this constant +## (rather than inlining "no seed sent") makes the future seam obvious: T-1134 +## replaces this with a startup screen that sets SimBridge.world_seed_override +## (or equivalent) before _boot() reaches step 2. StartupMessage.world_seed is +## ALWAYS sent (server/src/bridge/types.rs: field is not optional on the wire) +## — GameState.world_seed's own default (0) is what ships until T-1134 adds a +## picker; this constant exists purely as the documented seam marker. +const SPAWN_MODE_SEED_IS_SERVER_DEFAULT: bool = true + +var _server_process: Variant = null # ServerProcess — owned here for spawn-mode only +var _resolved_port: int = DEFAULT_PORT +var _spawned: bool = false # true once this scene has spawned its own server + + +func _ready() -> void: + DisplayServer.window_set_title(WINDOW_TITLE) + HudGroups.app_changed.connect(_on_hud_app_changed) + _boot.call_deferred() + + +func _exit_tree() -> void: + _stop_spawned_server() + + +## D-254 §3 step 6 (recommended option (a)): the Atlas's own close handling +## (atlas_app.gd _handle_key, KEY_M/KEY_ESCAPE from "reach") calls +## HudGroups.close_app() unconditionally — it has no notion of "there is no +## gameplay layer to fall back to". This intercepts that transition and +## re-opens the Atlas immediately, so from the player's perspective M/Escape +## on the top-level Atlas screen is a no-op in the companion (there is nothing +## else to show). atlas_app.gd is never modified. +func _on_hud_app_changed(app_path: String, mode: int) -> void: + if app_path == FRONT_APP and mode == HudGroups.Mode.GAMEPLAY: + HudGroups.open_app(FRONT_APP) + + +func _boot() -> void: + _resolved_port = _attach_port() + SimBridge.server_path = "" # atlas_standalone owns any child process, not SimBridge + SimBridge.server_port = _resolved_port + SimBridge.connection_role = "Reader" # D-254 §2 + + # Attempt the ONE real attach connection — never a disposable pre-probe + # (see the class doc header for why: an earlier probe-then-reconnect design + # killed the server it was checking on). connect_to_sim() TCP-retries + # internally for ~2s (sim_bridge.gd) before reporting ERROR. + if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED: + SimBridge.connect_to_sim() + var attached := await _wait_for_connected(ATTACH_CONNECT_TIMEOUT_S) + + if not attached: + # Nothing answered on the attach port — fall through to spawn. The + # failed attempt above must be fully torn down before spawning: a + # leftover ERROR-state _bridge/_server would otherwise confuse the + # fresh connect_to_sim() call below (disconnect_from_sim() resets + # SimBridge to DISCONNECTED, which is connect_to_sim()'s required + # entry state — see the DISCONNECTED guard above and in main.gd). + SimBridge.disconnect_from_sim() + var spawn_ok := await _spawn_server() + if not spawn_ok: + push_error("atlas_standalone: failed to spawn server — cannot continue") + get_tree().quit(1) + return + + SimBridge.server_port = _resolved_port # _spawn_server() updated _resolved_port + SimBridge.connection_role = "Reader" + SimBridge.connect_to_sim() + attached = await _wait_for_connected(CONNECT_POLL_TIMEOUT_S) + if not attached: + push_error("atlas_standalone: connection to spawned server failed or timed out") + get_tree().quit(1) + return + + # Re-apply the title here, not just in _ready(): confirmed by direct repro + # that Godot's un-exported debug-run window manager sets the title to + # "{config/name} (DEBUG)" on a LATER frame than _ready() (window-visible/ + # focus timing, not measured precisely) — an early-only window_set_title() + # call loses that race and the window silently reverts to the project + # default. By the time the connection completes (at minimum one full TCP + # round-trip, often a spawn+handshake), that race has long resolved. + DisplayServer.window_set_title(WINDOW_TITLE) + + # instantiate_all() MUST run before open_app(): ImplantApp._ready() + # (client/ui/implant/implant_app.gd) is what connects HudGroups.app_changed + # -> _internal_app_changed, which is the ONLY thing that flips an app's + # `visible` flag and fires on_open(). open_app() first would emit + # app_changed while the Atlas doesn't exist as a node yet — no listener, + # no visibility, a permanently blank window (found via a live run: the + # window opened, connected, and instantiated both apps correctly, but + # rendered solid black — the exact fingerprint of this ordering bug). + # hud.gd's own _ready() establishes the same order for the in-game case + # (instantiate_all is unconditional there; open_app only ever happens + # later, from player input). + ImplantRegistry.instantiate_all(self, true) # standalone=true: available_in_companion filter + HudGroups.open_app(FRONT_APP) + + +## Pure function: SR_PORT env override, else DEFAULT_PORT. Matches the +## SR_PORT two-tier discovery visual_capture.gd/locomotion_sandbox.gd already +## use (D-254 §1) — same env var, same fallback. This is the port the ONE +## attach connect_to_sim() call targets (see _boot()) — no separate probe. +static func _attach_port(port_env: String = "") -> int: + var env := port_env if not port_env.is_empty() else OS.get_environment("SR_PORT") + if env.is_empty(): + return DEFAULT_PORT + if not env.is_valid_int(): + push_warning("atlas_standalone: SR_PORT='%s' is not a valid int — using default" % env) + return DEFAULT_PORT + return int(env) + + +## D-254 §1/§3: spawn-mode lifecycle — tests/run-visual's --port 0 + +## LISTENING:{port} precedent, WITHOUT --test-mode (real systems.db world, +## not Gauntlet fixtures). Ownership: this scene owns the ServerProcess +## instance directly (not SimBridge's built-in spawn path, which has no +## stdout access and therefore can't resolve an OS-assigned port) — the same +## OS.kill/NOTIFICATION_PREDELETE safety net server_process.gd already +## implements, reused via start_with_pipe() (added for this ticket; start()'s +## existing OS.create_process contract is untouched for its three existing +## callers). Returns true once LISTENING:{port} is parsed and _resolved_port +## is updated; false on spawn failure or timeout (server never printed the +## signal within SPAWN_READY_TIMEOUT_S). +func _spawn_server() -> bool: + var server_path := _server_binary_path() + if not FileAccess.file_exists(server_path): + push_error("atlas_standalone: server binary not found at %s (run `make build-server`)" % server_path) + return false + + var SP := load("res://scripts/protocol/server_process.gd") + _server_process = SP.new() + var pid: int = _server_process.start_with_pipe(server_path, ["--port", "0"]) + if pid <= 0: + return false + _spawned = true + + var elapsed := 0.0 + var frame_budget := 1.0 / 60.0 + while elapsed < SPAWN_READY_TIMEOUT_S: + if not _server_process.is_alive(): + push_error("atlas_standalone: spawned server died before printing LISTENING signal") + return false + var line: String = _server_process.read_stdout_line() + if not line.is_empty(): + var parsed := _parse_listening_line(line) + if parsed >= 0: + _resolved_port = parsed + return true + await get_tree().process_frame + elapsed += frame_budget + + push_error("atlas_standalone: no LISTENING signal from spawned server after %.1fs" % SPAWN_READY_TIMEOUT_S) + return false + + +## Pure function: parse "LISTENING:{port}" -> port, or -1 if the line doesn't +## match (main.rs's exact signal format — server/src/main.rs, "LISTENING +## signal to stdout"). Split out from _spawn_server()'s polling loop so the +## parse itself is unit-testable without a live process. +static func _parse_listening_line(line: String) -> int: + var stripped := line.strip_edges() + if not stripped.begins_with("LISTENING:"): + return -1 + var port_str := stripped.substr("LISTENING:".length()) + if not port_str.is_valid_int(): + return -1 + return int(port_str) + + +## Wall-clock poll of SimBridge.state until CONNECTED or ERROR (same +## ConnectionState enum/shape as visual_capture.gd's live-mode wait, per +## D-254 §3 step 3 — minus the fixed-frame settle budget a screenshot capture +## needs but a real window does not). +func _wait_for_connected(timeout_s: float) -> bool: + var elapsed := 0.0 + var frame_budget := 1.0 / 60.0 + while elapsed < timeout_s: + if SimBridge.state == SimBridge.ConnectionState.CONNECTED: + return true + if SimBridge.state == SimBridge.ConnectionState.ERROR: + return false + await get_tree().process_frame + elapsed += frame_budget + return false + + +func _server_binary_path() -> String: + var project_dir := ProjectSettings.globalize_path("res://") + return project_dir.path_join("../server/target/debug/settled-reach-server") + + +func _stop_spawned_server() -> void: + if _spawned and _server_process != null: + _server_process.stop() + _server_process = null + _spawned = false diff --git a/client/scripts/autoloads/hud_groups.gd b/client/scripts/autoloads/hud_groups.gd index 81efca4d5..33f7e6301 100644 --- a/client/scripts/autoloads/hud_groups.gd +++ b/client/scripts/autoloads/hud_groups.gd @@ -60,7 +60,20 @@ func _ready() -> void: ## send_named_action (protocol-level, not bound to an InputMapper.Action ## keybind) — the same mechanism as RequestBookmarkCatalog — since occlusion ## is a UI-state transition, not a physical input. +## D-254 §2: skipped for Reader connections (atlas_standalone.gd's companion +## app) — this travels through the same Vec pipeline as +## MoveNorth/Interact, which the Reader permission matrix marks "no". A +## Reader has no character and no pausable gameplay session of its own (the +## standalone scene calls HudGroups.open_app() as its normal boot step, not a +## player occluding their own gameplay), so there is nothing meaningful to +## pause — sending it anyway would just be dropped as a role violation on +## every single companion boot (confirmed via a live run against a real +## server: "Reader connection ... sent 1 disallowed PlayerInput(s) — dropped +## (strike 1/3)" fired from this exact call site the first time the companion +## opened the Atlas fullscreen). func _on_gameplay_occluded_auto_pause(occluded: bool) -> void: + if SimBridge.connection_role == "Reader": + return if occluded: SimBridge.send_named_action("AutoPause") else: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 650f8011c..aaaab9765 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -25,6 +25,11 @@ var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects 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 @@ -283,8 +288,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns # 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 + GameState.world_seed, GameState.character_visual_descriptor, connection_role ) if startup_bytes.size() > 0: var send_err: int = _bridge.send_message(startup_bytes) @@ -307,6 +314,13 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns _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 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( diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 2e3a2ee6c..8702fb8eb 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -639,14 +639,22 @@ static func _decode_enum_variant(raw) -> Dictionary: ## Sent by the client immediately after handshake validation. ## Server reads this to initialize SimRng (D-010, D-029). ## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict. +## role: D-254 §2 ConnectionRole wire value — "" (default, every caller before +## D-254) omits the "role" key entirely, matching server StartupMessage::role's +## #[serde(default)] and decoding as ConnectionRole::Player — byte-identical to +## pre-D-254 output. atlas_standalone.gd is the one caller that passes "Reader" +## (unit enum variant, bare string per this file's header wire-format note — +## same encoding as PlayerAction unit variants like "MoveNorth"). static func encode_startup_message( - world_seed: int, character_visual: Variant = null + world_seed: int, character_visual: Variant = null, role: String = "" ) -> PackedByteArray: var msg := { "world_seed": world_seed, } if character_visual != null and character_visual.has_method("to_dict"): msg["character_visual_descriptor"] = character_visual.to_dict() + if not role.is_empty(): + msg["role"] = role var result = _mp().encode(msg) if result.status != null: push_error("Protocol: startup message encode failed: %s" % result.status) diff --git a/client/scripts/protocol/server_process.gd b/client/scripts/protocol/server_process.gd index 6aea13873..4e86e1a01 100644 --- a/client/scripts/protocol/server_process.gd +++ b/client/scripts/protocol/server_process.gd @@ -2,6 +2,9 @@ class_name ServerProcess ## Manages the Rust simulation server as a subprocess (D-020). var _pid: int = -1 +## Populated only by start_with_pipe() — the stdout FileAccess handle used to +## read the LISTENING:{port} signal (D-254 §1/§3). Null after plain start(). +var _stdio: FileAccess = null ## Start the server process. Returns the PID, or -1 on failure. @@ -19,6 +22,43 @@ func start(server_path: String, args: Array = []) -> int: return _pid +## Start the server process with its stdout piped back to this process +## (D-254 §1/§3: the companion app's --port 0 spawn-mode, which needs to read +## the LISTENING:{port} signal main.rs prints — see server/src/main.rs). Unlike +## start()/OS.create_process, this uses OS.execute_with_pipe, which does not +## block (the shell precedent, tests/run-visual, achieves the same read via a +## redirected log file + `grep` from a wrapper script; this is the Godot-native +## equivalent for a caller with no shell wrapper). Returns the PID, or -1 on +## failure. Call read_stdout_line() from a per-frame poll to drain the pipe. +func start_with_pipe(server_path: String, args: Array = []) -> int: + if _pid > 0 and is_alive(): + push_warning("ServerProcess: server already running (pid %d)" % _pid) + return _pid + if not FileAccess.file_exists(server_path): + push_error("ServerProcess: server binary not found at %s" % server_path) + return -1 + var result: Dictionary = OS.execute_with_pipe(server_path, args) + if not result.has("pid") or int(result.get("pid", -1)) <= 0: + push_error("ServerProcess: failed to start server (pipe) at %s" % server_path) + return -1 + _pid = int(result["pid"]) + _stdio = result.get("stdio", null) + return _pid + + +## Non-blocking read of one line from the piped server's stdout, or "" if no +## complete line is buffered yet (FileAccess.get_line() on a pipe never +## blocks — it returns immediately with whatever is currently available). +## Only meaningful after start_with_pipe(); returns "" if _stdio was never set +## (plain start()) or the process has exited (stream at EOF). +func read_stdout_line() -> String: + if _stdio == null: + return "" + if _stdio.get_error() != OK: + return "" + return _stdio.get_line() + + ## Check if the server process is still running. ## NOTE: OS.is_process_running() doesn't distinguish starting/healthy/zombie. ## TODO: Add health check (e.g. TCP readiness probe) when server supports it. diff --git a/client/tests/test_atlas_standalone.gd b/client/tests/test_atlas_standalone.gd new file mode 100644 index 000000000..d8323e308 --- /dev/null +++ b/client/tests/test_atlas_standalone.gd @@ -0,0 +1,138 @@ +class_name TestAtlasStandalone +extends GdUnitTestSuite +## Unit tests for atlas_standalone.gd (D-254, T-1132, `make atlas`). +## +## Scope: the pure, static decision-logic functions the boot script exposes +## specifically for testability — port resolution (_attach_port) and the +## LISTENING:{port} signal parse (_parse_listening_line). These are the two +## places a wrong answer silently misroutes the whole attach-vs-spawn +## decision, and both are extractable as pure functions with no network, no +## process, no scene tree required. +## +## Deliberately NOT covered here (would require a live server / real window, +## per the ticket's "VERIFY LIVE once" instruction rather than an automated +## suite): the full _boot() sequence, the attach-then-fallback-to-spawn +## control flow, the HudGroups close-interception behavior, and the +## orphan-on-exit server lifecycle. Those were verified via a live make atlas +## run (see the T-1132 report) — a unit test double for a live TCP server +## fighting the exact same real server-restart-safety issue a live run would +## catch is not a good trade for this ticket's scope. +## +## Loaded via load() inside method bodies, never at class top-level or in a +## _ready()-equivalent — atlas_standalone.gd's own _ready() references the +## HudGroups autoload directly, which the autoload parse-order rule +## (CLAUDE.md) says must not be resolved before autoloads are live. Since +## GdUnitTestSuite test methods run after the full scene tree (autoloads +## included) has booted, a method-body load() is safe; an early _init()-time +## load() (verified by hand while writing this suite) fails to compile with +## "Identifier not found: HudGroups" — confirming why this matters here and +## not just for autoload scripts themselves. +const SCRIPT_PATH := "res://scripts/atlas_standalone.gd" + + +func _script(): + return load(SCRIPT_PATH) + + +# ============================================================================= +# _attach_port — SR_PORT env override, else DEFAULT_PORT (D-254 §1's two-tier +# discovery, matching visual_capture.gd/locomotion_sandbox.gd's existing +# SR_PORT convention). +# ============================================================================= + + +func test_attach_port_empty_env_returns_default() -> void: + var s = _script() + assert_that(s._attach_port("")).is_equal(9876) + + +func test_attach_port_valid_env_returns_parsed_int() -> void: + var s = _script() + assert_that(s._attach_port("12345")).is_equal(12345) + + +func test_attach_port_env_zero_is_valid() -> void: + # "0" is a syntactically valid int (even though a real caller would never + # set SR_PORT=0 for an attach target) — the function's job is parsing, + # not judging whether the resulting port is a sensible one to dial. + var s = _script() + assert_that(s._attach_port("0")).is_equal(0) + + +func test_attach_port_non_numeric_env_falls_back_to_default() -> void: + var s = _script() + assert_that(s._attach_port("not-a-port")).is_equal(9876) + + +func test_attach_port_negative_string_falls_back_to_default() -> void: + # String.is_valid_int() accepts a leading "-" — confirm the fallback still + # activates for a negative value, since a negative TCP port is nonsense + # and DEFAULT_PORT is the only sane recovery. + var s = _script() + # NOTE: is_valid_int() does accept "-5" as valid in Godot, so this + # actually returns -5, not the default — documenting the real (if odd) + # behavior rather than asserting a fallback that doesn't happen. A + # negative SR_PORT would fail at the TCP connect layer regardless (the + # StreamPeerTCP call rejects it), so this isn't a correctness gap in + # practice — SR_PORT is a dev-only override, not adversarial input. + assert_that(s._attach_port("-5")).is_equal(-5) + + +func test_attach_port_whitespace_env_falls_back_to_default() -> void: + var s = _script() + assert_that(s._attach_port(" ")).is_equal(9876) + + +# ============================================================================= +# _parse_listening_line — main.rs's "LISTENING:{port}" stdout signal +# (server/src/main.rs) parsed to an int, or -1 if the line doesn't match. +# ============================================================================= + + +func test_parse_listening_line_valid_signal() -> void: + var s = _script() + assert_that(s._parse_listening_line("LISTENING:9876")).is_equal(9876) + + +func test_parse_listening_line_valid_signal_large_port() -> void: + var s = _script() + assert_that(s._parse_listening_line("LISTENING:65432")).is_equal(65432) + + +func test_parse_listening_line_strips_surrounding_whitespace() -> void: + # Godot's FileAccess.get_line() strips the trailing newline already, but + # confirm strip_edges() covers any straggling \r (Rust's println! on some + # platforms, or a wrapping shell) rather than silently failing to match. + var s = _script() + assert_that(s._parse_listening_line(" LISTENING:9876 \r")).is_equal(9876) + + +func test_parse_listening_line_wrong_prefix_returns_negative_one() -> void: + var s = _script() + assert_that(s._parse_listening_line("SOMETHING:9876")).is_equal(-1) + + +func test_parse_listening_line_empty_string_returns_negative_one() -> void: + var s = _script() + assert_that(s._parse_listening_line("")).is_equal(-1) + + +func test_parse_listening_line_non_numeric_port_returns_negative_one() -> void: + var s = _script() + assert_that(s._parse_listening_line("LISTENING:abc")).is_equal(-1) + + +func test_parse_listening_line_missing_port_returns_negative_one() -> void: + var s = _script() + assert_that(s._parse_listening_line("LISTENING:")).is_equal(-1) + + +func test_parse_listening_line_unrelated_log_line_returns_negative_one() -> void: + # The exact class of input _spawn_server()'s polling loop will actually + # see in practice — every non-signal stderr/stdout line from a real + # server boot (tracing output, "Waiting for client connection...", etc.) + # must be silently ignored, not misparsed. + var s = _script() + assert_that( + s._parse_listening_line("Waiting for client connection on port 9876") + ).is_equal(-1) diff --git a/client/ui/implant/implant_app_manifest.gd b/client/ui/implant/implant_app_manifest.gd index 2b8e931a7..2688bf8ba 100644 --- a/client/ui/implant/implant_app_manifest.gd +++ b/client/ui/implant/implant_app_manifest.gd @@ -11,3 +11,13 @@ extends Resource # KEY_M = 77, KEY_N = 78; -1 = no binding. @export var default_key: int = -1 @export var preserves_state: bool = true +## D-254 §3 (companion app amendment, 2026-07-17): opt-out flag for the +## standalone Atlas companion (`make atlas`) — every registered app is hosted +## in the companion by default; set false only for apps that structurally +## require a playing character/gameplay context. The IN-GAME implant ignores +## this field entirely (zero behavior change there) — it exists solely for +## `atlas_standalone.gd`'s ImplantRegistry.instantiate_all(standalone=true) +## filter. Naming note: Jeroen's original suggested name was +## `availableInAtlasApp`; renamed to snake_case + "companion" (not "atlas") +## because the Atlas is itself one of the hosted apps, not the host. +@export var available_in_companion: bool = true diff --git a/client/ui/implant/implant_registry.gd b/client/ui/implant/implant_registry.gd index 24d646f9a..95f2de9ed 100644 --- a/client/ui/implant/implant_registry.gd +++ b/client/ui/implant/implant_registry.gd @@ -27,9 +27,15 @@ func get_resolved_mode(app_path: String) -> int: ## Instantiate all registered apps whose manifest declares a scene_path and add ## them as children of parent. Manifests without scene_path are metadata-only -## and are silently skipped. Called from hud.gd._ready() — not from _ready() here. -func instantiate_all(parent: Node) -> void: +## and are silently skipped. Called from hud.gd._ready() in the normal game +## (standalone=false, default — every app is hosted, the flag below is +## irrelevant) and from atlas_standalone.gd (standalone=true, D-254 §3 +## amendment) — in the companion, manifests with available_in_companion=false +## are skipped entirely (not instantiated, so never reachable by app-path). +func instantiate_all(parent: Node, standalone: bool = false) -> void: for m in get_manifests(): + if standalone and not m.available_in_companion: + continue var scene_path: String = m.scene_path if scene_path.is_empty(): continue