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
+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")