Files
settled-reach/client/tests/test_client_p1.gd
T
jpmschweitzerandClaude Opus 4.6 3f5b4258ba 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>
2026-05-02 10:19:11 +02:00

214 lines
8.6 KiB
GDScript

## P1 client tests: fog shader state (4), entity lifecycle (2), pending recognition blob (1).
## Information boundary + core rendering contract tests — the perception/fog system
## that makes asymmetric information work.
## Bug #5 class prevention: fog byte encoding errors. Bug #2 class: entity lifecycle.
## Spec ref: stig-round3.md Section 1 (tests #3-#9), workshop-outcomes.md.
class_name TestClientP1
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
# -- Helpers -------------------------------------------------------------------
func _get_fog_state() -> Node:
var node = get_node_or_null("/root/FogState")
if node == null:
push_warning("TestClientP1: FogState autoload not found — test skipped")
return node
func _make_entity_renderer() -> Node2D:
var renderer = Node2D.new()
renderer.set_script(EntityRendererScript)
add_child(renderer)
return renderer
func _make_fog_entities() -> Node2D:
var node = Node2D.new()
node.set_script(FogEntitiesScript)
add_child(node)
return node
# -- Fog state: visibility byte values (#3-#4) --------------------------------
func test_fog_visibility_forward_tile() -> void:
# P1 #3: Forward-sector tile writes VIS_FORWARD (255) to _vis_bytes.
var fog = _get_fog_state()
if fog == null:
return
# 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(10, 10): true}
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
fog.update_from_state()
# 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()
func test_fog_visibility_peripheral_tile() -> void:
# P1 #4: Server simplified to forward-only (Sprint 22, #569). All visible
# tiles are now written as VIS_FORWARD regardless of visibility_sectors value.
# Peripheral sector is no longer a distinct visual state.
var fog = _get_fog_state()
if fog == null:
return
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
GameState.visible_positions = {Vector2i(10, 8): true}
GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"}
fog.update_from_state()
assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message(
"All visible tiles write VIS_FORWARD after forward-only simplification"
).is_equal(FogState.VIS_FORWARD)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Fog state: exploration persistence (#5) -----------------------------------
func test_fog_exploration_persistence() -> void:
# P1 #5: Tile visible in frame 1 (EXP_VISIBLE), not visible in frame 2
# → _exp_bytes decays to EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
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(10, 10)
var idx: int = 10 * 64 + 10
# Frame 1: tile visible
GameState.visible_positions = {pos: true}
GameState.visibility_sectors = {pos: "Forward"}
fog.update_from_state()
assert_that(fog._exp_bytes[idx]).override_failure_message(
"Visible tile should have EXP_VISIBLE=%d" % FogState.EXP_VISIBLE
).is_equal(FogState.EXP_VISIBLE)
# Frame 2: tile no longer visible — should decay to EXP_EXPLORED, not EXP_UNEXPLORED
GameState.visible_positions = {}
GameState.visibility_sectors = {}
fog.update_from_state()
assert_that(fog._exp_bytes[idx]).override_failure_message(
"Previously visible tile should decay to EXP_EXPLORED=%d, not EXP_UNEXPLORED" % FogState.EXP_EXPLORED
).is_equal(FogState.EXP_EXPLORED)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Fog state: hidden tile default (#6) --------------------------------------
func test_fog_hidden_tile_value() -> void:
# P1 #6: Tile never in visible_positions → _vis_bytes == VIS_HIDDEN (0).
# Verifies default state after _resize() and after update with other visible tiles.
var fog = _get_fog_state()
if fog == null:
return
GameState.visible_tiles = []
fog._resize(Rect2i(0, 0, 64, 64))
# Check a tile that was never made visible (30,30)
var idx: int = 30 * 64 + 30
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.
# 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
).is_equal(FogState.VIS_HIDDEN)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Entity lifecycle: removal on leaving visibility (#7) ----------------------
func test_entity_removed_when_leaving_visibility() -> void:
# P1 #7: Entity present in one update, absent in next → entity_nodes empty.
# Verifies removal (queue_free), not just hiding.
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 42, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
assert_that(renderer.entity_nodes.size()).override_failure_message(
"Entity should exist after first update"
).is_equal(1)
assert_that(renderer.entity_nodes.has(42)).is_true()
# Entity leaves visibility — removed from entity_nodes, node queue_free'd
renderer.update_entities([])
assert_that(renderer.entity_nodes.size()).override_failure_message(
"Entity should be removed when absent from update"
).is_equal(0)
assert_that(renderer.entity_nodes.has(42)).is_false()
renderer.queue_free()
# -- Entity lifecycle: creation on first appearance (#8) -----------------------
func test_entity_created_on_first_appearance() -> void:
# P1 #8: Empty renderer → update with 1 entity → entity_nodes.size() == 1,
# child exists in scene tree.
var renderer := _make_entity_renderer()
assert_that(renderer.entity_nodes.size()).override_failure_message(
"Fresh renderer should have no entities"
).is_equal(0)
var entity := [{"entity_id": 99, "x": 3.0, "y": 7.0, "z": 0,
"kind": {"variant": "Npc", "data": null}}]
renderer.update_entities(entity)
assert_that(renderer.entity_nodes.size()).override_failure_message(
"Renderer should have 1 entity after update"
).is_equal(1)
assert_that(renderer.entity_nodes.has(99)).is_true()
# Verify child actually exists in the scene tree
var node = renderer.entity_nodes[99]
assert_that(node.get_parent()).is_equal(renderer)
renderer.queue_free()
# -- Pending recognition blob rendering (#9) ----------------------------------
func test_pending_recognition_blob_rendering() -> void:
# P1 #9: pending_recognitions with 1 entry → FogEntities tracks blob entity
# (not a full entity sprite — drawn via _draw(), data in _entities dict).
var fog_entities := _make_fog_entities()
var saved_recognitions := GameState.pending_recognitions
GameState.pending_recognitions = [{
"entity_id": 77,
"x": 10.0,
"y": 15.0,
"z": 0,
"remaining_ticks": 5,
"total_delay_ticks": 10,
}]
fog_entities.update_from_state()
# FogEntities uses _draw() with no child nodes — verify internal tracking
assert_that(fog_entities._entities.size()).override_failure_message(
"FogEntities should track 1 blob entity"
).is_equal(1)
assert_that(fog_entities._entities.has(77)).is_true()
# Verify position stored correctly
var blob_data: Dictionary = fog_entities._entities[77]
assert_that(blob_data.pos).is_equal(Vector2(10.0, 15.0))
# Recognition progress should be calculated: 1.0 - 5/10 = 0.5
assert_that(blob_data.progress).is_equal_approx(0.5, 0.01)
# New entity triggers a sonar ping (D-059)
assert_that(fog_entities._pings.size()).override_failure_message(
"New fog entity should trigger a sonar ping"
).is_greater_equal(1)
# Cleanup
GameState.pending_recognitions = saved_recognitions
fog_entities.queue_free()