- 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>
214 lines
8.6 KiB
GDScript
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()
|