Merge remote-tracking branch 'origin/client'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-18 18:35:15 +01:00
22 changed files with 1702 additions and 80 deletions
+692
View File
@@ -0,0 +1,692 @@
## #503: Auto-checklist progress tracking — unit + integration tests.
##
## Tests cover:
## 1. YAML parser: basic types, conditions array, edge cases
## 2. Condition evaluation: all 7 condition types
## 3. Latching: conditions stay met once satisfied
## 4. Room change: per-room conditions reset, cross-room conditions persist
## 5. Overlay: visibility gating on gauntlet_mode
## 6. Integration: snapshot -> GameState -> evaluator -> overlay
##
## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof.
class_name TestChecklist
extends GdUnitTestSuite
var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd")
var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.gd")
func before_test() -> void:
SimBridge.reset_test_state()
SimBridge._last_snapshot = null
GameState.current_tick = 0
GameState.player_position = Vector2.ZERO
GameState.visible_entities = []
GameState.visible_tiles = []
GameState.visible_positions = {}
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.nearby_interactions = []
GameState.game_time = {}
GameState.pending_recognitions = []
GameState.room_id = null
GameState.gauntlet_mode = false
GameState.player_facing = "North"
GameState.player_stance = "Walk"
GameState.player_inventory = []
# -- YAML Parser Tests ---------------------------------------------------------
func test_parse_empty_yaml() -> void:
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("")
assert_that(result.size()).is_equal(0)
func test_parse_top_level_string() -> void:
var yaml := "room_id: inventory_warehouse"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("inventory_warehouse")
func test_parse_top_level_quoted_string() -> void:
var yaml := 'description: "Tests D-065 (9-slot inventory)."'
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).")
func test_parse_single_condition() -> void:
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.has("conditions")).is_true()
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(1)
assert_that(conditions[0]["id"]).is_equal("test-1")
assert_that(conditions[0]["condition_type"]).is_equal("player_near")
assert_that(conditions[0]["x"]).is_equal(10)
assert_that(conditions[0]["y"]).is_equal(20)
assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001)
func test_parse_multiple_conditions() -> void:
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(2)
assert_that(conditions[0]["id"]).is_equal("cond-a")
assert_that(conditions[1]["id"]).is_equal("cond-b")
assert_that(conditions[1]["direction"]).is_equal("East")
func test_parse_comments_ignored() -> void:
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
var conditions: Array = result["conditions"]
assert_that(conditions.size()).is_equal(1)
assert_that(conditions[0]["entity_id"]).is_equal(5)
func test_parse_integer_and_float_values() -> void:
var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
var cond: Dictionary = result["conditions"][0]
assert_that(cond["x"]).is_equal(42)
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
func test_parse_scope_field() -> void:
var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("scope")).is_equal("cross_room")
func test_parse_inline_comment_stripped() -> void:
var yaml := "room_id: test # this is a comment"
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
assert_that(result.get("room_id")).is_equal("test")
# -- Condition Evaluation Tests ------------------------------------------------
func _make_evaluator(conditions: Array):
var evaluator = ChecklistEvaluatorScript.new()
evaluator._room_conditions = conditions
evaluator._loaded = true
return evaluator
func test_eval_player_near_within_radius() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-1", "condition_type": "player_near",
"x": 10, "y": 21, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: player at (10,20), target (10,21), radius 2.0 — should be met"
).is_equal(1)
func test_eval_player_near_outside_radius() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-2", "condition_type": "player_near",
"x": 10, "y": 30, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met"
).is_equal(0)
func test_eval_player_near_exact_boundary() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "near-3", "condition_type": "player_near",
"x": 10, "y": 22, "radius": 2.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near: distance exactly equals radius — should be met (<=)"
).is_equal(1)
func test_eval_player_facing_match() -> void:
GameState.player_facing = "East"
var evaluator = _make_evaluator([{
"id": "face-1", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_player_facing_no_match() -> void:
GameState.player_facing = "North"
var evaluator = _make_evaluator([{
"id": "face-2", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_player_facing_diagonal_no_match() -> void:
# 8-directional facing "Northeast" should NOT match "East" or "North"
GameState.player_facing = "Northeast"
var evaluator = _make_evaluator([{
"id": "face-diag", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_facing: Northeast should NOT match East (exact match only)"
).is_equal(0)
func test_eval_entity_present_found() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
{"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"},
]
var evaluator = _make_evaluator([{
"id": "present-1", "condition_type": "entity_present",
"entity_id": 10,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_entity_present_not_found() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "present-2", "condition_type": "entity_present",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_entity_absent_when_not_visible() -> void:
GameState.visible_entities = [
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "absent-1", "condition_type": "entity_absent",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent: entity 99 not in visible_entities — should be met"
).is_equal(1)
func test_eval_entity_absent_when_visible() -> void:
GameState.visible_entities = [
{"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
]
var evaluator = _make_evaluator([{
"id": "absent-2", "condition_type": "entity_absent",
"entity_id": 10,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent: entity 10 IS visible — should NOT be met"
).is_equal(0)
func test_eval_expected_monologue_match() -> void:
GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0}
var evaluator = _make_evaluator([{
"id": "mono-1", "condition_type": "expected_monologue",
"contains": "wrong here",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_expected_monologue_no_match() -> void:
GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0}
var evaluator = _make_evaluator([{
"id": "mono-2", "condition_type": "expected_monologue",
"contains": "wrong here",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_expected_monologue_null() -> void:
GameState.current_monologue = null
var evaluator = _make_evaluator([{
"id": "mono-3", "condition_type": "expected_monologue",
"contains": "test",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_monologue: null monologue should not match"
).is_equal(0)
func test_eval_expected_dialogue_match() -> void:
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []}
var evaluator = _make_evaluator([{
"id": "dlg-1", "condition_type": "expected_dialogue",
"contains": "Who are you",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_expected_dialogue_no_match() -> void:
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []}
var evaluator = _make_evaluator([{
"id": "dlg-2", "condition_type": "expected_dialogue",
"contains": "Goodbye",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_eval_expected_dialogue_null() -> void:
GameState.current_dialogue = null
var evaluator = _make_evaluator([{
"id": "dlg-3", "condition_type": "expected_dialogue",
"contains": "test",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_dialogue: null dialogue should not match"
).is_equal(0)
func test_eval_interaction_verb_match() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
{"kind": "Observe", "label": "Examine", "priority": 2, "available": true},
],
}]
var evaluator = _make_evaluator([{
"id": "verb-1", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
func test_eval_interaction_verb_by_kind() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
],
}]
var evaluator = _make_evaluator([{
"id": "verb-kind", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Take",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: should match by kind='Take' as well as label"
).is_equal(1)
func test_eval_interaction_verb_wrong_entity() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}],
}]
var evaluator = _make_evaluator([{
"id": "verb-wrong", "condition_type": "expected_interaction_verb",
"entity_id": 99, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: wrong entity_id should not match"
).is_equal(0)
func test_eval_interaction_verb_unavailable() -> void:
GameState.nearby_interactions = [{
"entity_id": 13,
"entity_type": "Object",
"distance": 1,
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}],
}]
var evaluator = _make_evaluator([{
"id": "verb-unavail", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"expected_interaction_verb: unavailable verb should not match"
).is_equal(0)
func test_eval_interaction_verb_no_interactions() -> void:
GameState.nearby_interactions = []
var evaluator = _make_evaluator([{
"id": "verb-none", "condition_type": "expected_interaction_verb",
"entity_id": 13, "verb": "Pickup",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
# -- Latching Tests ------------------------------------------------------------
func test_latching_condition_stays_met() -> void:
# Condition met on first evaluate, stays met even when state changes.
GameState.player_facing = "East"
var evaluator = _make_evaluator([{
"id": "latch-1", "condition_type": "player_facing",
"direction": "East",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Change state so condition would be false if re-evaluated fresh
GameState.player_facing = "North"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Latched condition should stay met even after state changes"
).is_equal(1)
func test_latching_monologue_transient() -> void:
# Monologue appears for one tick, then disappears. Condition should latch.
var evaluator = _make_evaluator([{
"id": "mono-latch", "condition_type": "expected_monologue",
"contains": "recalibrated",
}])
# Tick 1: no monologue
GameState.current_monologue = null
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
# Tick 2: monologue fires
GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0}
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Tick 3: monologue consumed (null)
GameState.current_monologue = null
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Monologue condition should stay latched after monologue disappears"
).is_equal(1)
func test_multiple_conditions_partial_latching() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
{"id": "c2", "condition_type": "entity_present", "entity_id": 5},
{"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0},
])
# Tick 1: only facing matches
GameState.player_facing = "East"
GameState.visible_entities = []
GameState.player_position = Vector2(0, 0)
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
assert_that(evaluator.get_total_count()).is_equal(3)
# Tick 2: entity also visible
GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}]
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(2)
# Tick 3: player moves to target
GameState.player_position = Vector2(50.0, 50.0)
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(3)
assert_that(evaluator.is_complete()).is_true()
# -- Room Change Tests ---------------------------------------------------------
func test_room_change_resets_per_room_conditions() -> void:
var evaluator = ChecklistEvaluatorScript.new()
# Manually set conditions to avoid file loading
evaluator._room_conditions = [
{"id": "r1-c1", "condition_type": "player_facing", "direction": "East"},
]
evaluator._loaded = true
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
# Simulate room change by loading a new "room"
evaluator._current_room_id = "old_room"
evaluator._room_conditions = [
{"id": "r2-c1", "condition_type": "player_facing", "direction": "North"},
]
# Clear latches for the new room (simulating load_room behavior)
evaluator._latched.clear()
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"After room change, old latches should be cleared; new condition not met"
).is_equal(0)
func test_reset_clears_all_state() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
])
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(1)
evaluator.reset()
assert_that(evaluator.is_loaded()).is_false()
assert_that(evaluator.get_met_count()).is_equal(0)
assert_that(evaluator.get_total_count()).is_equal(0)
# -- get_results Tests ---------------------------------------------------------
func test_get_results_structure() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"},
])
GameState.player_facing = "North"
evaluator.evaluate()
var results: Array = evaluator.get_results()
assert_that(results.size()).is_equal(1)
assert_that(results[0]["id"]).is_equal("c1")
assert_that(results[0]["description"]).is_equal("Test condition")
assert_that(results[0]["condition_type"]).is_equal("player_facing")
assert_that(results[0]["met"]).is_true()
func test_get_results_unmet() -> void:
var evaluator = _make_evaluator([
{"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"},
])
GameState.player_facing = "North"
evaluator.evaluate()
var results: Array = evaluator.get_results()
assert_that(results[0]["met"]).is_false()
# -- Overlay Visibility Tests --------------------------------------------------
func _make_checklist_overlay() -> Control:
var overlay = Control.new()
overlay.set_script(ChecklistOverlayScript)
auto_free(overlay)
add_child(overlay)
return overlay
func test_overlay_hidden_in_non_gauntlet_mode() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = false
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Checklist overlay must be hidden in non-gauntlet mode"
).is_false()
func test_overlay_visible_in_gauntlet_mode() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = true
GameState.room_id = "test_room"
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Checklist overlay must be visible in gauntlet mode"
).is_true()
func test_overlay_hides_on_gauntlet_deactivation() -> void:
var overlay := _make_checklist_overlay()
GameState.gauntlet_mode = true
GameState.room_id = "test_room"
overlay.update_from_state()
assert_that(overlay.visible).is_true()
GameState.gauntlet_mode = false
overlay.update_from_state()
assert_that(overlay.visible).override_failure_message(
"Overlay must hide when gauntlet mode deactivates"
).is_false()
func test_overlay_evaluator_accessible() -> void:
var overlay := _make_checklist_overlay()
var evaluator = overlay.get_evaluator()
assert_that(evaluator).override_failure_message(
"Overlay should expose evaluator via get_evaluator()"
).is_not_null()
func test_overlay_in_main_scene() -> void:
var scene: PackedScene = load("res://scenes/main.tscn")
var instance: Node = scene.instantiate()
auto_free(instance)
add_child(instance)
instance._process(0.016)
var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay")
assert_that(overlay).override_failure_message(
"ChecklistOverlay node should exist in main scene tree"
).is_not_null()
if overlay is CanvasItem:
assert_that((overlay as CanvasItem).visible).override_failure_message(
"ChecklistOverlay should be hidden by default (non-gauntlet mode)"
).is_false()
# -- Integration: Snapshot -> Evaluation ----------------------------------------
func test_integration_snapshot_to_evaluator() -> void:
# Integration test: GameState snapshot data -> evaluator -> correct results.
# Tests the evaluator directly (overlay wiring tested separately).
var evaluator = ChecklistEvaluatorScript.new()
evaluator._room_conditions = [
{"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1},
{"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"},
]
evaluator._loaded = true
# Simulate gauntlet snapshot with player entity
GameState.visible_entities = [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"},
]
GameState.player_facing = "North"
evaluator.evaluate()
# Entity present should be met, facing should not
assert_that(evaluator.get_met_count()).is_equal(1)
assert_that(evaluator.get_total_count()).is_equal(2)
# Change facing — second condition should also latch
GameState.player_facing = "East"
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(2)
assert_that(evaluator.is_complete()).is_true()
# Verify results array contains both conditions as met
var results: Array = evaluator.get_results()
for r in results:
assert_that(r["met"]).override_failure_message(
"Condition '%s' should be met after snapshot sequence" % r["id"]
).is_true()
# -- Edge Cases ----------------------------------------------------------------
func test_empty_entity_list_entity_present() -> void:
GameState.visible_entities = []
var evaluator = _make_evaluator([{
"id": "edge-empty", "condition_type": "entity_present",
"entity_id": 0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).is_equal(0)
func test_empty_entity_list_entity_absent() -> void:
GameState.visible_entities = []
var evaluator = _make_evaluator([{
"id": "edge-absent-empty", "condition_type": "entity_absent",
"entity_id": 99,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"entity_absent with empty visible_entities should be met"
).is_equal(1)
func test_zero_radius_player_near() -> void:
GameState.player_position = Vector2(10.0, 20.0)
var evaluator = _make_evaluator([{
"id": "edge-zero-radius", "condition_type": "player_near",
"x": 10, "y": 20, "radius": 0.0,
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"player_near with radius 0 at exact position should be met"
).is_equal(1)
func test_unknown_condition_type() -> void:
var evaluator = _make_evaluator([{
"id": "edge-unknown", "condition_type": "nonexistent_type",
}])
evaluator.evaluate()
assert_that(evaluator.get_met_count()).override_failure_message(
"Unknown condition type should not be met"
).is_equal(0)
func test_no_conditions_loaded() -> void:
var evaluator = ChecklistEvaluatorScript.new()
assert_that(evaluator.is_loaded()).is_false()
assert_that(evaluator.get_total_count()).is_equal(0)
assert_that(evaluator.get_met_count()).is_equal(0)
assert_that(evaluator.is_complete()).is_false()
# -- Helper: recursive node search --------------------------------------------
func _find_node_recursive(root: Node, target_name: String) -> Node:
if root.name == target_name:
return root
for child in root.get_children():
var found := _find_node_recursive(child, target_name)
if found != null:
return found
return null
+14 -12
View File
@@ -253,27 +253,29 @@ func test_recognition_transition_progress() -> void:
fog_entities.queue_free()
func test_facing_indicator_rotation_matches_player_facing() -> void:
# P3-T02: Facing indicator rotation matches player_facing from snapshot.
func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
# P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float).
# Indicator rotation = facing_angle + PI/2 (0=North basis).
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity)
var indicator = renderer.entity_nodes[1].get_node("FacingIndicator")
# Test each cardinal + diagonal direction
var expected := {
"North": 0.0,
"East": PI / 2.0,
"South": PI,
"West": 3.0 * PI / 2.0,
# {facing_angle → expected indicator rotation}
var angles := {
-PI / 2.0: 0.0, # North
0.0: PI / 2.0, # East
PI / 2.0: PI, # South
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot)
}
for dir in expected:
GameState.player_facing = dir
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(entity)
assert_that(indicator.rotation).override_failure_message(
"%s: expected rotation %.3f, got %.3f" % [dir, expected[dir], indicator.rotation]
).is_equal_approx(expected[dir], 0.001)
"angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation]
).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default
renderer.queue_free()
+119
View File
@@ -0,0 +1,119 @@
## D-054 facing and movement tests — _angle_to_octant, _snap_to_octant_dir,
## _wasd_to_world_dir coverage. All functions are static or use only facing_angle.
##
## Spec ref: D-054 (mouse-relative facing), Sprint 10 Completion Proof.
class_name TestInputMapperFacing
extends GdUnitTestSuite
# -- _angle_to_octant ----------------------------------------------------------
func test_angle_to_octant_east() -> void:
assert_str(InputMapper._angle_to_octant(0.0)).is_equal("East")
func test_angle_to_octant_north() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 2.0)).is_equal("North")
func test_angle_to_octant_south() -> void:
assert_str(InputMapper._angle_to_octant(PI / 2.0)).is_equal("South")
func test_angle_to_octant_west() -> void:
assert_str(InputMapper._angle_to_octant(PI)).is_equal("West")
func test_angle_to_octant_northeast() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 4.0)).is_equal("Northeast")
func test_angle_to_octant_southeast() -> void:
assert_str(InputMapper._angle_to_octant(PI / 4.0)).is_equal("Southeast")
func test_angle_to_octant_southwest() -> void:
assert_str(InputMapper._angle_to_octant(3.0 * PI / 4.0)).is_equal("Southwest")
func test_angle_to_octant_northwest() -> void:
assert_str(InputMapper._angle_to_octant(-3.0 * PI / 4.0)).is_equal("Northwest")
# -- _snap_to_octant_dir -------------------------------------------------------
func test_snap_east() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(1.0, 0.0))).is_equal(Vector2i(1, 0))
func test_snap_north() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, -1.0))).is_equal(Vector2i(0, -1))
func test_snap_south() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, 1.0))).is_equal(Vector2i(0, 1))
func test_snap_west() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-1.0, 0.0))).is_equal(Vector2i(-1, 0))
func test_snap_northeast() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.7, -0.7))).is_equal(Vector2i(1, -1))
func test_snap_southwest() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-0.7, 0.7))).is_equal(Vector2i(-1, 1))
func test_snap_zero_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2.ZERO)).is_equal(Vector2i.ZERO)
func test_snap_tiny_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.001, 0.0))).is_equal(Vector2i.ZERO)
func test_snap_diagonal_bias() -> void:
# Slightly more east than north — should snap to northeast
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.8, -0.6))).is_equal(Vector2i(1, -1))
# -- _wasd_to_world_dir --------------------------------------------------------
func test_wasd_forward_facing_east() -> void:
# W pressed, facing east → move east
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_forward_facing_north() -> void:
# W pressed, facing north → move north
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, -1))
func test_wasd_backward_facing_north() -> void:
# S pressed, facing north → move south
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, 1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_strafe_right_facing_north() -> void:
# D pressed, facing north → move east
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_strafe_left_facing_north() -> void:
# A pressed, facing north → move west
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(-1, 0))
assert_object(result).is_equal(Vector2i(-1, 0))
func test_wasd_forward_facing_south() -> void:
# W pressed, facing south → move south
InputMapper.facing_angle = PI / 2.0 # South
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_diagonal_forward_right_facing_east() -> void:
# W+D pressed, facing east → move southeast
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(1, -1))
assert_object(result).is_equal(Vector2i(1, 1))
func test_wasd_strafe_right_facing_west() -> void:
# D pressed, facing west → move north
InputMapper.facing_angle = PI # West
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(0, -1))
func after_test() -> void:
InputMapper.reset_facing_state()
+17 -12
View File
@@ -298,25 +298,30 @@ func test_entity_renderer_player_has_facing_indicator() -> void:
renderer.queue_free()
func test_entity_renderer_facing_indicator_rotation_accuracy() -> void:
# D-054: Facing indicator now reads InputMapper.facing_angle (float), not
# GameState.player_facing (string). Indicator rotation = facing_angle + PI/2.
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var directions := {
"North": 0.0,
"Northeast": PI / 4.0,
"East": PI / 2.0,
"Southeast": 3.0 * PI / 4.0,
"South": PI,
"Southwest": 5.0 * PI / 4.0,
"West": 3.0 * PI / 2.0,
"Northwest": 7.0 * PI / 4.0,
# {facing_angle → expected indicator rotation}
# Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2.
var angles := {
-PI / 2.0: 0.0, # North
-PI / 4.0: PI / 4.0, # Northeast
0.0: PI / 2.0, # East
PI / 4.0: 3.0 * PI / 4.0, # Southeast
PI / 2.0: PI, # South
3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI])
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2)
-3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4)
}
renderer.update_entities(_test_entities_v2)
var player_node = renderer.entity_nodes[1]
var indicator = player_node.get_node_or_null("FacingIndicator")
for dir_name in directions:
GameState.player_facing = dir_name
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(_test_entities_v2)
assert_that(indicator.rotation).is_equal_approx(directions[dir_name], 0.001)
assert_that(indicator.rotation).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default (North)
renderer.queue_free()
func test_entity_renderer_npc_has_no_facing_indicator() -> void: