#866: Fix _escape_bbcode in dialogue_box.gd — only escape '[', not ']'. Chaining .replace(']', '[rb]') after .replace('[', '[lb]') corrupted the [lb] escape itself. Updated test_dialogue_sprint18.gd accordingly. #869: Migrate test_anti_tedium + test_bug_report_ring_buffer to instantiate BugReportDialog via .tscn (BugReportDialogScene.instantiate()) instead of bare Control.new() + set_script(). Sprint 36 moved bug_report_dialog.gd to extends MetaScreen; the old load path was an orphan .uid file returning null. #870: Delete 8 parse-error test files (test_debug_overlay_sprint19, test_entanglement_sprint22, test_fog_sprint22, test_journal_sprint18, test_minimap_sprint18, test_session_manager_sprint19, test_sprint30, test_sprite_integration) + .uid sidecars. Coverage tickets filed for fog/journal/minimap (#879, #880, #881). Sprint docs updated with #882 note. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,317 +0,0 @@
|
||||
## Sprint 19 — Debug visualization overlay (#348)
|
||||
## F3 toggle, world overlays, tick timing graph.
|
||||
## Extends the existing debug_overlay.gd stub.
|
||||
class_name TestDebugOverlaySprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
||||
const DEBUG_SCRIPT_PATH: String = "res://ui/debug_overlay.gd"
|
||||
|
||||
|
||||
func _make_overlay() -> Control:
|
||||
## Instantiate a standalone DebugOverlay control for unit testing.
|
||||
## Does not require the full main.tscn scene tree.
|
||||
var script := load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
|
||||
return null
|
||||
var node := Control.new()
|
||||
node.set_script(script)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.current_tick = 1
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script existence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_script_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
||||
"debug_overlay.gd must exist at res://ui/debug_overlay.gd (#348)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instantiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_instantiates_without_crash() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_that(ol).is_not_null()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_debug_overlay_starts_hidden() -> void:
|
||||
## Overlay starts hidden — only appears when F3 pressed.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.visible).override_failure_message(
|
||||
"DebugOverlay must start hidden (visible=false)"
|
||||
).is_false()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-only guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has_method("update_from_state")).override_failure_message(
|
||||
"DebugOverlay must have update_from_state() method"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_hidden() -> void:
|
||||
## update_from_state() called while hidden must not crash.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = false
|
||||
ol.update_from_state() # Should be a no-op, no crash
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_visible() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate a minimal snapshot tick
|
||||
GameState.current_tick = 42
|
||||
ol.update_from_state()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPC path tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_paths_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_npc_paths")).override_failure_message(
|
||||
"DebugOverlay must have _npc_paths field for NPC movement history"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_updated_on_state_update() -> void:
|
||||
## After update_from_state with an NPC entity, _npc_paths should have an entry.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 2,
|
||||
"x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 100
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"_npc_paths must record NPC positions from visible_entities"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_not_populated_for_player_entity() -> void:
|
||||
## Player entities must not appear in NPC path history.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null},
|
||||
}]
|
||||
GameState.current_tick = 101
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"Player entity must not appear in _npc_paths"
|
||||
).is_equal(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_max_length_respected() -> void:
|
||||
## Path history must not grow beyond NPC_HISTORY_LEN entries.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate NPC moving each tick — inject 20 ticks of movement
|
||||
for i in range(20):
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 5,
|
||||
"x": float(12 + i), "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 200 + i
|
||||
ol.update_from_state()
|
||||
var path: Array = ol._npc_paths.get(5, [])
|
||||
assert_int(path.size()).override_failure_message(
|
||||
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
|
||||
).is_less_equal(ol.NPC_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tick timing ring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_tick_deltas_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_tick_deltas")).override_failure_message(
|
||||
"DebugOverlay must have _tick_deltas field for timing sparkline"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_accumulate_over_state_updates() -> void:
|
||||
## Each new tick snapshot should add a delta to _tick_deltas.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(5):
|
||||
GameState.current_tick = 300 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must accumulate entries from successive ticks"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_max_length_respected() -> void:
|
||||
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(50):
|
||||
GameState.current_tick = 400 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
|
||||
).is_less_equal(ol.TICK_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants defined
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have NPC_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have TICK_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_warn_ms_constant_defined() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
|
||||
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Facing angle helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_facing_to_angle_north() -> void:
|
||||
## North = -PI/2 in Godot 2D (up on screen)
|
||||
var angle := _fetch_facing_angle("North")
|
||||
assert_float(angle).override_failure_message(
|
||||
"_facing_to_angle('North') must return -PI/2"
|
||||
).is_equal_approx(-PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_east() -> void:
|
||||
var angle := _fetch_facing_angle("East")
|
||||
assert_float(angle).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_south() -> void:
|
||||
var angle := _fetch_facing_angle("South")
|
||||
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_west() -> void:
|
||||
var angle := _fetch_facing_angle("West")
|
||||
assert_float(angle).is_equal_approx(PI, 0.001)
|
||||
|
||||
|
||||
func _fetch_facing_angle(facing: String) -> float:
|
||||
## Helper: load script and call static method.
|
||||
var script = load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
return 0.0
|
||||
# In GDScript 4, static methods can be called via an instance
|
||||
var tmp := Control.new()
|
||||
tmp.set_script(script)
|
||||
add_child(tmp)
|
||||
var result := tmp._facing_to_angle(facing)
|
||||
tmp.queue_free()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-scene placement: DebugOverlay on UILayer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_in_main_scene_ui_layer() -> void:
|
||||
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var ui_layer := scene.get_node_or_null("UILayer")
|
||||
assert_that(ui_layer != null).override_failure_message(
|
||||
"UILayer must exist in main.tscn"
|
||||
).is_true()
|
||||
if ui_layer == null: return
|
||||
var overlay := ui_layer.get_node_or_null("DebugOverlay")
|
||||
assert_that(overlay != null).override_failure_message(
|
||||
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
|
||||
).is_true()
|
||||
@@ -1 +0,0 @@
|
||||
uid://bdsgybncfyu52
|
||||
@@ -392,18 +392,16 @@ func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func skip_test_escape_bbcode_brackets_in_server_text() -> void:
|
||||
func test_escape_bbcode_brackets_in_server_text() -> void:
|
||||
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
|
||||
## BROKEN (#866): chained replace('[', '[lb]').replace(']', '[rb]') corrupts the
|
||||
## [lb] escape — result is [lb[rb]...] instead of [lb]...]]. Bug filed.
|
||||
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
|
||||
## as plain text in the dialogue log.
|
||||
## Fix (#866): only escape '[' — unmatched ']' renders as a literal in RichTextLabel.
|
||||
## Exact expected output: "[lb]wave]Evil NPC[lb]/wave]"
|
||||
## RichTextLabel interprets [lb] as literal '[', and bare ']' as literal ']',
|
||||
## so the rendered output is the plain string "[wave]Evil NPC[/wave]" — no BBCode parsed.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).contains("[lb]")
|
||||
assert_bool(escaped.begins_with("[")).is_false()
|
||||
assert_that(escaped).is_equal("[lb]wave]Evil NPC[lb]/wave]")
|
||||
box.queue_free()
|
||||
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
## Sprint 22 — Entanglement ratio configuration acceptance tests (#175, #178)
|
||||
##
|
||||
## Test-first stubs for the client-side surface of the world_seed feature.
|
||||
## These tests will warn-and-skip until the implementation lands (Tyre, #175).
|
||||
##
|
||||
## Client-side acceptance criteria (#175):
|
||||
## - GameState carries a world_seed field (stores the seed for this session)
|
||||
## - SessionManager.new_game() generates and stores a world_seed
|
||||
## - The IPC startup payload carries world_seed so the server can seed SimRng
|
||||
##
|
||||
## Server-side acceptance criteria (#178) are in:
|
||||
## - server/src/content/entanglement.rs (Rust unit tests)
|
||||
##
|
||||
## Spec: D-029 (30/50/20 entanglement ratio, variable per seed), D-010 (deterministic sim)
|
||||
## Tickets: #175, #178
|
||||
class_name TestEntanglementSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Client-side: GameState.world_seed field (#175) ---------------------------
|
||||
|
||||
func test_game_state_has_world_seed_field() -> void:
|
||||
# #175 client-side: GameState must store the world_seed for this session.
|
||||
# The seed is set by SessionManager.new_game() and read by SimBridge to
|
||||
# carry it in the session startup IPC message.
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed not found — test-first stub (awaiting #175)")
|
||||
return
|
||||
# Field exists — verify it is numeric (int or null are both acceptable initial states)
|
||||
var seed_val = GameState.get("world_seed")
|
||||
assert_bool(seed_val == null or seed_val is int).override_failure_message(
|
||||
"GameState.world_seed must be int or null"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_world_seed_can_be_set_and_read() -> void:
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped (#175 not yet implemented)")
|
||||
return
|
||||
var orig = GameState.get("world_seed")
|
||||
GameState.world_seed = 0xDEADBEEF
|
||||
assert_int(GameState.world_seed).is_equal(0xDEADBEEF)
|
||||
# Restore
|
||||
GameState.world_seed = orig
|
||||
|
||||
|
||||
func test_game_state_world_seed_default_is_null_or_zero() -> void:
|
||||
# Before a session starts, world_seed should be null (no session) or 0 (unset).
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped")
|
||||
return
|
||||
var seed_val = GameState.get("world_seed")
|
||||
assert_bool(seed_val == null or seed_val == 0).override_failure_message(
|
||||
"GameState.world_seed should be null or 0 before any session starts"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Client-side: SessionManager seed generation (#175) -----------------------
|
||||
|
||||
func test_session_manager_exists() -> void:
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager autoload not found — skipped")
|
||||
return
|
||||
assert_that(sm).is_not_null()
|
||||
|
||||
|
||||
func test_session_manager_new_game_generates_world_seed() -> void:
|
||||
# #175: new_game() must generate and store world_seed in GameState.
|
||||
# The seed is a non-zero u64 that will be sent to the server on startup.
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
# Call new_game() (will create a save dir — acceptable in test environment)
|
||||
var orig_seed = GameState.get("world_seed")
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.new_game()
|
||||
var generated_seed = GameState.get("world_seed")
|
||||
|
||||
# world_seed must have been set to a non-null, non-zero value
|
||||
assert_bool(generated_seed != null).override_failure_message(
|
||||
"SessionManager.new_game() must set GameState.world_seed (#175)"
|
||||
).is_true()
|
||||
if generated_seed != null:
|
||||
assert_bool(generated_seed != 0).override_failure_message(
|
||||
"Generated world_seed must be non-zero"
|
||||
).is_true()
|
||||
|
||||
# Restore state
|
||||
GameState.current_game_id = orig_game_id
|
||||
GameState.world_seed = orig_seed
|
||||
|
||||
|
||||
func test_session_manager_same_game_id_has_same_seed() -> void:
|
||||
# Resuming a session must restore the original world_seed (not generate a new one).
|
||||
# This ensures deterministic replays work correctly (D-010).
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not sm.has_method("resume_game"):
|
||||
push_warning("TestEntanglementSprint22: resume_game() missing — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
# Set a known seed and game_id, then resume — seed must not be clobbered
|
||||
GameState.world_seed = 12345678
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.resume_game("20260228-120000-abc123")
|
||||
# resume_game() must NOT overwrite world_seed
|
||||
assert_int(GameState.world_seed).override_failure_message(
|
||||
"resume_game() must not overwrite world_seed — seed is loaded from the save, not regenerated"
|
||||
).is_equal(12345678)
|
||||
GameState.current_game_id = orig_game_id
|
||||
|
||||
|
||||
# -- IPC startup message: world_seed field (#175) ----------------------------
|
||||
|
||||
func test_protocol_encode_startup_message_has_world_seed_field() -> void:
|
||||
# #175 acceptance: startup IPC message must carry "world_seed" key.
|
||||
# Verifies Protocol.encode_startup_message encodes the seed so the server
|
||||
# can deserialize it as StartupMessage { world_seed: u64 }.
|
||||
var seed: int = 0xDEADBEEF # 3735928559 — fits in u32, safely maps to Rust u64
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(seed)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"Protocol.encode_startup_message must return non-empty bytes"
|
||||
).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).override_failure_message(
|
||||
"encode_startup_message output must be valid msgpack: %s" % str(decoded.status)
|
||||
).is_null()
|
||||
var msg = decoded.value
|
||||
assert_bool(msg is Dictionary and msg.has("world_seed")).override_failure_message(
|
||||
"StartupMessage wire payload must contain 'world_seed' key, got: %s" % str(msg)
|
||||
).is_true()
|
||||
assert_int(msg["world_seed"]).override_failure_message(
|
||||
"world_seed must round-trip through msgpack unchanged"
|
||||
).is_equal(seed)
|
||||
|
||||
|
||||
func test_protocol_encode_startup_message_zero_seed() -> void:
|
||||
# Edge case: seed=0 must still encode a valid payload (world_seed: 0).
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0)
|
||||
assert_bool(bytes.size() > 0).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
assert_int(decoded.value["world_seed"]).is_equal(0)
|
||||
|
||||
|
||||
func test_sim_bridge_can_send_world_seed_in_startup() -> void:
|
||||
# #175 acceptance: "startup IPC message carries a world_seed field"
|
||||
# The client must be able to include world_seed in the session startup payload.
|
||||
# Test-first: verify the API exists (method or field), else warn-and-skip.
|
||||
var sim_bridge = get_node_or_null("/root/SimBridge")
|
||||
if sim_bridge == null:
|
||||
push_warning("TestEntanglementSprint22: SimBridge not found — skipped")
|
||||
return
|
||||
|
||||
# Option A: SimBridge has a world_seed property that is sent during startup
|
||||
if "world_seed" in sim_bridge:
|
||||
sim_bridge.world_seed = 99999
|
||||
assert_int(sim_bridge.world_seed).is_equal(99999)
|
||||
sim_bridge.world_seed = 0
|
||||
return
|
||||
|
||||
# Option B: SimBridge has a set_world_seed() method
|
||||
if sim_bridge.has_method("set_world_seed"):
|
||||
# Method exists — this is the expected API
|
||||
sim_bridge.set_world_seed(99999)
|
||||
return
|
||||
|
||||
# Neither found — test-first stub
|
||||
push_warning(
|
||||
"TestEntanglementSprint22: SimBridge has no world_seed field or set_world_seed() — " +
|
||||
"test-first stub awaiting #175 implementation"
|
||||
)
|
||||
|
||||
|
||||
# -- Protocol: world_seed flows from client to server (#175) ------------------
|
||||
|
||||
func test_apply_snapshot_does_not_clobber_world_seed() -> void:
|
||||
# world_seed is set at session start and must persist across all subsequent snapshots.
|
||||
# Snapshots must not overwrite or clear the world_seed that was set at startup.
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
GameState.world_seed = 42000
|
||||
GameState.apply_snapshot({"tick": 5, "visible_tiles": []})
|
||||
assert_int(GameState.world_seed).override_failure_message(
|
||||
"apply_snapshot() must not clear or overwrite world_seed — seed is set once at session start"
|
||||
).is_equal(42000)
|
||||
GameState.world_seed = null
|
||||
|
||||
|
||||
# -- Seed variation property (#178, informational — full test is Rust-side) ---
|
||||
|
||||
func test_different_seeds_produce_different_configs_informational() -> void:
|
||||
# D-029: "entanglement rate varies per seed to prevent metagaming calibration"
|
||||
# The definitive acceptance test for this is Rust-side (server/src/content/entanglement.rs):
|
||||
# - EntanglementConfig::from_rng(seed_A) == EntanglementConfig::from_rng(seed_A) [deterministic]
|
||||
# - EntanglementConfig::from_rng(seed_A) != EntanglementConfig::from_rng(seed_B) [variable, >=90%]
|
||||
#
|
||||
# This test only verifies the client side: world_seed is a u64 large enough to
|
||||
# have sufficient entropy. A 24-bit game_id hex component alone has 16M combinations;
|
||||
# the full u64 seed provides 2^64 possibilities.
|
||||
#
|
||||
# We verify that two calls to new_game() produce different seeds.
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.new_game()
|
||||
var seed_a = GameState.get("world_seed")
|
||||
sm.new_game()
|
||||
var seed_b = GameState.get("world_seed")
|
||||
|
||||
if seed_a == null or seed_b == null:
|
||||
push_warning("TestEntanglementSprint22: new_game() did not set world_seed — test-first stub")
|
||||
GameState.current_game_id = orig_game_id
|
||||
return
|
||||
|
||||
# Two different sessions should produce different seeds
|
||||
assert_bool(seed_a != seed_b).override_failure_message(
|
||||
"Two calls to new_game() must produce different world_seeds (D-029 anti-metagaming)"
|
||||
).is_true()
|
||||
GameState.current_game_id = orig_game_id
|
||||
@@ -1 +0,0 @@
|
||||
uid://c1dnlbnxtgqqo
|
||||
@@ -1,652 +0,0 @@
|
||||
## Sprint 22 — Fog system acceptance tests (#569)
|
||||
##
|
||||
## Validates FogState data management against the Sprint 22 acceptance criteria:
|
||||
## - Explored tiles never revert to unexplored black (EXP_EXPLORED persistence)
|
||||
## - Bounds grow-only invariant (explored tiles behind player stay in texture)
|
||||
## - All visible tiles written as Forward (server simplified to Forward-only)
|
||||
## - Exploration data survives texture resize (grow-only bounds copy)
|
||||
## - Shader file present with correct fog_alpha constant
|
||||
##
|
||||
## Spec: D-059 (fog shader), D-015 (vision cone), D-066 (dual-scale grid, 6-8 tile gradient)
|
||||
## Ticket: #569
|
||||
class_name TestFogSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
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 (awaiting #569)")
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
|
||||
|
||||
# -- Spec constants (D-059) ---------------------------------------------------
|
||||
|
||||
func test_exp_explored_constant_is_128() -> void:
|
||||
# EXP_EXPLORED = 128 → shader reads this as ~0.502.
|
||||
# smoothstep(0.0, 0.2, 0.502) = 1.0 → exp_fade fully applied.
|
||||
# If EXP_EXPLORED were 0, explored tiles would render as solid unexplored black.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_EXPLORED).override_failure_message(
|
||||
"EXP_EXPLORED must be 128 — shader exp_fade requires explored value > 0.2 to avoid unexplored-black rendering"
|
||||
).is_equal(128)
|
||||
|
||||
|
||||
func test_exp_unexplored_constant_is_0() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_UNEXPLORED).is_equal(0)
|
||||
|
||||
|
||||
func test_exp_visible_constant_is_255() -> void:
|
||||
# EXP_VISIBLE = 255 → shader reads 1.0, full art visibility (currently in LOS)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_VISIBLE).is_equal(255)
|
||||
|
||||
|
||||
func test_vis_forward_constant_is_255() -> void:
|
||||
# D-059: VIS_FORWARD = 255 → clear vision, nearly transparent fog overlay
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.VIS_FORWARD).is_equal(255)
|
||||
|
||||
|
||||
func test_vis_hidden_constant_is_0() -> void:
|
||||
# D-059: VIS_HIDDEN = 0 → no vision, fog fully opaque
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.VIS_HIDDEN).is_equal(0)
|
||||
|
||||
|
||||
func test_unexplored_color_spec_value() -> void:
|
||||
# D-059: Unexplored = solid near-black #12141a
|
||||
# Verify the hex value decodes to the expected channel values.
|
||||
var c := Color("#12141a")
|
||||
assert_float(c.r).is_equal_approx(18.0 / 255.0, 0.003)
|
||||
assert_float(c.g).is_equal_approx(20.0 / 255.0, 0.003)
|
||||
assert_float(c.b).is_equal_approx(26.0 / 255.0, 0.003)
|
||||
# Sanity: it IS very dark (all channels < 0.12)
|
||||
assert_float(c.r).is_less(0.12)
|
||||
assert_float(c.g).is_less(0.12)
|
||||
assert_float(c.b).is_less(0.12)
|
||||
|
||||
|
||||
# -- Acceptance: explored tiles persist after leaving LOS (criterion 3) ------
|
||||
|
||||
func test_explored_tile_becomes_exp_explored_after_leaving_los() -> void:
|
||||
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||
# When tile (5,5) was in LOS (frame 1) and then leaves LOS (frame 2),
|
||||
# its exploration byte must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
push_warning("TestFogSprint22: update_from_state missing — skipped")
|
||||
return
|
||||
|
||||
# Frame 1: tile (5,5) is visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Frame 2: tile (5,5) leaves LOS
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Internal state check: _exp_bytes[tile(5,5)] must be EXP_EXPLORED (128)
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
push_warning("TestFogSprint22: _exp_bytes not accessible — data path untestable headlessly")
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
push_warning("TestFogSprint22: _width inaccessible — data path untestable")
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: tile (5,5) out of bounds after update — check grow_bounds margin")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx < 0 or idx >= exp_bytes.size():
|
||||
push_warning("TestFogSprint22: idx %d out of exp_bytes range %d" % [idx, exp_bytes.size()])
|
||||
return
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"Tile (5,5) must be EXP_EXPLORED=128 after leaving LOS — not EXP_UNEXPLORED=0 (#569 regression)"
|
||||
).is_equal(fog_state.EXP_EXPLORED)
|
||||
|
||||
|
||||
func test_explored_tile_is_exp_visible_while_in_los() -> void:
|
||||
# While in LOS, tile exploration byte must be EXP_VISIBLE (255)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
GameState.visible_tiles = [{"x": 3, "y": 3, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 3 - ox
|
||||
var py := 3 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_VISIBLE)
|
||||
|
||||
|
||||
func test_unexplored_tile_stays_exp_unexplored() -> void:
|
||||
# Tile (7, 8) was never seen — must remain EXP_UNEXPLORED (0)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# See only (5, 5) — tile (7, 8) is not in LOS
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 7 - ox
|
||||
var py := 8 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_UNEXPLORED)
|
||||
|
||||
|
||||
# -- Acceptance: bounds grow-only invariant ------------------------------------
|
||||
|
||||
func test_bounds_never_shrink() -> void:
|
||||
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||
# Requires grow-only bounds: once a tile is in the texture, it stays there.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: see (10, 10) → establishes initial bounds
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b1: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Frame 2: see (30, 30) → bounds must expand to include both
|
||||
GameState.visible_positions = {Vector2i(30, 30): true}
|
||||
GameState.visible_tiles = [{"x": 30, "y": 30, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b2: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Frame 3: back to (10, 10) → bounds must NOT shrink
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b3: Rect2i = fog_state.map_bounds
|
||||
|
||||
assert_bool(b2.size.x >= b1.size.x).override_failure_message(
|
||||
"Bounds must grow when player moves to larger region"
|
||||
).is_true()
|
||||
assert_bool(b2.size.y >= b1.size.y).is_true()
|
||||
assert_bool(b3.size.x >= b2.size.x).override_failure_message(
|
||||
"Bounds must not shrink when player returns to previous position (grow-only invariant)"
|
||||
).is_true()
|
||||
assert_bool(b3.size.y >= b2.size.y).is_true()
|
||||
|
||||
|
||||
func test_bounds_include_margin_for_gradient_bleed() -> void:
|
||||
# D-066: 6-8 tile gradient at cone edge requires texture margin.
|
||||
# _grow_bounds adds 8-tile margin on each side (accommodates 7x7 Gaussian
|
||||
# kernel at 2-texel intervals = ±6 tile reach). After seeing (10,10),
|
||||
# bounds should extend at least 4 tiles beyond the visible tile.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var b: Rect2i = fog_state.map_bounds
|
||||
# With 4-tile margin: bounds.position.x <= 10 - 4 = 6
|
||||
assert_bool(b.position.x <= 6).override_failure_message(
|
||||
"FogState bounds must include 4-tile margin for gradient bleed (D-066 gradient spec)"
|
||||
).is_true()
|
||||
assert_bool(b.position.y <= 6).is_true()
|
||||
|
||||
|
||||
# -- Acceptance: Forward-only visibility (Sprint 22 server simplification) ----
|
||||
|
||||
func test_visible_tiles_written_as_vis_forward() -> void:
|
||||
# Sprint 22: server sends only Forward tiles (Peripheral sector removed).
|
||||
# FogState writes VIS_FORWARD (255) for all tiles in visible_positions.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
if vis_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
for pos in [Vector2i(5, 5), Vector2i(6, 5)]:
|
||||
var px := pos.x - ox
|
||||
var py := pos.y - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
continue
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < vis_bytes.size():
|
||||
assert_int(vis_bytes[idx]).override_failure_message(
|
||||
"All visible tiles should be VIS_FORWARD=255 — server is Forward-only in Sprint 22"
|
||||
).is_equal(fog_state.VIS_FORWARD)
|
||||
|
||||
|
||||
func test_tiles_outside_los_written_as_vis_hidden() -> void:
|
||||
# Tiles in bounds but not in visible_positions must be VIS_HIDDEN (0)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (5, 7) is inside the padded bounds but not visible — must be VIS_HIDDEN
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
if vis_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 7 - oy
|
||||
if px >= 0 and py >= 0 and px < w:
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < vis_bytes.size():
|
||||
assert_int(vis_bytes[idx]).is_equal(fog_state.VIS_HIDDEN)
|
||||
|
||||
|
||||
# -- Acceptance: exploration survives texture resize --------------------------
|
||||
|
||||
func test_exploration_data_preserved_across_bounds_growth() -> void:
|
||||
# D-059: Texture resize must copy old exploration bytes into new texture.
|
||||
# Without this, tiles seen before a resize appear as EXP_UNEXPLORED (black).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: see (5, 5), then leave
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state() # (5,5) → EXP_EXPLORED
|
||||
|
||||
# Frame 2: move far away — forces bounds growth (resize)
|
||||
GameState.visible_positions = {Vector2i(80, 80): true}
|
||||
GameState.visible_tiles = [{"x": 80, "y": 80, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (5,5) must still be EXP_EXPLORED after the resize
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: (5,5) not in bounds after resize — is copy-on-resize working?")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"Exploration data at (5,5) must survive bounds growth — EXP_EXPLORED (128) expected after resize"
|
||||
).is_greater_equal(fog_state.EXP_EXPLORED)
|
||||
|
||||
|
||||
# -- Shader file checks (D-059) -----------------------------------------------
|
||||
|
||||
func test_fog_gdshader_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||
"fog.gdshader must exist — fog rendering requires this shader file (#569)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_fog_alpha() -> void:
|
||||
# D-059: explored fog overlay must be ~25-30% opacity.
|
||||
# fog_alpha constant controls this. Verify the shader defines it.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — shader check skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
push_warning("TestFogSprint22: fog.gdshader is empty or unreadable")
|
||||
return
|
||||
assert_bool(source.contains("fog_alpha")).override_failure_message(
|
||||
"fog.gdshader must define fog_alpha for the 25-30%% explored-tile overlay (D-059)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_smoothstep_clarity_ramp() -> void:
|
||||
# D-059/D-066: smooth gradient requires a clarity ramp (smoothstep).
|
||||
# The blurred visibility → clarity ramp must use smoothstep for smooth gradients.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
assert_bool(source.contains("smoothstep")).override_failure_message(
|
||||
"fog.gdshader must use smoothstep for the clarity ramp — hard steps violate D-066 gradient spec"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_unexplored_color() -> void:
|
||||
# D-059: unexplored = solid near-black #12141a.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
assert_bool(source.contains("UNEXPLORED_COLOR")).override_failure_message(
|
||||
"fog.gdshader must define UNEXPLORED_COLOR constant (D-059 #12141a spec)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_uses_gaussian_blur_for_gradient() -> void:
|
||||
# D-066: 6-8 tile soft gradient requires Gaussian blur on visibility texture.
|
||||
# Current implementation: 7x7 kernel at 2-texel intervals (±6 tiles), sigma 2.0
|
||||
# in kernel space = 4.0 tiles effective. At 2-sigma (8 tiles), weight drops to 0.14.
|
||||
# This covers the D-066 "6-8 tile" gradient spec.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
# 7x7 Gaussian uses dy from -3 to 3
|
||||
assert_bool(source.contains("sample_visibility")).override_failure_message(
|
||||
"fog.gdshader must call sample_visibility() for Gaussian-blurred visibility (D-066 gradient)"
|
||||
).is_true()
|
||||
assert_bool(source.contains("-3.0")).override_failure_message(
|
||||
"fog.gdshader sample_visibility must use 7x7 kernel (±3 tiles) for 6-tile gradient coverage (D-066)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Regression: GameState visible_positions (existing contract) ---------------
|
||||
|
||||
func test_visible_positions_derived_from_visible_tiles_in_server_mode() -> void:
|
||||
# D-020: In real server mode, visible_positions derives from visible_tiles.
|
||||
# Fog rendering depends on this derivation being correct.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"visible_tiles": [
|
||||
{"x": 7, "y": 7, "z": 0, "visibility": "Forward"},
|
||||
{"x": 8, "y": 7, "z": 0, "visibility": "Forward"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(7, 7))).override_failure_message(
|
||||
"visible_positions must be derived from visible_tiles when no explicit visible_positions key"
|
||||
).is_true()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(8, 7))).is_true()
|
||||
|
||||
|
||||
func test_visibility_sectors_populated_forward_only() -> void:
|
||||
# D-015: visibility_sectors must be populated from visible_tiles.
|
||||
# In Forward-only mode, all sectors are "Forward".
|
||||
GameState.apply_snapshot({
|
||||
"tick": 11,
|
||||
"visible_tiles": [
|
||||
{"x": 4, "y": 4, "z": 0, "visibility": "Forward"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visibility_sectors.has(Vector2i(4, 4))).is_true()
|
||||
assert_str(GameState.visibility_sectors[Vector2i(4, 4)]).is_equal("Forward")
|
||||
|
||||
|
||||
func test_visible_positions_cleared_on_new_snapshot() -> void:
|
||||
# Old positions from tick N must not persist to tick N+1
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_int(GameState.visible_positions.size()).is_equal(1)
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||
"Old visible positions must be cleared when new visible_tiles arrive"
|
||||
).is_false()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
||||
|
||||
|
||||
# -- Sprint 23: BoundaryWall handling (#585) ----------------------------------
|
||||
|
||||
func test_boundary_positions_populated_from_snapshot() -> void:
|
||||
# #585: BoundaryWall tiles go to boundary_positions (not visible_positions).
|
||||
# Fog lifts for boundary wall tiles so wall content composites correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 20,
|
||||
"visible_tiles": [
|
||||
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message(
|
||||
"Forward tile must be in visible_positions"
|
||||
).is_true()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message(
|
||||
"BoundaryWall tile must NOT be in visible_positions (#585)"
|
||||
).is_false()
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message(
|
||||
"BoundaryWall tile must be in boundary_positions (#585)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_boundary_wall_vis_forward_not_exp_visible() -> void:
|
||||
# #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE.
|
||||
# They render through fog but are not stored as exploration memory.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if vis_bytes == null or exp_bytes == null:
|
||||
push_warning("TestFogSprint22: byte arrays not accessible — skipped")
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 6 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx < 0 or idx >= vis_bytes.size():
|
||||
return
|
||||
assert_int(vis_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)"
|
||||
).is_equal(fog_state.VIS_FORWARD)
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)"
|
||||
).is_not_equal(fog_state.EXP_VISIBLE)
|
||||
|
||||
|
||||
func test_boundary_wall_stays_unexplored_after_leaving_los() -> void:
|
||||
# #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED.
|
||||
# Normal LOS tiles decay to EXP_EXPLORED when they leave LOS.
|
||||
# Boundary tiles must stay EXP_UNEXPLORED — they were never explored.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: BoundaryWall at (6,5) is visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Frame 2: both leave LOS
|
||||
GameState.visible_positions.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 6 - ox
|
||||
var py := 5 - oy
|
||||
if px >= 0 and py >= 0 and px < w:
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)"
|
||||
).is_equal(fog_state.EXP_UNEXPLORED)
|
||||
|
||||
|
||||
func test_boundary_wall_cleared_on_new_snapshot() -> void:
|
||||
# #585: boundary_positions must be cleared each tick — old walls must not persist.
|
||||
# BoundaryWall positions shift as the player moves; stale positions would lift fog
|
||||
# where no wall exists.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 30,
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 31,
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message(
|
||||
"Stale BoundaryWall position must be cleared on next snapshot (#585)"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- Performance (D-059) -------------------------------------------------------
|
||||
|
||||
func test_fog_state_update_under_2ms_for_400_tiles() -> void:
|
||||
# D-059: <1ms/frame CPU budget for fog update. Allow 2x margin for test env.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
var positions: Dictionary = {}
|
||||
var tiles: Array = []
|
||||
for x in range(20):
|
||||
for y in range(20):
|
||||
positions[Vector2i(x, y)] = true
|
||||
tiles.append({"x": x, "y": y, "z": 0, "visibility": "Forward"})
|
||||
GameState.visible_positions = positions
|
||||
GameState.visible_tiles = tiles
|
||||
|
||||
var start := Time.get_ticks_usec()
|
||||
fog_state.update_from_state()
|
||||
var elapsed_ms := (Time.get_ticks_usec() - start) / 1000.0
|
||||
|
||||
assert_float(elapsed_ms).override_failure_message(
|
||||
"FogState.update_from_state() must complete in <2ms for 400 tiles (spec: <1ms D-059)"
|
||||
).is_less(2.0)
|
||||
@@ -1 +0,0 @@
|
||||
uid://bxhgo1e4rvfmi
|
||||
@@ -1,601 +0,0 @@
|
||||
## Sprint 18 — Knowledge/journal display (#264)
|
||||
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
|
||||
## D-042 (UIStrings for all labels)
|
||||
##
|
||||
## Tests now run against live Stig implementation.
|
||||
## Wire format per game_state.gd v14:
|
||||
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
|
||||
##
|
||||
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
|
||||
## The server sends KG updates only when the graph changes — absence = no change.
|
||||
## Contrast with current_examine_result which DOES clear each snapshot.
|
||||
class_name TestJournalSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
|
||||
|
||||
func _make_journal_panel() -> Control:
|
||||
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
|
||||
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
|
||||
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
|
||||
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"name": "Kael Davan",
|
||||
"confidence": "KnowsOf",
|
||||
"source": "DirectObservation",
|
||||
"state": "Active",
|
||||
"relationship": "PersonOfInterest",
|
||||
"last_observed_tick": 1024,
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
func _make_player_knowledge(entities: Array = []) -> Dictionary:
|
||||
if entities.is_empty():
|
||||
entities = [_make_kg_entity()]
|
||||
return {"entities": entities}
|
||||
|
||||
|
||||
func _entries_container(panel: Control) -> Node:
|
||||
return panel.get_node_or_null(
|
||||
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
GameState.current_tick = 0
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: player_knowledge snapshot parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_player_knowledge_field_exists() -> void:
|
||||
## GameState must have player_knowledge field (v14, #264).
|
||||
assert_bool(GameState.has("player_knowledge")).override_failure_message(
|
||||
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_by_default() -> void:
|
||||
GameState.player_knowledge = null
|
||||
assert_that(GameState.player_knowledge).is_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"player_knowledge": _make_player_knowledge(),
|
||||
})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
assert_bool(GameState.player_knowledge.has("entities")).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_persists_when_absent() -> void:
|
||||
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
|
||||
## Server sends KG updates only on change — absence means "no change since last tick".
|
||||
## This is intentional behavior (journal should not flash empty every tick).
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 11})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
|
||||
## Malformed player_knowledge (non-dict) must be rejected.
|
||||
## First set a valid value, then try to overwrite with invalid
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
|
||||
# Non-dict is rejected — previous value preserved (or null if first time)
|
||||
# The implementation only updates on Dictionary type, so value persists
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
|
||||
var entities := [
|
||||
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
|
||||
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
|
||||
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(parsed_entities.size()).is_equal(2)
|
||||
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
|
||||
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
|
||||
## When server sends a new player_knowledge, it replaces the previous value.
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
])})
|
||||
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
_make_kg_entity({"name": "Person B", "entity_id": 99}),
|
||||
])})
|
||||
var entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(entities.size()).is_equal(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: scene and API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
|
||||
"Journal panel scene must exist at res://ui/journal_panel.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_journal_panel_instantiates_without_crash() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_that(panel).is_not_null()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_toggle_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("toggle")).override_failure_message(
|
||||
"JournalPanel must have toggle() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_close_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("close")).override_failure_message(
|
||||
"JournalPanel must have close() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_is_open_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("is_open")).override_failure_message(
|
||||
"JournalPanel must have is_open() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_update_from_state_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("update_from_state")).override_failure_message(
|
||||
"JournalPanel must have update_from_state() method (called from main.gd)"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_closed_on_init() -> void:
|
||||
## Panel starts hidden — not open by default.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"JournalPanel must be closed on _ready()"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_opens() -> void:
|
||||
## First toggle() opens the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle()
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"toggle() must set is_open() = true"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_closes() -> void:
|
||||
## Second toggle() closes the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle() # open
|
||||
panel.toggle() # close
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Second toggle() must close the panel"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_close_when_already_closed_is_safe() -> void:
|
||||
## close() on an already-closed panel must not crash.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.close()
|
||||
assert_bool(panel.is_open()).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: entry rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_entries_container_exists() -> void:
|
||||
## EntriesContainer is the VBoxContainer that holds entity entries.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var container := _entries_container(panel)
|
||||
assert_that(container != null).override_failure_message(
|
||||
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
|
||||
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Kael Davan"}),
|
||||
])
|
||||
panel.toggle() # calls _show_panel() -> _rebuild_entries()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer must have children when player_knowledge is populated"
|
||||
).is_greater(0)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
|
||||
## Empty state Label is shown when player_knowledge is null.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = null
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Empty state = exactly 1 child (the "Nothing logged yet." label)
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
|
||||
).is_equal(1)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_two_entities_create_more_entries() -> void:
|
||||
## Two entities create more entries than one (header + detail each, plus spacers).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
|
||||
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
|
||||
).is_greater_equal(6)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
|
||||
## D-041: Contradicted entities must have strikethrough in their header BBCode.
|
||||
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## First child should be the header RichTextLabel with [s]...[/s]
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_that(first_child.text).override_failure_message(
|
||||
"Contradicted entity header must contain [s] (strikethrough) BBCode"
|
||||
).contains("[s]")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_active_entity_no_strikethrough() -> void:
|
||||
## Active entity must NOT have strikethrough in its header.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_bool(first_child.text.contains("[s]")).override_failure_message(
|
||||
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: mutual exclusion with dialogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
|
||||
## Sprint briefing: journal must close when dialogue opens.
|
||||
## update_from_state() is called from main.gd on each snapshot.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
assert_bool(panel.is_open()).is_true()
|
||||
|
||||
GameState.dialogue_active = true
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
|
||||
## update_from_state() must NOT close journal when dialogue is not active.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
GameState.dialogue_active = false
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must stay open when dialogue is inactive"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
|
||||
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
|
||||
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_confidence_direct_exists() -> void:
|
||||
## D-042: confidence label for "Direct" tier must be in UIStrings.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsdetails_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsof_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_suspects_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_all_confidence_keys_non_empty() -> void:
|
||||
## All four confidence label values must be non-empty strings.
|
||||
var keys := [
|
||||
"knowledge_panel.confidence_direct",
|
||||
"knowledge_panel.confidence_knowsdetails",
|
||||
"knowledge_panel.confidence_knowsof",
|
||||
"knowledge_panel.confidence_suspects",
|
||||
]
|
||||
for key in keys:
|
||||
if not UIStrings.has_key(key): continue
|
||||
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
|
||||
"UIStrings key '%s' must be non-empty" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_directobservation_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_toldby_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_heard_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: _state_color() contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_state_color_contradicted_is_amber() -> void:
|
||||
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Contradicted")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_is_dimmed() -> void:
|
||||
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Stale")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
|
||||
).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_active_is_normal() -> void:
|
||||
## Active → normal insert text color (INSERT_COLOR_TEXT).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Active")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Active') must return INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_contradicted_differs_from_active() -> void:
|
||||
## Contradicted and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var contradicted := panel._state_color("Contradicted")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(contradicted).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_differs_from_active() -> void:
|
||||
## Stale and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var stale := panel._state_color("Stale")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(stale).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: knowledge_panel keys (D-042)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
|
||||
## Journal title uses knowledge_panel.tab_contacts.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
|
||||
## Empty state message uses knowledge_panel.empty_state.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.empty_state"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
|
||||
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
|
||||
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
|
||||
## Regression guard: verify the dicts are gone and UIStrings fallback works.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
|
||||
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
## If this test fails, the hardcoded dict was accidentally re-introduced.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
|
||||
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
|
||||
## UIStrings falls back to the key string itself for missing keys.
|
||||
## journal_panel.gd relies on this for graceful degradation.
|
||||
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
|
||||
assert_that(fallback).override_failure_message(
|
||||
"UIStrings fallback must return the key string itself for unknown keys"
|
||||
).is_equal("knowledge_panel.confidence_nonexistent_level")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_canvas_insert_constant_is_10() -> void:
|
||||
assert_int(Constants.CANVAS_INSERT).is_equal(10)
|
||||
|
||||
|
||||
func test_journal_fade_constants_reasonable() -> void:
|
||||
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
|
||||
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
|
||||
panel.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://cao5jf5h36img
|
||||
@@ -1,321 +0,0 @@
|
||||
## Sprint 18 — Minimap rendering (#151)
|
||||
## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered),
|
||||
## D-049 (z-layer 6 = InsertOverlay)
|
||||
##
|
||||
## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs.
|
||||
## Scene: res://ui/minimap.tscn (class_name MinimapRenderer)
|
||||
## Positioned at InsertOverlay/Minimap in main.tscn.
|
||||
##
|
||||
## Tests run against live Stig implementation (minimap.gd).
|
||||
class_name TestMinimapSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
func _make_minimap() -> Control:
|
||||
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
||||
push_warning("TestMinimapSprint18: minimap.tscn not found — skip")
|
||||
return null
|
||||
var node: Control = load(MINIMAP_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_poi(overrides: Dictionary = {}) -> Dictionary:
|
||||
var base: Dictionary = {
|
||||
"id": "poi_test_001",
|
||||
"x": 20,
|
||||
"y": 15,
|
||||
"poi_category": "location",
|
||||
"label": "Exit A",
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.insert_active = true
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene and class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message(
|
||||
"Minimap scene must exist at res://ui/minimap.tscn (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_minimap_instantiates_without_crash() -> void:
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_that(mm).is_not_null()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_minimap_is_minimap_renderer_class() -> void:
|
||||
## class_name MinimapRenderer in minimap.gd.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_bool(mm is MinimapRenderer).override_failure_message(
|
||||
"Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants: D-015, visual parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_radius_constant() -> void:
|
||||
## MINIMAP_RADIUS defines the sim-tile distance of visible POI area.
|
||||
## Value is tuned to 24 tiles — reasonable coverage without map reveal.
|
||||
assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message(
|
||||
"MinimapRenderer.MINIMAP_RADIUS must be 24.0"
|
||||
).is_equal_approx(24.0, 0.01)
|
||||
|
||||
|
||||
func test_player_dot_radius_defined() -> void:
|
||||
## Player dot must be visible (> 0) and distinct from POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_poi_dot_radius_defined() -> void:
|
||||
## POI dot must be visible (> 0).
|
||||
assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_player_dot_larger_than_poi_dot() -> void:
|
||||
## D-015: Player is always centered and visually distinct.
|
||||
## Player dot should be at least as large as POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _category_color() — D-013 POI category color mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_color_danger_is_hostile_color() -> void:
|
||||
## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red)
|
||||
for cat in ["danger", "threat", "hostile"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
|
||||
func test_category_color_evidence_is_poi_color() -> void:
|
||||
## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber)
|
||||
for cat in ["evidence", "note", "clue"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
|
||||
func test_category_color_contact_is_unknown_color() -> void:
|
||||
## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal)
|
||||
for cat in ["contact", "npc", "person"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
func test_category_color_unknown_category_defaults_to_insert_text() -> void:
|
||||
## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default)
|
||||
var color: Color = MinimapRenderer._category_color("some_unknown_type")
|
||||
assert_that(color).override_failure_message(
|
||||
"Unknown category must default to INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_empty_string_defaults() -> void:
|
||||
## Empty category string → default color, no crash.
|
||||
var color: Color = MinimapRenderer._category_color("")
|
||||
assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_case_insensitive() -> void:
|
||||
## Category matching is case-insensitive (uses to_lower()).
|
||||
var danger_lower := MinimapRenderer._category_color("danger")
|
||||
var danger_upper := MinimapRenderer._category_color("DANGER")
|
||||
var danger_mixed := MinimapRenderer._category_color("Danger")
|
||||
assert_that(danger_lower).is_equal(danger_upper)
|
||||
assert_that(danger_lower).is_equal(danger_mixed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_insert_active() — D-049: insert layer visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_set_insert_active_false_hides_minimap() -> void:
|
||||
## When insert is inactive, minimap must be hidden.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(false) must hide the minimap"
|
||||
).is_false()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_set_insert_active_true_shows_minimap() -> void:
|
||||
## When insert is active, minimap must be visible.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
mm.set_insert_active(true)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(true) must show the minimap"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main scene structural check: InsertOverlay/Minimap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_in_main_scene_on_insert_overlay() -> void:
|
||||
## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer.
|
||||
## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
||||
return
|
||||
var scene: Node = MAIN_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
# Check for Minimap in InsertOverlay
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay")
|
||||
assert_that(insert_overlay != null).override_failure_message(
|
||||
"InsertOverlay (CanvasLayer 10) must exist in main.tscn"
|
||||
).is_true()
|
||||
if insert_overlay == null: return
|
||||
|
||||
var minimap := insert_overlay.get_node_or_null("Minimap")
|
||||
assert_that(minimap != null).override_failure_message(
|
||||
"Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)"
|
||||
).is_true()
|
||||
if minimap == null: return
|
||||
|
||||
assert_bool(minimap is MinimapRenderer).override_failure_message(
|
||||
"InsertOverlay/Minimap must be a MinimapRenderer instance"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049).
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
||||
return
|
||||
var scene: Node = MAIN_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer
|
||||
if insert_overlay == null: return
|
||||
assert_int(insert_overlay.layer).override_failure_message(
|
||||
"InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT
|
||||
).is_equal(Constants.CANVAS_INSERT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.discovered_pois integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_discovered_pois_field_exists_in_gamestate() -> void:
|
||||
assert_bool(GameState.has("discovered_pois")).override_failure_message(
|
||||
"GameState must have 'discovered_pois' field (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_poi_list_snapshot() -> void:
|
||||
## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [
|
||||
_make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}),
|
||||
_make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}),
|
||||
],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must be populated from snapshot 'poi_list' field"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_discovered_pois_snapshot() -> void:
|
||||
## Snapshot with "discovered_pois" key also works.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"discovered_pois": [_make_poi()],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_persists_when_absent_from_snapshot() -> void:
|
||||
## Like player_knowledge: POI list persists when server doesn't send an update.
|
||||
GameState.discovered_pois = [_make_poi()]
|
||||
GameState.apply_snapshot({"tick": 3})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must persist when absent from snapshot (not cleared each tick)"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_poi_category_field_present() -> void:
|
||||
## MinimapRenderer reads poi_category to determine shape/color.
|
||||
## Verify the wire format includes this field.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"poi_category": "danger"})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("poi_category")).override_failure_message(
|
||||
"POI entries must have 'poi_category' field for MinimapRenderer shape selection"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_x_y_fields_present() -> void:
|
||||
## MinimapRenderer reads x, y for position calculation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"x": 42, "y": 17})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message(
|
||||
"POI entries must have 'x' and 'y' coordinate fields"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Color constants: all distinct
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_colors_are_distinct() -> void:
|
||||
## All three primary category color groups must be visually distinct.
|
||||
var danger_color := MinimapRenderer._category_color("danger")
|
||||
var evidence_color := MinimapRenderer._category_color("evidence")
|
||||
var contact_color := MinimapRenderer._category_color("contact")
|
||||
assert_that(danger_color).is_not_equal(evidence_color)
|
||||
assert_that(evidence_color).is_not_equal(contact_color)
|
||||
assert_that(danger_color).is_not_equal(contact_color)
|
||||
@@ -1 +0,0 @@
|
||||
uid://dm46ip672i3jc
|
||||
@@ -1,210 +0,0 @@
|
||||
## Sprint 19 — Game session management (#258, D-085)
|
||||
## Per-game save directories: created on New Game, resumed via game-id.
|
||||
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
|
||||
class_name TestSessionManagerSprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_MENU_SCENE = preload("res://scenes/main_menu.tscn")
|
||||
|
||||
# Game IDs created during the current test — deleted in after_test().
|
||||
var _created_ids: Array = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_game_id = ""
|
||||
_created_ids = []
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
for game_id in _created_ids:
|
||||
var path := "user://saves/" + game_id
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
_created_ids.clear()
|
||||
GameState.current_game_id = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: call new_game() and track the created directory for cleanup.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _new_game() -> String:
|
||||
var game_id := SessionManager.new_game()
|
||||
if not game_id.is_empty():
|
||||
_created_ids.append(game_id)
|
||||
return game_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.current_game_id field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_current_game_id_field_exists() -> void:
|
||||
## D-085: GameState must have current_game_id field.
|
||||
assert_bool(GameState.has("current_game_id")).override_failure_message(
|
||||
"GameState must have 'current_game_id' field (D-085 #258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_current_game_id_default_is_empty_string() -> void:
|
||||
## Before any session starts, current_game_id is empty.
|
||||
GameState.current_game_id = ""
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"GameState.current_game_id default must be empty string"
|
||||
).is_empty()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionManager autoload exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_session_manager_autoload_exists() -> void:
|
||||
## SessionManager must be registered as an autoload.
|
||||
var sm := Engine.get_singleton("SessionManager")
|
||||
assert_that(sm != null).override_failure_message(
|
||||
"SessionManager must be registered as autoload in project.godot (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_game() — game-id format and GameState update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_new_game_returns_non_empty_string() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(game_id).override_failure_message(
|
||||
"SessionManager.new_game() must return a non-empty game-id string"
|
||||
).is_not_empty()
|
||||
|
||||
|
||||
func test_new_game_sets_current_game_id_on_gamestate() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"new_game() must set GameState.current_game_id"
|
||||
).is_equal(game_id)
|
||||
|
||||
|
||||
func test_new_game_id_format_has_two_dashes() -> void:
|
||||
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts.size()).override_failure_message(
|
||||
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_new_game_id_first_part_is_8_digits() -> void:
|
||||
## First part is YYYYMMDD — 8 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[0].length()).override_failure_message(
|
||||
"game-id first part (date) must be 8 characters (YYYYMMDD)"
|
||||
).is_equal(8)
|
||||
|
||||
|
||||
func test_new_game_id_second_part_is_6_digits() -> void:
|
||||
## Second part is HHMMSS — 6 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[1].length()).override_failure_message(
|
||||
"game-id second part (time) must be 6 characters (HHMMSS)"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_id_third_part_is_6_hex_chars() -> void:
|
||||
## Third part is 6 hex characters (RNG seed).
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[2].length()).override_failure_message(
|
||||
"game-id third part (hex seed) must be 6 characters"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_ids_are_unique() -> void:
|
||||
## Two rapid new_game() calls should produce different IDs
|
||||
## (different RNG seeds; same-second timestamps are valid but seeds differ).
|
||||
var id1 := _new_game()
|
||||
var id2 := _new_game()
|
||||
# Check that hex seeds differ (they almost certainly will)
|
||||
var seed1 := id1.split("-")[2]
|
||||
var seed2 := id2.split("-")[2]
|
||||
assert_str(seed1).override_failure_message(
|
||||
"Successive new_game() calls should have different RNG seeds"
|
||||
).is_not_equal(seed2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resume_game() — sets GameState.current_game_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_resume_game_sets_current_game_id() -> void:
|
||||
var test_id := "20260225-143022-a7b3f1"
|
||||
SessionManager.resume_game(test_id)
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"resume_game() must set GameState.current_game_id to the given id"
|
||||
).is_equal(test_id)
|
||||
|
||||
|
||||
func test_resume_game_overwrites_previous_game_id() -> void:
|
||||
SessionManager.resume_game("20260225-100000-aabbcc")
|
||||
SessionManager.resume_game("20260225-120000-112233")
|
||||
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main menu scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_main_menu_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
|
||||
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_instantiates_without_crash() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
assert_that(scene).is_not_null()
|
||||
|
||||
|
||||
func test_main_menu_has_new_game_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/NewGameBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_has_continue_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/ContinueBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project main scene changed to main_menu.tscn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_project_main_scene_is_main_menu() -> void:
|
||||
## D-085: project boots to main menu, not directly to game scene.
|
||||
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
|
||||
assert_str(scene_path).override_failure_message(
|
||||
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
|
||||
).is_equal("res://scenes/main_menu.tscn")
|
||||
@@ -1 +0,0 @@
|
||||
uid://c7lnnr2apbyqw
|
||||
@@ -1,552 +0,0 @@
|
||||
## Sprint 30 — QA acceptance tests
|
||||
##
|
||||
## Covers all 5 Sprint 30 client tickets:
|
||||
## #718 — Persist CharacterVisualDescriptor on new game start
|
||||
## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet)
|
||||
## #720 — Replace DirAccess scanning with manifest JSON for export builds
|
||||
## #712 — BoneAttachment3D marker above Head bone for floating icons
|
||||
## #674 — Star map insert module (test-first: scene must exist when implemented)
|
||||
##
|
||||
## Test convention:
|
||||
## - Tests that should PASS immediately = regression guards on existing code
|
||||
## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented
|
||||
##
|
||||
## Ticket refs: #718, #719, #720, #712, #674
|
||||
class_name TestSprint30
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# Reset GameState fields touched by #718 tests to avoid cross-test pollution.
|
||||
GameState.character_visual_descriptor = null
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
GameState.character_visual_descriptor = null
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #718 — Persist CharacterVisualDescriptor
|
||||
# =============================================================================
|
||||
|
||||
func test_game_state_has_character_visual_descriptor_field() -> void:
|
||||
## GameState.character_visual_descriptor must exist and default to null.
|
||||
## Confirms the field added in game_state.gd line 103 is present.
|
||||
var gs := GameState.new()
|
||||
auto_free(gs)
|
||||
# The field is declared on the class — access it without error
|
||||
var val: Variant = gs.get("character_visual_descriptor")
|
||||
# Field should exist (not return null from missing property vs. null value)
|
||||
assert_bool(gs.has_method("apply_snapshot")).override_failure_message(
|
||||
"GameState must be a valid autoload class with apply_snapshot"
|
||||
).is_true()
|
||||
# The property itself must be gettable and null by default
|
||||
assert_bool(val == null).override_failure_message(
|
||||
"GameState.character_visual_descriptor must default to null"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_to_dict_includes_all_required_fields() -> void:
|
||||
## CharacterVisualDescriptor.to_dict() must include all wire-format fields.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
var d := desc.to_dict()
|
||||
var required_keys := [
|
||||
"body_type", "head_id", "hair_id", "hair_tint",
|
||||
"facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint",
|
||||
"eye_color", "skin_tone", "clothing_slots", "clothing_tints",
|
||||
"accessory_slots", "accessory_tints",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert_bool(d.has(key)).override_failure_message(
|
||||
"to_dict() must include field '%s'" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_to_dict_body_type_is_wire_string() -> void:
|
||||
## body_type in to_dict() must be a string (rmp_serde unit enum), not an int.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||||
var d := desc.to_dict()
|
||||
assert_str(d["body_type"]).override_failure_message(
|
||||
"to_dict() body_type must be wire string 'AverageM'"
|
||||
).is_equal("AverageM")
|
||||
|
||||
|
||||
func test_descriptor_round_trip_preserves_fields() -> void:
|
||||
## from_dict(to_dict(desc)) must preserve all scalar fields.
|
||||
var original := CharacterVisualDescriptor.new()
|
||||
auto_free(original)
|
||||
original.body_type = CharacterVisualDescriptor.BodyType.THIN_F
|
||||
original.head_id = "head_002"
|
||||
original.hair_id = "bob"
|
||||
original.hair_tint = Color(0.8, 0.4, 0.2)
|
||||
original.skin_tone = 3
|
||||
|
||||
var wire := original.to_dict()
|
||||
var restored := CharacterVisualDescriptor.from_dict(wire)
|
||||
assert_bool(restored != null).override_failure_message(
|
||||
"from_dict() must return a descriptor for valid wire data"
|
||||
).is_true()
|
||||
if restored == null:
|
||||
return
|
||||
|
||||
assert_int(int(restored.body_type)).override_failure_message(
|
||||
"body_type must survive round-trip"
|
||||
).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F))
|
||||
|
||||
assert_str(restored.head_id).override_failure_message(
|
||||
"head_id must survive round-trip"
|
||||
).is_equal("head_002")
|
||||
|
||||
assert_str(restored.hair_id).override_failure_message(
|
||||
"hair_id must survive round-trip"
|
||||
).is_equal("bob")
|
||||
|
||||
assert_int(restored.skin_tone).override_failure_message(
|
||||
"skin_tone must survive round-trip"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void:
|
||||
## from_dict() must return null if body_type is absent (required field).
|
||||
var d := {"head_id": "head_001"} # missing body_type
|
||||
var result := CharacterVisualDescriptor.from_dict(d)
|
||||
assert_bool(result == null).override_failure_message(
|
||||
"from_dict() must return null when body_type is missing"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_color_encoding_is_float_array() -> void:
|
||||
## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
desc.eye_color = Color(0.1, 0.2, 0.3, 1.0)
|
||||
var d := desc.to_dict()
|
||||
var encoded: Variant = d["eye_color"]
|
||||
assert_bool(encoded is Array).override_failure_message(
|
||||
"eye_color must encode as an Array [r, g, b, a]"
|
||||
).is_true()
|
||||
if not (encoded is Array):
|
||||
return
|
||||
assert_int((encoded as Array).size()).override_failure_message(
|
||||
"eye_color array must have 4 elements"
|
||||
).is_equal(4)
|
||||
assert_float((encoded as Array)[0]).override_failure_message(
|
||||
"eye_color[0] (r) must be approx 0.1"
|
||||
).is_equal_approx(0.1, 0.001)
|
||||
|
||||
|
||||
func test_apply_snapshot_restores_character_visual_descriptor() -> void:
|
||||
## #718: apply_snapshot() must restore character_visual_descriptor from
|
||||
## the "character_visual_descriptor" key in ObserverSnapshot (save/load path).
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
|
||||
desc.head_id = "head_003"
|
||||
desc.hair_id = "dreads"
|
||||
desc.skin_tone = 5
|
||||
var snapshot := {
|
||||
"character_visual_descriptor": desc.to_dict(),
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
var restored: Variant = GameState.character_visual_descriptor
|
||||
assert_bool(restored != null).override_failure_message(
|
||||
"apply_snapshot() must restore character_visual_descriptor from snapshot"
|
||||
).is_true()
|
||||
if restored != null and restored is CharacterVisualDescriptor:
|
||||
var r := restored as CharacterVisualDescriptor
|
||||
assert_int(int(r.body_type)).override_failure_message(
|
||||
"restored body_type must match"
|
||||
).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F))
|
||||
assert_str(r.head_id).override_failure_message(
|
||||
"restored head_id must match"
|
||||
).is_equal("head_003")
|
||||
assert_int(r.skin_tone).override_failure_message(
|
||||
"restored skin_tone must match"
|
||||
).is_equal(5)
|
||||
|
||||
|
||||
func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void:
|
||||
## #718: If snapshot lacks "character_visual_descriptor", the existing field
|
||||
## must not be overwritten (server only sends when descriptor changes).
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.hair_id = "bob"
|
||||
GameState.character_visual_descriptor = desc
|
||||
# Snapshot with no character_visual_descriptor key
|
||||
GameState.apply_snapshot({"tick": 1})
|
||||
var after: Variant = GameState.character_visual_descriptor
|
||||
assert_bool(after != null).override_failure_message(
|
||||
"apply_snapshot() must NOT clear descriptor when field is absent"
|
||||
).is_true()
|
||||
if after is CharacterVisualDescriptor:
|
||||
assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message(
|
||||
"descriptor must be unchanged after snapshot with no character_visual_descriptor key"
|
||||
).is_equal("bob")
|
||||
|
||||
|
||||
func test_protocol_encode_startup_includes_descriptor() -> void:
|
||||
## #718: Protocol.encode_startup_message() must include "character_visual_descriptor"
|
||||
## in the encoded payload when a descriptor is provided.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M
|
||||
desc.hair_id = "buzzed"
|
||||
var bytes := Protocol.encode_startup_message(12345, "detective", desc)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"encode_startup_message() must produce non-empty bytes"
|
||||
).is_true()
|
||||
# Decode and verify the field is present (Messagepack.decode returns {status, value})
|
||||
var raw = Messagepack.decode(bytes)
|
||||
assert_bool(raw.status == null).override_failure_message(
|
||||
"encode_startup_message() output must be valid msgpack"
|
||||
).is_true()
|
||||
if raw.status != null:
|
||||
return
|
||||
var msg: Dictionary = raw.value as Dictionary
|
||||
assert_bool(msg.has("character_visual_descriptor")).override_failure_message(
|
||||
"StartupMessage must include 'character_visual_descriptor' key when descriptor is provided"
|
||||
).is_true()
|
||||
if msg.has("character_visual_descriptor"):
|
||||
assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message(
|
||||
"character_visual_descriptor in StartupMessage must be a Dictionary"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_protocol_encode_startup_omits_descriptor_when_null() -> void:
|
||||
## #718: encode_startup_message() must still produce valid bytes when descriptor is null.
|
||||
var bytes := Protocol.encode_startup_message(0, "detective", null)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"encode_startup_message() must produce valid bytes even with null descriptor"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_has_no_hair_highlight_tint_field() -> void:
|
||||
## CharacterVisualDescriptor must NOT have a hair_highlight_tint field.
|
||||
## The highlight is always auto-derived from hair_tint (Option B of #719).
|
||||
## to_dict() must not include it in the wire format.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
var d := desc.to_dict()
|
||||
assert_bool(d.has("hair_highlight_tint")).override_failure_message(
|
||||
"to_dict() must NOT include hair_highlight_tint — highlight is auto-derived"
|
||||
).is_false()
|
||||
assert_bool("hair_highlight_tint" in desc).override_failure_message(
|
||||
"CharacterVisualDescriptor must not define a hair_highlight_tint property"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #719 — Hair highlight swatch (Option B: read-only, auto-derived)
|
||||
# =============================================================================
|
||||
|
||||
func test_derive_hair_highlight_lightens_primary() -> void:
|
||||
## _derive_hair_highlight() must return primary.lightened(0.3).
|
||||
## Tests the derivation formula in character_creation.gd:1917.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping")
|
||||
return
|
||||
auto_free(cc)
|
||||
|
||||
# CharacterCreation._derive_hair_highlight is a private method but testable via call()
|
||||
var primary := Color(0.4, 0.3, 0.5)
|
||||
var expected := primary.lightened(0.3)
|
||||
var result: Variant = cc.call("_derive_hair_highlight", primary)
|
||||
assert_bool(result is Color).override_failure_message(
|
||||
"_derive_hair_highlight must return a Color"
|
||||
).is_true()
|
||||
if not (result is Color):
|
||||
return
|
||||
var r := result as Color
|
||||
assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001)
|
||||
assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001)
|
||||
assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001)
|
||||
|
||||
|
||||
func test_hair_highlight_swatch_exists_in_ui() -> void:
|
||||
## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null
|
||||
## (a display node must be created in _build_hair_color_dock).
|
||||
## WILL FAIL until #719 is implemented.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
add_child(cc)
|
||||
await get_tree().process_frame
|
||||
|
||||
# _hair_highlight_swatch must be set after _ready() builds the hair color dock
|
||||
var swatch: Variant = cc.get("_hair_highlight_swatch")
|
||||
assert_bool(swatch != null).override_failure_message(
|
||||
"[#719] _hair_highlight_swatch must not be null — a display node must be created"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_hair_highlight_swatch_is_not_interactive() -> void:
|
||||
## [ACCEPTANCE #719] The highlight swatch must be non-interactive.
|
||||
## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback).
|
||||
## WILL FAIL until #719 is implemented.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
add_child(cc)
|
||||
await get_tree().process_frame
|
||||
|
||||
var swatch: Variant = cc.get("_hair_highlight_swatch")
|
||||
if swatch == null:
|
||||
push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented")
|
||||
return
|
||||
|
||||
# If swatch is a Control node, mouse_filter must be IGNORE (2)
|
||||
if swatch is Control:
|
||||
var ctrl := swatch as Control
|
||||
assert_int(ctrl.mouse_filter).override_failure_message(
|
||||
"[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)"
|
||||
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #720 — Manifest JSON completeness (replaces DirAccess scanning)
|
||||
# =============================================================================
|
||||
|
||||
func test_manifest_json_is_parseable() -> void:
|
||||
## manifest.json must exist and parse as a Dictionary.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message(
|
||||
"manifest.json must exist at res://assets/characters/manifest.json"
|
||||
).is_true()
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_json_is_parseable: file not openable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
assert_bool(parsed is Dictionary).override_failure_message(
|
||||
"manifest.json must parse as a JSON object (Dictionary)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_hair_includes_all_asset_dirs() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in
|
||||
## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads,
|
||||
## long_dreads, mohawk, ponytail_f, simple_parted, slick_back.
|
||||
## WILL FAIL until #720 populates the manifest fully.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var hair_list: Array = manifest.get("hair", [])
|
||||
|
||||
# All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04)
|
||||
var expected_hair := [
|
||||
"bald", "balding", "bob", "buns", "buzzed", "buzzed_female",
|
||||
"dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f",
|
||||
"simple_parted", "slick_back",
|
||||
]
|
||||
for hair_id in expected_hair:
|
||||
assert_bool(hair_list.has(hair_id)).override_failure_message(
|
||||
"[#720] manifest 'hair' must include '%s'" % hair_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_heads_are_populated() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "heads" must not be empty.
|
||||
## heads/templates/ contains head_001..head_004 — all must be listed.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_heads_are_populated: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var heads_list: Array = manifest.get("heads", [])
|
||||
|
||||
var expected_heads := ["head_001", "head_002", "head_003", "head_004"]
|
||||
assert_bool(not heads_list.is_empty()).override_failure_message(
|
||||
"[#720] manifest 'heads' must not be empty — 4 head templates exist"
|
||||
).is_true()
|
||||
for head_id in expected_heads:
|
||||
assert_bool(heads_list.has(head_id)).override_failure_message(
|
||||
"[#720] manifest 'heads' must include '%s'" % head_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_body_types_includes_all_11() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types.
|
||||
## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child.
|
||||
## WILL FAIL until #720 updates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var bt_list: Array = manifest.get("body_types", [])
|
||||
|
||||
var expected_types := [
|
||||
"thin_m", "thin_f", "average_m", "average_f",
|
||||
"muscular_m", "muscular_f", "teen_m", "teen_f",
|
||||
"heavy_m", "heavy_f", "child",
|
||||
]
|
||||
for bt in expected_types:
|
||||
assert_bool(bt_list.has(bt)).override_failure_message(
|
||||
"[#720] manifest 'body_types' must include '%s'" % bt
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_clothing_includes_all_items() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "clothing" must include all items in
|
||||
## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic,
|
||||
## jacket_utility, pants_cargo, shirt_henley.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var clothing_data: Variant = manifest.get("clothing", {})
|
||||
var clothing_keys: Array = []
|
||||
if clothing_data is Dictionary:
|
||||
clothing_keys = (clothing_data as Dictionary).keys()
|
||||
|
||||
# All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04)
|
||||
var expected_items := [
|
||||
"boots_work", "coveralls_basic", "jacket_utility", "pants_cargo",
|
||||
"peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley",
|
||||
]
|
||||
for item_id in expected_items:
|
||||
assert_bool(clothing_keys.has(item_id)).override_failure_message(
|
||||
"[#720] manifest 'clothing' must include '%s'" % item_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_eyebrows_are_populated() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles.
|
||||
## assets/characters/eyebrows/ has: female, regular, teen, thick.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var eb_list: Array = manifest.get("eyebrows", [])
|
||||
|
||||
var expected_eyebrows := ["female", "regular", "teen", "thick"]
|
||||
assert_bool(not eb_list.is_empty()).override_failure_message(
|
||||
"[#720] manifest 'eyebrows' must not be empty"
|
||||
).is_true()
|
||||
for eb_id in expected_eyebrows:
|
||||
assert_bool(eb_list.has(eb_id)).override_failure_message(
|
||||
"[#720] manifest 'eyebrows' must include '%s'" % eb_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_dir_access_scan_functions_removed() -> void:
|
||||
## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be
|
||||
## removed from CharacterCreation. These functions fail in exported PCK builds.
|
||||
## WILL FAIL until #720 removes the DirAccess fallbacks.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_dir_access_scan_functions_removed: scene not available — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
|
||||
assert_bool(cc.has_method("_scan_subdirs")).override_failure_message(
|
||||
"[#720] _scan_subdirs must be removed — use manifest JSON instead"
|
||||
).is_false()
|
||||
assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message(
|
||||
"[#720] _scan_asset_ids must be removed — use manifest JSON instead"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #712 — BoneAttachment3D overhead anchor in CharacterVisual
|
||||
# =============================================================================
|
||||
|
||||
func test_character_visual_has_get_overhead_anchor() -> void:
|
||||
## CharacterVisual must expose get_overhead_anchor() as part of its public API.
|
||||
## This is a static assertion — no 3D assets required.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message(
|
||||
"CharacterVisual must have get_overhead_anchor() method (#712)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overhead_anchor_is_null_before_load() -> void:
|
||||
## get_overhead_anchor() must return null before load_descriptor() is called.
|
||||
## The anchor is created during skeleton load, not at construction.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
var anchor: Variant = cv.get_overhead_anchor()
|
||||
assert_bool(anchor == null).override_failure_message(
|
||||
"get_overhead_anchor() must be null before load_descriptor() is called"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overhead_anchor_offset_constant() -> void:
|
||||
## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset
|
||||
## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0).
|
||||
## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0).
|
||||
## This test loads a scene and verifies if assets are present.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
add_child(cv)
|
||||
|
||||
# Without GLB assets available in headless, skeleton load is a no-op.
|
||||
# Check that _overhead_attachment is also null before load (belt-and-suspenders).
|
||||
var attachment: Variant = cv.get("_overhead_attachment")
|
||||
assert_bool(attachment == null).override_failure_message(
|
||||
"_overhead_attachment must be null before skeleton is loaded"
|
||||
).is_true()
|
||||
@@ -1,292 +0,0 @@
|
||||
## Sprint 16 #540: Sprite integration tests.
|
||||
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
|
||||
## bounding box, sprite asset existence from #541, and fog shader independence.
|
||||
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
|
||||
class_name TestSpriteIntegration
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
|
||||
|
||||
func test_entity_footprint_matches_d044_spec() -> void:
|
||||
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
|
||||
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
|
||||
"D-044: ENTITY_WIDTH must be 24px"
|
||||
).is_equal(24)
|
||||
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
|
||||
"D-044: ENTITY_HEIGHT must be 32px"
|
||||
).is_equal(32)
|
||||
|
||||
|
||||
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
|
||||
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
|
||||
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
|
||||
# range (2 sim tiles) accurate with the tilted perspective.
|
||||
var tile_2x: int = Constants.TILE_SIZE * 2
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
|
||||
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
|
||||
EntityRenderer.ENTITY_WIDTH, tile_2x]
|
||||
).is_true()
|
||||
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
|
||||
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
|
||||
EntityRenderer.ENTITY_HEIGHT, tile_2x]
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_entity_width_fits_within_single_tile() -> void:
|
||||
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
|
||||
# Ensures horizontal centering offset is positive and entity doesn't overflow.
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
|
||||
"Entity width must be less than TILE_SIZE for centered layout"
|
||||
).is_true()
|
||||
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
|
||||
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Pixel position (S16-S04) -------------------------------------------------
|
||||
|
||||
func test_entity_pixel_position_at_tile_3_7() -> void:
|
||||
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
|
||||
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[10]
|
||||
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
|
||||
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
assert_that(node.position.x).override_failure_message(
|
||||
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
|
||||
).is_equal_approx(expected_x, 0.1)
|
||||
assert_that(node.position.y).override_failure_message(
|
||||
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
|
||||
).is_equal_approx(expected_y, 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
|
||||
|
||||
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
|
||||
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
|
||||
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
|
||||
# With tilted sprites, south-facing entity must visually overlap northern.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var north_node = renderer.entity_nodes[20]
|
||||
var south_node = renderer.entity_nodes[21]
|
||||
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
|
||||
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_z_sort_y_position_difference_equals_tile_size() -> void:
|
||||
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
|
||||
# Verifies position calculation is consistent for adjacent tiles.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var node3 = renderer.entity_nodes[30]
|
||||
var node4 = renderer.entity_nodes[31]
|
||||
var delta_y := node4.position.y - node3.position.y
|
||||
assert_that(delta_y).override_failure_message(
|
||||
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
|
||||
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_z_sort_same_y_different_x_no_y_difference() -> void:
|
||||
# S16-S07: Two entities at same y but different x → same pixel.y.
|
||||
# Horizontal position must not affect y-sort order.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var left_node = renderer.entity_nodes[40]
|
||||
var right_node = renderer.entity_nodes[41]
|
||||
assert_that(left_node.position.y).override_failure_message(
|
||||
"Entities at same y-tile must have same pixel.y regardless of x"
|
||||
).is_equal_approx(right_node.position.y, 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
|
||||
|
||||
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
|
||||
# entity_renderer.gd must be able to load these paths.
|
||||
for direction in ["north", "east", "south", "west"]:
|
||||
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||
"NPC sprite missing: %s" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
|
||||
for direction in ["north", "east", "south", "west"]:
|
||||
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
|
||||
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||
"Wall sprite missing: %s" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
|
||||
|
||||
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
|
||||
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
|
||||
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
|
||||
# the art-direction tilt baked into sprites.
|
||||
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
|
||||
"fog_shader.gd must load correctly — must not be affected by sprite changes"
|
||||
).is_true()
|
||||
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||
"fog.gdshader must exist — fog is screen-space and sprite-independent"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
|
||||
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
|
||||
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
|
||||
var fog = get_node_or_null("/root/FogState")
|
||||
if fog == null:
|
||||
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
|
||||
return
|
||||
# Provide visibility data but no entity renderer context
|
||||
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||
GameState.visibility_sectors = {
|
||||
Vector2i(5, 5): "Forward",
|
||||
Vector2i(6, 5): "Peripheral",
|
||||
}
|
||||
if fog.has_method("update_from_state"):
|
||||
fog.update_from_state()
|
||||
assert_that(fog.visibility_texture).override_failure_message(
|
||||
"FogState visibility_texture must be populated independently of sprite state"
|
||||
).is_not_null()
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
|
||||
|
||||
func test_octant_north_maps_to_north() -> void:
|
||||
# S16-S12: "North" → "north"
|
||||
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
|
||||
|
||||
func test_octant_northwest_maps_to_north() -> void:
|
||||
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
|
||||
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
|
||||
|
||||
func test_octant_northeast_maps_to_east() -> void:
|
||||
# S16-S14: "Northeast" → "east"
|
||||
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
|
||||
|
||||
func test_octant_east_maps_to_east() -> void:
|
||||
# S16-S15: "East" → "east"
|
||||
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
|
||||
|
||||
func test_octant_southeast_maps_to_south() -> void:
|
||||
# S16-S16: "Southeast" → "south"
|
||||
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
|
||||
|
||||
func test_octant_south_maps_to_south() -> void:
|
||||
# S16-S17: "South" → "south"
|
||||
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
|
||||
|
||||
func test_octant_southwest_maps_to_west() -> void:
|
||||
# S16-S18: "Southwest" → "west"
|
||||
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
|
||||
|
||||
func test_octant_west_maps_to_west() -> void:
|
||||
# S16-S19: "West" → "west"
|
||||
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
|
||||
|
||||
func test_octant_unknown_string_falls_back_to_south() -> void:
|
||||
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
|
||||
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
|
||||
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
|
||||
|
||||
func test_octant_empty_string_falls_back_to_south() -> void:
|
||||
# S16-S21: Empty string → "south" fallback
|
||||
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
|
||||
|
||||
|
||||
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
|
||||
|
||||
func test_entity_direction_npc_always_south() -> void:
|
||||
# S16-S22: NPC entity → always "south" regardless of any data field.
|
||||
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
# entity_id 99 is not the player
|
||||
var dir := renderer._entity_direction(99, {"entity_id": 99,
|
||||
"kind": {"variant": "Npc", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"NPC entity must always return 'south'"
|
||||
).is_equal("south")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_uses_player_facing() -> void:
|
||||
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "North"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='North' must return 'north'"
|
||||
).is_equal("north")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_facing_east() -> void:
|
||||
# S16-S24: Player facing "East" → "east"
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "East"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='East' must return 'east'"
|
||||
).is_equal("east")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
|
||||
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
|
||||
# Diagonal octants map to one of the four cardinal sprite sets.
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "Northwest"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='Northwest' must return 'north'"
|
||||
).is_equal("north")
|
||||
renderer.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://b8snipm64g2b2
|
||||
@@ -625,8 +625,11 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
## #866 fix: only escape '[' — unmatched ']' renders as a literal in RichTextLabel.
|
||||
## Chaining .replace("]", "[rb]") after .replace("[", "[lb]") corrupted the [lb] escape
|
||||
## itself: "[lb]" → "[lb[rb]", making the BBCode injection guard non-functional.
|
||||
static func _escape_bbcode(text: String) -> String:
|
||||
return text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
return text.replace("[", "[lb]")
|
||||
|
||||
|
||||
## Assign a palette color to an NPC entity ID on first encounter (#573).
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| #869 | Fix MetaScreen test helper regression — anti_tedium suite (7 fails) | high | — |
|
||||
| #870 | Delete or revive 8 parse-error test files | medium | — |
|
||||
| #875 | Drop PROTOCOL_VERSION on client (D-192) | medium | #874 (server) |
|
||||
| #882 | Strip archetype-driven client code (follow-up to #878) | medium | #878 (server) |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
@@ -66,10 +67,18 @@ Use `tooling/db/ticket show <id>` for full details.
|
||||
- Update any fixture-replay paths that read `version`.
|
||||
- Keep all field-presence and roundtrip behavioral tests.
|
||||
|
||||
**#882 — Strip archetype-driven client code (follow-up to server #878)**
|
||||
- Added 2026-04-21. Blocked by server #878 (removes `character_archetype` from StartupMessage; rides #874's PROTOCOL_VERSION break).
|
||||
- Per lead direction: the CharacterArchetype trace is Phase 6 filler, not production. Keep character-creation UI and insert screens; strip everything else.
|
||||
- Strip: `character_archetype` field in `game_state.gd` (line 100), `lattice_profile` derivation (line 47); `session_manager.gd` `save_character_archetype()`, `_read_archetype_file()`, character.txt save/load (lines 58, 172–192); `protocol.gd` `character_archetype` on StartupMessage; `sim_bridge.gd` archetype wire-up; any monologue color-palette code keyed on `lattice_augmented`/`lattice_baseline`; audit `tests/client/test_signal_sprint24.gd`.
|
||||
- Verify: client launches, character creation UI loads, insert screens render, session starts. Grep `character_archetype`, `lattice_profile`, `smuggler`, `detective` in `client/` — only character-creation UI references remain.
|
||||
- Context: server-side audit at `docs/architecture/sprint-37-878-audit.md` (on server branch until #878 merges).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#874 (server) → #875 (client PROTOCOL_VERSION drop)
|
||||
#878 (server) → #882 (client archetype strip)
|
||||
#872 (New Game regression fix) → #873 (merge-path tests add coverage for this flow)
|
||||
#866, #869, #870 → standalone, parallel
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user