diff --git a/CHANGELOG.md b/CHANGELOG.md index df85442e5..075c45b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states - Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552) - Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330) +- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263) +- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537) +- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time - `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team - `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection) - Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 0a866f02b..5362fca82 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=24 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -22,6 +22,7 @@ [ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] [ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"] [ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"] +[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -118,6 +119,9 @@ zoom = Vector2(2, 2) [node name="InsertOverlay" type="CanvasLayer" parent="."] layer = 10 +; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031) +[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")] + ; InteractionPrompt — v0.1 fallback single-line "E - Talk" display [node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")] diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 5fc354777..31d4efa68 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0 # two columns of text comfortably, leaves world game visible alongside. const DIALOGUE_MAX_WIDTH: int = 1200 +# D-031: Format game-minutes (0..1439) as station local time string "HH:MM". +static func format_game_time(time_of_day: int) -> String: + var clamped: int = clampi(time_of_day, 0, 1439) + return "%02d:%02d" % [clamped / 60, clamped % 60] + # Default camera zoom — used as fallback when get_camera_2d() returns null const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 454c5fc44..9c68ba529 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -14,6 +14,7 @@ extends Node2D @onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7 @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress +@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031) @onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) @@ -104,7 +105,7 @@ func _process(delta: float) -> void: if interaction_list and interaction_list.has_method("update_from_state"): if dialogue_box and dialogue_box.is_dialogue_active(): if interaction_list.is_showing(): - interaction_list._hide() + interaction_list.hide_list() else: interaction_list.update_from_state() @@ -131,6 +132,10 @@ func _process(delta: float) -> void: if checklist_overlay and checklist_overlay.has_method("update_from_state"): checklist_overlay.update_from_state() + # #263: Update time display (D-013, D-031) + if time_display and time_display.has_method("update_from_state"): + time_display.update_from_state() + # #511: Update debug overlay (F3 toggle, dev tool) if debug_overlay and debug_overlay.has_method("update_from_state"): debug_overlay.update_from_state() diff --git a/client/tests/test_etalk_overlay_sprint17.gd b/client/tests/test_etalk_overlay_sprint17.gd new file mode 100644 index 000000000..9446f662d --- /dev/null +++ b/client/tests/test_etalk_overlay_sprint17.gd @@ -0,0 +1,298 @@ +## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1 +## Tests for relationship color indicator in interaction_list.gd. +## +## Spec refs: +## D-033 (entity color = relationship to player) +## D-051 (diegetic insert display — insert-only overlay) +## D-057 (interaction list, z-layer 6) +## +## Phase 1 scope (2026-02-24): +## Color bar only — NPC name and dialogue tier deferred to Phase 2 pending +## server protocol change (no known_attributes in wire protocol v13). +## +## Implementation: client/ui/interaction_list.gd +## `var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM` +## `func _cache_entity_relationship() -> void` +## 3px left-edge bar drawn in _draw() at 85% alpha, called from update_from_state() +class_name TestETalkOverlaySprint17 +extends GdUnitTestSuite + + +func before_test() -> void: + SimBridge.reset_test_state() + GameState.nearby_interactions = [] + GameState.visible_entities = [] + GameState.player_stance = "" + + +func after_test() -> void: + GameState.nearby_interactions = [] + GameState.visible_entities = [] + GameState.player_stance = "" + + +# ------------------------------------------------------------------------- +# D-033: Constants.color_for_relationship() — palette baseline (pure logic) +# ------------------------------------------------------------------------- + +func test_relationship_unknown_maps_to_teal() -> void: + assert_that(Constants.color_for_relationship("Unknown")).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + +func test_relationship_friendly_maps_to_green() -> void: + assert_that(Constants.color_for_relationship("Friendly")).is_equal(Constants.ENTITY_COLOR_FRIENDLY) + +func test_relationship_poi_maps_to_amber() -> void: + assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Constants.ENTITY_COLOR_POI) + +func test_relationship_hostile_maps_to_red() -> void: + assert_that(Constants.color_for_relationship("Hostile")).is_equal(Constants.ENTITY_COLOR_HOSTILE) + +func test_relationship_known_falls_back_to_unknown_color() -> void: + # "Known" not matched in color_for_relationship() — falls through _ → ENTITY_COLOR_UNKNOWN + assert_that(Constants.color_for_relationship("Known")).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + +func test_relationship_unknown_string_falls_back_to_unknown() -> void: + assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + + +# ------------------------------------------------------------------------- +# UIStrings: relationship state labels exist (Phase 2 pre-fixture) +# ------------------------------------------------------------------------- + +func test_ui_strings_has_relationship_unknown_label() -> void: + assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true() + +func test_ui_strings_has_relationship_known_label() -> void: + assert_that(UIStrings.has_key("relationship_states.known.label")).is_true() + +func test_ui_strings_has_relationship_friendly_label() -> void: + assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true() + +func test_ui_strings_has_relationship_poi_label() -> void: + assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true() + +func test_ui_strings_has_relationship_hostile_label() -> void: + assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true() + + +# ------------------------------------------------------------------------- +# _relationship_color initial state +# ------------------------------------------------------------------------- + +func test_relationship_color_default_is_implant_dim() -> void: + # Before any update, _relationship_color starts at IMPLANT_TEXT_DIM + var list = _make_list() + assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM) + list.queue_free() + + +# ------------------------------------------------------------------------- +# _cache_entity_relationship(): D-033 palette via update_from_state() (#537 core) +# ------------------------------------------------------------------------- + +func test_cache_relationship_unknown_sets_unknown_color() -> void: + _setup_npc_interaction(2, "Unknown") + var list = _make_list() + list.update_from_state() + assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + list.queue_free() + +func test_cache_relationship_friendly_sets_green() -> void: + _setup_npc_interaction(2, "Friendly") + var list = _make_list() + list.update_from_state() + assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY) + list.queue_free() + +func test_cache_relationship_poi_sets_amber() -> void: + _setup_npc_interaction(2, "PersonOfInterest") + var list = _make_list() + list.update_from_state() + assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_POI) + list.queue_free() + +func test_cache_relationship_hostile_sets_red() -> void: + _setup_npc_interaction(2, "Hostile") + var list = _make_list() + list.update_from_state() + assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE) + list.queue_free() + +func test_cache_relationship_absent_field_uses_unknown_color() -> void: + # Entity present but "relationship" key missing → get("relationship", "Unknown") → Unknown color + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", + }] + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var list = _make_list() + list.update_from_state() + assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + list.queue_free() + +func test_cache_relationship_entity_not_in_visible_uses_dim_fallback() -> void: + # Entity in nearby_interactions but NOT in visible_entities → IMPLANT_TEXT_DIM fallback + GameState.visible_entities = [] + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var list = _make_list() + list.update_from_state() + assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM) + list.queue_free() + +func test_cache_relationship_updates_when_relationship_changes() -> void: + # First call: Unknown + _setup_npc_interaction(2, "Unknown") + var list = _make_list() + list.update_from_state() + assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + + # Relationship shifts → re-cache picks up new value + _setup_npc_interaction(2, "Hostile") + list.update_from_state() + assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE) + list.queue_free() + +func test_cache_relationship_targets_correct_entity_by_id() -> void: + # Two entities visible; nearby_interactions[0] is the target (entity 2, Friendly) + # Entity 3 (Hostile) must not pollute the color + GameState.visible_entities = [ + { + "entity_id": 2, "x": 11.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Friendly", "observation": "Visible", + }, + { + "entity_id": 3, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Hostile", "observation": "Visible", + }, + ] + GameState.nearby_interactions = [ + { + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }, + { + "entity_id": 3, "entity_type": "Npc", "distance": 2, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }, + ] + var list = _make_list() + list.update_from_state() + # interaction[0] = entity 2 (Friendly) → green bar + assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY) + list.queue_free() + +func test_z_layer_is_insert_canvas() -> void: + # D-049 / D-057: interaction list lives on InsertOverlay (CanvasLayer 10) + var list = _make_list() + assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT) + list.queue_free() + + +# ------------------------------------------------------------------------- +# Regression: interaction_list public API unaffected by #537 changes +# ------------------------------------------------------------------------- + +func test_list_hides_when_no_interactions() -> void: + GameState.nearby_interactions = [] + var list = _make_list() + list.update_from_state() + assert_that(list.is_showing()).is_false() + list.queue_free() + +func test_list_shows_on_npc_interaction() -> void: + _setup_npc_interaction(2, "Unknown") + var list = _make_list() + list.update_from_state() + assert_that(list.is_showing()).is_true() + list.queue_free() + +func test_list_get_selected_verb_returns_first_verb() -> void: + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true}, + ], + }] + var list = _make_list() + assert_that(list.get_selected_verb()).is_equal("Talk") + list.queue_free() + +func test_list_get_interaction_target_returns_entity_id() -> void: + GameState.visible_entities = [{ + "entity_id": 5, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Unknown", "observation": "Visible", + }] + GameState.nearby_interactions = [{ + "entity_id": 5, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var list = _make_list() + list.update_from_state() + assert_that(list.get_interaction_target()).is_equal(5) + list.queue_free() + +func test_list_suppressed_when_insert_inactive() -> void: + _setup_npc_interaction(2, "Unknown") + var list = _make_list() + list.set_insert_active(false) + list.update_from_state() + assert_that(list.is_showing()).is_false() + list.queue_free() + +func test_list_hides_on_empty_verb_list() -> void: + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [], + }] + var list = _make_list() + list.update_from_state() + assert_that(list.is_showing()).is_false() + list.queue_free() + + +# ------------------------------------------------------------------------- +# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13) +# ------------------------------------------------------------------------- + +func skip_test_npc_name_displayed_when_known() -> void: + pass + +func skip_test_dialogue_tier_context_hint_for_friendly() -> void: + pass + +func skip_test_dialogue_tier_context_hint_for_hostile() -> void: + pass + + +# ------------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------------- + +func _make_list() -> Control: + var scene = load("res://ui/interaction_list.tscn") + var list = scene.instantiate() + add_child(list) # _ready() fires here — @onready var _vbox resolves + return list + + +## Set up GameState with a single NPC entity + matching interaction for tests. +func _setup_npc_interaction(entity_id: int, relationship: String) -> void: + GameState.visible_entities = [{ + "entity_id": entity_id, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": relationship, "observation": "Visible", + }] + GameState.nearby_interactions = [{ + "entity_id": entity_id, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] diff --git a/client/tests/test_time_display_sprint17.gd b/client/tests/test_time_display_sprint17.gd new file mode 100644 index 000000000..c19ca6a0e --- /dev/null +++ b/client/tests/test_time_display_sprint17.gd @@ -0,0 +1,318 @@ +## Sprint 17 — Time display on insert HUD (#263) +## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration. +## +## Spec refs: +## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display) +## D-051 (diegetic insert display) +## +## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay. +## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for +## testability from time_display.gd:46 inline Constants.format_game_time(tod)). +## +## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly +## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted +## test pattern for draw-based UI — the private vars ARE the rendered output contract. +## If the rendering approach changes (e.g. to Label nodes), these tests should switch to +## reading Label.text via public node paths instead. +class_name TestTimeDisplaySprint17 +extends GdUnitTestSuite + +var _clock: Control = null + + +func before_test() -> void: + SimBridge.reset_test_state() + GameState.game_time = {} + var ClockScript = load("res://ui/time_display.gd") + _clock = Control.new() + _clock.set_script(ClockScript) + add_child(_clock) + + +func after_test() -> void: + if _clock and is_instance_valid(_clock): + _clock.queue_free() + _clock = null + GameState.game_time = {} + + +# ------------------------------------------------------------------------- +# Constants.format_game_time() — pure logic, D-031 +# ------------------------------------------------------------------------- + +func test_format_midnight() -> void: + assert_that(Constants.format_game_time(0)).is_equal("00:00") + +func test_format_morning_start() -> void: + # 360 game-minutes = 6 h exactly (Morning phase boundary, D-031) + assert_that(Constants.format_game_time(360)).is_equal("06:00") + +func test_format_noon() -> void: + assert_that(Constants.format_game_time(720)).is_equal("12:00") + +func test_format_evening_start() -> void: + assert_that(Constants.format_game_time(1080)).is_equal("18:00") + +func test_format_end_of_day() -> void: + # Last valid minute — must not wrap or overflow + assert_that(Constants.format_game_time(1439)).is_equal("23:59") + +func test_format_pads_single_digit_hour() -> void: + # 30 min = 00:30 + assert_that(Constants.format_game_time(30)).is_equal("00:30") + +func test_format_pads_single_digit_minute() -> void: + # 121 min = 02:01 + assert_that(Constants.format_game_time(121)).is_equal("02:01") + +func test_format_half_past_hour() -> void: + assert_that(Constants.format_game_time(90)).is_equal("01:30") + +func test_format_arbitrary_midday() -> void: + # 835 min = 13:55 + assert_that(Constants.format_game_time(835)).is_equal("13:55") + + +# ------------------------------------------------------------------------- +# InsertClock initial state +# ------------------------------------------------------------------------- + +func test_insert_clock_initial_time_str_is_placeholder() -> void: + # Before any snapshot, _time_str must be "--:--" (not shown by _draw) + assert_that(_clock._time_str).is_equal("--:--") + +func test_insert_clock_initial_phase_str_is_empty() -> void: + assert_that(_clock._phase_str).is_equal("") + +func test_insert_clock_initial_day_str_is_empty() -> void: + assert_that(_clock._day_str).is_equal("") + + +# ------------------------------------------------------------------------- +# InsertClock.update_from_state() — reads GameState.game_time +# ------------------------------------------------------------------------- + +func test_update_from_state_formats_time_str() -> void: + GameState.game_time = { + "day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._time_str).is_equal("12:00") + +func test_update_from_state_sets_phase_str() -> void: + GameState.game_time = { + "day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._phase_str).is_equal("Evening") + +func test_update_from_state_sets_day_str_one_indexed() -> void: + # Day 0 from server → "D1" display (1-indexed) + GameState.game_time = { + "day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._day_str).is_equal("D1") + +func test_update_from_state_day_2() -> void: + GameState.game_time = { + "day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._day_str).is_equal("D2") + +func test_update_from_state_midnight() -> void: + GameState.game_time = { + "day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._time_str).is_equal("00:00") + +func test_update_from_state_end_of_day() -> void: + GameState.game_time = { + "day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._time_str).is_equal("23:59") + +func test_update_from_state_skips_empty_game_time() -> void: + # Empty game_time must not overwrite --:-- (guard in update_from_state) + GameState.game_time = {} + _clock.update_from_state() + assert_that(_clock._time_str).is_equal("--:--") + +func test_update_from_state_deduplicates_same_tick() -> void: + # Calling twice with identical data must produce same result (signature cache) + GameState.game_time = { + "day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._time_str).is_equal("06:00") + # Call again — result unchanged, no crash + _clock.update_from_state() + assert_that(_clock._time_str).is_equal("06:00") + +func test_update_from_state_updates_on_new_tick() -> void: + # time_of_day changes → signature changes → _time_str updates + GameState.game_time = { + "day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._time_str).is_equal("01:00") + + GameState.game_time = { + "day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._time_str).is_equal("02:00") + + +# ------------------------------------------------------------------------- +# InsertClock.PHASE_COLORS — all four D-031 phases have colors +# ------------------------------------------------------------------------- + +func test_phase_colors_has_morning() -> void: + assert_that(_clock.PHASE_COLORS.has("Morning")).is_true() + +func test_phase_colors_has_afternoon() -> void: + assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true() + +func test_phase_colors_has_evening() -> void: + assert_that(_clock.PHASE_COLORS.has("Evening")).is_true() + +func test_phase_colors_has_night() -> void: + assert_that(_clock.PHASE_COLORS.has("Night")).is_true() + +func test_phase_color_applied_after_update() -> void: + GameState.game_time = { + "day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"]) + +func test_phase_color_unknown_phase_uses_dim_fallback() -> void: + # Unknown phase string → Constants.IMPLANT_TEXT_DIM + GameState.game_time = { + "day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full", + } + _clock.update_from_state() + assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM) + + +# ------------------------------------------------------------------------- +# GameState: game_time field parsing (confirms apply_snapshot wiring) +# ------------------------------------------------------------------------- + +func test_game_time_populated_from_snapshot() -> void: + GameState.apply_snapshot({ + "tick": 5, "entities": [], + "game_time": { + "day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full", + }, + }) + assert_that(GameState.game_time.get("time_of_day")).is_equal(720) + +func test_game_time_all_four_phases_store_correctly() -> void: + for phase in ["Morning", "Afternoon", "Evening", "Night"]: + GameState.apply_snapshot({ + "tick": 1, "entities": [], + "game_time": { + "day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full", + }, + }) + assert_that(GameState.game_time.get("day_phase")).is_equal(phase) + +func test_game_time_missing_from_snapshot_preserves_previous() -> void: + GameState.game_time = { + "day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full", + } + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_that(GameState.game_time.get("time_of_day")).is_equal(360) + +func test_game_time_zero_time_of_day_stored() -> void: + # time_of_day = 0 (midnight) must not be treated as falsy/missing + GameState.apply_snapshot({ + "tick": 1, "entities": [], + "game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"}, + }) + assert_that(GameState.game_time.get("time_of_day")).is_equal(0) + + +# ------------------------------------------------------------------------- +# SimBridge test mode: game_time fields are valid +# ------------------------------------------------------------------------- + +func test_sim_bridge_snapshot_has_game_time() -> void: + var snap = SimBridge._test_snapshot() + assert_that(snap.has("game_time")).is_true() + assert_that(snap.game_time is Dictionary).is_true() + +func test_sim_bridge_game_time_has_required_fields() -> void: + var snap = SimBridge._test_snapshot() + var gt: Dictionary = snap.game_time + assert_that(gt.has("day")).is_true() + assert_that(gt.has("time_of_day")).is_true() + assert_that(gt.has("day_phase")).is_true() + assert_that(gt.has("tick_rate")).is_true() + +func test_sim_bridge_time_of_day_is_non_negative() -> void: + var snap = SimBridge._test_snapshot() + assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0) + +func test_sim_bridge_time_of_day_within_day_bounds() -> void: + # D-031: 1440 game-minutes per day, valid range 0..1439 + var snap = SimBridge._test_snapshot() + assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439) + +func test_sim_bridge_day_phase_is_valid() -> void: + var snap = SimBridge._test_snapshot() + var phase: String = snap.game_time.get("day_phase", "") + assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true() + + +# ------------------------------------------------------------------------- +# Scene: InsertClock node at InsertOverlay/TimeDisplay +# ------------------------------------------------------------------------- + +func test_insert_clock_exists_in_ui_layer() -> void: + var scene := load("res://scenes/main.tscn") + var instance = scene.instantiate() + auto_free(instance) + add_child(instance) + + assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null() + +func test_insert_clock_time_str_updates_after_process() -> void: + var scene := load("res://scenes/main.tscn") + var instance = scene.instantiate() + auto_free(instance) + add_child(instance) + + GameState.apply_snapshot({ + "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], + "game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"}, + }) + instance._process(0.016) + + var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay") + assert_that(clock).is_not_null() + assert_that(clock._time_str).is_equal("12:00") + + +# ------------------------------------------------------------------------- +# Regression: debug_overlay still reads game_time correctly (#511) +# ------------------------------------------------------------------------- + +func test_debug_overlay_reads_game_time_day_phase() -> void: + GameState.apply_snapshot({ + "tick": 1, "entities": [], + "game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"}, + }) + assert_that(GameState.game_time.get("day_phase")).is_equal("Morning") + +func test_debug_overlay_reads_game_time_tick_rate() -> void: + GameState.apply_snapshot({ + "tick": 1, "entities": [], + "game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"}, + }) + assert_that(GameState.game_time.get("tick_rate")).is_equal("Half") diff --git a/client/ui/interaction_list.gd b/client/ui/interaction_list.gd index 2a96d6850..94c099bc9 100644 --- a/client/ui/interaction_list.gd +++ b/client/ui/interaction_list.gd @@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}] var _selected_index: int = 0 var _active_tween: Tween = null var _verb_labels: Array[Label] = [] +# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar +var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM @onready var _vbox: VBoxContainer = $VBox @@ -39,10 +41,14 @@ func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE +var _last_screen_pos: Vector2 = Vector2.ZERO + func _process(_delta: float) -> void: if _showing: _update_screen_position() - queue_redraw() + if position != _last_screen_pos: + _last_screen_pos = position + queue_redraw() func _draw() -> void: @@ -52,6 +58,9 @@ func _draw() -> void: var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2) draw_rect(bg_rect, INSERT_BG) draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0) + # #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship + var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y) + draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85)) func update_from_state() -> void: @@ -88,6 +97,7 @@ func update_from_state() -> void: _verb_items = sorted _selected_index = 0 _cache_entity_position() + _cache_entity_relationship() _rebuild_labels() _show() @@ -127,6 +137,17 @@ func _cache_entity_position() -> void: return +## #537: Cache relationship color for the target entity (D-033 palette). +## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities. +func _cache_entity_relationship() -> void: + for entity in GameState.visible_entities: + if entity.get("entity_id") == _current_target_id: + var rel: String = str(entity.get("relationship", "Unknown")) + _relationship_color = Constants.color_for_relationship(rel) + return + _relationship_color = Constants.IMPLANT_TEXT_DIM + + ## Convert entity world position to screen coords and reposition this Control. ## Runs every frame while showing so the list tracks the entity as the camera moves. func _update_screen_position() -> void: @@ -187,6 +208,10 @@ func get_visible_verb_count() -> int: return _verb_items.size() +func hide_list() -> void: + _hide() + + func is_showing() -> bool: return _showing diff --git a/client/ui/time_display.gd b/client/ui/time_display.gd new file mode 100644 index 000000000..bf1fe44ee --- /dev/null +++ b/client/ui/time_display.gd @@ -0,0 +1,97 @@ +extends Control + +## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031). +## Shows station local time (HH:MM), day phase, and day number. +## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle. +## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd. +## +## Placeholder layout — position and style will be refined when #314 wireframe lands. + +const FONT_SIZE_TIME: int = 15 +const FONT_SIZE_META: int = 10 +const PADDING := Vector2(10, 7) +const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70) +const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65) + +# Day phase colors — station lighting cycle (D-031) +const PHASE_COLORS := { + "Morning": Color("#aed6dc"), # pale cyan-blue — early light + "Afternoon": Color("#E0F7FA"), # bright cyan-white — full day + "Evening": Color("#9EBFC4"), # dimmed — dusk transition + "Night": Color("#4a7080"), # dark teal — station nightwatch +} + +var _time_str: String = "--:--" +var _phase_str: String = "" +var _day_str: String = "" +var _day_text: String = "" +var _phase_color: Color = Constants.IMPLANT_TEXT_DIM +var _last_signature: String = "" +var _has_data: bool = false + +# Cached geometry — recomputed in update_from_state(), used in _draw() +var _time_size: Vector2 = Vector2.ZERO +var _phase_size: Vector2 = Vector2.ZERO +var _day_size: Vector2 = Vector2.ZERO +var _meta_h: float = 0.0 +var _box_w: float = 0.0 +var _box_h: float = 0.0 + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +func update_from_state() -> void: + var gt: Dictionary = GameState.game_time + if gt.is_empty(): + return + var tod: int = int(gt.get("time_of_day", 0)) + var day: int = int(gt.get("day", 0)) + var phase: String = str(gt.get("day_phase", "")) + var sig: String = "%d:%d:%s" % [tod, day, phase] + if sig == _last_signature: + return + _last_signature = sig + _time_str = Constants.format_game_time(tod) + _phase_str = phase + _day_str = "D%d" % (day + 1) + _day_text = " " + _day_str + _phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM) + _has_data = true + _cache_geometry() + queue_redraw() + + +func _cache_geometry() -> void: + var font := ThemeDB.fallback_font + _time_size = font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME) + _phase_size = font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META) + _day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META) + _meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y + var meta_w := _phase_size.x + _day_size.x + var content_w := max(_time_size.x, meta_w) + _box_w = content_w + PADDING.x * 2 + _box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h + + +func _draw() -> void: + if not _has_data: + return + + # Background + draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BG_COLOR) + draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BORDER_COLOR, false, 1.0) + + var font := ThemeDB.fallback_font + + # HH:MM (primary, full brightness) + draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y), + _time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR) + + # Phase + day number (secondary, dimmed + phase-tinted) + var meta_y := PADDING.y + _time_size.y + 3 + _meta_h + draw_string(font, Vector2(PADDING.x, meta_y), + _phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color) + draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y), + _day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM) diff --git a/client/ui/time_display.tscn b/client/ui/time_display.tscn new file mode 100644 index 000000000..7145a5abb --- /dev/null +++ b/client/ui/time_display.tscn @@ -0,0 +1,22 @@ +[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"] + +[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"] + +; #263: Time display — top-left placeholder per D-013/D-051. +; Position and size will be refined when #314 wireframe lands. +; NOTE: draw-based content manages its own layout; Control rect is a +; minimum bounding box, not a clip rect. Increase if content grows. +[node name="TimeDisplay" type="Control"] +anchors_preset = 0 +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +offset_left = 16.0 +offset_top = 16.0 +offset_right = 200.0 +offset_bottom = 70.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_tdisplay")