feat(client): sprint 38 — free camera viewer, archetype strip, test fixes

- Add free camera mode (F4 toggle): WASD pan, scroll zoom, decoupled
  from player position (#898)
- Strip archetype-driven code: remove character_archetype, lattice_profile,
  and lattice color palettes from client (#882)
- Fix confrontation_monologue signal not firing in headless test mode (#867)
- Revive fog state behavioral tests: EXP_EXPLORED persistence, grow-only
  bounds, texture-resize copy, BoundaryWall handling (#879)
- Triage pre-existing test failures: fix examine_display dismiss timing,
  fog test position fragility, rendering snapshot assertions,
  time_display format (#871)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 10:19:11 +02:00
co-authored by Claude Opus 4.6
parent f9fdfb7712
commit 3f5b4258ba
18 changed files with 504 additions and 235 deletions
+5
View File
@@ -124,6 +124,11 @@ stance_down={
"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":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
]
}
free_camera={
"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":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
bug_report={
"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":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+2 -9
View File
@@ -41,11 +41,6 @@ var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs
# v5 fields (#414)
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
# Server sends this field as part of the player's capability snapshot.
var lattice_profile: String = "lattice_baseline"
# v6 fields (#449, D-053, D-065)
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
var player_inventory: Array = [] # [{item_id, name, slot}]
@@ -94,10 +89,8 @@ var debug_response: Variant = null
# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load.
var pending_load_path: String = ""
# #588: Character archetype chosen at character select screen.
# "detective" or "smuggler". Set before game scene loads; sent in StartupMessage.
# Default: "detective" — fallback for legacy saves without character.txt.
var character_archetype: String = "detective"
# #898: Free camera mode — camera decoupled from player, WASD pans camera directly.
var free_camera_mode: bool = false
# #705: Character visual descriptor — set by character_creation.gd on confirmation.
# Passed to EntityRenderer for the player entity's CharacterVisual on game start.
+1 -1
View File
@@ -68,7 +68,7 @@ func _process(_delta: float) -> void:
# D-054: Update facing angle from mouse position every frame
_update_facing_from_mouse()
if GameState.dialogue_active:
if GameState.dialogue_active or GameState.free_camera_mode:
return
# D-054: Send facing octant to server when it changes (even without movement)
+1 -25
View File
@@ -55,12 +55,11 @@ func new_game() -> String:
## Resume an existing game session by setting the active game-id.
## Restores world_seed and character_archetype from the save directory.
## Restores world_seed from the save directory.
func resume_game(game_id: String) -> void:
GameState.current_game_id = game_id
var save_path := SAVES_DIR + game_id + "/"
GameState.world_seed = _read_seed_file(save_path)
GameState.character_archetype = _read_archetype_file(save_path)
## List all game directories under user://saves/ sorted by last-modified (most recent first).
@@ -171,29 +170,6 @@ func _read_seed_file(save_path: String) -> int:
return file.get_64() & 0x7FFFFFFFFFFFFFFF
## Write character_archetype to save directory. Called after new_game() creates the dir.
func save_character_archetype(game_id: String, archetype: String) -> void:
var save_path := SAVES_DIR + game_id + "/"
var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE)
if file == null:
push_error(
(
"SessionManager: failed to write character.txt: %s"
% error_string(FileAccess.get_open_error())
)
)
return
file.store_string(archetype)
## Read character_archetype from save directory. Returns "detective" if missing (legacy saves).
func _read_archetype_file(save_path: String) -> String:
var file := FileAccess.open(save_path + "character.txt", FileAccess.READ)
if file == null:
return "detective"
return file.get_as_text().strip_edges()
func _find_newest_save(dir_path: String) -> String:
var dir := DirAccess.open(dir_path)
if dir == null:
-1
View File
@@ -261,7 +261,6 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
# Server blocks waiting for this before entering the tick loop.
var startup_bytes := Protocol.encode_startup_message(
GameState.world_seed,
GameState.character_archetype,
GameState.character_visual_descriptor
)
if startup_bytes.size() > 0:
+41 -1
View File
@@ -1,6 +1,11 @@
extends Node2D
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
# #898: Free camera pan speed in pixels/second (unzoomed) and zoom step per scroll tick.
const FREE_CAMERA_PAN_SPEED: float = 400.0
const FREE_CAMERA_ZOOM_STEP: float = 0.1
const FREE_CAMERA_ZOOM_MIN: float = 0.5
const FREE_CAMERA_ZOOM_MAX: float = 8.0
var economics_app = null # EconomicsApp — populated in _ready() via ImplantRegistry
var atlas_app = null # AtlasApp — populated in _ready() via ImplantRegistry
@@ -184,10 +189,31 @@ func _ready() -> void:
atlas_app.economics_link_requested.connect(_on_atlas_economics_link)
func _unhandled_input(event: InputEvent) -> void:
# #898: Scroll wheel zoom in free camera mode.
if GameState.free_camera_mode and event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed:
var zoom := camera.zoom
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
zoom -= Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
camera.zoom = zoom.clamp(
Vector2(FREE_CAMERA_ZOOM_MIN, FREE_CAMERA_ZOOM_MIN),
Vector2(FREE_CAMERA_ZOOM_MAX, FREE_CAMERA_ZOOM_MAX)
)
get_viewport().set_input_as_handled()
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.is_pressed() or event.is_echo():
return
var key_event := event as InputEventKey
# #898: F4 toggles free camera mode.
if key_event.physical_keycode == KEY_F4:
GameState.free_camera_mode = not GameState.free_camera_mode
return
# Registry-driven toggle: each manifest declares its own default_key.
for manifest: ImplantAppManifest in ImplantRegistry.get_manifests():
if manifest.app_path.is_empty():
@@ -239,9 +265,23 @@ func _process(delta: float) -> void:
# #559: Dispatch snapshot to registered handlers (router pattern).
_router.dispatch(snapshot)
# #898: Free camera WASD pan — runs in place of player tracking.
if GameState.free_camera_mode:
var pan := Vector2.ZERO
if Input.is_action_pressed("move_north"):
pan.y -= 1.0
if Input.is_action_pressed("move_south"):
pan.y += 1.0
if Input.is_action_pressed("move_east"):
pan.x += 1.0
if Input.is_action_pressed("move_west"):
pan.x -= 1.0
if pan != Vector2.ZERO:
var speed := FREE_CAMERA_PAN_SPEED / camera.zoom.x
camera.global_position += pan.normalized() * speed * delta
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing.
if _camera_anchored:
elif _camera_anchored:
var target := GameState.player_position * Constants.TILE_SIZE
if _teleport_in_progress:
camera.global_position = target
+3 -22
View File
@@ -613,34 +613,15 @@ static func _decode_enum_variant(raw) -> Dictionary:
# -- Encode: GDScript types → bytes to server ----------------------------------
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
## Encode a StartupMessage to MessagePack bytes (#175, #718).
## Sent by the client immediately after handshake validation.
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
## Server reads this to initialize SimRng (D-010, D-029).
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
static func encode_startup_message(
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null
world_seed: int, character_visual: Variant = null
) -> PackedByteArray:
# Map client lowercase archetype string to server PascalCase enum variant.
# Explicit match prevents unknown strings silently reaching the server as
# garbage enum values — fail loudly and fall back to "Detective".
var archetype_variant: String
match character_archetype:
"detective":
archetype_variant = "Detective"
"smuggler":
archetype_variant = "Smuggler"
_:
push_error(
(
"Protocol: unknown character_archetype '%s' — defaulting to 'Detective'"
% character_archetype
)
)
archetype_variant = "Detective"
var msg := {
"world_seed": world_seed,
"character_archetype": archetype_variant,
}
if character_visual != null and character_visual.has_method("to_dict"):
msg["character_visual_descriptor"] = character_visual.to_dict()
-4
View File
@@ -88,10 +88,6 @@ static func apply(snapshot: Dictionary) -> void:
else:
GameState.current_monologue = null
# #122: lattice_profile
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
GameState.lattice_profile = snapshot.lattice_profile
# v6: player_stance (#449, D-053)
if snapshot.has("player_stance") and snapshot.player_stance is String:
GameState.player_stance = snapshot.player_stance
+16 -11
View File
@@ -40,15 +40,17 @@ func test_fog_visibility_forward_tile() -> void:
var fog = _get_fog_state()
if fog == null:
return
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed.
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
GameState.visible_positions = {Vector2i(10, 10): true}
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
fog.update_from_state()
# Index: row 5 * width 64 + col 5
assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message(
"Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
# Index: row 10 * width 64 + col 10
assert_that(fog._vis_bytes[10 * 64 + 10]).override_failure_message(
"Forward tile at (10,10) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
).is_equal(FogState.VIS_FORWARD)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
@@ -81,10 +83,12 @@ func test_fog_exploration_persistence() -> void:
var fog = _get_fog_state()
if fog == null:
return
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
var pos := Vector2i(5, 5)
var idx: int = 5 * 64 + 5
var pos := Vector2i(10, 10)
var idx: int = 10 * 64 + 10
# Frame 1: tile visible
GameState.visible_positions = {pos: true}
GameState.visibility_sectors = {pos: "Forward"}
@@ -118,9 +122,10 @@ func test_fog_hidden_tile_value() -> void:
assert_that(fog._vis_bytes[idx]).override_failure_message(
"Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN
).is_equal(FogState.VIS_HIDDEN)
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible.
# Use position (10,10): 8-tile padding stays within the 64x64 box, no resize triggered.
GameState.visible_positions = {Vector2i(10, 10): true}
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
fog.update_from_state()
assert_that(fog._vis_bytes[idx]).override_failure_message(
"Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN
+3 -4
View File
@@ -182,11 +182,10 @@ func test_d063_dim_alpha_is_set() -> void:
box.queue_free()
func skip_test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
## BROKEN (#867): signal_fired stays false in headless; create_tween() before emit
## may abort _start_confrontation_beat if panel node is null. Bug filed.
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
## Fixed (#867): guard tween_property behind is_instance_valid(panel) so emit fires
## even in headless mode where the panel node may not be in the scene tree.
var box := _make_dialogue_box()
if box == null: return
+392
View File
@@ -0,0 +1,392 @@
## Sprint 22 fog state behavioral tests — revived in Sprint 38 (#879).
## Original: deleted in Sprint 37 (#870 parse-error cleanup).
## Spec refs: D-059, D-066, #569, #585
##
## Coverage: EXP_EXPLORED persistence, grow-only bounds invariant,
## texture-resize copy, BoundaryWall handling.
##
## Uses FogState autoload directly via /root/FogState — byte-level assertions
## on _vis_bytes and _exp_bytes, consistent with test_fog_shader.gd approach.
class_name TestFogSprint22
extends GdUnitTestSuite
# -- Helpers -------------------------------------------------------------------
func _get_fog_state() -> Node:
var node = get_node_or_null("/root/FogState")
if node == null:
push_warning("TestFogSprint22: FogState autoload not found — test skipped")
return node
func _reset_fog_state(fog_state: Node) -> void:
GameState.visible_positions.clear()
GameState.boundary_positions.clear()
GameState.visible_tiles.clear()
GameState.visibility_sectors.clear()
fog_state._resize(Rect2i(0, 0, 32, 32))
# -- EXP_EXPLORED persistence --------------------------------------------------
## D-059: Previously-seen tiles render as "deep fog" (EXP_EXPLORED = 128).
## Once a tile enters LOS, leaving LOS must NOT reset it to EXP_UNEXPLORED.
## This is the core "fog of war memory" invariant.
func test_exp_explored_persists_after_leaving_los() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: tile (2,2) is in LOS → must become EXP_VISIBLE
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_2_2: int = (2 - oy) * w + (2 - ox)
assert_int(exp[idx_2_2]).override_failure_message(
"D-059: visible tile must have EXP_VISIBLE (255) on first sight"
).is_equal(FogState.EXP_VISIBLE)
# Tick 2: tile (2,2) leaves LOS — only (3,3) is visible now
GameState.visible_positions = {Vector2i(3, 3): true}
fog_state.update_from_state()
# After leaving LOS, (2,2) must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0)
ox = fog_state.map_bounds.position.x
oy = fog_state.map_bounds.position.y
w = fog_state.map_bounds.size.x
exp = fog_state._exp_bytes
idx_2_2 = (2 - oy) * w + (2 - ox)
assert_int(exp[idx_2_2]).override_failure_message(
"D-059: tile leaving LOS must decay to EXP_EXPLORED (128), not EXP_UNEXPLORED (0)"
).is_equal(FogState.EXP_EXPLORED)
_reset_fog_state(fog_state)
func test_never_seen_tile_stays_unexplored() -> void:
## Corollary: a tile that was never in LOS stays EXP_UNEXPLORED.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tile (5,5) never enters LOS
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_5_5: int = (5 - oy) * w + (5 - ox)
assert_int(exp[idx_5_5]).override_failure_message(
"D-059: tile never in LOS must remain EXP_UNEXPLORED (0)"
).is_equal(FogState.EXP_UNEXPLORED)
_reset_fog_state(fog_state)
func test_exp_explored_not_overwritten_by_subsequent_invisible_ticks() -> void:
## EXP_EXPLORED must not decay further after the player moves away.
## If the player is never in the area again, the tile stays at EXP_EXPLORED.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: see tile (4,4)
GameState.visible_positions = {Vector2i(4, 4): true}
fog_state.update_from_state()
# Tick 2: player moves far away, (4,4) out of LOS
GameState.visible_positions = {Vector2i(20, 20): true}
fog_state.update_from_state()
# Tick 3: player stays far away
GameState.visible_positions = {Vector2i(20, 20): true}
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_4_4: int = (4 - oy) * w + (4 - ox)
assert_int(exp[idx_4_4]).override_failure_message(
"D-059: EXP_EXPLORED must not decay further once set — tile stays at 128"
).is_equal(FogState.EXP_EXPLORED)
_reset_fog_state(fog_state)
# -- Grow-only bounds invariant ------------------------------------------------
## D-059: map_bounds only ever grows. Previously-explored tiles that leave the
## visible area must not be evicted from the texture. The bounds never shrink.
func test_bounds_grow_when_player_moves_to_new_area() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: small area visible
GameState.visible_positions = {Vector2i(2, 2): true, Vector2i(3, 3): true}
fog_state.update_from_state()
var bounds_after_t1: Rect2i = fog_state.map_bounds
# Tick 2: player moves to a larger area
GameState.visible_positions = {Vector2i(20, 20): true, Vector2i(25, 25): true}
fog_state.update_from_state()
var bounds_after_t2: Rect2i = fog_state.map_bounds
# Bounds must have grown or stayed the same — never shrunk
assert_bool(bounds_after_t2.size.x >= bounds_after_t1.size.x).override_failure_message(
"D-059: map_bounds width must never shrink (grow-only invariant)"
).is_true()
assert_bool(bounds_after_t2.size.y >= bounds_after_t1.size.y).override_failure_message(
"D-059: map_bounds height must never shrink (grow-only invariant)"
).is_true()
_reset_fog_state(fog_state)
func test_bounds_contain_new_visible_positions() -> void:
## After update_from_state, all visible positions must lie within map_bounds.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
GameState.visible_positions = {Vector2i(10, 5): true, Vector2i(15, 12): true}
fog_state.update_from_state()
var bounds: Rect2i = fog_state.map_bounds
for pos in GameState.visible_positions:
assert_bool(bounds.has_point(pos)).override_failure_message(
"D-059: visible position %s must be within map_bounds %s" % [pos, bounds]
).is_true()
_reset_fog_state(fog_state)
func test_bounds_encompass_previous_area_after_player_moves() -> void:
## Old area coordinates must still be within map_bounds after player moves away.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: see area around (2,2)
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
# Tick 2: player moves far away
GameState.visible_positions = {Vector2i(20, 20): true}
fog_state.update_from_state()
# The original tile (2,2) must still be within map_bounds
var bounds: Rect2i = fog_state.map_bounds
assert_bool(bounds.has_point(Vector2i(2, 2))).override_failure_message(
"D-059: grow-only — previously-visited area (2,2) must remain within map_bounds"
).is_true()
_reset_fog_state(fog_state)
# -- Texture-resize copy -------------------------------------------------------
## D-059: When bounds grow (resize), exploration data from the old bounds
## must be preserved in the new texture at the correct offsets.
## This is the "texture-resize copy" invariant.
func test_exploration_data_preserved_across_resize() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: mark (3,3) as explored
GameState.visible_positions = {Vector2i(3, 3): true}
fog_state.update_from_state()
# Force a resize by moving far away
GameState.visible_positions = {Vector2i(25, 25): true}
fog_state.update_from_state()
# After resize, (3,3) must still be EXP_EXPLORED (not reset to EXP_UNEXPLORED)
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_3_3: int = (3 - oy) * w + (3 - ox)
assert_int(exp[idx_3_3]).override_failure_message(
"D-059: exploration state (EXP_EXPLORED=128) must survive texture resize"
).is_equal(FogState.EXP_EXPLORED)
_reset_fog_state(fog_state)
func test_newly_added_area_starts_unexplored_after_resize() -> void:
## When bounds grow to include a new area, those new tiles start as EXP_UNEXPLORED.
## The copy preserves old data; new tiles get the default (0).
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Establish a small explored area
GameState.visible_positions = {Vector2i(2, 2): true}
fog_state.update_from_state()
# Move far away to trigger bounds growth
GameState.visible_positions = {Vector2i(30, 30): true}
fog_state.update_from_state()
# A completely new tile (30,30) on this tick should be EXP_VISIBLE (just entered LOS)
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var idx_30_30: int = (30 - oy) * w + (30 - ox)
assert_int(exp[idx_30_30]).override_failure_message(
"D-059: tile first entering LOS after resize must be EXP_VISIBLE (255)"
).is_equal(FogState.EXP_VISIBLE)
_reset_fog_state(fog_state)
# -- BoundaryWall handling (#585) ----------------------------------------------
## BoundaryWall margin tiles: fog lifts (VIS_FORWARD) so wall content composites,
## but they do NOT persist as explored (not in visible_positions or _exp_bytes).
func test_boundary_wall_vis_bytes_are_forward() -> void:
## #585: BoundaryWall tiles must receive VIS_FORWARD in the vis texture
## so the wall sprite composites correctly (not occluded by fog).
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
fog_state.update_from_state()
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var vis: PackedByteArray = fog_state._vis_bytes
var boundary_idx: int = (5 - oy) * w + (6 - ox)
assert_int(vis[boundary_idx]).override_failure_message(
"#585: BoundaryWall tile must have VIS_FORWARD (255) in vis texture"
).is_equal(FogState.VIS_FORWARD)
_reset_fog_state(fog_state)
func test_boundary_wall_does_not_persist_as_explored() -> void:
## #585: BoundaryWall tiles must NOT become EXP_EXPLORED after leaving the area.
## They are rendering artifacts, not player memory.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: have a boundary wall tile at (6,5)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
fog_state.update_from_state()
# Tick 2: player moves away; (6,5) is no longer a boundary wall
GameState.visible_positions = {Vector2i(20, 20): true}
GameState.boundary_positions.clear()
fog_state.update_from_state()
# (6,5) must not be EXP_EXPLORED — it was never a true explored tile
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var boundary_idx: int = (5 - oy) * w + (6 - ox)
assert_int(exp[boundary_idx]).override_failure_message(
"#585: BoundaryWall tile must NOT persist as EXP_EXPLORED — only true LOS tiles are explored"
).is_equal(FogState.EXP_UNEXPLORED)
_reset_fog_state(fog_state)
func test_normal_tile_adjacent_to_boundary_still_explored() -> void:
## The normal LOS tile adjacent to a BoundaryWall must still be marked explored.
## BoundaryWall exclusion must not affect neighboring tiles.
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: normal tile (5,5) in LOS, boundary wall at (6,5)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
fog_state.update_from_state()
# Tick 2: player moves away
GameState.visible_positions = {Vector2i(20, 20): true}
GameState.boundary_positions.clear()
fog_state.update_from_state()
# Normal tile (5,5) must be EXP_EXPLORED
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var exp: PackedByteArray = fog_state._exp_bytes
var normal_idx: int = (5 - oy) * w + (5 - ox)
assert_int(exp[normal_idx]).override_failure_message(
"#585: normal LOS tile adjacent to BoundaryWall must still be EXP_EXPLORED (128)"
).is_equal(FogState.EXP_EXPLORED)
_reset_fog_state(fog_state)
func test_boundary_wall_visibility_only_when_present() -> void:
## #585: A tile that is a BoundaryWall in tick 1 but absent in tick 2
## must have VIS_HIDDEN in tick 2 (fog reapplied).
var fog_state = _get_fog_state()
if fog_state == null:
return
_reset_fog_state(fog_state)
# Tick 1: boundary wall at (6,5)
GameState.visible_positions = {Vector2i(5, 5): true}
GameState.boundary_positions = {Vector2i(6, 5): true}
fog_state.update_from_state()
# Tick 2: player moves far away; (6,5) no longer visible or boundary
GameState.visible_positions = {Vector2i(20, 20): true}
GameState.boundary_positions.clear()
fog_state.update_from_state()
# (6,5) must be VIS_HIDDEN — fog returned
var ox: int = fog_state.map_bounds.position.x
var oy: int = fog_state.map_bounds.position.y
var w: int = fog_state.map_bounds.size.x
var vis: PackedByteArray = fog_state._vis_bytes
var boundary_idx: int = (5 - oy) * w + (6 - ox)
assert_int(vis[boundary_idx]).override_failure_message(
"#585: BoundaryWall tile must return to VIS_HIDDEN when not in current boundary set"
).is_equal(FogState.VIS_HIDDEN)
_reset_fog_state(fog_state)
-39
View File
@@ -4,7 +4,6 @@
##
## API per Tyre architecture review:
## show_monologue(text, duration, priority=2, is_urgent=false)
## GameState.lattice_profile selects colour palette
class_name TestMonologueDisplay
extends GdUnitTestSuite
@@ -35,14 +34,11 @@ func _label_text(d: Node) -> String:
func before_test() -> void:
## Reset GameState fields touched by this suite so tests don't bleed into each other.
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
## current_monologue: GameState integration tests need null as start state.
GameState.current_monologue = null
GameState.lattice_profile = "lattice_baseline"
func after_test() -> void:
GameState.current_monologue = null
GameState.lattice_profile = "lattice_baseline"
# ---------------------------------------------------------------------------
@@ -310,34 +306,10 @@ func test_text_has_color_bbcode() -> void:
d.queue_free()
# ---------------------------------------------------------------------------
# Lattice colour palette
# ---------------------------------------------------------------------------
func test_augmented_colour_differs_from_baseline() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_augmented"
d.show_monologue("Detective.", 5.0)
var aug_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
d._next_fade_in_msec = 0.0
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Smuggler.", 5.0)
var base_txt := _label_text(d)
assert_that(aug_txt).is_not_equal(base_txt)
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
func test_urgent_colour_differs_from_standard() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Normal.", 5.0, 2, false)
var std_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
@@ -350,17 +322,6 @@ func test_urgent_colour_differs_from_standard() -> void:
d.queue_free()
func test_unknown_profile_falls_back_without_crash() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_hypothetical_tier_x"
d.show_monologue("Future proof.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
# ---------------------------------------------------------------------------
# Slot lifecycle
# ---------------------------------------------------------------------------
+13 -9
View File
@@ -123,23 +123,26 @@ func test_game_state_warns_on_missing_player() -> void:
# -- SimBridge: test data completeness --
func test_sim_bridge_test_snapshot_has_tiles() -> void:
## Protocol uses "visible_tiles" (not "tiles") for test snapshot — updated from stale assertion.
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("tiles")).is_true()
assert_that(snap.tiles.size()).is_greater(0)
var tile = snap.tiles[0]
assert_that(snap.has("visible_tiles")).is_true()
assert_that(snap.visible_tiles.size()).is_greater(0)
var tile = snap.visible_tiles[0]
assert_that(tile.has("x")).is_true()
assert_that(tile.has("y")).is_true()
assert_that(tile.has("type")).is_true()
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
## Protocol uses "visible_tiles" for position data — visible_positions is derived client-side.
## Updated from stale assertion: TestHarness snapshot never had a top-level "visible_positions".
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("visible_positions")).is_true()
assert_that(snap.visible_positions.size()).is_greater(0)
var pos = snap.visible_positions[0]
assert_that(pos.has("x")).is_true()
assert_that(pos.has("y")).is_true()
assert_that(snap.has("visible_tiles")).is_true()
assert_that(snap.visible_tiles.size()).is_greater(0)
var vtile = snap.visible_tiles[0]
assert_that(vtile.has("x")).is_true()
assert_that(vtile.has("y")).is_true()
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
SimBridge.reset_test_state()
@@ -162,10 +165,11 @@ func test_sim_bridge_test_snapshot_has_npc() -> void:
assert_that(has_npc).is_true()
func test_sim_bridge_test_tiles_contain_all_types() -> void:
## Protocol uses "visible_tiles" — updated from stale "tiles" assertion.
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
var types: Dictionary = {}
for tile in snap.tiles:
for tile in snap.visible_tiles:
types[tile.type] = true
assert_that(types.has("floor")).is_true()
assert_that(types.has("wall")).is_true()
+3 -65
View File
@@ -1,79 +1,17 @@
## Sprint 24 — Signal acceptance tests (#588, #590, #592)
## Sprint 24 — Signal acceptance tests (#590, #592)
##
## Client-side acceptance criteria:
## - #588: character_archetype field in GameState, StartupMessage, SessionManager persistence
## - #590: triangle_crisis_events decoded by Protocol, chimed once per triangle_id
## - #592: news_ticker decode + update_from_state hide/show behavior
##
## Spec: D-032 (monologue pools per character), D-016 (client displays server data only),
## D-042 (UI strings in yaml), D-067 (chime on recognition onset)
## Spec: D-016 (client displays server data only), D-042 (UI strings in yaml),
## D-067 (chime on recognition onset)
class_name TestSignalSprint24
extends GdUnitTestSuite
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
# -- #588: Character archetype field ------------------------------------------
func test_game_state_has_character_archetype_field() -> void:
assert_bool("character_archetype" in GameState).override_failure_message(
"GameState must have a character_archetype field (#588)"
).is_true()
func test_game_state_character_archetype_default_is_detective() -> void:
# Fresh GameState defaults to "detective" (safest fallback for legacy saves).
var archetype = GameState.get("character_archetype")
assert_str(archetype).override_failure_message(
"GameState.character_archetype default must be 'detective'"
).is_equal("detective")
func test_protocol_startup_message_unknown_archetype_defaults_to_detective() -> void:
# Unknown archetype strings must not silently pass garbage to the server.
# The match guard falls back to "Detective" and calls push_error.
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "hacker")
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status).is_null()
assert_str(decoded.value["character_archetype"]).override_failure_message(
"Unknown archetype must fall back to 'Detective'"
).is_equal("Detective")
func test_protocol_startup_message_includes_character_archetype() -> void:
# StartupMessage wire payload must carry "character_archetype" key (#588).
var bytes: PackedByteArray = Protocol.encode_startup_message(12345, "detective")
assert_bool(bytes.size() > 0).is_true()
var decoded = Messagepack.decode(bytes)
assert_that(decoded.status).is_null()
var msg: Dictionary = decoded.value
assert_bool(msg.has("character_archetype")).override_failure_message(
"StartupMessage must contain 'character_archetype' key, got: %s" % str(msg.keys())
).is_true()
func test_protocol_startup_message_detective_maps_to_pascal_case() -> void:
# "detective" client string must map to "Detective" PascalCase server enum variant.
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "detective")
var decoded = Messagepack.decode(bytes)
assert_str(decoded.value["character_archetype"]).is_equal("Detective")
func test_protocol_startup_message_smuggler_maps_to_pascal_case() -> void:
# "smuggler" client string must map to "Smuggler" PascalCase server enum variant.
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "smuggler")
var decoded = Messagepack.decode(bytes)
assert_str(decoded.value["character_archetype"]).is_equal("Smuggler")
func test_protocol_startup_message_preserves_world_seed() -> void:
# Adding character_archetype must not break world_seed encoding.
var seed: int = 0xDEADBEEF
var bytes: PackedByteArray = Protocol.encode_startup_message(seed, "detective")
var decoded = Messagepack.decode(bytes)
assert_int(decoded.value["world_seed"]).is_equal(seed)
# -- #590: triangle_crisis_events decode --------------------------------------
func test_protocol_decode_includes_triangle_crisis_events_field() -> void:
+4 -2
View File
@@ -294,10 +294,12 @@ func test_hud_time_row_updates_after_process() -> void:
"tick": 1, "version": 23, "entities": [],
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
})
instance._process(0.016)
# In test mode SimBridge is disconnected — poll_snapshot() returns null so the
# SnapshotEventRouter inside main._process() never fires. Call the HUD directly
# instead, which is what the router would do in a live session.
var hud = instance.get_node_or_null("InsertOverlay/HUD")
assert_that(hud).is_not_null()
hud.update_from_state()
assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1")
+2 -1
View File
@@ -498,7 +498,8 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
if _active_tween and _active_tween.is_valid() and is_instance_valid(panel):
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
confrontation_monologue.emit(
UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION
+5 -1
View File
@@ -82,12 +82,16 @@ func _start_fade_out() -> void:
## Dismiss immediately (e.g. when dialogue opens).
## Sets _active = false immediately so is_active() returns false before the fade completes.
func dismiss() -> void:
if not _active:
return
_active = false
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
_start_fade_out()
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func(): visible = false)
func is_active() -> bool:
+13 -40
View File
@@ -9,7 +9,6 @@ extends Control
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
#
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
const MAX_VISIBLE: int = 3
@@ -20,29 +19,16 @@ const FADE_IN_SEC: float = 0.3
const FADE_OUT_SEC: float = 0.5
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
# standard opacity = 0.85, urgent opacity = 1.0.
# Monologue colour palette — standard opacity = 0.85, urgent opacity = 1.0.
# Source: Tyre architecture review, Sprint 14.
const _LATTICE_COLORS: Dictionary = {
"lattice_augmented":
{ # detective
"standard": Color("#d0d4e0"),
"urgent": Color("#e0e8f8"),
},
"lattice_baseline":
{ # smuggler
"standard": Color("#d8d0c4"),
"urgent": Color("#f0e4d4"),
},
}
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
const _STANDARD_COLOR: Color = Color("#c8d0e0")
const _URGENT_COLOR: Color = Color("#e0e8f8")
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
const _NOTIFICATION_DURATION: float = 2.5
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
var _visible: Array[Dictionary] = []
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
# Queue entry: {text, duration, priority, is_urgent}
var _queue: Array[Dictionary] = []
# Msec timestamp when the next fade-in may begin (stagger enforcement)
var _next_fade_in_msec: float = 0.0
@@ -69,19 +55,15 @@ func _process(delta: float) -> void:
if next.get("is_notification", false):
_show_notification_line(next.text)
else:
_show_line(
next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile
)
_show_line(next.text, next.duration, next.priority, next.is_urgent)
# Display a monologue line.
# priority: higher number = more important (default 2; urgent beats normal).
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
# Empty text is silently ignored — no slot created, no queue entry.
# lattice_profile is read from GameState here and passed down — renderer stays
# decoupled from the autoload (D-020 renderer contract).
# #554: Show a brief system notification (save/load result, connection status).
# Uses neutral color, short duration, bypasses lattice_profile styling.
# Uses neutral color, short duration.
func show_notification(text: String) -> void:
if text.is_empty():
return
@@ -94,7 +76,6 @@ func show_notification(text: String) -> void:
duration = _NOTIFICATION_DURATION,
priority = 1,
is_urgent = false,
lattice_profile = "",
is_notification = true
}
if _queue.size() < MAX_QUEUE:
@@ -116,12 +97,11 @@ func show_monologue(
) -> void:
if text.is_empty():
return
var profile := GameState.lattice_profile
var now := float(Time.get_ticks_msec())
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
_show_line(text, duration, priority, is_urgent, profile)
_show_line(text, duration, priority, is_urgent)
else:
_enqueue(text, duration, priority, is_urgent, profile)
_enqueue(text, duration, priority, is_urgent)
# ---------------------------------------------------------------------------
@@ -159,9 +139,9 @@ func _show_notification_line(text: String) -> void:
func _show_line(
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
text: String, duration: float, priority: int, is_urgent: bool
) -> void:
var line_node := _build_line_node(text, is_urgent, lattice_profile)
var line_node := _build_line_node(text, is_urgent)
_vbox.add_child(line_node)
var slot := {
@@ -192,7 +172,7 @@ func _retire_slot(slot: Dictionary) -> void:
func _enqueue(
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
text: String, duration: float, priority: int, is_urgent: bool
) -> void:
if _queue.size() < MAX_QUEUE:
_queue.append(
@@ -201,7 +181,6 @@ func _enqueue(
duration = duration,
priority = priority,
is_urgent = is_urgent,
lattice_profile = lattice_profile
}
)
_queue.sort_custom(
@@ -216,7 +195,6 @@ func _enqueue(
duration = duration,
priority = priority,
is_urgent = is_urgent,
lattice_profile = lattice_profile
}
_queue.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
@@ -232,13 +210,8 @@ func _lowest_priority_idx() -> int:
return idx
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
var color: Color = (
palette.get("urgent", _FALLBACK_URGENT)
if is_urgent
else palette.get("standard", _FALLBACK_STANDARD)
)
func _build_line_node(text: String, is_urgent: bool) -> Control:
var color: Color = _URGENT_COLOR if is_urgent else _STANDARD_COLOR
var container := MarginContainer.new()
container.add_theme_constant_override("margin_left", 4)