diff --git a/.claude/skills/sprint-start/SKILL.md b/.claude/skills/sprint-start/SKILL.md index 541ccd0ec..cb5148525 100644 --- a/.claude/skills/sprint-start/SKILL.md +++ b/.claude/skills/sprint-start/SKILL.md @@ -288,24 +288,63 @@ Task( prompt: "You are on the {team} team for Sprint {N}. Branch: `{team}` - RULES: - - GIT: Do NOT run any git commands (commit, push, pull, merge, - checkout, branch, stash, tag, etc.). All git operations are - handled by the team lead. - - DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use - the exact command with no wrappers or chaining. Examples: - db/connectors/ticket show 528 - db/connectors/ticket list --sprint {N} - Do NOT prepend python3, do NOT chain with && or ;, do NOT - add cleanup commands. Just the bare command. + RULES (NON-NEGOTIABLE): + + 1. GIT: Do NOT run any git commands (commit, push, pull, merge, + checkout, branch, stash, tag, etc.). All git operations are + handled by the team lead. No exceptions. + + 2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use + the exact command with no wrappers or chaining. Examples: + db/connectors/ticket show 528 + db/connectors/ticket list --sprint {N} + Do NOT prepend python3, do NOT chain with && or ;, do NOT + add cleanup commands. Just the bare command. + + 3. READ BEFORE WRITE: Before modifying ANY file, Read it first. + Before creating a new file, Glob for similar files to learn + the existing patterns (naming, structure, imports). Follow + the conventions you find — do not invent new ones. + + 4. VERIFY AFTER WRITE: After implementing a change, grep for + all references to functions/properties/classes you modified + or removed. If you renamed, moved, or deleted something, + update EVERY call site. Missing a call site breaks tests + and blocks the team. + + 5. NO PARTIAL WORK: Do not mark a task completed unless ALL + parts of the ticket are implemented. If the ticket says + 'deliver A, B, and C', all three must exist and work. If + you cannot complete part of a task, message the team lead + explaining what is blocked and what remains — do NOT mark + it completed. + + 6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve + in 3 attempts, stop and message the team lead immediately. + Do not silently skip work or leave stubs. Do not move to + the next task while the current one is incomplete. + + 7. BACKWARD COMPATIBILITY: When extracting, moving, or + refactoring code, ensure all existing consumers still work. + Add proxy methods/properties if needed. Grep for the old + name to find every call site. + + WORKFLOW: 1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md 2. Read the decision files referenced in the briefing. 3. Check TaskList for available work. 4. Claim an unblocked task (TaskUpdate with owner: your name), mark it in_progress, and implement it. - 5. When done, mark the task completed and check TaskList for - the next available task. + 5. Before marking done, verify: + - All deliverables from the ticket exist (not just some) + - No broken references (grep for changed names/signatures) + - New files follow existing naming and directory conventions + - Modified files still parse (no syntax errors) + 6. Mark the task completed and check TaskList for the next + available task. + 7. If no tasks remain, message the team lead. Do NOT shut down + on your own. Use `db/connectors/ticket show ` for full ticket specs.", description: "Sprint {N} {team}: {name}", diff --git a/CHANGELOG.md b/CHANGELOG.md index 124d54482..b7f8c6cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added - Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines) +- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines) - Protocol version handshake — `HandshakeMessage` as first IPC frame before tick loop, forward-compatible input handling (#555, D-020) - Protocol v15 — `save_result` field on ObserverSnapshot for client save/load confirmation - State serialization primitives — `serialize_npc_to_frozen`/`deserialize_npc_from_frozen` with full D-024 10-axis coverage for tier eviction freeze/thaw (#96, D-026) @@ -17,6 +18,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - ScopePinned eviction regression test — adversarial at-scale test proving pinned NPCs survive eviction even with oldest ticks - Test infrastructure — Layer 3 integration test entry point, three-layer architecture per D-030 (#200) - Information boundary negative tests — 4 tests proving no passive KG leakage, LOS fog holds, tier boundary holds, per-NPC save isolation (#272, D-010) +- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205) +- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206) +- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206) +- Game session management — per-game save directories under `user://saves/-/` per D-085, SessionManager autoload, main menu scene (#258) +- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348) +- SimBridge→TestHarness extraction — test simulation logic separated into dedicated RefCounted class with backward-compat proxy API - Workshop outcomes files — formal closure for content-gap-analysis, KG-information-boundaries, v01-content-scoping, v01-gap-analysis, wiki-review - D-087 through D-092 — recovered decisions from v01-content-scoping and wiki-review workshops (triangle config, pause system, content scope, voice registers, anchor lines, complicity theme) - Q-030 through Q-039 — open questions from workshop backlog (seed schema, style guide, cultural ingredients, NPC architecture, PC archetypes, sacred/profane framework, district skeleton, generator pipeline, authored content estimate, gate topology) diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index 3f4d2d452..4cca59cca 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -170,6 +170,8 @@ dialogue: menu: pause_title: "Paused" resume: "Resume" + new_game: "New Game" + continue: "Continue" settings: "Settings" save_game: "Save" load_game: "Load" diff --git a/client/project.godot b/client/project.godot index 479146b4c..eee056cf3 100644 --- a/client/project.godot +++ b/client/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="The Settled Reach" -run/main_scene="res://scenes/main.tscn" +run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" @@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd" UIStrings="*res://scripts/autoloads/ui_strings.gd" FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" +SessionManager="*res://scripts/autoloads/session_manager.gd" [audio] diff --git a/client/scenes/main_menu.tscn b/client/scenes/main_menu.tscn new file mode 100644 index 000000000..b9cab3c20 --- /dev/null +++ b/client/scenes/main_menu.tscn @@ -0,0 +1,66 @@ +[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"] + +[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"] + +; Main menu — New Game / Continue / Quit. +; #258: D-085 per-game save directory created on New Game. + +[node name="MainMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_mainmenu") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.05, 0.05, 0.08, 1.0) +mouse_filter = 2 + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -120.0 +offset_top = -80.0 +offset_right = 120.0 +offset_bottom = 100.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 16 +alignment = 1 + +[node name="TitleLabel" type="Label" parent="VBox"] +layout_mode = 2 +text = "THE SETTLED REACH" +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 36 +theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0) + +[node name="Spacer" type="Control" parent="VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 24) + +[node name="NewGameBtn" type="Button" parent="VBox"] +layout_mode = 2 +text = "NEW GAME" +theme_override_font_sizes/font_size = 15 +theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0) + +[node name="ContinueBtn" type="Button" parent="VBox"] +layout_mode = 2 +text = "CONTINUE" +theme_override_font_sizes/font_size = 15 +theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0) + +[node name="QuitBtn" type="Button" parent="VBox"] +layout_mode = 2 +text = "QUIT" +theme_override_font_sizes/font_size = 15 +theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 881be4bd3..d96294fd2 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -1,9 +1,19 @@ extends Node +signal game_id_changed(new_id: String) + # Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}). # Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}. # Tiles use format: [{x, y, z, type}]. var current_snapshot: Dictionary = {} + +# D-085 (#258): Active game session identifier. Format: -- +# Set by SessionManager.new_game() or SessionManager.resume_game(). +# Empty string when no session is active (main menu state). +var current_game_id: String = "": + set(v): + current_game_id = v + game_id_changed.emit(v) var current_tick: int = 0 var player_position: Vector2 = Vector2.ZERO var visible_entities: Array = [] diff --git a/client/scripts/autoloads/session_manager.gd b/client/scripts/autoloads/session_manager.gd new file mode 100644 index 000000000..da1ddade7 --- /dev/null +++ b/client/scripts/autoloads/session_manager.gd @@ -0,0 +1,115 @@ +extends Node +## D-085 (#258): Game session lifecycle manager. +## Creates per-game save directories on New Game, resumes existing sessions, +## and handles quit-to-menu flow with save confirmation. +## +## All save dirs live under user://saves// where game-id is +## -- (e.g. "20260225-143022-a7b3f1"). + +const SAVES_DIR := "user://saves/" +const GAME_SCENE := "res://scenes/main.tscn" +const MENU_SCENE := "res://scenes/main_menu.tscn" + +var _quit_dialog: ConfirmationDialog = null + + +## Generate a new game-id, create its save directory, and activate the session. +## Returns the new game-id string. +func new_game() -> String: + var now := Time.get_datetime_dict_from_system() + var timestamp := "%04d%02d%02d-%02d%02d%02d" % [ + now.year, now.month, now.day, + now.hour, now.minute, now.second, + ] + var rng := RandomNumberGenerator.new() + var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF) + var game_id := "%s-%s" % [timestamp, hex_seed] + var save_path := SAVES_DIR + game_id + "/" + var err := DirAccess.make_dir_recursive_absolute(save_path) + if err != OK: + push_error("SessionManager: failed to create save dir %s: %s" % [ + save_path, error_string(err)]) + return "" + GameState.current_game_id = game_id + return game_id + + +## Resume an existing game session by setting the active game-id. +func resume_game(game_id: String) -> void: + GameState.current_game_id = game_id + + +## List all game directories under user://saves/ sorted by last-modified (most recent first). +## Returns Array of {game_id: String, modified_time: int, newest_save: String}. +func list_game_dirs() -> Array: + var dir := DirAccess.open(SAVES_DIR) + if dir == null: + return [] + var results: Array = [] + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if dir.current_is_dir() and not entry.begins_with("."): + var dir_path := SAVES_DIR + entry + "/" + var newest_save := _find_newest_save(dir_path) + var mtime: int = 0 + if newest_save != "": + mtime = FileAccess.get_modified_time(dir_path + newest_save) + results.append({ + "game_id": entry, + "modified_time": mtime, + "newest_save": newest_save, + }) + entry = dir.get_next() + dir.list_dir_end() + results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a.modified_time > b.modified_time) + return results + + +## Show "Save before quitting?" confirmation dialog, then return to main menu. +## #554: The actual F5 save will be wired here once server supports SaveCommand. +func quit_to_menu() -> void: + if _quit_dialog != null and is_instance_valid(_quit_dialog): + return # Dialog already open + _quit_dialog = ConfirmationDialog.new() + _quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit") + _quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes") + _quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no") + get_tree().root.add_child(_quit_dialog) + _quit_dialog.confirmed.connect(_do_quit_to_menu) + _quit_dialog.canceled.connect(_cleanup_quit_dialog) + _quit_dialog.popup_centered() + + +func _do_quit_to_menu() -> void: + _cleanup_quit_dialog() + # #554: F5 quicksave will be triggered here before scene change when server + # supports SaveCommand. For now: navigate to menu without saving. + GameState.current_game_id = "" + get_tree().change_scene_to_file(MENU_SCENE) + + +func _cleanup_quit_dialog() -> void: + if _quit_dialog != null and is_instance_valid(_quit_dialog): + _quit_dialog.queue_free() + _quit_dialog = null + + +func _find_newest_save(dir_path: String) -> String: + var dir := DirAccess.open(dir_path) + if dir == null: + return "" + var best_name := "" + var best_time: int = 0 + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if not dir.current_is_dir() and entry.ends_with(".sav"): + var mtime := FileAccess.get_modified_time(dir_path + entry) + if mtime > best_time: + best_time = mtime + best_name = entry + entry = dir.get_next() + dir.list_dir_end() + return best_name diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index fa91fb3c3..5e7b52283 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -5,13 +5,7 @@ enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR } var state: ConnectionState = ConnectionState.DISCONNECTED var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server -var _test_tick: int = 0 -var _test_player_pos: Vector2i = Vector2i(10, 10) -var _test_facing: String = "North" -var _test_input_queue: Array = [] # Queued actions for test mode -var _test_in_dialogue: bool = false # Mock dialogue state (#434) -var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard -var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color +var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client) var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot) var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport @@ -33,17 +27,56 @@ signal snapshot_received(snapshot: Dictionary) func _ready() -> void: if test_mode: + harness = TestHarness.new() print("SimBridge: Running in test mode (dynamic snapshot)") -# Reset test state — call before tests that use _test_snapshot() + +# -- Test mode proxy API (backward compat for 13+ test files) ------------------ + func reset_test_state() -> void: - _test_tick = 0 - _test_player_pos = Vector2i(10, 10) - _test_facing = "North" - _test_input_queue.clear() - _test_in_dialogue = false - _test_gauntlet_mode = false - _test_npc_relationship = "Unknown" + if harness: harness.reset() + +func _test_snapshot() -> Dictionary: + return harness.snapshot() + +func _test_has_los(from: Vector2i, to: Vector2i) -> bool: + return harness.has_los(from, to) + +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 [] + + +# -- Connection lifecycle ------------------------------------------------------ # Change connection state and emit signal func _set_state(new_state: ConnectionState) -> void: @@ -66,8 +99,13 @@ func connect_to_sim() -> void: # Spawn server subprocess if not server_path.is_empty(): _server = ServerProcess.new() - # Server reads first positional arg as bind address (e.g. "127.0.0.1:9876") - var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)]) + # Server reads first positional arg as bind address (e.g. "127.0.0.1:9876"). + # D-085 (#258): pass --game-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 := _server.start(server_path, args) if pid <= 0: push_error("SimBridge: failed to start server") _set_state(ConnectionState.ERROR) @@ -170,9 +208,13 @@ func _process(delta: float) -> void: 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 silently dropped. In live mode, encoded and buffered for transport. +# 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: @@ -182,25 +224,20 @@ func send_input(player_input: Dictionary) -> Error: var wire_name: String = action_enum_to_wire(action) if not wire_name.is_empty(): if wire_name == "SetFacing": - # D-054: Use action_data.facing from the input dict, not InputMapper global 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(): - _test_facing = facing + harness.process_facing(facing) else: - _test_input_queue.append(wire_name) + harness.process_input(wire_name) return OK var action_name := action_enum_to_wire(player_input.get("action", -1)) if action_name.is_empty(): - # action_enum_to_wire already emits push_warning for invalid actions return ERR_INVALID_PARAMETER - # Use the server's current tick so drain_for_tick processes this input immediately. - # The client-side timestamp_msec is only useful for ordering within a frame. var tick: int = GameState.current_tick var entry: Dictionary = { "tick": tick, "action_name": action_name } - # Data variants (e.g. UsePerceptionMode) carry payload var action_data: Variant = player_input.get("action_data") if action_data != null: entry["action_data"] = action_data @@ -208,13 +245,13 @@ func send_input(player_input: Dictionary) -> Error: return OK # Poll for snapshot from simulation. -# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any). +# 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 = _test_snapshot() + var snapshot = harness.snapshot() snapshot_received.emit(snapshot) return snapshot @@ -260,6 +297,9 @@ func drain_outbound() -> Array[Dictionary]: _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: @@ -289,316 +329,3 @@ static func action_enum_to_wire(action: int) -> String: _: push_warning("SimBridge: unknown action enum %s" % action) return "" - -# Dynamic test snapshot — processes queued inputs to move player, generates -# visibility based on current position. Matches Protocol.decode_snapshot() format. -# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10) -# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at -# 16,13; wall at 16,14). This ensures standalone tests don't depend on server -# map layout and can exercise the rendering pipeline independently. -func _test_snapshot() -> Dictionary: - _test_tick += 1 - - # Process queued inputs - for action_name in _test_input_queue: - if action_name == "TeleportToHub": - # #501: Reset to hub spawn position, clear dialogue - _test_player_pos = Vector2i(10, 10) - _test_in_dialogue = false - continue - if action_name == "Interact": - # Mock dialogue trigger (#434): if near NPC, start dialogue - var npc_pos := Vector2i(12, 9) - var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y) - if dist <= 2 and _test_has_los(_test_player_pos, npc_pos): - _test_in_dialogue = true - continue - var delta := _action_to_delta(action_name) - var new_pos := _test_player_pos + delta - if _test_is_walkable(new_pos): - _test_player_pos = new_pos - if delta != Vector2i.ZERO: - # Walk-away dismisses dialogue (D-064) - if _test_in_dialogue: - _test_in_dialogue = false - _test_input_queue.clear() - - var px := _test_player_pos.x - var py := _test_player_pos.y - - # Build entities — player always visible - var entities: Array = [{ - "entity_id": 1, - "x": float(px), - "y": float(py), - "z": 0, - "kind": { "variant": "Player", "data": null }, - "visibility": "Forward", - }] - - # NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10) - var npc_pos := Vector2i(12, 9) - var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y) - if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos): - var sector: String = "Forward" if npc_pos.y <= py else "Peripheral" - entities.append({ - "entity_id": 2, - "x": float(npc_pos.x), - "y": float(npc_pos.y), - "z": 0, - "kind": { "variant": "Npc", "data": null }, - "visibility": sector, - "relationship": _test_npc_relationship, - }) - - # v4: nearby_interactions when NPC is nearby and visible (#404/#405) - var nearby: Array = [] - if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos): - nearby.append({ - "entity_id": 2, - "entity_type": "Npc", - "distance": npc_dist, - "verbs": [ - {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, - {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, - ], - }) - - # v5: monologue on first tick (#414) - var monologue: Variant = null - if _test_tick == 1: - monologue = { - "id": "test_enter_001", - "text": "Sova Transit District. Population twelve thousand and change.", - "duration_seconds": 5.0, - } - - # v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC - # Sustained: dialogue persists across ticks while _test_in_dialogue is true. - # Movement (walk-away) clears it. Client consume-once guards against re-show. - # Options: structured {text, response_id, priority} per #435. - var dialogue: Variant = null - if _test_in_dialogue: - dialogue = { - "npc_name": "Kael", - "npc_entity_id": 2, - "speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?", - "options": [ - {"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, - {"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, - {"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, - ], - } - - # v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity - # Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks. - # Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters). - var pending_recs: Array = [] - var cycle_pos := _test_tick % 12 - if cycle_pos < 6: - var total_delay := 6 - var remaining := total_delay - cycle_pos - pending_recs.append({ - "entity_id": 100, - "x": 13.5, - "y": 12.5, - "z": 0, - "remaining_ticks": remaining, - "total_delay_ticks": total_delay, - }) - - # #535: Mock overheard NPC-NPC conversation (D-078) - # Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3. - # Conversation ends after 6 exchanges (~30 ticks). - var conv_events: Array = [] - var conv_ended: Array = [] - var conv_start := 3 - var conv_lines := [ - {"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."}, - {"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."}, - {"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."}, - {"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."}, - {"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, - {"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."}, - ] - var conv_tick_interval := 5 - var conv_total_ticks := conv_lines.size() * conv_tick_interval - if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks: - var conv_index := (_test_tick - conv_start) / conv_tick_interval - var within_tick := (_test_tick - conv_start) % conv_tick_interval - if within_tick == 0 and conv_index < conv_lines.size(): - var cl: Dictionary = conv_lines[conv_index] - conv_events.append({ - "speaker_id": 10, - "target_id": 11, - "speaker_name": cl.speaker, - "target_name": cl.target, - "occluded_line": cl.line, - }) - elif _test_tick == conv_start + conv_total_ticks: - conv_ended.append({"speaker_id": 10, "target_id": 11}) - - return { - "tick": _test_tick, - "version": Protocol.PROTOCOL_VERSION, - "game_time": { - "day": 0, - "time_of_day": _test_tick * 10, - "day_phase": "Morning", - "tick_rate": "Full", - }, - "player_facing": _test_facing, - "player_stance": "Walk", - "player_inventory": [], - "entities": entities, - "tiles": _test_tiles(), - "visible_tiles": _test_visible_tiles(), - "visible_positions": _test_visible_positions(), - "nearby_interactions": nearby, - "current_monologue": monologue, - "current_dialogue": dialogue, - "pending_recognitions": pending_recs, - "gauntlet_mode": _test_gauntlet_mode, - "conversation_events": conv_events, - "conversation_ended": conv_ended, - } - -# Generate a small test room: 8x6 room with walls, a door, and floor -func _test_tiles() -> Array: - var tiles: Array = [] - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(room_x, room_x + room_w): - for y in range(room_y, room_y + room_h): - var is_edge := (x == room_x or x == room_x + room_w - 1 - or y == room_y or y == room_y + room_h - 1) - var tile_type: String - if is_edge: - # Door on the south wall, center - if y == room_y + room_h - 1 and x == room_x + room_w / 2: - tile_type = "door" - else: - tile_type = "wall" - else: - tile_type = "floor" - tiles.append({"x": x, "y": y, "z": 0, "type": tile_type}) - - # Corridor south of the door - var door_x := room_x + room_w / 2 - for y in range(room_y + room_h, room_y + room_h + 4): - tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"}) - tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"}) - tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"}) - - return tiles - -# Test visible tiles with visibility sectors (v2 format) -# Tiles ahead of the player are Forward, others Peripheral. -func _test_visible_tiles() -> Array: - var vtiles: Array = [] - var px := _test_player_pos.x - var py := _test_player_pos.y - var radius := 4 - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(px - radius, px + radius + 1): - for y in range(py - radius, py + radius + 1): - var dist := absf(x - px) + absf(y - py) - if dist <= radius: - if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: - var sector: String = "Forward" if y <= py else "Peripheral" - vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector}) - return vtiles - -# Test visibility: tiles within radius 4 of player, inside room bounds -func _test_visible_positions() -> Array: - var positions: Array = [] - var px := _test_player_pos.x - var py := _test_player_pos.y - var radius := 4 - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(px - radius, px + radius + 1): - for y in range(py - radius, py + radius + 1): - var dist := absf(x - px) + absf(y - py) - if dist <= radius: - if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: - positions.append({"x": x, "y": y}) - return positions - - -# -- Test mode helpers -- - -const _TEST_WALLS: Array = [ - # Room walls (8x8 room from (7,7) to (14,14)) - Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7), - Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7), - Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14), - Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14), - Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11), - Vector2i(7,12), Vector2i(7,13), - Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11), - Vector2i(14,12), Vector2i(14,13), - # Interior wall blocking NPC - Vector2i(12, 10), -] - -func _test_is_walkable(pos: Vector2i) -> bool: - return not _TEST_WALLS.has(pos) - -# Simple LOS check — blocked if a wall tile sits between start and end -func _test_has_los(from: Vector2i, to: Vector2i) -> bool: - # Bresenham-lite: check tiles along the line - var dx := absi(to.x - from.x) - var dy := absi(to.y - from.y) - var sx := 1 if from.x < to.x else -1 - var sy := 1 if from.y < to.y else -1 - var err := dx - dy - var cx := from.x - var cy := from.y - while true: - if cx == to.x and cy == to.y: - return true - if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)): - return false - var e2 := 2 * err - if e2 > -dy: - err -= dy - cx += sx - if e2 < dx: - err += dx - cy += sy - return true - -static func _action_to_delta(action_name: String) -> Vector2i: - match action_name: - "MoveNorth": return Vector2i(0, -1) - "MoveNortheast": return Vector2i(1, -1) - "MoveEast": return Vector2i(1, 0) - "MoveSoutheast": return Vector2i(1, 1) - "MoveSouth": return Vector2i(0, 1) - "MoveSouthwest": return Vector2i(-1, 1) - "MoveWest": return Vector2i(-1, 0) - "MoveNorthwest": return Vector2i(-1, -1) - _: return Vector2i.ZERO - -static func _delta_to_facing(delta: Vector2i) -> String: - match delta: - Vector2i(0, -1): return "North" - Vector2i(1, -1): return "Northeast" - Vector2i(1, 0): return "East" - Vector2i(1, 1): return "Southeast" - Vector2i(0, 1): return "South" - Vector2i(-1, 1): return "Southwest" - Vector2i(-1, 0): return "West" - Vector2i(-1, -1): return "Northwest" - _: return "North" diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index c18b3c274..49175d52f 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -207,7 +207,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: }) # v14: poi_list (#151) — discovered POIs for minimap rendering. - # Each entry: {poi_id, name, x, y, z, category}. Positions in sim tile coords. + # Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords. var poi_list: Array = [] var raw_pois: Variant = raw.get("poi_list") if raw_pois is Array: @@ -219,7 +219,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "x": int(raw_poi["x"]), "y": int(raw_poi["y"]), "z": int(raw_poi.get("z", 0)), - "category": str(raw_poi.get("category", "Location")), + "poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))), }) # v14: examine_result (#174, #242) — character-filtered observation text. diff --git a/client/scripts/protocol/test_harness.gd b/client/scripts/protocol/test_harness.gd new file mode 100644 index 000000000..b376322c3 --- /dev/null +++ b/client/scripts/protocol/test_harness.gd @@ -0,0 +1,333 @@ +class_name TestHarness +extends RefCounted +## Standalone test simulation for client development without a running server. +## Generates mock ObserverSnapshots with movement, LOS, dialogue, and NPC +## interactions. Extracted from sim_bridge.gd to enforce D-020 information +## boundary (no game logic in the production client autoload). + +var tick: int = 0 +var player_pos: Vector2i = Vector2i(10, 10) +var facing: String = "North" +var input_queue: Array = [] +var in_dialogue: bool = false +var gauntlet_mode: bool = false +var npc_relationship: String = "Unknown" + + +func reset() -> void: + tick = 0 + player_pos = Vector2i(10, 10) + facing = "North" + input_queue.clear() + in_dialogue = false + gauntlet_mode = false + npc_relationship = "Unknown" + + +func process_input(action_name: String) -> void: + input_queue.append(action_name) + + +func process_facing(new_facing: String) -> void: + facing = new_facing + + +# -- Snapshot generation ------------------------------------------------------- + +func snapshot() -> Dictionary: + tick += 1 + + # Process queued inputs + for action_name in input_queue: + if action_name == "TeleportToHub": + player_pos = Vector2i(10, 10) + in_dialogue = false + continue + if action_name == "Interact": + var npc_pos := Vector2i(12, 9) + var dist := absi(player_pos.x - npc_pos.x) + absi(player_pos.y - npc_pos.y) + if dist <= 2 and has_los(player_pos, npc_pos): + in_dialogue = true + continue + var delta := action_to_delta(action_name) + var new_pos := player_pos + delta + if _is_walkable(new_pos): + player_pos = new_pos + if delta != Vector2i.ZERO: + if in_dialogue: + in_dialogue = false + input_queue.clear() + + var px := player_pos.x + var py := player_pos.y + + # Build entities — player always visible + var entities: Array = [{ + "entity_id": 1, + "x": float(px), + "y": float(py), + "z": 0, + "kind": { "variant": "Player", "data": null }, + "visibility": "Forward", + }] + + # NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10) + var npc_pos := Vector2i(12, 9) + var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y) + if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos): + var sector: String = "Forward" if npc_pos.y <= py else "Peripheral" + entities.append({ + "entity_id": 2, + "x": float(npc_pos.x), + "y": float(npc_pos.y), + "z": 0, + "kind": { "variant": "Npc", "data": null }, + "visibility": sector, + "relationship": npc_relationship, + }) + + # v4: nearby_interactions when NPC is nearby and visible (#404/#405) + var nearby: Array = [] + if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos): + nearby.append({ + "entity_id": 2, + "entity_type": "Npc", + "distance": npc_dist, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, + ], + }) + + # v5: monologue on first tick (#414) + var monologue: Variant = null + if tick == 1: + monologue = { + "id": "test_enter_001", + "text": "Sova Transit District. Population twelve thousand and change.", + "duration_seconds": 5.0, + } + + # v7: mock dialogue (#435, D-061/D-062) + var dialogue: Variant = null + if in_dialogue: + dialogue = { + "npc_name": "Kael", + "npc_entity_id": 2, + "speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?", + "options": [ + {"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, + {"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, + {"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, + ], + } + + # v7: mock pending_recognitions (#431, D-059/D-060) + var pending_recs: Array = [] + var cycle_pos := tick % 12 + if cycle_pos < 6: + var total_delay := 6 + var remaining := total_delay - cycle_pos + pending_recs.append({ + "entity_id": 100, + "x": 13.5, + "y": 12.5, + "z": 0, + "remaining_ticks": remaining, + "total_delay_ticks": total_delay, + }) + + # #535: Mock overheard NPC-NPC conversation (D-078) + var conv_events: Array = [] + var conv_ended: Array = [] + var conv_start := 3 + var conv_lines := [ + {"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."}, + {"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."}, + {"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."}, + {"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."}, + {"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, + {"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."}, + ] + var conv_tick_interval := 5 + var conv_total_ticks := conv_lines.size() * conv_tick_interval + if tick >= conv_start and tick < conv_start + conv_total_ticks: + var conv_index := (tick - conv_start) / conv_tick_interval + var within_tick := (tick - conv_start) % conv_tick_interval + if within_tick == 0 and conv_index < conv_lines.size(): + var cl: Dictionary = conv_lines[conv_index] + conv_events.append({ + "speaker_id": 10, + "target_id": 11, + "speaker_name": cl.speaker, + "target_name": cl.target, + "occluded_line": cl.line, + }) + elif tick == conv_start + conv_total_ticks: + conv_ended.append({"speaker_id": 10, "target_id": 11}) + + return { + "tick": tick, + "version": Protocol.PROTOCOL_VERSION, + "game_time": { + "day": 0, + "time_of_day": tick * 10, + "day_phase": "Morning", + "tick_rate": "Full", + }, + "player_facing": facing, + "player_stance": "Walk", + "player_inventory": [], + "entities": entities, + "tiles": _tiles(), + "visible_tiles": _visible_tiles(), + "visible_positions": _visible_positions(), + "nearby_interactions": nearby, + "current_monologue": monologue, + "current_dialogue": dialogue, + "pending_recognitions": pending_recs, + "gauntlet_mode": gauntlet_mode, + "conversation_events": conv_events, + "conversation_ended": conv_ended, + } + + +# -- Map generation ------------------------------------------------------------ + +func _tiles() -> Array: + var tiles: Array = [] + var room_x := 7 + var room_y := 7 + var room_w := 8 + var room_h := 8 + + for x in range(room_x, room_x + room_w): + for y in range(room_y, room_y + room_h): + var is_edge := (x == room_x or x == room_x + room_w - 1 + or y == room_y or y == room_y + room_h - 1) + var tile_type: String + if is_edge: + if y == room_y + room_h - 1 and x == room_x + room_w / 2: + tile_type = "door" + else: + tile_type = "wall" + else: + tile_type = "floor" + tiles.append({"x": x, "y": y, "z": 0, "type": tile_type}) + + var door_x := room_x + room_w / 2 + for y in range(room_y + room_h, room_y + room_h + 4): + tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"}) + tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"}) + tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"}) + + return tiles + + +func _visible_tiles() -> Array: + var vtiles: Array = [] + var px := player_pos.x + var py := player_pos.y + var radius := 4 + var room_x := 7 + var room_y := 7 + var room_w := 8 + var room_h := 8 + + for x in range(px - radius, px + radius + 1): + for y in range(py - radius, py + radius + 1): + var dist := absf(x - px) + absf(y - py) + if dist <= radius: + if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: + var sector: String = "Forward" if y <= py else "Peripheral" + vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector}) + return vtiles + + +func _visible_positions() -> Array: + var positions: Array = [] + var px := player_pos.x + var py := player_pos.y + var radius := 4 + var room_x := 7 + var room_y := 7 + var room_w := 8 + var room_h := 8 + + for x in range(px - radius, px + radius + 1): + for y in range(py - radius, py + radius + 1): + var dist := absf(x - px) + absf(y - py) + if dist <= radius: + if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: + positions.append({"x": x, "y": y}) + return positions + + +# -- Spatial helpers ----------------------------------------------------------- + +const _WALLS: Array = [ + # Room walls (8x8 room from (7,7) to (14,14)) + Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7), + Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7), + Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14), + Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14), + Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11), + Vector2i(7,12), Vector2i(7,13), + Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11), + Vector2i(14,12), Vector2i(14,13), + # Interior wall blocking NPC + Vector2i(12, 10), +] + + +func _is_walkable(pos: Vector2i) -> bool: + return not _WALLS.has(pos) + + +func has_los(from: Vector2i, to: Vector2i) -> bool: + var dx := absi(to.x - from.x) + var dy := absi(to.y - from.y) + var sx := 1 if from.x < to.x else -1 + var sy := 1 if from.y < to.y else -1 + var err := dx - dy + var cx := from.x + var cy := from.y + while true: + if cx == to.x and cy == to.y: + return true + if Vector2i(cx, cy) != from and not _is_walkable(Vector2i(cx, cy)): + return false + var e2 := 2 * err + if e2 > -dy: + err -= dy + cx += sx + if e2 < dx: + err += dx + cy += sy + return true + + +static func action_to_delta(action_name: String) -> Vector2i: + match action_name: + "MoveNorth": return Vector2i(0, -1) + "MoveNortheast": return Vector2i(1, -1) + "MoveEast": return Vector2i(1, 0) + "MoveSoutheast": return Vector2i(1, 1) + "MoveSouth": return Vector2i(0, 1) + "MoveSouthwest": return Vector2i(-1, 1) + "MoveWest": return Vector2i(-1, 0) + "MoveNorthwest": return Vector2i(-1, -1) + _: return Vector2i.ZERO + + +static func delta_to_facing(delta: Vector2i) -> String: + match delta: + Vector2i(0, -1): return "North" + Vector2i(1, -1): return "Northeast" + Vector2i(1, 0): return "East" + Vector2i(1, 1): return "Southeast" + Vector2i(0, 1): return "South" + Vector2i(-1, 1): return "Southwest" + Vector2i(-1, 0): return "West" + Vector2i(-1, -1): return "Northwest" + _: return "North" diff --git a/client/scripts/ui/debug_overlay.gd b/client/scripts/ui/debug_overlay.gd index b2f6eb8cb..362e5758d 100644 --- a/client/scripts/ui/debug_overlay.gd +++ b/client/scripts/ui/debug_overlay.gd @@ -1,5 +1,15 @@ extends Control -# #511: F3 debug overlay — real-time game state display for dev use. +## #348: F3 debug overlay — real-time visualization of game state for dev use. +## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false). +## +## Panels: +## 1. Stats text (top-left): tick, pos, fps, etc. +## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags +## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- const HEADER_COLOR := Color("#e8c547") const LABEL_COLOR := Color("#8890a0") @@ -8,31 +18,150 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85) const FONT_SIZE := 12 const LINE_HEIGHT := 16 const PADDING := Vector2(10, 8) -const COL_GAP := 16 # gap between left and right columns +const COL_GAP := 16 + +# World overlay colors +const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50) +const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85) +const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12) +const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07) +const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55) +const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75) +const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90) +const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80) +const TAG_TEXT_COLOR := Color("#c8d0e0") +const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82) +const GRAPH_LINE_COLOR := Color("#6bc9a6") +const GRAPH_WARN_COLOR := Color("#e8c547") + +# Vision cone geometry (radians) +# Forward: ±60° around facing direction (120° total) +# Peripheral: ±60° to ±120° on each side (60° band each side) +const CONE_FORWARD_HALF: float = PI / 3.0 # 60° +const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120° +const CONE_ARC_STEPS: int = 20 + +# NPC path history +const NPC_HISTORY_LEN: int = 12 +const NPC_DOT_RADIUS: float = 3.5 +const PLAYER_DOT_RADIUS: float = 5.0 + +# Tick timing graph +const GRAPH_W: float = 160.0 +const GRAPH_H: float = 48.0 +const GRAPH_MARGIN: float = 10.0 +const TICK_HISTORY_LEN: int = 30 +const TICK_WARN_MS: float = 120.0 + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- var _cached_font: Font = null +var _dev_mode: bool = false + +# NPC path history: entity_id (int) → Array of Vector2 (world positions) +var _npc_paths: Dictionary = {} +var _last_tick_processed: int = -1 + +# Tick timing ring +var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot +var _tick_deltas: Array = [] # ms between consecutive snapshots + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- func _ready() -> void: + _dev_mode = OS.is_debug_build() visible = false _cached_font = ThemeDB.fallback_font + # Clear NPC path history on session change to prevent entity ID collisions + GameState.connect("game_id_changed", _on_game_id_changed) + + +func _on_game_id_changed(_new_id: String) -> void: + _npc_paths.clear() + _tick_deltas.clear() + _tick_times.clear() + _last_tick_processed = -1 + func _unhandled_input(event: InputEvent) -> void: + if not _dev_mode: + return if event.is_action_pressed("debug_overlay"): visible = not visible if visible: queue_redraw() + func update_from_state() -> void: - if not visible: + if not _dev_mode or not visible: return + + # Record tick arrival time for timing graph + var now_ms := Time.get_ticks_msec() + if _last_tick_processed != GameState.current_tick: + _last_tick_processed = GameState.current_tick + if _tick_times.size() > 0: + _tick_deltas.append(float(now_ms - _tick_times.back())) + if _tick_deltas.size() > TICK_HISTORY_LEN: + _tick_deltas.pop_front() + _tick_times.append(now_ms) + if _tick_times.size() > TICK_HISTORY_LEN + 1: + _tick_times.pop_front() + _update_npc_paths() + queue_redraw() + +func _update_npc_paths() -> void: + var seen_ids: Dictionary = {} + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + var kind_variant: String = entity.get("kind", {}).get("variant", "") + if kind_variant != "Npc": + continue + var eid: int = entity.get("entity_id", -1) + if eid < 0: + continue + seen_ids[eid] = true + var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0)) + if not _npc_paths.has(eid): + _npc_paths[eid] = [] + var path: Array = _npc_paths[eid] + if path.size() == 0 or path.back() != pos: + path.append(pos) + if path.size() > NPC_HISTORY_LEN: + path.pop_front() + # Prune entities no longer visible + for eid in _npc_paths.keys(): + if not seen_ids.has(eid): + _npc_paths.erase(eid) + + +# --------------------------------------------------------------------------- +# Draw dispatch +# --------------------------------------------------------------------------- + func _draw() -> void: if not visible: return + _draw_stats_panel() + _draw_world_overlays() + _draw_tick_graph() + + +# --------------------------------------------------------------------------- +# Panel 1: Stats text (top-left) +# --------------------------------------------------------------------------- + +func _draw_stats_panel() -> void: var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font - # Build lines as [label, value, label, value] pairs (two columns) var left_lines: Array = [] var right_lines: Array = [] @@ -71,7 +200,10 @@ func _draw() -> void: left_lines.append(["mode", mode_str]) right_lines.append(["gauntlet", gauntlet_str]) - # Measure column widths + var gid := GameState.current_game_id + left_lines.append(["game_id", gid if gid != "" else "-"]) + right_lines.append(["npc_paths", str(_npc_paths.size())]) + var left_label_w: float = 0.0 var left_value_w: float = 0.0 var right_label_w: float = 0.0 @@ -89,27 +221,240 @@ func _draw() -> void: var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w var box_w: float = max(header_w, content_w) + PADDING.x * 2 var line_count: int = maxi(left_lines.size(), right_lines.size()) - var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines + var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count - # Background draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR) - # Header var y: float = PADDING.y + FONT_SIZE draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR) y += LINE_HEIGHT - # Data lines (two columns) var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP for i in range(line_count): if i < left_lines.size(): - var lbl: String = left_lines[i][0] + ": " - var val: String = left_lines[i][1] - draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) - draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) + draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) + draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) if i < right_lines.size(): - var lbl: String = right_lines[i][0] + ": " - var val: String = right_lines[i][1] - draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) - draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) + draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) + draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) y += LINE_HEIGHT + + +# --------------------------------------------------------------------------- +# Panel 2: World overlays +# --------------------------------------------------------------------------- + +func _draw_world_overlays() -> void: + var vp := get_viewport() + if vp == null: + return + # get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content. + # The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space. + # Using this transform converts world coords → screen pixel coords for the overlays. + var canvas_xf := vp.get_canvas_transform() + var player_screen := _w2s(GameState.player_position, canvas_xf) + + _draw_vision_cone(player_screen, canvas_xf) + _draw_los_rays(player_screen, canvas_xf) + _draw_npc_paths(canvas_xf) + _draw_info_tags(canvas_xf) + + +# Convert world tile position → screen pixel position +func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2: + return canvas_xf * (world_pos * Constants.TILE_SIZE) + + +# Vision cone: filled forward sector + peripheral bands. +# Uses player facing direction and visibility sector distance. +func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void: + var facing_angle := _facing_to_angle(GameState.player_facing) + + # Estimate visible radius from furthest visibility sector tile + var max_d: float = 4.0 + for vpos in GameState.visibility_sectors.keys(): + var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position) + if d > max_d: + max_d = d + var scale_x := canvas_xf.x.length() + var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0) + + # Helper: build a polygon fan from center outward over arc [angle_from, angle_to] + var forward_from := facing_angle - CONE_FORWARD_HALF + var forward_to := facing_angle + CONE_FORWARD_HALF + var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF + var perip_l_to := facing_angle - CONE_FORWARD_HALF + var perip_r_from := facing_angle + CONE_FORWARD_HALF + var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF + + draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR) + draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR) + draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR) + + # Forward arc boundary ring + draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0) + + # Player dot + draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR) + + +# Build a filled polygon fan from center through an arc +func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array: + var pts := PackedVector2Array() + pts.append(center) + for i in range(CONE_ARC_STEPS + 1): + var t := float(i) / float(CONE_ARC_STEPS) + var a := angle_from + t * (angle_to - angle_from) + pts.append(center + Vector2(cos(a), sin(a)) * radius) + return pts + + +# Dashed LOS lines from player to each visible non-player entity +func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void: + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + if entity.get("kind", {}).get("variant", "") == "Player": + continue + var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0)) + var entity_screen := _w2s(entity_world, canvas_xf) + var rel: String = entity.get("relationship", "Unknown") + var color := Constants.color_for_relationship(rel) + color.a = 0.45 + draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0) + draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7)) + + +# Fading NPC movement path trails from position history +func _draw_npc_paths(canvas_xf: Transform2D) -> void: + for eid in _npc_paths.keys(): + var path: Array = _npc_paths[eid] + if path.size() < 2: + continue + for i in range(1, path.size()): + var a_screen := _w2s(path[i - 1], canvas_xf) + var b_screen := _w2s(path[i], canvas_xf) + var alpha := float(i) / float(path.size()) + draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5) + draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR) + + +# Information state tags above visible NPCs from player_knowledge +func _draw_info_tags(canvas_xf: Transform2D) -> void: + if GameState.player_knowledge == null: + return + var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font + var knowledge: Dictionary = GameState.player_knowledge + + # Build entity_id → knowledge entry lookup + var kg_by_id: Dictionary = {} + for entry in knowledge.get("entities", []): + if entry is Dictionary and entry.has("entity_id"): + kg_by_id[entry.entity_id] = entry + + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + if entity.get("kind", {}).get("variant", "") != "Npc": + continue + var eid: int = entity.get("entity_id", -1) + if not kg_by_id.has(eid): + continue + var kg_entry: Dictionary = kg_by_id[eid] + var confidence: String = kg_entry.get("confidence", "Unknown") + var name_str: String = kg_entry.get("name", "?") + var label := "%s [%s]" % [name_str, confidence] + + var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf) + var tag_baseline := entity_screen.y - 18.0 + var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x + var tag_rect := Rect2( + entity_screen.x - text_w / 2.0 - 3.0, + tag_baseline - FONT_SIZE + 2.0, + text_w + 6.0, + FONT_SIZE) + draw_rect(tag_rect, TAG_BG_COLOR) + draw_string(font, + Vector2(entity_screen.x - text_w / 2.0, tag_baseline), + label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR) + + +# --------------------------------------------------------------------------- +# Panel 3: Tick timing sparkline (bottom-left) +# --------------------------------------------------------------------------- + +func _draw_tick_graph() -> void: + if _tick_deltas.size() < 2: + return + var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font + var vp_size := get_viewport_rect().size + var box_x := GRAPH_MARGIN + var label_h := LINE_HEIGHT + var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN + + draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR) + draw_string(font, + Vector2(box_x + 4, box_y + FONT_SIZE + 1), + "tick ms (n=%d)" % _tick_deltas.size(), + HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR) + + var chart_top := box_y + label_h + var chart_left := box_x + 4.0 + var chart_w := GRAPH_W - 8.0 + var chart_h := GRAPH_H - 4.0 + + # Max value for scale + var max_ms: float = TICK_WARN_MS + for d in _tick_deltas: + if float(d) > max_ms: + max_ms = float(d) + max_ms *= 1.1 + + # Warn threshold dashed line + var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms) + draw_dashed_line( + Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y), + Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3), + 1.0, 4.0) + + # Sparkline + var n := _tick_deltas.size() + var prev_pt := Vector2.ZERO + for i in range(n): + var x := chart_left + chart_w * (float(i) / float(n - 1)) + var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms) + var y := chart_top + chart_h * (1.0 - clamped / max_ms) + var pt := Vector2(x, y) + var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR + if i > 0: + draw_line(prev_pt, pt, color, 1.5) + draw_circle(pt, 2.0, color) + prev_pt = pt + + # Average label + var avg_ms := 0.0 + for d in _tick_deltas: + avg_ms += float(d) + avg_ms /= float(_tick_deltas.size()) + draw_string(font, + Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2), + "avg %.0fms" % avg_ms, + HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North) +static func _facing_to_angle(facing: String) -> float: + match facing: + "North": return -PI / 2.0 + "Northeast": return -PI / 4.0 + "East": return 0.0 + "Southeast": return PI / 4.0 + "South": return PI / 2.0 + "Southwest": return PI * 3.0 / 4.0 + "West": return PI + "Northwest": return -PI * 3.0 / 4.0 + _: return -PI / 2.0 diff --git a/client/tests/run_gdunit4.gd b/client/tests/run_gdunit4.gd new file mode 100644 index 000000000..52aab6e23 --- /dev/null +++ b/client/tests/run_gdunit4.gd @@ -0,0 +1,30 @@ +#!/usr/bin/env -S godot -s +## gdUnit4 CI runner for the Settled Reach client. +## +## Run all client tests headlessly: +## godot --headless --path client/ \ +## -s res://tests/run_gdunit4.gd \ +## -- --ignoreHeadlessMode -a res://tests/ +## +## Run a specific test file: +## godot --headless --path client/ \ +## -s res://tests/run_gdunit4.gd \ +## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd +## +## Exit code: 0 = all pass, non-zero = failures. +## +## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework. +## Test output format: JSON summary per D-030 sub-decision #6. +extends SceneTree + +var _cli_runner: GdUnitTestCIRunner + + +func _initialize() -> void: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED) + _cli_runner = GdUnitTestCIRunner.new() + root.add_child(_cli_runner) + + +func _finalize() -> void: + queue_delete(_cli_runner) diff --git a/client/tests/test_debug_overlay_sprint19.gd b/client/tests/test_debug_overlay_sprint19.gd new file mode 100644 index 000000000..b87810ada --- /dev/null +++ b/client/tests/test_debug_overlay_sprint19.gd @@ -0,0 +1,317 @@ +## Sprint 19 — Debug visualization overlay (#348) +## F3 toggle, world overlays, tick timing graph. +## Extends the existing debug_overlay.gd stub. +class_name TestDebugOverlaySprint19 +extends GdUnitTestSuite + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn" +const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd" + + +func _make_overlay() -> Control: + ## Instantiate a standalone DebugOverlay control for unit testing. + ## Does not require the full main.tscn scene tree. + var script := load(DEBUG_SCRIPT_PATH) + if script == null: + push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip") + return null + var node := Control.new() + node.set_script(script) + add_child(node) + return node + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func before_test() -> void: + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.player_position = Vector2(10.0, 10.0) + GameState.player_facing = "North" + GameState.player_stance = "Walk" + GameState.current_tick = 1 + GameState.player_knowledge = null + + +func after_test() -> void: + GameState.visible_entities = [] + GameState.player_knowledge = null + + +# --------------------------------------------------------------------------- +# Script existence +# --------------------------------------------------------------------------- + +func test_debug_overlay_script_exists() -> void: + assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message( + "debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)" + ).is_true() + + +# --------------------------------------------------------------------------- +# Instantiation +# --------------------------------------------------------------------------- + +func test_debug_overlay_instantiates_without_crash() -> void: + var ol := _make_overlay() + if ol == null: return + assert_that(ol).is_not_null() + ol.queue_free() + + +func test_debug_overlay_starts_hidden() -> void: + ## Overlay starts hidden — only appears when F3 pressed. + var ol := _make_overlay() + if ol == null: return + assert_bool(ol.visible).override_failure_message( + "DebugOverlay must start hidden (visible=false)" + ).is_false() + ol.queue_free() + + +# --------------------------------------------------------------------------- +# Dev-only guard +# --------------------------------------------------------------------------- + +func test_update_from_state_exists() -> void: + var ol := _make_overlay() + if ol == null: return + assert_bool(ol.has_method("update_from_state")).override_failure_message( + "DebugOverlay must have update_from_state() method" + ).is_true() + ol.queue_free() + + +func test_update_from_state_does_not_crash_when_hidden() -> void: + ## update_from_state() called while hidden must not crash. + var ol := _make_overlay() + if ol == null: return + ol.visible = false + ol.update_from_state() # Should be a no-op, no crash + ol.queue_free() + + +func test_update_from_state_does_not_crash_when_visible() -> void: + var ol := _make_overlay() + if ol == null: return + ol.visible = true + # Simulate a minimal snapshot tick + GameState.current_tick = 42 + ol.update_from_state() + ol.queue_free() + + +# --------------------------------------------------------------------------- +# NPC path tracking +# --------------------------------------------------------------------------- + +func test_npc_paths_field_exists() -> void: + var ol := _make_overlay() + if ol == null: return + assert_bool(ol.has("_npc_paths")).override_failure_message( + "DebugOverlay must have _npc_paths field for NPC movement history" + ).is_true() + ol.queue_free() + + +func test_npc_paths_updated_on_state_update() -> void: + ## After update_from_state with an NPC entity, _npc_paths should have an entry. + var ol := _make_overlay() + if ol == null: return + ol.visible = true + + GameState.visible_entities = [{ + "entity_id": 2, + "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "relationship": "Unknown", + }] + GameState.current_tick = 100 + ol.update_from_state() + assert_int(ol._npc_paths.size()).override_failure_message( + "_npc_paths must record NPC positions from visible_entities" + ).is_greater(0) + ol.queue_free() + + +func test_npc_paths_not_populated_for_player_entity() -> void: + ## Player entities must not appear in NPC path history. + var ol := _make_overlay() + if ol == null: return + ol.visible = true + GameState.visible_entities = [{ + "entity_id": 1, + "x": 10.0, "y": 10.0, "z": 0, + "kind": {"variant": "Player", "data": null}, + }] + GameState.current_tick = 101 + ol.update_from_state() + assert_int(ol._npc_paths.size()).override_failure_message( + "Player entity must not appear in _npc_paths" + ).is_equal(0) + ol.queue_free() + + +func test_npc_paths_max_length_respected() -> void: + ## Path history must not grow beyond NPC_HISTORY_LEN entries. + var ol := _make_overlay() + if ol == null: return + ol.visible = true + # Simulate NPC moving each tick — inject 20 ticks of movement + for i in range(20): + GameState.visible_entities = [{ + "entity_id": 5, + "x": float(12 + i), "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "relationship": "Unknown", + }] + GameState.current_tick = 200 + i + ol.update_from_state() + var path: Array = ol._npc_paths.get(5, []) + assert_int(path.size()).override_failure_message( + "NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN + ).is_less_equal(ol.NPC_HISTORY_LEN) + ol.queue_free() + + +# --------------------------------------------------------------------------- +# Tick timing ring +# --------------------------------------------------------------------------- + +func test_tick_deltas_field_exists() -> void: + var ol := _make_overlay() + if ol == null: return + assert_bool(ol.has("_tick_deltas")).override_failure_message( + "DebugOverlay must have _tick_deltas field for timing sparkline" + ).is_true() + ol.queue_free() + + +func test_tick_deltas_accumulate_over_state_updates() -> void: + ## Each new tick snapshot should add a delta to _tick_deltas. + var ol := _make_overlay() + if ol == null: return + ol.visible = true + for i in range(5): + GameState.current_tick = 300 + i + ol.update_from_state() + assert_int(ol._tick_deltas.size()).override_failure_message( + "_tick_deltas must accumulate entries from successive ticks" + ).is_greater(0) + ol.queue_free() + + +func test_tick_deltas_max_length_respected() -> void: + ## _tick_deltas must not grow beyond TICK_HISTORY_LEN. + var ol := _make_overlay() + if ol == null: return + ol.visible = true + for i in range(50): + GameState.current_tick = 400 + i + ol.update_from_state() + assert_int(ol._tick_deltas.size()).override_failure_message( + "_tick_deltas must not exceed TICK_HISTORY_LEN entries" + ).is_less_equal(ol.TICK_HISTORY_LEN) + ol.queue_free() + + +# --------------------------------------------------------------------------- +# Constants defined +# --------------------------------------------------------------------------- + +func test_npc_history_len_constant_exists() -> void: + var ol := _make_overlay() + if ol == null: return + assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message( + "DebugOverlay must have NPC_HISTORY_LEN constant" + ).is_true() + ol.queue_free() + + +func test_tick_history_len_constant_exists() -> void: + var ol := _make_overlay() + if ol == null: return + assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message( + "DebugOverlay must have TICK_HISTORY_LEN constant" + ).is_true() + ol.queue_free() + + +func test_tick_warn_ms_constant_defined() -> void: + var ol := _make_overlay() + if ol == null: return + assert_bool(ol.has("TICK_WARN_MS")).override_failure_message( + "DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold" + ).is_true() + ol.queue_free() + + +# --------------------------------------------------------------------------- +# Facing angle helper +# --------------------------------------------------------------------------- + +func test_facing_to_angle_north() -> void: + ## North = -PI/2 in Godot 2D (up on screen) + var angle := _fetch_facing_angle("North") + assert_float(angle).override_failure_message( + "_facing_to_angle('North') must return -PI/2" + ).is_equal_approx(-PI / 2.0, 0.001) + + +func test_facing_to_angle_east() -> void: + var angle := _fetch_facing_angle("East") + assert_float(angle).is_equal_approx(0.0, 0.001) + + +func test_facing_to_angle_south() -> void: + var angle := _fetch_facing_angle("South") + assert_float(angle).is_equal_approx(PI / 2.0, 0.001) + + +func test_facing_to_angle_west() -> void: + var angle := _fetch_facing_angle("West") + assert_float(angle).is_equal_approx(PI, 0.001) + + +func _fetch_facing_angle(facing: String) -> float: + ## Helper: load script and call static method. + var script = load(DEBUG_SCRIPT_PATH) + if script == null: + return 0.0 + # In GDScript 4, static methods can be called via an instance + var tmp := Control.new() + tmp.set_script(script) + add_child(tmp) + var result := tmp._facing_to_angle(facing) + tmp.queue_free() + return result + + +# --------------------------------------------------------------------------- +# In-scene placement: DebugOverlay on UILayer +# --------------------------------------------------------------------------- + +func test_debug_overlay_in_main_scene_ui_layer() -> void: + ## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay. + if not ResourceLoader.exists("res://scenes/main.tscn"): + push_warning("TestDebugOverlaySprint19: main.tscn not found — skip") + return + var scene: Node = load("res://scenes/main.tscn").instantiate() + auto_free(scene) + add_child(scene) + var ui_layer := scene.get_node_or_null("UILayer") + assert_that(ui_layer != null).override_failure_message( + "UILayer must exist in main.tscn" + ).is_true() + if ui_layer == null: return + var overlay := ui_layer.get_node_or_null("DebugOverlay") + assert_that(overlay != null).override_failure_message( + "DebugOverlay must be a child of UILayer in main.tscn (#348)" + ).is_true() diff --git a/client/tests/test_examine_display_sprint18.gd b/client/tests/test_examine_display_sprint18.gd new file mode 100644 index 000000000..d5479e9d3 --- /dev/null +++ b/client/tests/test_examine_display_sprint18.gd @@ -0,0 +1,334 @@ +## Sprint 18 — Examine result display (#174) +## Spec refs: D-061 (adjacent to dialogue spec), D-041 (character-filtered observation) +## +## ExamineDisplay: non-interactive overlay, diegetic, auto-dismisses after DISMISS_DELAY. +## Positioned in InsertOverlay (CanvasLayer 10). +## GameState.current_examine_result: cleared every snapshot (unlike player_knowledge). +## +## Tests run against live Stig implementation (examine_display.gd). +class_name TestExamineDisplaySprint18 +extends GdUnitTestSuite + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +const EXAMINE_SCENE_PATH: String = "res://ui/examine_display.tscn" + +func _make_examine_display() -> Control: + if not ResourceLoader.exists(EXAMINE_SCENE_PATH): + push_warning("TestExamineDisplaySprint18: examine_display.tscn not found — skip") + return null + var node: Control = load(EXAMINE_SCENE_PATH).instantiate() + add_child(node) + return node + + +func _make_result(overrides: Dictionary = {}) -> Dictionary: + var base: Dictionary = { + "entity_id": 42, + "text": "Kael Davan — nervous energy. He's scanning exits.", + "confidence": "KnowsOf", + } + base.merge(overrides, true) + return base + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func before_test() -> void: + GameState.current_examine_result = null + +func after_test() -> void: + GameState.current_examine_result = null + + +# --------------------------------------------------------------------------- +# GameState: current_examine_result parsing +## (Now tests real implementation — not test-first stubs) +# --------------------------------------------------------------------------- + +func test_gamestate_examine_result_field_exists() -> void: + assert_bool(GameState.has("current_examine_result")).override_failure_message( + "GameState must have 'current_examine_result' field (#174)" + ).is_true() + + +func test_gamestate_examine_result_null_by_default() -> void: + GameState.current_examine_result = null + assert_that(GameState.current_examine_result).is_null() + + +func test_gamestate_examine_result_set_from_snapshot() -> void: + GameState.apply_snapshot({"tick": 5, "examine_result": _make_result()}) + assert_that(GameState.current_examine_result).is_not_null() + assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan") + + +func test_gamestate_examine_result_null_when_absent() -> void: + ## CONTRAST with player_knowledge: examine_result DOES clear each snapshot. + ## The overlay must auto-dismiss — the server never re-sends the same result. + GameState.current_examine_result = _make_result() + GameState.apply_snapshot({"tick": 6}) + assert_that(GameState.current_examine_result).is_null() + + +func test_gamestate_examine_result_null_when_non_dict() -> void: + GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"}) + assert_that(GameState.current_examine_result).is_null() + + +func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void: + GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"entity_id": 99})}) + assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99) + + +func test_gamestate_examine_result_confidence_survives_roundtrip() -> void: + GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"confidence": "Direct"})}) + assert_that(GameState.current_examine_result.get("confidence")).is_equal("Direct") + + +func test_gamestate_examine_result_replaced_on_next_snapshot() -> void: + ## Two examine results in sequence — second replaces first. + GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"text": "First observation."})}) + GameState.apply_snapshot({"tick": 2, "examine_result": _make_result({"text": "Second observation."})}) + assert_that(GameState.current_examine_result.get("text")).is_equal("Second observation.") + + +# --------------------------------------------------------------------------- +# ExamineDisplay scene +# --------------------------------------------------------------------------- + +func test_examine_display_scene_exists() -> void: + assert_bool(ResourceLoader.exists(EXAMINE_SCENE_PATH)).override_failure_message( + "ExamineDisplay scene must exist at res://ui/examine_display.tscn" + ).is_true() + + +func test_examine_display_instantiates_without_crash() -> void: + var display := _make_examine_display() + if display == null: return + assert_that(display).is_not_null() + display.queue_free() + + +func test_examine_display_has_show_result_method() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.has_method("show_result")).override_failure_message( + "ExamineDisplay must have show_result(result: Dictionary) method" + ).is_true() + display.queue_free() + + +func test_examine_display_has_dismiss_method() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.has_method("dismiss")).override_failure_message( + "ExamineDisplay must have dismiss() method" + ).is_true() + display.queue_free() + + +func test_examine_display_has_is_active_method() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.has_method("is_active")).override_failure_message( + "ExamineDisplay must have is_active() method" + ).is_true() + display.queue_free() + + +# --------------------------------------------------------------------------- +# ExamineDisplay behavior +# --------------------------------------------------------------------------- + +func test_examine_display_not_active_on_init() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.is_active()).override_failure_message( + "ExamineDisplay must start inactive (no result showing)" + ).is_false() + display.queue_free() + + +func test_examine_display_not_visible_on_init() -> void: + var display := _make_examine_display() + if display == null: return + assert_bool(display.visible).override_failure_message( + "ExamineDisplay must start invisible" + ).is_false() + display.queue_free() + + +func test_examine_display_active_after_show_result() -> void: + ## show_result() with valid text sets is_active() = true. + var display := _make_examine_display() + if display == null: return + display.show_result(_make_result()) + assert_bool(display.is_active()).override_failure_message( + "show_result() with text must set is_active() = true" + ).is_true() + display.queue_free() + + +func test_examine_display_visible_after_show_result() -> void: + var display := _make_examine_display() + if display == null: return + display.show_result(_make_result()) + assert_bool(display.visible).override_failure_message( + "show_result() must set visible = true" + ).is_true() + display.queue_free() + + +func test_examine_display_empty_text_ignored() -> void: + ## show_result() with empty text must not activate (D-041: no empty observations). + var display := _make_examine_display() + if display == null: return + display.show_result({"entity_id": 1, "text": "", "confidence": "KnowsOf"}) + assert_bool(display.is_active()).override_failure_message( + "show_result() with empty text must not activate the display" + ).is_false() + display.queue_free() + + +func test_examine_display_inactive_after_dismiss() -> void: + ## dismiss() immediately starts fade-out and sets _active = false. + var display := _make_examine_display() + if display == null: return + display.show_result(_make_result()) + assert_bool(display.is_active()).is_true() + display.dismiss() + assert_bool(display.is_active()).override_failure_message( + "dismiss() must set is_active() = false immediately" + ).is_false() + display.queue_free() + + +func test_examine_display_dismiss_when_inactive_no_crash() -> void: + ## dismiss() on an inactive display must be safe (no crash, no state corruption). + var display := _make_examine_display() + if display == null: return + display.dismiss() # called when not active + assert_bool(display.is_active()).is_false() + display.queue_free() + + +func test_examine_display_show_replaces_previous() -> void: + ## Second show_result() replaces first (only one result at a time). + var display := _make_examine_display() + if display == null: return + display.show_result(_make_result({"text": "First observation."})) + display.show_result(_make_result({"text": "Second observation."})) + assert_bool(display.is_active()).override_failure_message( + "show_result() called twice must leave display active" + ).is_true() + ## Text label should reflect the second result + var text_label := display.get_node_or_null("PanelContainer/MarginContainer/TextLabel") + if text_label is RichTextLabel: + assert_that(text_label.text).override_failure_message( + "Second show_result() must replace the displayed text" + ).is_equal("Second observation.") + display.queue_free() + + +# --------------------------------------------------------------------------- +# ExamineDisplay: DISMISS_DELAY within spec +# --------------------------------------------------------------------------- + +func test_dismiss_delay_within_spec() -> void: + ## Spec (sprint-18/client.md): auto-dismisses after 4–6 seconds. + var display := _make_examine_display() + if display == null: return + assert_float(display.DISMISS_DELAY).override_failure_message( + "DISMISS_DELAY must be 4–6 seconds per spec" + ).is_between(4.0, 6.0) + display.queue_free() + + +# --------------------------------------------------------------------------- +# ExamineDisplay: CONFIDENCE_ALPHA — confidence-based alpha modulation +# --------------------------------------------------------------------------- + +func test_confidence_alpha_dict_covers_all_levels() -> void: + ## All four confidence levels must have alpha mappings. + var display := _make_examine_display() + if display == null: return + var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA + for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]: + assert_bool(alpha_dict.has(level)).override_failure_message( + "CONFIDENCE_ALPHA must map '%s'" % level + ).is_true() + display.queue_free() + + +func test_confidence_alpha_direct_is_highest() -> void: + ## Direct confidence = brightest (alpha 1.0). Character fully trusts this observation. + var display := _make_examine_display() + if display == null: return + var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA + assert_float(alpha_dict.get("Direct", 0.0)).override_failure_message( + "Direct confidence must have alpha 1.0 (brightest)" + ).is_equal_approx(1.0, 0.001) + display.queue_free() + + +func test_confidence_alpha_suspects_is_lowest() -> void: + ## Suspects = most dimmed (lowest alpha). Uncertainty is visually represented. + var display := _make_examine_display() + if display == null: return + var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA + var suspects_alpha: float = alpha_dict.get("Suspects", 1.0) + var direct_alpha: float = alpha_dict.get("Direct", 0.0) + assert_float(suspects_alpha).override_failure_message( + "Suspects alpha must be less than Direct alpha (dimmer = less certain)" + ).is_less(direct_alpha) + display.queue_free() + + +func test_confidence_alpha_all_values_valid() -> void: + ## All alpha values must be in [0.0, 1.0]. + var display := _make_examine_display() + if display == null: return + for key in display.CONFIDENCE_ALPHA: + var alpha: float = display.CONFIDENCE_ALPHA[key] + assert_float(alpha).override_failure_message( + "CONFIDENCE_ALPHA['%s'] = %.2f must be in [0, 1]" % [key, alpha] + ).is_between(0.0, 1.0) + display.queue_free() + + +# --------------------------------------------------------------------------- +# Non-interactive: mouse_filter must be IGNORE +# --------------------------------------------------------------------------- + +func test_examine_display_mouse_filter_ignore() -> void: + ## ExamineDisplay is non-interactive — must not consume mouse events. + var display := _make_examine_display() + if display == null: return + assert_int(display.mouse_filter).override_failure_message( + "ExamineDisplay must have mouse_filter=IGNORE (non-interactive overlay)" + ).is_equal(Control.MOUSE_FILTER_IGNORE) + display.queue_free() + + +# --------------------------------------------------------------------------- +# Fade constants +# --------------------------------------------------------------------------- + +func test_fade_in_is_short() -> void: + var display := _make_examine_display() + if display == null: return + assert_float(display.FADE_IN).is_between(0.0, 0.5) + display.queue_free() + + +func test_fade_out_is_short() -> void: + var display := _make_examine_display() + if display == null: return + assert_float(display.FADE_OUT).is_between(0.0, 0.5) + display.queue_free() diff --git a/client/tests/test_game_state.gd b/client/tests/test_game_state.gd new file mode 100644 index 000000000..48b3c69cf --- /dev/null +++ b/client/tests/test_game_state.gd @@ -0,0 +1,156 @@ +## GameState.apply_snapshot() tests — v2+ field coverage. +## +## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities, +## player_position). This file covers v2+ fields and derived state. +## +## D-030: fixture-based, server-free, no subprocess required. +class_name TestGameState +extends GdUnitTestSuite + + +func before_each() -> void: + # Reset fields touched by these tests to known defaults. + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.player_facing = "North" + GameState.game_time = {} + GameState.nearby_interactions = [] + GameState.current_monologue = null + GameState.player_stance = "Walk" + GameState.player_inventory = [] + GameState.stationary_ticks = 0 + GameState.insert_active = true + + +# -- v2: game_time (D-031) ------------------------------------------------- + +func test_apply_snapshot_sets_game_time() -> void: + var snapshot := { + "tick": 10, + "entities": [], + "game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1}, + } + GameState.apply_snapshot(snapshot) + assert_that(GameState.game_time).is_not_null() + assert_that(GameState.game_time.get("day")).is_equal(3) + assert_that(GameState.game_time.get("time_of_day")).is_equal(480) + + +func test_apply_snapshot_game_time_missing_keeps_previous() -> void: + GameState.game_time = {"day": 2, "time_of_day": 360} + GameState.apply_snapshot({"tick": 5, "entities": []}) + # No "game_time" key → field unchanged + assert_that(GameState.game_time.get("day")).is_equal(2) + + +# -- v2: player_facing (D-015) -------------------------------------------- + +func test_apply_snapshot_sets_player_facing() -> void: + var snapshot := { + "tick": 1, + "entities": [], + "player_facing": "Southeast", + } + GameState.apply_snapshot(snapshot) + assert_that(GameState.player_facing).is_equal("Southeast") + + +func test_apply_snapshot_player_facing_missing_keeps_default() -> void: + GameState.player_facing = "West" + GameState.apply_snapshot({"tick": 1, "entities": []}) + assert_that(GameState.player_facing).is_equal("West") + + +# -- v4: nearby_interactions (#404/#405) ---------------------------------- + +func test_apply_snapshot_sets_nearby_interactions() -> void: + var interactions := [ + {"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]}, + ] + GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions}) + assert_that(GameState.nearby_interactions.size()).is_equal(1) + assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5) + + +func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void: + GameState.nearby_interactions = [{"entity_id": 1}] + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_that(GameState.nearby_interactions.size()).is_equal(0) + + +# -- v5: current_monologue (#414) ----------------------------------------- + +func test_apply_snapshot_sets_monologue() -> void: + var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false} + GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue}) + assert_that(GameState.current_monologue).is_not_null() + assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.") + + +func test_apply_snapshot_monologue_absent_clears_field() -> void: + GameState.current_monologue = {"id": "old", "text": "Old line."} + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_that(GameState.current_monologue).is_null() + + +# -- v6: player_stance (#449, D-053) -------------------------------------- + +func test_apply_snapshot_sets_player_stance() -> void: + GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"}) + assert_that(GameState.player_stance).is_equal("Crouch") + + +# -- v6: player_inventory (#449, D-065) ----------------------------------- + +func test_apply_snapshot_sets_player_inventory() -> void: + var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}] + GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory}) + assert_that(GameState.player_inventory.size()).is_equal(1) + assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass") + + +func test_apply_snapshot_inventory_absent_clears_list() -> void: + GameState.player_inventory = [{"item_id": 1}] + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_that(GameState.player_inventory.size()).is_equal(0) + + +# -- Stationary tick counter (D-071) ------------------------------------- + +func test_stationary_ticks_increments_when_player_position_unchanged() -> void: + var snapshot := { + "tick": 1, + "entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}], + } + GameState.apply_snapshot(snapshot) # first call: position changes from ZERO + GameState.apply_snapshot(snapshot) # second call: position unchanged → +1 + assert_int(GameState.stationary_ticks).is_greater(0) + + +func test_stationary_ticks_resets_on_player_movement() -> void: + var s1 := { + "tick": 1, + "entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}], + } + var s2 := { + "tick": 2, + "entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}], + } + GameState.apply_snapshot(s1) + GameState.apply_snapshot(s1) # stationary + assert_int(GameState.stationary_ticks).is_greater(0) + GameState.apply_snapshot(s2) # moved → reset + assert_int(GameState.stationary_ticks).is_equal(0) + + +# -- insert_active (OQ-07, #522) ----------------------------------------- + +func test_apply_snapshot_insert_active_false() -> void: + GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false}) + assert_bool(GameState.insert_active).is_false() + + +func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void: + GameState.insert_active = false + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_bool(GameState.insert_active).is_true() diff --git a/client/tests/test_minimap_sprint18.gd b/client/tests/test_minimap_sprint18.gd new file mode 100644 index 000000000..5013ed5e6 --- /dev/null +++ b/client/tests/test_minimap_sprint18.gd @@ -0,0 +1,320 @@ +## Sprint 18 — Minimap rendering (#151) +## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered), +## D-049 (z-layer 6 = InsertOverlay) +## +## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs. +## Scene: res://ui/minimap.tscn (class_name MinimapRenderer) +## Positioned at InsertOverlay/Minimap in main.tscn. +## +## Tests run against live Stig implementation (minimap.gd). +class_name TestMinimapSprint18 +extends GdUnitTestSuite + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn" + +func _make_minimap() -> Control: + if not ResourceLoader.exists(MINIMAP_SCENE_PATH): + push_warning("TestMinimapSprint18: minimap.tscn not found — skip") + return null + var node: Control = load(MINIMAP_SCENE_PATH).instantiate() + add_child(node) + return node + + +func _make_poi(overrides: Dictionary = {}) -> Dictionary: + var base: Dictionary = { + "id": "poi_test_001", + "x": 20, + "y": 15, + "poi_category": "location", + "label": "Exit A", + } + base.merge(overrides, true) + return base + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func before_test() -> void: + GameState.discovered_pois = [] + GameState.player_position = Vector2(10.0, 10.0) + GameState.insert_active = true + +func after_test() -> void: + GameState.discovered_pois = [] + GameState.player_position = Vector2.ZERO + GameState.insert_active = true + + +# --------------------------------------------------------------------------- +# Scene and class +# --------------------------------------------------------------------------- + +func test_minimap_scene_exists() -> void: + assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message( + "Minimap scene must exist at res://ui/minimap.tscn (#151)" + ).is_true() + + +func test_minimap_instantiates_without_crash() -> void: + var mm := _make_minimap() + if mm == null: return + assert_that(mm).is_not_null() + mm.queue_free() + + +func test_minimap_is_minimap_renderer_class() -> void: + ## class_name MinimapRenderer in minimap.gd. + var mm := _make_minimap() + if mm == null: return + assert_bool(mm is MinimapRenderer).override_failure_message( + "Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)" + ).is_true() + mm.queue_free() + + +# --------------------------------------------------------------------------- +# Constants: D-015, visual parameters +# --------------------------------------------------------------------------- + +func test_minimap_radius_constant() -> void: + ## MINIMAP_RADIUS defines the sim-tile distance of visible POI area. + ## Value is tuned to 24 tiles — reasonable coverage without map reveal. + assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message( + "MinimapRenderer.MINIMAP_RADIUS must be 24.0" + ).is_equal_approx(24.0, 0.01) + + +func test_player_dot_radius_defined() -> void: + ## Player dot must be visible (> 0) and distinct from POI dot. + assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0) + + +func test_poi_dot_radius_defined() -> void: + ## POI dot must be visible (> 0). + assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0) + + +func test_player_dot_larger_than_poi_dot() -> void: + ## D-015: Player is always centered and visually distinct. + ## Player dot should be at least as large as POI dot. + assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS) + + +# --------------------------------------------------------------------------- +# _category_color() — D-013 POI category color mapping +# --------------------------------------------------------------------------- + +func test_category_color_danger_is_hostile_color() -> void: + ## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red) + for cat in ["danger", "threat", "hostile"]: + var color: Color = MinimapRenderer._category_color(cat) + assert_that(color).override_failure_message( + "Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat + ).is_equal(Constants.ENTITY_COLOR_HOSTILE) + + +func test_category_color_evidence_is_poi_color() -> void: + ## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber) + for cat in ["evidence", "note", "clue"]: + var color: Color = MinimapRenderer._category_color(cat) + assert_that(color).override_failure_message( + "Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat + ).is_equal(Constants.ENTITY_COLOR_POI) + + +func test_category_color_contact_is_unknown_color() -> void: + ## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal) + for cat in ["contact", "npc", "person"]: + var color: Color = MinimapRenderer._category_color(cat) + assert_that(color).override_failure_message( + "Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat + ).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + + +func test_category_color_unknown_category_defaults_to_insert_text() -> void: + ## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default) + var color: Color = MinimapRenderer._category_color("some_unknown_type") + assert_that(color).override_failure_message( + "Unknown category must default to INSERT_COLOR_TEXT" + ).is_equal(Constants.INSERT_COLOR_TEXT) + + +func test_category_color_empty_string_defaults() -> void: + ## Empty category string → default color, no crash. + var color: Color = MinimapRenderer._category_color("") + assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT) + + +func test_category_color_case_insensitive() -> void: + ## Category matching is case-insensitive (uses to_lower()). + var danger_lower := MinimapRenderer._category_color("danger") + var danger_upper := MinimapRenderer._category_color("DANGER") + var danger_mixed := MinimapRenderer._category_color("Danger") + assert_that(danger_lower).is_equal(danger_upper) + assert_that(danger_lower).is_equal(danger_mixed) + + +# --------------------------------------------------------------------------- +# set_insert_active() — D-049: insert layer visibility +# --------------------------------------------------------------------------- + +func test_set_insert_active_false_hides_minimap() -> void: + ## When insert is inactive, minimap must be hidden. + var mm := _make_minimap() + if mm == null: return + mm.set_insert_active(false) + assert_bool(mm.visible).override_failure_message( + "set_insert_active(false) must hide the minimap" + ).is_false() + mm.queue_free() + + +func test_set_insert_active_true_shows_minimap() -> void: + ## When insert is active, minimap must be visible. + var mm := _make_minimap() + if mm == null: return + mm.set_insert_active(false) + mm.set_insert_active(true) + assert_bool(mm.visible).override_failure_message( + "set_insert_active(true) must show the minimap" + ).is_true() + mm.queue_free() + + +# --------------------------------------------------------------------------- +# Main scene structural check: InsertOverlay/Minimap +# --------------------------------------------------------------------------- + +func test_minimap_in_main_scene_on_insert_overlay() -> void: + ## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer. + ## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap. + if not ResourceLoader.exists("res://scenes/main.tscn"): + push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped") + return + var scene: Node = load("res://scenes/main.tscn").instantiate() + auto_free(scene) + add_child(scene) + + # Check for Minimap in InsertOverlay + var insert_overlay := scene.get_node_or_null("InsertOverlay") + assert_that(insert_overlay != null).override_failure_message( + "InsertOverlay (CanvasLayer 10) must exist in main.tscn" + ).is_true() + if insert_overlay == null: return + + var minimap := insert_overlay.get_node_or_null("Minimap") + assert_that(minimap != null).override_failure_message( + "Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)" + ).is_true() + if minimap == null: return + + assert_bool(minimap is MinimapRenderer).override_failure_message( + "InsertOverlay/Minimap must be a MinimapRenderer instance" + ).is_true() + + +func test_insert_overlay_is_canvas_layer_10() -> void: + ## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049). + if not ResourceLoader.exists("res://scenes/main.tscn"): + push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped") + return + var scene: Node = load("res://scenes/main.tscn").instantiate() + auto_free(scene) + add_child(scene) + + var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer + if insert_overlay == null: return + assert_int(insert_overlay.layer).override_failure_message( + "InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT + ).is_equal(Constants.CANVAS_INSERT) + + +# --------------------------------------------------------------------------- +# GameState.discovered_pois integration +# --------------------------------------------------------------------------- + +func test_discovered_pois_field_exists_in_gamestate() -> void: + assert_bool(GameState.has("discovered_pois")).override_failure_message( + "GameState must have 'discovered_pois' field (#151)" + ).is_true() + + +func test_discovered_pois_set_from_poi_list_snapshot() -> void: + ## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois. + GameState.apply_snapshot({ + "tick": 1, + "poi_list": [ + _make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}), + _make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}), + ], + }) + assert_int(GameState.discovered_pois.size()).override_failure_message( + "discovered_pois must be populated from snapshot 'poi_list' field" + ).is_equal(2) + + +func test_discovered_pois_set_from_discovered_pois_snapshot() -> void: + ## Snapshot with "discovered_pois" key also works. + GameState.apply_snapshot({ + "tick": 2, + "discovered_pois": [_make_poi()], + }) + assert_int(GameState.discovered_pois.size()).is_equal(1) + + +func test_discovered_pois_persists_when_absent_from_snapshot() -> void: + ## Like player_knowledge: POI list persists when server doesn't send an update. + GameState.discovered_pois = [_make_poi()] + GameState.apply_snapshot({"tick": 3}) + assert_int(GameState.discovered_pois.size()).override_failure_message( + "discovered_pois must persist when absent from snapshot (not cleared each tick)" + ).is_equal(1) + + +func test_discovered_pois_poi_category_field_present() -> void: + ## MinimapRenderer reads poi_category to determine shape/color. + ## Verify the wire format includes this field. + GameState.apply_snapshot({ + "tick": 1, + "poi_list": [_make_poi({"poi_category": "danger"})], + }) + assert_int(GameState.discovered_pois.size()).is_greater(0) + var first_poi: Dictionary = GameState.discovered_pois[0] + assert_bool(first_poi.has("poi_category")).override_failure_message( + "POI entries must have 'poi_category' field for MinimapRenderer shape selection" + ).is_true() + + +func test_discovered_pois_x_y_fields_present() -> void: + ## MinimapRenderer reads x, y for position calculation. + GameState.apply_snapshot({ + "tick": 1, + "poi_list": [_make_poi({"x": 42, "y": 17})], + }) + assert_int(GameState.discovered_pois.size()).is_greater(0) + var first_poi: Dictionary = GameState.discovered_pois[0] + assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message( + "POI entries must have 'x' and 'y' coordinate fields" + ).is_true() + + +# --------------------------------------------------------------------------- +# Color constants: all distinct +# --------------------------------------------------------------------------- + +func test_category_colors_are_distinct() -> void: + ## All three primary category color groups must be visually distinct. + var danger_color := MinimapRenderer._category_color("danger") + var evidence_color := MinimapRenderer._category_color("evidence") + var contact_color := MinimapRenderer._category_color("contact") + assert_that(danger_color).is_not_equal(evidence_color) + assert_that(evidence_color).is_not_equal(contact_color) + assert_that(danger_color).is_not_equal(contact_color) diff --git a/client/tests/test_session_manager_sprint19.gd b/client/tests/test_session_manager_sprint19.gd new file mode 100644 index 000000000..ef6f68fd3 --- /dev/null +++ b/client/tests/test_session_manager_sprint19.gd @@ -0,0 +1,208 @@ +## Sprint 19 — Game session management (#258, D-085) +## Per-game save directories: created on New Game, resumed via game-id. +## SessionManager autoload: new_game(), resume_game(), list_game_dirs(). +class_name TestSessionManagerSprint19 +extends GdUnitTestSuite + +# Game IDs created during the current test — deleted in after_test(). +var _created_ids: Array = [] + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func before_test() -> void: + GameState.current_game_id = "" + _created_ids = [] + + +func after_test() -> void: + for game_id in _created_ids: + var path := "user://saves/" + game_id + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + _created_ids.clear() + GameState.current_game_id = "" + + +# --------------------------------------------------------------------------- +# Helper: call new_game() and track the created directory for cleanup. +# --------------------------------------------------------------------------- + +func _new_game() -> String: + var game_id := SessionManager.new_game() + if not game_id.is_empty(): + _created_ids.append(game_id) + return game_id + + +# --------------------------------------------------------------------------- +# GameState.current_game_id field +# --------------------------------------------------------------------------- + +func test_current_game_id_field_exists() -> void: + ## D-085: GameState must have current_game_id field. + assert_bool(GameState.has("current_game_id")).override_failure_message( + "GameState must have 'current_game_id' field (D-085 #258)" + ).is_true() + + +func test_current_game_id_default_is_empty_string() -> void: + ## Before any session starts, current_game_id is empty. + GameState.current_game_id = "" + assert_str(GameState.current_game_id).override_failure_message( + "GameState.current_game_id default must be empty string" + ).is_empty() + + +# --------------------------------------------------------------------------- +# SessionManager autoload exists +# --------------------------------------------------------------------------- + +func test_session_manager_autoload_exists() -> void: + ## SessionManager must be registered as an autoload. + var sm := Engine.get_singleton("SessionManager") + assert_that(sm != null).override_failure_message( + "SessionManager must be registered as autoload in project.godot (#258)" + ).is_true() + + +# --------------------------------------------------------------------------- +# new_game() — game-id format and GameState update +# --------------------------------------------------------------------------- + +func test_new_game_returns_non_empty_string() -> void: + var game_id := _new_game() + assert_str(game_id).override_failure_message( + "SessionManager.new_game() must return a non-empty game-id string" + ).is_not_empty() + + +func test_new_game_sets_current_game_id_on_gamestate() -> void: + var game_id := _new_game() + assert_str(GameState.current_game_id).override_failure_message( + "new_game() must set GameState.current_game_id" + ).is_equal(game_id) + + +func test_new_game_id_format_has_two_dashes() -> void: + ## Format: -- — two separator dashes. + var game_id := _new_game() + var parts := game_id.split("-") + assert_int(parts.size()).override_failure_message( + "game-id must have format -- (3 parts separated by '-')" + ).is_equal(3) + + +func test_new_game_id_first_part_is_8_digits() -> void: + ## First part is YYYYMMDD — 8 decimal digits. + var game_id := _new_game() + var parts := game_id.split("-") + assert_int(parts[0].length()).override_failure_message( + "game-id first part (date) must be 8 characters (YYYYMMDD)" + ).is_equal(8) + + +func test_new_game_id_second_part_is_6_digits() -> void: + ## Second part is HHMMSS — 6 decimal digits. + var game_id := _new_game() + var parts := game_id.split("-") + assert_int(parts[1].length()).override_failure_message( + "game-id second part (time) must be 6 characters (HHMMSS)" + ).is_equal(6) + + +func test_new_game_id_third_part_is_6_hex_chars() -> void: + ## Third part is 6 hex characters (RNG seed). + var game_id := _new_game() + var parts := game_id.split("-") + assert_int(parts[2].length()).override_failure_message( + "game-id third part (hex seed) must be 6 characters" + ).is_equal(6) + + +func test_new_game_ids_are_unique() -> void: + ## Two rapid new_game() calls should produce different IDs + ## (different RNG seeds; same-second timestamps are valid but seeds differ). + var id1 := _new_game() + var id2 := _new_game() + # Check that hex seeds differ (they almost certainly will) + var seed1 := id1.split("-")[2] + var seed2 := id2.split("-")[2] + assert_str(seed1).override_failure_message( + "Successive new_game() calls should have different RNG seeds" + ).is_not_equal(seed2) + + +# --------------------------------------------------------------------------- +# resume_game() — sets GameState.current_game_id +# --------------------------------------------------------------------------- + +func test_resume_game_sets_current_game_id() -> void: + var test_id := "20260225-143022-a7b3f1" + SessionManager.resume_game(test_id) + assert_str(GameState.current_game_id).override_failure_message( + "resume_game() must set GameState.current_game_id to the given id" + ).is_equal(test_id) + + +func test_resume_game_overwrites_previous_game_id() -> void: + SessionManager.resume_game("20260225-100000-aabbcc") + SessionManager.resume_game("20260225-120000-112233") + assert_str(GameState.current_game_id).is_equal("20260225-120000-112233") + + +# --------------------------------------------------------------------------- +# Main menu scene +# --------------------------------------------------------------------------- + +func test_main_menu_scene_exists() -> void: + assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message( + "Main menu scene must exist at res://scenes/main_menu.tscn (#258)" + ).is_true() + + +func test_main_menu_instantiates_without_crash() -> void: + if not ResourceLoader.exists("res://scenes/main_menu.tscn"): + push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip") + return + var scene: Node = load("res://scenes/main_menu.tscn").instantiate() + auto_free(scene) + add_child(scene) + assert_that(scene).is_not_null() + + +func test_main_menu_has_new_game_button() -> void: + if not ResourceLoader.exists("res://scenes/main_menu.tscn"): + return + var scene: Node = load("res://scenes/main_menu.tscn").instantiate() + auto_free(scene) + add_child(scene) + var btn := scene.get_node_or_null("VBox/NewGameBtn") + assert_that(btn != null).override_failure_message( + "Main menu must have VBox/NewGameBtn (#258)" + ).is_true() + + +func test_main_menu_has_continue_button() -> void: + if not ResourceLoader.exists("res://scenes/main_menu.tscn"): + return + var scene: Node = load("res://scenes/main_menu.tscn").instantiate() + auto_free(scene) + add_child(scene) + var btn := scene.get_node_or_null("VBox/ContinueBtn") + assert_that(btn != null).override_failure_message( + "Main menu must have VBox/ContinueBtn (#258)" + ).is_true() + + +# --------------------------------------------------------------------------- +# Project main scene changed to main_menu.tscn +# --------------------------------------------------------------------------- + +func test_project_main_scene_is_main_menu() -> void: + ## D-085: project boots to main menu, not directly to game scene. + var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "") + assert_str(scene_path).override_failure_message( + "project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)" + ).is_equal("res://scenes/main_menu.tscn") diff --git a/client/tests/util/scene_helper.gd b/client/tests/util/scene_helper.gd new file mode 100644 index 000000000..e0c6a9bb5 --- /dev/null +++ b/client/tests/util/scene_helper.gd @@ -0,0 +1,99 @@ +## Scene testing utilities for gdUnit4 tests. +## +## Loads a scene, instantiates it into the test suite's node tree, +## and provides helpers for node existence, signal, and node-path queries. +## +## Usage (from a GdUnitTestSuite subclass): +## var helper := SceneHelper.create(self, "res://scenes/main.tscn") +## helper.assert_node_exists("World") +## var world := helper.get_node_at("World") +## helper.monitor_signal(world, "ready") +## # ... trigger something ... +## helper.assert_signal_emitted(world, "ready") +## +## Design constraints (D-030): server-free, no running autoload dependencies. +class_name SceneHelper +extends RefCounted + +var _suite # GdUnitTestSuite — untyped to avoid load-order dependency +var _scene: Node +# signal_key -> int. Key is ":" for uniqueness. +var _signal_hits: Dictionary = {} + + +## Load, instantiate, and attach a scene to the test suite's node tree. +## The scene node is registered for auto-free by gdUnit4. +## Returns a helper instance; fails the test if the scene cannot be loaded. +static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper: + var helper := SceneHelper.new() + helper._suite = suite + + var packed: PackedScene = load(scene_path) + if packed == null: + suite.assert_that(packed).override_failure_message( + "SceneHelper: could not load scene at '%s'" % scene_path + ).is_not_null() + return helper + + helper._scene = packed.instantiate() + suite.auto_free(helper._scene) + suite.add_child(helper._scene) + return helper + + +## Returns the scene root node. +func scene() -> Node: + return _scene + + +## Assert that a node at node_path exists under the scene root. +## Fails the current test if the node is absent. +func assert_node_exists(node_path: String) -> void: + var node := _scene.get_node_or_null(NodePath(node_path)) + _suite.assert_that(node).override_failure_message( + "SceneHelper: expected node at path '%s' — not found" % node_path + ).is_not_null() + + +## Return the node at node_path under the scene root, or null if absent. +func get_node_at(node_path: String) -> Node: + return _scene.get_node_or_null(NodePath(node_path)) + + +## Begin tracking emissions of signal_name on node. +## Must be called before the action that triggers the signal. +## Fails the test if node does not have the named signal. +func monitor_signal(node: Node, signal_name: String) -> void: + if not node.has_signal(signal_name): + _suite.assert_that(false).override_failure_message( + "SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name] + ).is_true() + return + var key := _signal_key(node, signal_name) + _signal_hits[key] = 0 + # Lambda accepts up to 4 positional args to tolerate signals with up to 4 params. + # GDScript default-param lambdas handle being called with fewer args correctly. + node.connect(signal_name, func(a := null, b := null, c := null, d := null): + _signal_hits[key] = _signal_hits.get(key, 0) + 1 + ) + + +## Assert that signal_name was emitted at least once since monitor_signal(). +## Fails the test if monitor_signal() was not called first, or if count is zero. +func assert_signal_emitted(node: Node, signal_name: String) -> void: + var key := _signal_key(node, signal_name) + if not _signal_hits.has(key): + _suite.assert_that(false).override_failure_message( + "SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name + ).is_true() + return + var count: int = _signal_hits[key] + _suite.assert_int(count).override_failure_message( + "SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [ + signal_name, node.name, count + ] + ).is_greater(0) + + +static func _signal_key(node: Node, signal_name: String) -> String: + return "%d:%s" % [node.get_instance_id(), signal_name] diff --git a/client/ui/main_menu.gd b/client/ui/main_menu.gd new file mode 100644 index 000000000..705abc86e --- /dev/null +++ b/client/ui/main_menu.gd @@ -0,0 +1,53 @@ +extends Control +## #258: Main menu — New Game / Continue / Quit. +## New Game: generates per-game save directory (D-085), starts game. +## Continue: loads most recent save directory (#554 full loading screen deferred). + +const GAME_SCENE := "res://scenes/main.tscn" + +const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0) +const TITLE_COLOR := Color("#c8d0e0") +const SUBTITLE_COLOR := Color("#8890a0") +const BTN_NORMAL_COLOR := Color("#e8c547") +const BTN_DISABLED_COLOR := Color("#4a5060") +const FONT_SIZE_TITLE := 36 +const FONT_SIZE_SUBTITLE := 14 +const FONT_SIZE_BTN := 15 + +@onready var _new_game_btn: Button = $VBox/NewGameBtn +@onready var _continue_btn: Button = $VBox/ContinueBtn +@onready var _quit_btn: Button = $VBox/QuitBtn + + +func _ready() -> void: + _new_game_btn.pressed.connect(_on_new_game) + _continue_btn.pressed.connect(_on_continue) + _quit_btn.pressed.connect(_on_quit) + _refresh_continue_state() + + +func _refresh_continue_state() -> void: + var saves := SessionManager.list_game_dirs() + _continue_btn.disabled = saves.is_empty() + + +func _on_new_game() -> void: + var game_id := SessionManager.new_game() + if game_id.is_empty(): + push_error("MainMenu: new_game() failed to create save directory — cannot start") + return + get_tree().change_scene_to_file(GAME_SCENE) + + +func _on_continue() -> void: + # #554: Full loading screen blocked until server SaveCommand lands. + # For now: automatically load the most recent game directory. + var saves := SessionManager.list_game_dirs() + if saves.is_empty(): + return + SessionManager.resume_game(saves[0].game_id) + get_tree().change_scene_to_file(GAME_SCENE) + + +func _on_quit() -> void: + get_tree().quit() diff --git a/client/ui/minimap.gd b/client/ui/minimap.gd index ecfa129dc..0f27ee575 100644 --- a/client/ui/minimap.gd +++ b/client/ui/minimap.gd @@ -91,7 +91,7 @@ func _draw() -> void: var dx: float = float(poi.x) - px var dy: float = float(poi.y) - py var dist: float = sqrt(dx * dx + dy * dy) - var category: String = poi.get("category", "") + var category: String = poi.get("poi_category", "") var color: Color = _category_color(category) if dist < 0.01: diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index 588e9b4eb..cbde721e3 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -121,6 +121,14 @@ func _build_ui() -> void: close_btn.pressed.connect(close) _container.add_child(close_btn) + # Quit to Menu button (#258: D-085 session management) + var quit_btn := Button.new() + quit_btn.text = UIStrings.get_text("menu.quit_to_menu") + quit_btn.add_theme_font_size_override("font_size", FONT_SIZE) + quit_btn.add_theme_color_override("font_color", Color("#c87040")) + quit_btn.pressed.connect(_on_quit_to_menu) + _container.add_child(quit_btn) + func _destroy_ui() -> void: if _container: @@ -153,6 +161,11 @@ func _draw() -> void: HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR) +func _on_quit_to_menu() -> void: + close() + SessionManager.quit_to_menu() + + static func _format_db(db: float) -> String: if db <= -40.0: return "mute"