From 6bebbd9933596e85c582c195f4ecc20374af0912 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 23:43:37 +0100 Subject: [PATCH 1/5] feat(client): add interaction prompt system (#405) Server-driven interaction prompt that displays "E - Talk" when near an interactable NPC. Decodes v4 nearby_interactions from snapshot, stores in GameState, renders via InteractionPrompt UI with fade animation. Extensible interface (get_interaction_target/get_selected_verb) for future radial verb menu (v0.2). - Protocol: decode nearby_interactions array with nested VerbOption structs, entity relationship/observation fields, tick_rate in GameTime - GameState: store/clear nearby_interactions per snapshot - SimBridge: test mode generates v4 format with structured verbs - InteractionPrompt: PanelContainer with fade in/out, polls GameState - Tests: 19 new test cases covering protocol, state, sim bridge, UI, encoding - Fixture assertions updated for v4 protocol version Co-Authored-By: Claude Opus 4.6 --- client/scenes/main.tscn | 5 +- client/scripts/autoloads/game_state.gd | 9 + client/scripts/autoloads/sim_bridge.gd | 18 +- client/scripts/constants.gd.uid | 1 + client/scripts/main.gd | 9 + client/scripts/protocol/protocol.gd | 71 ++++++ client/scripts/rendering/tile_renderer.gd.uid | 1 + client/tests/test_e2e_connection.gd.uid | 1 + client/tests/test_interaction_prompt.gd | 219 ++++++++++++++++++ client/tests/test_interaction_prompt.gd.uid | 1 + client/tests/test_protocol.gd | 7 +- client/tests/test_rendering.gd | 2 +- client/tests/test_rendering.gd.uid | 1 + client/tests/test_sprint2_proof.gd.uid | 1 + client/ui/interaction_prompt.gd | 80 +++++++ client/ui/interaction_prompt.gd.uid | 1 + client/ui/interaction_prompt.tscn | 28 +++ 17 files changed, 447 insertions(+), 8 deletions(-) create mode 100644 client/scripts/constants.gd.uid create mode 100644 client/scripts/rendering/tile_renderer.gd.uid create mode 100644 client/tests/test_e2e_connection.gd.uid create mode 100644 client/tests/test_interaction_prompt.gd create mode 100644 client/tests/test_interaction_prompt.gd.uid create mode 100644 client/tests/test_rendering.gd.uid create mode 100644 client/tests/test_sprint2_proof.gd.uid create mode 100644 client/ui/interaction_prompt.gd create mode 100644 client/ui/interaction_prompt.gd.uid create mode 100644 client/ui/interaction_prompt.tscn diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index da297226c..0a826303e 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=9 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=10 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"] @@ -8,6 +8,7 @@ [ext_resource type="PackedScene" path="res://ui/hud.tscn" id="6_hud"] [ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="7_minimap"] [ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="8_monologue"] +[ext_resource type="PackedScene" path="res://ui/interaction_prompt.tscn" id="9_prompt"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -36,3 +37,5 @@ zoom = Vector2(2, 2) [node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")] [node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")] + +[node name="InteractionPrompt" parent="UILayer" instance=ExtResource("9_prompt")] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index c695e08e1..b8fde5743 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -19,6 +19,9 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral" # refined when the server assigns explicit player entity IDs). var player_entity_id: int = 1 +# v4 fields (#404/#405) +var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}] + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -54,6 +57,12 @@ func apply_snapshot(snapshot: Dictionary) -> void: if snapshot.has("player_facing") and snapshot.player_facing is String: player_facing = snapshot.player_facing + # v4: nearby_interactions (#404/#405) + if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array: + nearby_interactions = snapshot.nearby_interactions + else: + nearby_interactions = [] + # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 98aad66b8..9e19a70c1 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -294,20 +294,34 @@ func _test_snapshot() -> Dictionary: "visibility": sector, }) + # 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": 0, "available": true}, + {"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true}, + ], + }) + return { "tick": _test_tick, - "version": 2, + "version": 4, "game_time": { "day": 0, "time_of_day": _test_tick * 10, "day_phase": "Morning", - "paused": false, + "tick_rate": "Full", }, "player_facing": _test_facing, "entities": entities, "tiles": _test_tiles(), "visible_tiles": _test_visible_tiles(), "visible_positions": _test_visible_positions(), + "nearby_interactions": nearby, } # Generate a small test room: 8x6 room with walls, a door, and floor diff --git a/client/scripts/constants.gd.uid b/client/scripts/constants.gd.uid new file mode 100644 index 000000000..f0d00dd4a --- /dev/null +++ b/client/scripts/constants.gd.uid @@ -0,0 +1 @@ +uid://wmbu7ivynu5d diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 3d26cc0a3..f76dccd20 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -4,6 +4,7 @@ extends Node2D @onready var camera = $Camera2D @onready var hud = $UILayer/HUD @onready var monologue_display = $UILayer/MonologueDisplay +@onready var interaction_prompt = $UILayer/InteractionPrompt func _ready() -> void: print("The Settled Reach — client initialized") @@ -28,4 +29,12 @@ func _process(_delta: float) -> void: # Send queued input to simulation var inputs = InputMapper.flush_queue() for input in inputs: + # TODO: Once server accepts Interact(InteractData), attach target + verb: + # if input.action == InputMapper.Action.INTERACT: + # var target_id: int = interaction_prompt.get_interaction_target() + # if target_id >= 0: + # input["action_data"] = { + # "target_entity_id": target_id, + # "verb": interaction_prompt.get_selected_verb(), + # } SimBridge.send_input(input) diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index dad23ca0c..af8410f1a 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -70,6 +70,15 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: tile_entry["visibility"] = vis visible_tiles.append(tile_entry) + # v4: nearby_interactions (#404/#405) + var nearby_interactions: Array = [] + var raw_interactions: Variant = raw.get("nearby_interactions") + if raw_interactions is Array: + for raw_ni in raw_interactions: + var ni = _decode_nearby_interaction(raw_ni) + if ni != null: + nearby_interactions.append(ni) + return { "tick": tick, "entities": entities, @@ -78,6 +87,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "game_time": game_time, "player_facing": player_facing, "visible_tiles": visible_tiles, + "nearby_interactions": nearby_interactions, } @@ -96,6 +106,19 @@ static func _decode_entity(raw: Dictionary) -> Variant: if raw_vis is String: visibility = raw_vis + # v4: relationship (D-033) and observation state + var relationship: String = "Unknown" + var raw_rel: Variant = raw.get("relationship") + if raw_rel is String: + relationship = raw_rel + + var observation: Variant = "Visible" + var raw_obs: Variant = raw.get("observation") + if raw_obs is String: + observation = raw_obs + elif raw_obs is Dictionary: + observation = _decode_enum_variant(raw_obs) + return { "entity_id": entity_id, "x": float(raw["x"]), @@ -103,6 +126,54 @@ static func _decode_entity(raw: Dictionary) -> Variant: "z": int(raw["z"]), "kind": _decode_enum_variant(raw["kind"]), "visibility": visibility, + "relationship": relationship, + "observation": observation, + } + + +## Decode a NearbyInteraction from a raw msgpack map. +## Returns {entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]} or null. +static func _decode_nearby_interaction(raw) -> Variant: + if not raw is Dictionary: + return null + if not raw.has("entity_id") or not raw.has("verbs"): + push_warning("Protocol: nearby_interaction missing required fields: %s" % str(raw.keys())) + return null + var verbs: Array[Dictionary] = [] + var raw_verbs: Variant = raw.get("verbs") + if raw_verbs is Array: + for rv in raw_verbs: + var verb = _decode_verb_option(rv) + if verb != null: + verbs.append(verb) + if verbs.is_empty(): + return null + var entity_type: String = "" + var raw_et: Variant = raw.get("entity_type") + if raw_et is String: + entity_type = raw_et + return { + "entity_id": int(raw["entity_id"]), + "entity_type": entity_type, + "distance": int(raw.get("distance", 0)), + "verbs": verbs, + } + + +## Decode a VerbOption from a raw msgpack map. +## Returns {kind, label, priority, available} or null. +static func _decode_verb_option(raw) -> Variant: + if not raw is Dictionary or not raw.has("label"): + return null + var kind: String = "" + var raw_kind: Variant = raw.get("kind") + if raw_kind is String: + kind = raw_kind + return { + "kind": kind, + "label": str(raw["label"]), + "priority": int(raw.get("priority", 0)), + "available": bool(raw.get("available", true)), } diff --git a/client/scripts/rendering/tile_renderer.gd.uid b/client/scripts/rendering/tile_renderer.gd.uid new file mode 100644 index 000000000..63f01eb5c --- /dev/null +++ b/client/scripts/rendering/tile_renderer.gd.uid @@ -0,0 +1 @@ +uid://d2evnkstnqtpb diff --git a/client/tests/test_e2e_connection.gd.uid b/client/tests/test_e2e_connection.gd.uid new file mode 100644 index 000000000..4ac961947 --- /dev/null +++ b/client/tests/test_e2e_connection.gd.uid @@ -0,0 +1 @@ +uid://bcny2nvx6b8q3 diff --git a/client/tests/test_interaction_prompt.gd b/client/tests/test_interaction_prompt.gd new file mode 100644 index 000000000..f3edd42d5 --- /dev/null +++ b/client/tests/test_interaction_prompt.gd @@ -0,0 +1,219 @@ +class_name TestInteractionPrompt +extends GdUnitTestSuite + +# Tests for ticket #405: Interaction prompt system. +# Covers protocol v4 decode, GameState storage, SimBridge test mode, +# InteractionPrompt UI, and input encoding. + + +# -- Protocol: v4 nearby_interactions decoding -- + +func test_protocol_decode_v4_with_nearby_interactions() -> void: + var raw := { + "tick": 10, + "version": 4, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player", + "visibility": "Forward", "relationship": "Unknown", "observation": "Visible"}, + ], + "nearby_interactions": [{ + "entity_id": 2, + "entity_type": "Npc", + "distance": 1, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 0, "available": true}, + {"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true}, + ], + }], + } + var encoded = Messagepack.encode(raw) + assert_that(encoded.status == null).is_true() + var snapshot = Protocol.decode_snapshot(encoded.value) + + assert_that(snapshot).is_not_null() + assert_that(snapshot.nearby_interactions.size()).is_equal(1) + var ni = snapshot.nearby_interactions[0] + assert_that(ni.entity_id).is_equal(2) + assert_that(ni.entity_type).is_equal("Npc") + assert_that(ni.distance).is_equal(1) + assert_that(ni.verbs.size()).is_equal(2) + assert_that(ni.verbs[0].kind).is_equal("Talk") + assert_that(ni.verbs[0].label).is_equal("Talk") + assert_that(ni.verbs[0].priority).is_equal(0) + assert_that(ni.verbs[0].available).is_true() + assert_that(ni.verbs[1].kind).is_equal("ExamineNpc") + +func test_protocol_decode_v2_no_nearby_interactions() -> void: + var raw := { + "tick": 5, + "version": 2, + "entities": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_not_null() + assert_that(snapshot.nearby_interactions.size()).is_equal(0) + +func test_protocol_decode_empty_nearby_interactions() -> void: + var raw := { + "tick": 1, + "entities": [], + "nearby_interactions": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.nearby_interactions.size()).is_equal(0) + +func test_protocol_decode_interaction_missing_verbs() -> void: + var raw := { + "tick": 1, + "entities": [], + "nearby_interactions": [{"entity_id": 2}], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.nearby_interactions.size()).is_equal(0) + +func test_protocol_decode_interaction_empty_verbs() -> void: + var raw := { + "tick": 1, + "entities": [], + "nearby_interactions": [{"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": []}], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.nearby_interactions.size()).is_equal(0) + +func test_protocol_decode_v4_entity_relationship() -> void: + var raw := { + "tick": 1, + "version": 4, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc", + "visibility": "Forward", "relationship": "Friendly", "observation": "Visible"}, + ], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.entities[0].relationship).is_equal("Friendly") + + +# -- GameState: nearby_interactions storage -- + +func test_game_state_stores_nearby_interactions() -> void: + var ni := [{"entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 0, "available": true}]}] + GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": ni}) + assert_that(GameState.nearby_interactions.size()).is_equal(1) + assert_that(GameState.nearby_interactions[0].entity_id).is_equal(2) + GameState.nearby_interactions = [] + +func test_game_state_clears_nearby_interactions_when_absent() -> void: + var ni := [{"entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 0, "available": true}]}] + GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": ni}) + assert_that(GameState.nearby_interactions.size()).is_equal(1) + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_that(GameState.nearby_interactions.size()).is_equal(0) + + +# -- SimBridge test mode: nearby_interactions generation -- + +func test_sim_bridge_test_snapshot_interaction_when_near_npc() -> void: + SimBridge.reset_test_state() + SimBridge._test_player_pos = Vector2i(11, 9) + var snap = SimBridge._test_snapshot() + assert_that(snap.has("nearby_interactions")).is_true() + assert_that(snap.nearby_interactions.size()).is_equal(1) + assert_that(snap.nearby_interactions[0].entity_id).is_equal(2) + assert_that(snap.nearby_interactions[0].verbs.size()).is_greater(0) + +func test_sim_bridge_test_snapshot_no_interaction_when_far() -> void: + SimBridge.reset_test_state() + SimBridge._test_player_pos = Vector2i(10, 10) + var snap = SimBridge._test_snapshot() + assert_that(snap.nearby_interactions.size()).is_equal(0) + +func test_sim_bridge_test_snapshot_interaction_at_range_2() -> void: + SimBridge.reset_test_state() + SimBridge._test_player_pos = Vector2i(10, 9) + var snap = SimBridge._test_snapshot() + assert_that(snap.nearby_interactions.size()).is_equal(1) + +func test_sim_bridge_test_snapshot_v4_version() -> void: + SimBridge.reset_test_state() + var snap = SimBridge._test_snapshot() + assert_that(snap.version).is_equal(4) + + +# -- InteractionPrompt UI -- + +func test_prompt_get_selected_verb_returns_first_kind() -> void: + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 0, "available": true}, + {"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true}, + ], + }] + var prompt = _make_prompt() + assert_that(prompt.get_selected_verb()).is_equal("Talk") + prompt.queue_free() + GameState.nearby_interactions = [] + +func test_prompt_get_selected_verb_empty_when_no_interactions() -> void: + GameState.nearby_interactions = [] + var prompt = _make_prompt() + assert_that(prompt.get_selected_verb()).is_equal("") + prompt.queue_free() + +func test_prompt_get_target_negative_when_no_interactions() -> void: + GameState.nearby_interactions = [] + var prompt = _make_prompt() + assert_that(prompt.get_interaction_target()).is_equal(-1) + prompt.queue_free() + +func test_prompt_hidden_initially() -> void: + GameState.nearby_interactions = [] + var prompt = _make_prompt() + assert_that(prompt._is_showing).is_false() + prompt.queue_free() + + +# -- Input encoding: Interact -- + +func test_interact_encodes_as_unit_variant() -> void: + var inputs: Array = [{"tick": 100, "action_name": "Interact"}] + var bytes := Protocol.encode_player_inputs(inputs) + var raw = Messagepack.decode(bytes) + assert_that(raw.status == null).is_true() + assert_that(raw.value[0]["action"]).is_equal("Interact") + +func test_interact_with_data_encodes_as_data_variant() -> void: + # Future: once server accepts Interact(InteractData) + var inputs: Array = [{ + "tick": 100, + "action_name": "Interact", + "action_data": {"target_entity_id": 2, "verb": "Talk"}, + }] + var bytes := Protocol.encode_player_inputs(inputs) + var raw = Messagepack.decode(bytes) + assert_that(raw.status == null).is_true() + assert_that(raw.value[0]["action"] is Dictionary).is_true() + assert_that(raw.value[0]["action"].has("Interact")).is_true() + + +# -- Helpers -- + +func _make_prompt() -> PanelContainer: + var PromptScript = load("res://ui/interaction_prompt.gd") + var panel = PanelContainer.new() + panel.set_script(PromptScript) + var margin = MarginContainer.new() + margin.name = "MarginContainer" + panel.add_child(margin) + var label = Label.new() + label.name = "PromptLabel" + margin.add_child(label) + add_child(panel) + return panel diff --git a/client/tests/test_interaction_prompt.gd.uid b/client/tests/test_interaction_prompt.gd.uid new file mode 100644 index 000000000..7b6fc5f1b --- /dev/null +++ b/client/tests/test_interaction_prompt.gd.uid @@ -0,0 +1 @@ +uid://bu0ib6ucyleoc diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 818f7659c..172e1be10 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -279,13 +279,12 @@ func test_decode_snapshot_v2_full() -> void: assert_that(snapshot).is_not_null() assert_that(snapshot.tick).is_equal(500) - assert_that(snapshot.version).is_equal(2) + assert_that(snapshot.version).is_equal(4) # game_time assert_that(snapshot.game_time).is_not_null() assert_that(snapshot.game_time.day).is_equal(1) assert_that(snapshot.game_time.time_of_day).is_equal(720) - assert_that(snapshot.game_time.paused).is_false() # player_facing (unit enum → bare string) assert_that(snapshot.player_facing).is_equal("Southeast") @@ -302,12 +301,12 @@ func test_decode_snapshot_v2_full() -> void: func test_existing_fixtures_have_v2_fields() -> void: - # All fixtures are generated by v2 fixture_snapshot() — verify decoder extracts v2 fields + # All fixtures are generated by fixture_snapshot() — verify decoder extracts v2+ fields for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]: var bytes = _load_fixture(fixture_name) var snapshot = Protocol.decode_snapshot(bytes) assert_that(snapshot).is_not_null() - assert_that(snapshot.version).is_equal(2) + assert_that(snapshot.version).is_equal(4) assert_that(snapshot.player_facing).is_equal("North") assert_that(snapshot.game_time).is_not_null() diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index f2d354ae4..125a4ab7c 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -175,7 +175,7 @@ func test_sim_bridge_test_snapshot_has_v2_fields() -> void: SimBridge.reset_test_state() var snap = SimBridge._test_snapshot() assert_that(snap.has("version")).is_true() - assert_that(snap.version).is_equal(2) + assert_that(snap.version).is_equal(4) assert_that(snap.has("game_time")).is_true() assert_that(snap.has("player_facing")).is_true() assert_that(snap.has("visible_tiles")).is_true() diff --git a/client/tests/test_rendering.gd.uid b/client/tests/test_rendering.gd.uid new file mode 100644 index 000000000..4a8ac0423 --- /dev/null +++ b/client/tests/test_rendering.gd.uid @@ -0,0 +1 @@ +uid://cwyma3isr20u0 diff --git a/client/tests/test_sprint2_proof.gd.uid b/client/tests/test_sprint2_proof.gd.uid new file mode 100644 index 000000000..84fd263dd --- /dev/null +++ b/client/tests/test_sprint2_proof.gd.uid @@ -0,0 +1 @@ +uid://dn7p6lsej3702 diff --git a/client/ui/interaction_prompt.gd b/client/ui/interaction_prompt.gd new file mode 100644 index 000000000..dfa569d0c --- /dev/null +++ b/client/ui/interaction_prompt.gd @@ -0,0 +1,80 @@ +extends PanelContainer + +# Interaction prompt — displays available interaction verb for nearby entity. +# Server-driven: shows when GameState.nearby_interactions is non-empty, +# hides when empty. No game logic — pure display layer. +# +# v0.1: Single-line "E - Talk" (first verb on nearest entity) +# v0.2: Will be replaced/extended with radial verb menu. +# Public interface: get_interaction_target(), get_selected_verb() + +@onready var prompt_label: Label = $MarginContainer/PromptLabel + +var _is_showing: bool = false +var _active_tween: Tween = null +var _current_target_id: int = -1 + +const FADE_IN: float = 0.15 +const FADE_OUT: float = 0.15 + +func _ready() -> void: + modulate.a = 0.0 + visible = false + _is_showing = false + +func _process(_delta: float) -> void: + var interactions: Array = GameState.nearby_interactions + if interactions.size() > 0: + _show_prompt(interactions[0]) + elif _is_showing: + _hide_prompt() + +func _show_prompt(interaction: Dictionary) -> void: + var target_id: int = interaction.get("entity_id", -1) + var verbs: Array = interaction.get("verbs", []) + + if verbs.is_empty(): + if _is_showing: + _hide_prompt() + return + + # v0.1: pick first verb (sorted by priority from server) + var verb_label: String = verbs[0].get("label", "") + var display_text: String = "E - %s" % verb_label + + if _current_target_id != target_id or prompt_label.text != display_text: + prompt_label.text = display_text + _current_target_id = target_id + + if not _is_showing: + visible = true + _is_showing = true + if _active_tween and _active_tween.is_valid(): + _active_tween.kill() + _active_tween = create_tween() + _active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN) + +func _hide_prompt() -> void: + if not _is_showing: + return + _is_showing = false + _current_target_id = -1 + if _active_tween and _active_tween.is_valid(): + _active_tween.kill() + _active_tween = create_tween() + _active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT) + _active_tween.tween_callback(func(): visible = false) + +## Returns the current interaction target entity ID, or -1 if no interaction. +func get_interaction_target() -> int: + return _current_target_id + +## Returns the selected verb kind (v0.1: first verb on nearest, v0.2: radial selection). +func get_selected_verb() -> String: + var interactions: Array = GameState.nearby_interactions + if interactions.is_empty(): + return "" + var verbs: Array = interactions[0].get("verbs", []) + if verbs.is_empty(): + return "" + return verbs[0].get("kind", "") diff --git a/client/ui/interaction_prompt.gd.uid b/client/ui/interaction_prompt.gd.uid new file mode 100644 index 000000000..ebd01b47c --- /dev/null +++ b/client/ui/interaction_prompt.gd.uid @@ -0,0 +1 @@ +uid://b43jk3eu2uig diff --git a/client/ui/interaction_prompt.tscn b/client/ui/interaction_prompt.tscn new file mode 100644 index 000000000..3ed02c5a0 --- /dev/null +++ b/client/ui/interaction_prompt.tscn @@ -0,0 +1,28 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/interaction_prompt.gd" id="1_prompt"] + +[node name="InteractionPrompt" type="PanelContainer"] +anchors_preset = 7 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 200.0 +offset_top = -60.0 +offset_right = -200.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 +script = ExtResource("1_prompt") + +[node name="MarginContainer" type="MarginContainer" parent="."] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 8 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 8 + +[node name="PromptLabel" type="Label" parent="MarginContainer"] +layout_mode = 2 +horizontal_alignment = 1 +text = "" From 5d94363d48c4a9e3154835f10ef261763e7f0552 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 23:43:52 +0100 Subject: [PATCH 2/5] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a986d77d6..26a4ea2eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- Interaction prompt system (#405) — server-driven "E - Talk" prompt decoding v4 nearby_interactions with nested VerbOption structs, fade animation, extensible get_interaction_target/get_selected_verb interface for future radial verb menu - Tick rate scaling system (#406, D-052) — Full/Half/Paused rates with fractional accumulation, SetTickRate player action, replaces binary pause flag - Proximity detection and interaction verbs (#404, D-060) — compute_nearby_interactions system with Manhattan distance ranges (close ≤2, mid ≤5), context-sensitive verb computation (Talk, Observe, Examine), PersonOfInterest priority flip, NearbyInteraction in ObserverSnapshot v4 - Content directory skeleton (#385, D-057) — district-as-atomic-pack layout with Sova Transit first district, 17 NPC stubs, 3 locations, 5 triangles, dialogue/monologue pools, factions, knowledge catalogs, enum definitions From 52972e6f21d436e71417cf9251a2b04fe645dfb8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 00:04:58 +0100 Subject: [PATCH 3/5] fix(client): align tests and data with server v4 protocol changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server team shipped strict PROTOCOL_VERSION enforcement (c05ff7b), 1-indexed verb priorities, and "Observe" label for ExamineNpc. Updates all test snapshots to include version: 4, fixes sim_bridge test mode priorities (0-indexed → 1-indexed) and labels, replaces v1 backward-compat tests with strict version rejection tests. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/game_state.gd | 2 +- client/scripts/autoloads/sim_bridge.gd | 4 ++-- client/tests/test_interaction_prompt.gd | 31 ++++++++++++++++++------- client/tests/test_local_bridge.gd | 2 +- client/tests/test_protocol.gd | 28 +++++++++++----------- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index b8fde5743..1ab7a0dd7 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -11,7 +11,7 @@ var visible_tiles: Array = [] var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups # v2 fields (D-015, D-031) -var game_time: Dictionary = {} # {day, time_of_day, day_phase, paused} or empty +var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty var player_facing: String = "North" # 8-directional facing direction var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral" diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 7a87b4476..72538f0e6 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -302,8 +302,8 @@ func _test_snapshot() -> Dictionary: "entity_type": "Npc", "distance": npc_dist, "verbs": [ - {"kind": "Talk", "label": "Talk", "priority": 0, "available": true}, - {"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true}, + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, ], }) diff --git a/client/tests/test_interaction_prompt.gd b/client/tests/test_interaction_prompt.gd index f3edd42d5..83f7d53b8 100644 --- a/client/tests/test_interaction_prompt.gd +++ b/client/tests/test_interaction_prompt.gd @@ -21,8 +21,8 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void: "entity_type": "Npc", "distance": 1, "verbs": [ - {"kind": "Talk", "label": "Talk", "priority": 0, "available": true}, - {"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true}, + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, ], }], } @@ -39,14 +39,14 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void: assert_that(ni.verbs.size()).is_equal(2) assert_that(ni.verbs[0].kind).is_equal("Talk") assert_that(ni.verbs[0].label).is_equal("Talk") - assert_that(ni.verbs[0].priority).is_equal(0) + assert_that(ni.verbs[0].priority).is_equal(1) assert_that(ni.verbs[0].available).is_true() assert_that(ni.verbs[1].kind).is_equal("ExamineNpc") -func test_protocol_decode_v2_no_nearby_interactions() -> void: +func test_protocol_decode_v4_no_nearby_interactions() -> void: var raw := { "tick": 5, - "version": 2, + "version": 4, "entities": [], } var encoded = Messagepack.encode(raw) @@ -57,6 +57,7 @@ func test_protocol_decode_v2_no_nearby_interactions() -> void: func test_protocol_decode_empty_nearby_interactions() -> void: var raw := { "tick": 1, + "version": 4, "entities": [], "nearby_interactions": [], } @@ -67,6 +68,7 @@ func test_protocol_decode_empty_nearby_interactions() -> void: func test_protocol_decode_interaction_missing_verbs() -> void: var raw := { "tick": 1, + "version": 4, "entities": [], "nearby_interactions": [{"entity_id": 2}], } @@ -77,6 +79,7 @@ func test_protocol_decode_interaction_missing_verbs() -> void: func test_protocol_decode_interaction_empty_verbs() -> void: var raw := { "tick": 1, + "version": 4, "entities": [], "nearby_interactions": [{"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": []}], } @@ -97,12 +100,22 @@ func test_protocol_decode_v4_entity_relationship() -> void: var snapshot = Protocol.decode_snapshot(encoded.value) assert_that(snapshot.entities[0].relationship).is_equal("Friendly") +func test_protocol_rejects_version_mismatch() -> void: + var raw := { + "tick": 5, + "version": 2, + "entities": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_null() + # -- GameState: nearby_interactions storage -- func test_game_state_stores_nearby_interactions() -> void: var ni := [{"entity_id": 2, "entity_type": "Npc", "distance": 1, - "verbs": [{"kind": "Talk", "label": "Talk", "priority": 0, "available": true}]}] + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]}] GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": ni}) assert_that(GameState.nearby_interactions.size()).is_equal(1) assert_that(GameState.nearby_interactions[0].entity_id).is_equal(2) @@ -110,7 +123,7 @@ func test_game_state_stores_nearby_interactions() -> void: func test_game_state_clears_nearby_interactions_when_absent() -> void: var ni := [{"entity_id": 2, "entity_type": "Npc", "distance": 1, - "verbs": [{"kind": "Talk", "label": "Talk", "priority": 0, "available": true}]}] + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]}] GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": ni}) assert_that(GameState.nearby_interactions.size()).is_equal(1) GameState.apply_snapshot({"tick": 2, "entities": []}) @@ -152,8 +165,8 @@ func test_prompt_get_selected_verb_returns_first_kind() -> void: GameState.nearby_interactions = [{ "entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [ - {"kind": "Talk", "label": "Talk", "priority": 0, "available": true}, - {"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true}, + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, ], }] var prompt = _make_prompt() diff --git a/client/tests/test_local_bridge.gd b/client/tests/test_local_bridge.gd index 166978bcd..08cb729ab 100644 --- a/client/tests/test_local_bridge.gd +++ b/client/tests/test_local_bridge.gd @@ -95,7 +95,7 @@ func test_frame_encode_large_payload_length() -> void: func test_framed_protocol_snapshot_roundtrip() -> void: # Encode a snapshot with Protocol, frame it, decode the frame, decode the snapshot - var snapshot_data := {"tick": 42, "entities": []} + var snapshot_data := {"tick": 42, "version": Protocol.PROTOCOL_VERSION, "entities": []} var encoded: Variant = Messagepack.encode(snapshot_data) assert_that(encoded.status).is_null() diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 172e1be10..6ac1db5ed 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -188,6 +188,7 @@ func test_decode_snapshot_malformed_entities_counted() -> void: # Snapshot with one valid and one malformed entity — decode_errors should count the bad one var raw := { "tick": 7, + "version": Protocol.PROTOCOL_VERSION, "entities": [ {"entity_id": 1, "x": 5.0, "y": 10.0, "z": 0, "kind": "Npc"}, {"entity_id": 2, "broken": true}, # Missing required fields @@ -321,10 +322,10 @@ func test_multi_entity_visibility_sectors() -> void: assert_that(snapshot.entities[3].visibility).is_equal("Forward") -# -- v1 backward compatibility (no v2 fields → graceful null defaults) -------- +# -- Version enforcement (strict PROTOCOL_VERSION check) -------------------- -func test_decode_v1_snapshot_graceful_defaults() -> void: - # Minimal v1 snapshot — only tick + entities, no v2 fields +func test_decode_snapshot_rejects_missing_version() -> void: + # Snapshot without version field → rejected by strict version check var v1_raw := {"tick": 10, "entities": [ {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player"}, ]} @@ -332,16 +333,17 @@ func test_decode_v1_snapshot_graceful_defaults() -> void: assert_that(encoded.status).is_null() var snapshot: Variant = Protocol.decode_snapshot(encoded.value) - assert_that(snapshot).is_not_null() - assert_that(snapshot.tick).is_equal(10) - assert_that(snapshot.entities.size()).is_equal(1) - # v2 fields should be null/empty, not crash - assert_that(snapshot.version).is_null() - assert_that(snapshot.game_time).is_null() - assert_that(snapshot.player_facing).is_null() - assert_that(snapshot.visible_tiles.size()).is_equal(0) - # Entity should have null visibility - assert_that(snapshot.entities[0].visibility).is_null() + assert_that(snapshot).is_null() + + +func test_decode_snapshot_rejects_old_version() -> void: + # Snapshot with version 2 → rejected by strict version check + var old_raw := {"tick": 10, "version": 2, "entities": []} + var encoded: Variant = Messagepack.encode(old_raw) + assert_that(encoded.status).is_null() + + var snapshot: Variant = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_null() # -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ---------------- From a1c5db94905f2271635246ac628f51a97177f6c1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 00:05:07 +0100 Subject: [PATCH 4/5] fix(client): make E2E tests resilient to entity ordering Server proof room now has 3 NPCs instead of 1. Find player entity by kind instead of assuming entities[0]. Wall-hides test checks specific NPC position (16.5, 13.5) rather than asserting zero NPC count. Corner-reveal test searches for NPC1 by position. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_e2e_connection.gd | 12 ++++++-- client/tests/test_sprint2_proof.gd | 47 +++++++++++++++++------------ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/client/tests/test_e2e_connection.gd b/client/tests/test_e2e_connection.gd index 1f3e6df10..d3d9b9a59 100644 --- a/client/tests/test_e2e_connection.gd +++ b/client/tests/test_e2e_connection.gd @@ -109,12 +109,18 @@ func test_send_input_receive_snapshot() -> void: # Server starts at tick 0, snapshot reflects state after processing assert_that(snapshot.tick).is_equal(0) - assert_that(snapshot.entities.size()).is_equal(1) + assert_that(snapshot.entities.size()).is_greater(0) + + # Find the player entity by kind (entity order is not guaranteed) + var player: Dictionary = {} + for entity in snapshot.entities: + if entity.kind.variant == "Player": + player = entity + break + assert_that(player.size()).is_greater(0) # Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0) # Render coords: tile center offset -> (16.5, 15.5, 0) - var player: Dictionary = snapshot.entities[0] assert_float(player.x).is_equal_approx(16.5, 0.001) assert_float(player.y).is_equal_approx(15.5, 0.001) assert_that(player.z).is_equal(0) - assert_that(player.kind.variant).is_equal("Player") diff --git a/client/tests/test_sprint2_proof.gd b/client/tests/test_sprint2_proof.gd index 21a0164d6..21d689b41 100644 --- a/client/tests/test_sprint2_proof.gd +++ b/client/tests/test_sprint2_proof.gd @@ -6,9 +6,10 @@ ## Requires: server binary built (cargo build in server/) ## ## Server proof room layout: -## (16,13) = NPC (16,14) = WALL (16,16) = Player start -## Player facing North → NPC blocked by wall. -## Move East+North around the wall → NPC becomes visible. +## (16,13) = NPC1 (16,14) = WALL (16,16) = Player start +## (14,18) = NPC2 (18,14) = NPC3 +## Player facing North → NPC1 blocked by wall. +## Move East+North around the wall → NPC1 becomes visible. class_name TestSprint2Proof extends GdUnitTestSuite @@ -118,13 +119,17 @@ func test_proof_player_moves_and_v2_snapshot() -> void: var snapshot: Dictionary = await _send_and_receive("MoveNorth") # AC#1: Player moved from (16,16) to (16,15) - var player: Dictionary = snapshot.entities[0] + var player: Dictionary = {} + for entity in snapshot.entities: + if entity.kind.variant == "Player": + player = entity + break + assert_that(player.size()).is_greater(0) assert_float(player.x).is_equal_approx(16.5, 0.001) assert_float(player.y).is_equal_approx(15.5, 0.001) - assert_that(player.kind.variant).is_equal("Player") - # v2 protocol fields present - assert_that(snapshot.version).is_equal(2) + # v4 protocol fields present + assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snapshot.player_facing).is_equal("North") assert_that(snapshot.game_time).is_not_null() @@ -143,15 +148,18 @@ func test_proof_wall_hides_entity() -> void: return # After MoveNorth: player at (16,15) facing North. - # Wall at (16,14) blocks LOS to NPC at (16,13). + # Wall at (16,14) blocks LOS to NPC1 at (16,13). var snapshot: Dictionary = await _send_and_receive("MoveNorth") - # Only player should be visible — NPC is behind wall - var npc_count := 0 + # NPC1 at (16,13) should be hidden — wall at (16,14) blocks LOS. + # Other NPCs (NPC2 at (14,18), NPC3 at (18,14)) may be visible. + var hidden_npc_visible := false for entity in snapshot.entities: if entity.kind.variant == "Npc": - npc_count += 1 - assert_that(npc_count).is_equal(0) + if is_equal_approx(entity.x, 16.5) and is_equal_approx(entity.y, 13.5): + hidden_npc_visible = true + break + assert_that(hidden_npc_visible).is_false() # -- AC#4, AC#7: Entity appears via LOS / corner reveal ---------------------------- @@ -172,13 +180,12 @@ func test_proof_corner_reveal() -> void: await _send_and_receive("MoveNorth", 3) var snapshot: Dictionary = await _send_and_receive("MoveNorth", 4) - # Player at (18,13) facing North. NPC at (16,13) is 2 tiles west — - # within peripheral cone, no wall between. NPC should be visible. - var npc_found := false + # Player at (18,13) facing North. NPC1 at (16,13) is 2 tiles west — + # within peripheral cone, no wall between. NPC1 should be visible. + var npc1_found := false for entity in snapshot.entities: if entity.kind.variant == "Npc": - assert_float(entity.x).is_equal_approx(16.5, 0.001) - assert_float(entity.y).is_equal_approx(13.5, 0.001) - npc_found = true - break - assert_that(npc_found).is_true() + if is_equal_approx(entity.x, 16.5) and is_equal_approx(entity.y, 13.5): + npc1_found = true + break + assert_that(npc1_found).is_true() From 8117f775c5bad56e04191b159ee4fe22c892da38 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 00:05:25 +0100 Subject: [PATCH 5/5] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c76b1454..9ffe6f32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Campaign, system, station JSON schemas for hierarchical content validation ### Fixed +- Test suite aligned with server v4 protocol enforcement — all hand-built snapshots include version field, verb priorities 1-indexed, ExamineNpc label corrected to "Observe" +- E2E proof tests resilient to entity ordering — player found by kind instead of array position, wall-hides test checks specific NPC position instead of total count, supports 3-NPC proof room layout - Wire entity_id now uses StableId consistently across observer, observation, interpretation, and interaction systems (was Entity::to_bits() in some paths) - Restored system/station/district hierarchical fields in district metadata (incorrectly removed during canonical_id cleanup) - Unregistered entities in observer/interaction now log tracing::error instead of silently falling back to Entity::to_bits()