Merge remote-tracking branch 'origin/client'

This commit is contained in:
2026-02-19 09:10:37 +01:00
8 changed files with 782 additions and 7 deletions
+5
View File
@@ -116,6 +116,11 @@ bug_report={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
teleport_hub={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
+4
View File
@@ -20,6 +20,7 @@ enum Action {
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
SET_FACING, # D-054: facing octant update (no movement)
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
}
var input_queue: Array[Dictionary] = []
@@ -105,6 +106,9 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.TOGGLE_STANCE_DOWN
elif event.is_action_pressed("bug_report"):
action = Action.BUG_REPORT
elif event.is_action_pressed("teleport_hub"):
if GameState.gauntlet_mode:
action = Action.TELEPORT_HUB
if action != -1:
input_queue.append({
+13
View File
@@ -10,6 +10,8 @@ var _test_player_pos: Vector2i = Vector2i(10, 10)
var _test_facing: String = "North"
var _test_input_queue: Array = [] # Queued actions for test mode
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -40,6 +42,8 @@ func reset_test_state() -> void:
_test_facing = "North"
_test_input_queue.clear()
_test_in_dialogue = false
_test_gauntlet_mode = false
_test_npc_relationship = "Unknown"
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
@@ -269,6 +273,8 @@ static func _action_enum_to_wire(action: int) -> String:
return "" # Client-only action (#495), not part of wire protocol
InputMapper.Action.SET_FACING:
return "SetFacing" # D-054: facing octant update (no movement)
InputMapper.Action.TELEPORT_HUB:
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
@@ -284,6 +290,11 @@ func _test_snapshot() -> Dictionary:
# Process queued inputs
for action_name in _test_input_queue:
if action_name == "TeleportToHub":
# #501: Reset to hub spawn position, clear dialogue
_test_player_pos = Vector2i(10, 10)
_test_in_dialogue = false
continue
if action_name == "Interact":
# Mock dialogue trigger (#434): if near NPC, start dialogue
var npc_pos := Vector2i(12, 9)
@@ -326,6 +337,7 @@ func _test_snapshot() -> Dictionary:
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": sector,
"relationship": _test_npc_relationship,
})
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
@@ -404,6 +416,7 @@ func _test_snapshot() -> Dictionary:
"current_monologue": monologue,
"current_dialogue": dialogue,
"pending_recognitions": pending_recs,
"gauntlet_mode": _test_gauntlet_mode,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
+11 -2
View File
@@ -59,13 +59,22 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
# D-033 color lookup by entity kind (Phase 1: defaults, Phase 2 #361: relationship-based)
# D-033 color lookup by relationship string (#521)
static func color_for_relationship(relationship: String) -> Color:
match relationship:
"Friendly": return ENTITY_COLOR_FRIENDLY
"PersonOfInterest": return ENTITY_COLOR_POI
"Hostile": return ENTITY_COLOR_HOSTILE
"Unknown": return ENTITY_COLOR_UNKNOWN
_: return ENTITY_COLOR_UNKNOWN
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant:
"Player": return ENTITY_COLOR_PLAYER
"Npc": return ENTITY_COLOR_UNKNOWN
"Object", "Terrain": return ENTITY_COLOR_OBJECT
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
_: return ENTITY_COLOR_OBJECT
# D-048/D-056: Insert-styled UI color palette
+54 -3
View File
@@ -20,7 +20,8 @@ var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
var _camera_anchored: bool = false
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_dialogue_tick: int = -1
var _flash_rect: ColorRect = null # #502: ephemeral screen flash overlay
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -62,8 +63,13 @@ func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
var old_pos := GameState.player_position
GameState.apply_snapshot(snapshot)
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
_teleport_transition()
# Late anchor: live mode — first snapshot arrives during _process.
# Smoothing is already OFF (disabled in _ready), so setting
# global_position takes effect immediately with no lerp.
@@ -118,9 +124,15 @@ func _process(_delta: float) -> void:
# rendered used smoothing=OFF (correct viewport from frame one). Now we
# turn smoothing back on and sync its internal state so subsequent frames
# get smooth camera tracking during gameplay.
# #501: Skip re-enable during teleport — _teleport_transition() disables
# smoothing for a clean camera snap. Defer by one frame to avoid the
# re-enable block in the same _process() call undoing the snap.
if _camera_anchored and not camera.position_smoothing_enabled:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
if _teleport_in_progress:
_teleport_in_progress = false
else:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
@@ -230,6 +242,45 @@ func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_stat
gauntlet_hud.finalize()
# #501: Detect large position jump indicating a teleport (not normal movement).
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
# Clears dialogue/monologue/interaction state (server clears its side too).
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
func _teleport_transition() -> void:
# Snap camera: disable smoothing, force re-anchor.
# _teleport_in_progress defers smoothing re-enable by one frame so the
# re-enable block at the bottom of _process() doesn't undo the snap.
camera.position_smoothing_enabled = false
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
_teleport_in_progress = true
# Clear client-side buffers
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
if dialogue_box and dialogue_box.is_dialogue_active():
dialogue_box.hide_dialogue()
# Fade from black: instant black overlay, fades to transparent over 0.3s
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(0, 0, 0, 1.0)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
tween.tween_callback(_flash_rect.queue_free)
# #502: Full-screen color flash — fades from color to transparent over duration.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
func _screen_flash(color: Color, duration: float) -> void:
+41 -2
View File
@@ -23,6 +23,11 @@ const LERP_SPEED: float = 12.0
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
func _ready() -> void:
print("EntityRenderer: Initialized")
@@ -40,6 +45,22 @@ func _process(delta: float) -> void:
if not node.position.is_equal_approx(target):
node.position = node.position.lerp(target, weight)
# #521: Advance color transitions (manual lerp, testable without SceneTree)
var finished_ids: Array = []
for entity_id in _entity_tweens.keys():
if not entity_nodes.has(entity_id):
finished_ids.append(entity_id)
continue
var tween_data: Dictionary = _entity_tweens[entity_id]
tween_data.elapsed += delta
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
node_c.color = tween_data.from.lerp(tween_data.target, t)
if t >= 1.0:
finished_ids.append(entity_id)
for eid in finished_ids:
_entity_tweens.erase(eid)
# Update entities from snapshot data
func update_entities(entities: Array) -> void:
@@ -75,12 +96,12 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
# D-033 color by entity kind (Phase 1 default)
# TODO(#361): derive from RelationshipState via knowledge graph
# D-033 color by relationship (#521)
entity_node.color = _color_for_kind(entity_data)
add_child(entity_node)
entity_nodes[entity_id] = entity_node
_entity_relationships[entity_id] = entity_data.get("relationship", "Unknown")
# Add facing indicator for the player entity
if entity_id == GameState.player_entity_id:
@@ -112,6 +133,22 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
)
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
var new_rel: String = entity_data.get("relationship", "Unknown")
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
if new_rel != old_rel:
_entity_relationships[entity_id] = new_rel
var new_color := _color_for_kind(entity_data)
_entity_tweens[entity_id] = {
"from": entity_node.color,
"target": new_color,
"elapsed": 0.0,
}
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
# are compositionally independent — both can change simultaneously without
# interference. If alpha tweening is added later, coordinate with color tween.
# v2: Peripheral vision dimming (D-015)
# null visibility (v1 backward compat) defaults to full alpha
var visibility: Variant = entity_data.get("visibility")
@@ -137,6 +174,8 @@ func _remove_entity_node(entity_id: int) -> void:
entity_node.queue_free()
entity_nodes.erase(entity_id)
_entity_targets.erase(entity_id)
_entity_relationships.erase(entity_id)
_entity_tweens.erase(entity_id)
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
static func _color_for_kind(entity_data: Dictionary) -> Color:
+366
View File
@@ -0,0 +1,366 @@
## #521: Confrontation D-033 color shift — QA test suite
## Spec refs: D-033 (entity color = relationship), D-063 (confrontation same box)
## Sprint Completion Proof (joint.md):
## - Entity tint fades 0.5-1s to new relationship color on confrontation delivery
## - Cursor hover tint also updates to match relationship
## - Palette matches D-033 exactly
##
## Tests are structured in layers:
## 1. D-033 color palette constants — always pass (no implementation dependency)
## 2. Relationship-to-color mapping — tests the lookup function
## 3. Entity renderer relationship coloring — tests that entities USE relationship
## 4. Tween on relationship change — tests fade behavior (0.5-1s)
## 5. Protocol/GameState passthrough — tests data pipeline integrity
class_name TestColorShift
extends GdUnitTestSuite
var EntityRendererScript: GDScript = load("res://scripts/rendering/entity_renderer.gd")
var ConstantsScript: GDScript = load("res://scripts/constants.gd")
# -- Test data -----------------------------------------------------------------
# Entity with relationship field (v4 protocol format)
func _make_entity(entity_id: int, kind: String, relationship: String, x: float = 5.0, y: float = 5.0) -> Dictionary:
return {
"entity_id": entity_id,
"x": x, "y": y, "z": 0,
"kind": {"variant": kind, "data": null},
"visibility": "Forward",
"relationship": relationship,
"observation": "Visible",
}
func _make_entity_renderer() -> Node2D:
var renderer: Node2D = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func before_test() -> void:
GameState.player_entity_id = 1
# ==============================================================================
# Layer 1: D-033 Color Palette Constants
# These tests verify the palette is defined correctly. No implementation needed.
# ==============================================================================
func test_d033_unknown_teal() -> void:
assert_that(Constants.ENTITY_COLOR_UNKNOWN).is_equal(Color("#4a9ebb"))
func test_d033_friendly_green() -> void:
assert_that(Constants.ENTITY_COLOR_FRIENDLY).is_equal(Color("#6bc9a6"))
func test_d033_poi_amber() -> void:
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
func test_d033_hostile_red() -> void:
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
func test_d033_object_grey() -> void:
assert_that(Constants.ENTITY_COLOR_OBJECT).is_equal(Color("#8b8ba0"))
func test_d033_player_cool_white() -> void:
assert_that(Constants.ENTITY_COLOR_PLAYER).is_equal(Color("#e0e8ff"))
func test_d033_palette_all_distinct() -> void:
# All 6 D-033 colors must be distinct from each other
var colors: Array[Color] = [
Constants.ENTITY_COLOR_UNKNOWN,
Constants.ENTITY_COLOR_FRIENDLY,
Constants.ENTITY_COLOR_POI,
Constants.ENTITY_COLOR_HOSTILE,
Constants.ENTITY_COLOR_OBJECT,
Constants.ENTITY_COLOR_PLAYER,
]
for i in range(colors.size()):
for j in range(i + 1, colors.size()):
assert_that(colors[i] != colors[j]).is_true()
# ==============================================================================
# Layer 2: Relationship-to-Color Mapping
# Tests the lookup function that maps relationship strings to D-033 colors.
# Depends on #521 adding color_for_relationship() to Constants.
# Uses ConstantsScript method list to skip gracefully if not yet implemented.
# ==============================================================================
func _has_color_for_relationship() -> bool:
# Check if the Constants script has a color_for_relationship method.
for method in ConstantsScript.get_script_method_list():
if method.name == "color_for_relationship":
return true
return false
func test_relationship_color_unknown() -> void:
if not _has_color_for_relationship():
push_warning("TestColorShift: color_for_relationship not implemented yet — awaiting #521")
return
var color: Color = Constants.color_for_relationship("Unknown")
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
func test_relationship_color_friendly() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("Friendly")
assert_that(color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
func test_relationship_color_person_of_interest() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("PersonOfInterest")
assert_that(color).is_equal(Constants.ENTITY_COLOR_POI)
func test_relationship_color_hostile() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("Hostile")
assert_that(color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
func test_relationship_color_fallback() -> void:
if not _has_color_for_relationship():
return
var color: Color = Constants.color_for_relationship("SomethingWeird")
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# ==============================================================================
# Layer 3: Entity Renderer — Relationship-Based Coloring
# Tests that entity_renderer uses the relationship field for NPC colors.
# Player and Object entities should remain unaffected by relationship field.
# ==============================================================================
func test_npc_uses_relationship_color_unknown() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
renderer.queue_free()
func test_npc_uses_relationship_color_friendly() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
renderer.queue_free()
func test_npc_uses_relationship_color_poi() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
renderer.queue_free()
func test_npc_uses_relationship_color_hostile() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
renderer.queue_free()
func test_player_color_ignores_relationship() -> void:
# Player entity always uses ENTITY_COLOR_PLAYER regardless of relationship
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(1, "Player", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
renderer.queue_free()
func test_object_color_ignores_relationship() -> void:
# Object entities always use ENTITY_COLOR_OBJECT
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(3, "Object", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
# ==============================================================================
# Layer 4: Tween on Relationship Change
# Tests that color transitions use a 0.5-1s fade, not an instant flip.
# D-033: "Color shifts smoothly (0.5s fade) when relationship state changes."
# D-063: "entity D-033 color may fade" on confrontation delivery.
# ==============================================================================
func test_color_shift_not_instant() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities_before)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
# Change relationship to Hostile
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities_after)
# Immediately after update, color should NOT yet be the target
var color_after_immediate: Color = node.color
if not _renderer_has_tween_support(renderer):
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
renderer.queue_free()
return
# The color should NOT be exactly the target yet (tween in progress)
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
renderer.queue_free()
func test_color_shift_reaches_target() -> void:
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
if not _renderer_has_tween_support(renderer):
renderer.queue_free()
return
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
# Simulate time passing: ~1.5 seconds of frames
var elapsed: float = 0.0
while elapsed < 1.5:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_mid_transition_retrigger() -> void:
# Rapid relationship changes: Unknown → Friendly → Hostile in quick succession.
# The second change should preempt the first tween and converge to Hostile.
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
if not _renderer_has_tween_support(renderer):
renderer.queue_free()
return
renderer.update_entities([_make_entity(2, "Npc", "Unknown")])
# First change: Unknown → Friendly
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
# Advance partway (0.1s of a 0.5s tween)
for i in range(6):
renderer._process(1.0 / 60.0)
# Second change mid-tween: Friendly → Hostile (preempts first)
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
# Advance past full duration
var elapsed: float = 0.0
while elapsed < 1.0:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_same_relationship_no_tween() -> void:
var renderer: Node2D = _make_entity_renderer()
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var color_first: Color = node.color
renderer.update_entities(entities)
var color_second: Color = node.color
assert_that(color_first).is_equal(color_second)
renderer.queue_free()
# ==============================================================================
# Layer 5: Protocol — Relationship Field Round-Trip
# ==============================================================================
func test_protocol_entity_relationship_decoded() -> void:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
"relationship": "PersonOfInterest",
"visibility": "Forward",
"observation": "Visible",
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal("PersonOfInterest")
func test_protocol_entity_relationship_defaults_unknown() -> void:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal("Unknown")
func test_protocol_entity_all_relationship_values() -> void:
var relationships: Array[String] = ["Unknown", "Friendly", "PersonOfInterest", "Hostile"]
for rel in relationships:
var raw: Dictionary = {
"entity_id": 5,
"x": 10.0, "y": 10.0, "z": 0,
"kind": "Npc",
"relationship": rel,
}
var decoded: Variant = Protocol._decode_entity(raw)
assert_that(decoded).is_not_null()
assert_that(decoded.relationship).is_equal(rel)
# ==============================================================================
# Layer 6: GameState — Relationship Data Passthrough
# ==============================================================================
func test_game_state_preserves_relationship() -> void:
var snapshot: Dictionary = {
"tick": 1,
"entities": [_make_entity(2, "Npc", "Friendly")],
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.visible_entities.size()).is_equal(1)
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
func test_game_state_relationship_changes_between_snapshots() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [_make_entity(2, "Npc", "Friendly")]})
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
GameState.apply_snapshot({"tick": 2, "entities": [_make_entity(2, "Npc", "PersonOfInterest")]})
assert_that(GameState.visible_entities[0].relationship).is_equal("PersonOfInterest")
# ==============================================================================
# Helpers
# ==============================================================================
func _entity_uses_relationship(renderer: Node2D) -> bool:
var friendly: Dictionary = _make_entity(10, "Npc", "Friendly", 3.0, 3.0)
var hostile: Dictionary = _make_entity(11, "Npc", "Hostile", 5.0, 5.0)
renderer.update_entities([friendly, hostile])
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
return false
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
var f_color: Color = f_node.color
var h_color: Color = h_node.color
var uses_rel: bool = not f_color.is_equal_approx(h_color)
renderer.update_entities([])
return uses_rel
func _renderer_has_tween_support(renderer: Node2D) -> bool:
return renderer.get("_entity_tweens") != null
+288
View File
@@ -0,0 +1,288 @@
## #501: Hub teleport client UX — QA test suite
## Spec refs: D-020 (protocol), D-030 (testability)
## Sprint Completion Proof (joint.md):
## - Home key sends TeleportToHub in Gauntlet mode
## - 0.3s fade-to-black-and-back plays on teleport
## - Dialogue/monologue/interaction buffer cleared on teleport
## - Non-Gauntlet: action rejected, client shows no effect
class_name TestHubTeleport
extends GdUnitTestSuite
# -- Fixtures ------------------------------------------------------------------
var _gauntlet_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
],
"tiles": [],
"visible_tiles": [],
"visible_positions": [],
"nearby_interactions": [],
"current_monologue": null,
"current_dialogue": null,
"pending_recognitions": [],
"gauntlet_mode": true,
"room_id": "proof_room",
}
var _normal_snapshot := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"entities": [
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
],
"tiles": [],
"visible_tiles": [],
"visible_positions": [],
"nearby_interactions": [],
"current_monologue": null,
"current_dialogue": null,
"pending_recognitions": [],
}
func before_test() -> void:
GameState.gauntlet_mode = false
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
GameState.room_id = null
InputMapper.input_queue.clear()
SimBridge.reset_test_state()
# -- InputMapper: TELEPORT_HUB action enum ------------------------------------
func test_teleport_hub_action_exists() -> void:
# Verify the enum value exists and is distinct
var action: int = InputMapper.Action.TELEPORT_HUB
assert_that(action).is_not_equal(InputMapper.Action.INTERACT)
assert_that(action).is_not_equal(InputMapper.Action.MOVE_NORTH)
# -- InputMapper: Gauntlet mode guard -----------------------------------------
func test_teleport_hub_blocked_outside_gauntlet() -> void:
# Non-gauntlet: Home key input should NOT queue TELEPORT_HUB
GameState.gauntlet_mode = false
InputMapper.input_queue.clear()
var event := InputEventKey.new()
event.physical_keycode = KEY_HOME
event.pressed = true
InputMapper._unhandled_input(event)
var has_teleport := false
for entry in InputMapper.input_queue:
if entry.action == InputMapper.Action.TELEPORT_HUB:
has_teleport = true
assert_that(has_teleport).is_false()
func test_teleport_hub_allowed_in_gauntlet_mode() -> void:
# Gauntlet mode: Home key SHOULD queue TELEPORT_HUB
GameState.gauntlet_mode = true
InputMapper.input_queue.clear()
var event := InputEventKey.new()
event.physical_keycode = KEY_HOME
event.pressed = true
InputMapper._unhandled_input(event)
var has_teleport := false
for entry in InputMapper.input_queue:
if entry.action == InputMapper.Action.TELEPORT_HUB:
has_teleport = true
assert_that(has_teleport).is_true()
# -- GameState: gauntlet_mode from snapshot ------------------------------------
func test_gauntlet_mode_set_from_snapshot() -> void:
GameState.apply_snapshot(_gauntlet_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
assert_that(GameState.room_id).is_equal("proof_room")
func test_gauntlet_mode_false_when_absent() -> void:
GameState.apply_snapshot(_normal_snapshot)
assert_that(GameState.gauntlet_mode).is_false()
assert_that(GameState.room_id).is_null()
func test_gauntlet_mode_transitions_off() -> void:
# Gauntlet on → off: mode should clear
GameState.apply_snapshot(_gauntlet_snapshot)
assert_that(GameState.gauntlet_mode).is_true()
GameState.apply_snapshot(_normal_snapshot)
assert_that(GameState.gauntlet_mode).is_false()
# -- SimBridge: wire format encoding -------------------------------------------
func test_teleport_hub_wire_name() -> void:
# TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction)
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name).is_equal("TeleportToHub")
func test_teleport_hub_wire_not_empty() -> void:
# Wire name must not be empty (empty = client-only, not sent to server)
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
assert_that(wire_name.is_empty()).is_false()
func test_teleport_hub_encode_roundtrip() -> void:
# Verify MessagePack encode→decode roundtrip for TeleportToHub
var encoded: PackedByteArray = Protocol.encode_player_input(42, "TeleportToHub")
assert_that(encoded.size()).is_greater(0)
var decoded: Variant = Protocol.decode_player_input(encoded)
assert_that(decoded).is_not_null()
assert_that(decoded.tick).is_equal(42)
assert_that(decoded.action.variant).is_equal("TeleportToHub")
assert_that(decoded.action.data).is_null()
# -- SimBridge: test mode teleport behavior ------------------------------------
func test_test_mode_teleport_resets_position() -> void:
# In test mode, TeleportToHub should reset player to hub spawn (10, 10)
SimBridge.reset_test_state()
# Move player away first
SimBridge._test_player_pos = Vector2i(50, 50)
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
# Player should be back at hub spawn
var player_entity: Dictionary = snap.entities[0]
assert_that(player_entity.x).is_equal(10.0)
assert_that(player_entity.y).is_equal(10.0)
func test_test_mode_teleport_clears_dialogue() -> void:
# TeleportToHub in test mode should clear dialogue state
SimBridge.reset_test_state()
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
SimBridge._test_snapshot()
assert_that(SimBridge._test_in_dialogue).is_false()
# -- Teleport detection -------------------------------------------------------
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
# These tests verify the distance math against that threshold.
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
func test_detect_teleport_large_jump() -> void:
# Position jump > threshold should be detected as teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(50.0, 50.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
func test_detect_teleport_normal_movement() -> void:
# Normal 1-tile movement should NOT be detected as teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(11.0, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_diagonal_movement() -> void:
# Diagonal movement (1,1) — distance ~1.41, not a teleport
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(11.0, 11.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_boundary_exactly_threshold() -> void:
# Exactly threshold — should NOT trigger (> not >=)
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(15.0, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
func test_detect_teleport_boundary_just_over() -> void:
# Just over threshold — should trigger
var old_pos := Vector2(10.0, 10.0)
var new_pos := Vector2(15.1, 10.0)
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
# -- Buffer clearing on teleport -----------------------------------------------
func test_teleport_clears_dialogue_in_test_mode() -> void:
# TeleportToHub in test mode should clear dialogue state via the pipeline
SimBridge.reset_test_state()
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
# Dialogue should be cleared by teleport
assert_that(SimBridge._test_in_dialogue).is_false()
assert_that(snap.current_dialogue).is_null()
func test_teleport_resets_position_in_test_mode() -> void:
# TeleportToHub must reset to hub spawn and clear dialogue (integration)
SimBridge.reset_test_state()
SimBridge._test_player_pos = Vector2i(50, 50)
SimBridge._test_in_dialogue = true
SimBridge._test_input_queue.append("TeleportToHub")
var snap: Dictionary = SimBridge._test_snapshot()
var player: Dictionary = snap.entities[0]
assert_that(player.x).is_equal(10.0)
assert_that(player.y).is_equal(10.0)
assert_that(SimBridge._test_in_dialogue).is_false()
# -- Send input integration (test mode) ----------------------------------------
func test_send_teleport_hub_in_test_mode() -> void:
# Verify send_input accepts TELEPORT_HUB in test mode
SimBridge.reset_test_state()
SimBridge.state = SimBridge.ConnectionState.CONNECTED
var err := SimBridge.send_input({
"action": InputMapper.Action.TELEPORT_HUB,
"timestamp_msec": 12345,
})
assert_that(err).is_equal(OK)
func test_send_teleport_hub_queues_wire_action() -> void:
# Verify TELEPORT_HUB is queued as "TeleportToHub" in test mode
SimBridge.reset_test_state()
SimBridge.state = SimBridge.ConnectionState.CONNECTED
SimBridge.send_input({
"action": InputMapper.Action.TELEPORT_HUB,
"timestamp_msec": 12345,
})
assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true()
func test_gauntlet_mode_from_test_snapshot() -> void:
# Verify test snapshot includes gauntlet_mode field
SimBridge.reset_test_state()
SimBridge._test_gauntlet_mode = true
var snap: Dictionary = SimBridge._test_snapshot()
assert_that(snap.gauntlet_mode).is_true()
SimBridge._test_gauntlet_mode = false
snap = SimBridge._test_snapshot()
assert_that(snap.gauntlet_mode).is_false()
# -- Live mode outbound encoding -----------------------------------------------
func test_teleport_hub_outbound_entry() -> void:
# In live mode, TELEPORT_HUB should produce a valid outbound buffer entry
# (We can't test full live mode in unit tests, but we test the encode path)
var encoded: PackedByteArray = Protocol.encode_player_input(100, "TeleportToHub")
assert_that(encoded.size()).is_greater(0)
# Decode and verify
var decoded: Variant = Protocol.decode_player_input(encoded)
assert_that(decoded.action.variant).is_equal("TeleportToHub")