Merge remote-tracking branch 'origin/client'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-13 00:10:37 +01:00
21 changed files with 514 additions and 44 deletions
+3
View File
@@ -8,6 +8,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
### Added
- Sprint 4 "Feel" team briefings — copy (8 tickets), server (9), client (1 carry-over), CI (1), joint coordination
- 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
- Art direction & mood board workshop (3 rounds + closing) — 4-agent team establishes visual identity, 16 art direction principles, 9 mood board images, 10 candidate decisions (D-042D-051)
- 3D-to-2D sprite render pipeline (`client/tooling/sprite_renderer/`) — Godot @tool scene renders textured 3D models at "the angle" (-72.5deg ortho) from 4 cardinal directions at 1024/256/64 resolutions with outline applied at working resolution
- `/render-sprite` skill — CLI wrapper for the render pipeline with headless import step
@@ -36,6 +37,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()
+4 -1
View File
@@ -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")]
+10 -1
View File
@@ -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"
@@ -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:
+14
View File
@@ -294,6 +294,19 @@ 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": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
],
})
return {
"tick": _test_tick,
"version": Protocol.PROTOCOL_VERSION,
@@ -308,6 +321,7 @@ func _test_snapshot() -> Dictionary:
"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
+1
View File
@@ -0,0 +1 @@
uid://wmbu7ivynu5d
+9
View File
@@ -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)
+71
View File
@@ -79,6 +79,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,
@@ -87,6 +96,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"game_time": game_time,
"player_facing": player_facing,
"visible_tiles": visible_tiles,
"nearby_interactions": nearby_interactions,
}
@@ -105,6 +115,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"]),
@@ -112,6 +135,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)),
}
@@ -0,0 +1 @@
uid://d2evnkstnqtpb
+9 -3
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bcny2nvx6b8q3
+232
View File
@@ -0,0 +1,232 @@
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": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "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(1)
assert_that(ni.verbs[0].available).is_true()
assert_that(ni.verbs[1].kind).is_equal("ExamineNpc")
func test_protocol_decode_v4_no_nearby_interactions() -> void:
var raw := {
"tick": 5,
"version": 4,
"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,
"version": 4,
"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,
"version": 4,
"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,
"version": 4,
"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")
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": 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)
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": 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": []})
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": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "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
@@ -0,0 +1 @@
uid://bu0ib6ucyleoc
+1 -1
View File
@@ -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()
+18 -17
View File
@@ -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
@@ -279,13 +280,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 +302,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()
@@ -322,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"},
]}
@@ -333,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) ----------------
+1 -1
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://cwyma3isr20u0
+27 -20
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://dn7p6lsej3702
+80
View File
@@ -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", "")
+1
View File
@@ -0,0 +1 @@
uid://b43jk3eu2uig
+28
View File
@@ -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 = ""