Room reset (#502): amber reset_plate tile type in TileRenderer, 0.15s screen flash on room_reset monologue, 'Reset Room' verb via existing nearby_interactions. Insert pause (#518, D-058): explicit PauseSimulation on insert open, ResumeSimulation on close. Replaces toggle-style pause with idempotent pair per D-058. Auto-checklist (#503): ChecklistEvaluator parses room YAML, evaluates 7 condition types against GameState with latching. ChecklistOverlay renders progress in gauntlet mode only. 48 tests covering parser, evaluation, latching, visibility, and integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=19 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -17,7 +17,8 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
|
||||
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -136,6 +137,9 @@ layer = 20
|
||||
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
|
||||
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
|
||||
|
||||
; #503: Auto-checklist overlay — condition progress in gauntlet mode
|
||||
[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")]
|
||||
|
||||
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||
|
||||
@@ -149,4 +153,4 @@ script = ExtResource("10_cursor")
|
||||
layer = 30
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")]
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
extends RefCounted
|
||||
|
||||
## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against
|
||||
## checklist YAML conditions and latches satisfied conditions.
|
||||
##
|
||||
## Usage:
|
||||
## var evaluator := ChecklistEvaluator.new()
|
||||
## evaluator.load_room("inventory_warehouse")
|
||||
## evaluator.evaluate() # call each tick
|
||||
## var results := evaluator.get_results()
|
||||
##
|
||||
## Condition types (per checklist.schema.json):
|
||||
## player_near, player_facing, entity_present, entity_absent,
|
||||
## expected_monologue, expected_dialogue, expected_interaction_verb
|
||||
##
|
||||
## Spec ref: D-030 (testability), checklist.schema.json (#497).
|
||||
|
||||
var _room_conditions: Array = [] # Conditions from per-room checklist
|
||||
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
|
||||
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
|
||||
var _current_room_id: String = ""
|
||||
var _content_base: String = "" # Absolute path to content/ directory
|
||||
var _loaded: bool = false
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
var project_path := ProjectSettings.globalize_path("res://")
|
||||
_content_base = project_path.path_join("../content")
|
||||
|
||||
|
||||
## Load checklist for a room. Clears per-room latches; cross-room latches persist.
|
||||
func load_room(room_id: String) -> void:
|
||||
if room_id == _current_room_id and _loaded:
|
||||
return
|
||||
|
||||
_current_room_id = room_id
|
||||
_room_conditions.clear()
|
||||
|
||||
# Clear per-room latches (keep cross-room latches)
|
||||
var cross_ids := {}
|
||||
for cond in _cross_conditions:
|
||||
cross_ids[cond.get("id", "")] = true
|
||||
var kept := {}
|
||||
for cid in _latched:
|
||||
if cross_ids.has(cid):
|
||||
kept[cid] = true
|
||||
_latched = kept
|
||||
|
||||
# Load per-room checklist
|
||||
var room_path := _content_base.path_join(
|
||||
"gauntlet/rooms/%s/checklist.yaml" % room_id)
|
||||
var room_data := _load_checklist_file(room_path)
|
||||
if room_data.has("conditions"):
|
||||
_room_conditions = room_data["conditions"]
|
||||
|
||||
# Load cross-room checks (only on first load)
|
||||
if _cross_conditions.is_empty():
|
||||
var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml")
|
||||
var cross_data := _load_checklist_file(cross_path)
|
||||
if cross_data.has("conditions"):
|
||||
_cross_conditions = cross_data["conditions"]
|
||||
|
||||
_loaded = true
|
||||
|
||||
|
||||
## Evaluate all conditions against current GameState. Latches newly met conditions.
|
||||
func evaluate() -> void:
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty() or _latched.has(cid):
|
||||
continue
|
||||
if _evaluate_condition(cond):
|
||||
_latched[cid] = true
|
||||
|
||||
|
||||
## Returns array of {id, description, met} for all loaded conditions.
|
||||
func get_results() -> Array:
|
||||
var results: Array = []
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
var cid: String = cond.get("id", "")
|
||||
results.append({
|
||||
"id": cid,
|
||||
"description": cond.get("description", ""),
|
||||
"condition_type": cond.get("condition_type", ""),
|
||||
"met": _latched.has(cid),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
## Total number of loaded conditions.
|
||||
func get_total_count() -> int:
|
||||
return _room_conditions.size() + _cross_conditions.size()
|
||||
|
||||
|
||||
## Number of latched (met) conditions.
|
||||
func get_met_count() -> int:
|
||||
return _latched.size()
|
||||
|
||||
|
||||
## Whether all conditions are met.
|
||||
func is_complete() -> bool:
|
||||
return get_met_count() >= get_total_count() and get_total_count() > 0
|
||||
|
||||
|
||||
## Whether any checklist is loaded.
|
||||
func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
||||
## Reset all state (room change to null, or disconnect).
|
||||
func reset() -> void:
|
||||
_room_conditions.clear()
|
||||
_cross_conditions.clear()
|
||||
_latched.clear()
|
||||
_current_room_id = ""
|
||||
_loaded = false
|
||||
|
||||
|
||||
# -- Condition evaluation ------------------------------------------------------
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool:
|
||||
match cond.get("condition_type", ""):
|
||||
"player_near":
|
||||
return _eval_player_near(cond)
|
||||
"player_facing":
|
||||
return _eval_player_facing(cond)
|
||||
"entity_present":
|
||||
return _eval_entity_present(cond)
|
||||
"entity_absent":
|
||||
return _eval_entity_absent(cond)
|
||||
"expected_monologue":
|
||||
return _eval_expected_monologue(cond)
|
||||
"expected_dialogue":
|
||||
return _eval_expected_dialogue(cond)
|
||||
"expected_interaction_verb":
|
||||
return _eval_expected_interaction_verb(cond)
|
||||
push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", ""))
|
||||
return false
|
||||
|
||||
|
||||
func _eval_player_near(cond: Dictionary) -> bool:
|
||||
var tx: float = float(cond.get("x", 0))
|
||||
var ty: float = float(cond.get("y", 0))
|
||||
var radius: float = float(cond.get("radius", 0.0))
|
||||
var target := Vector2(tx, ty)
|
||||
return GameState.player_position.distance_to(target) <= radius
|
||||
|
||||
|
||||
func _eval_player_facing(cond: Dictionary) -> bool:
|
||||
var direction: String = str(cond.get("direction", ""))
|
||||
# Schema uses 4-cardinal (North/South/East/West).
|
||||
# GameState uses 8-directional. Exact match only.
|
||||
return GameState.player_facing == direction
|
||||
|
||||
|
||||
func _eval_entity_present(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
return _find_entity(entity_id)
|
||||
|
||||
|
||||
func _eval_entity_absent(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
return not _find_entity(entity_id)
|
||||
|
||||
|
||||
func _eval_expected_monologue(cond: Dictionary) -> bool:
|
||||
var contains: String = str(cond.get("contains", ""))
|
||||
if GameState.current_monologue == null:
|
||||
return false
|
||||
var text: String = str(GameState.current_monologue.get("text", ""))
|
||||
return text.find(contains) >= 0
|
||||
|
||||
|
||||
func _eval_expected_dialogue(cond: Dictionary) -> bool:
|
||||
var contains: String = str(cond.get("contains", ""))
|
||||
if GameState.current_dialogue == null:
|
||||
return false
|
||||
var text: String = str(GameState.current_dialogue.get("speech", ""))
|
||||
return text.find(contains) >= 0
|
||||
|
||||
|
||||
func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
var verb: String = str(cond.get("verb", ""))
|
||||
for interaction in GameState.nearby_interactions:
|
||||
if not interaction is Dictionary:
|
||||
continue
|
||||
if int(interaction.get("entity_id", -1)) != entity_id:
|
||||
continue
|
||||
var verbs: Array = interaction.get("verbs", [])
|
||||
for v in verbs:
|
||||
if not v is Dictionary:
|
||||
continue
|
||||
if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb:
|
||||
if v.get("available", true):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _find_entity(entity_id: int) -> bool:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if int(entity.get("entity_id", -1)) == entity_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- YAML parsing (checklist-specific) -----------------------------------------
|
||||
# Handles the constrained checklist YAML format: top-level key:value pairs,
|
||||
# a conditions array of flat dictionaries. No nested arrays or anchors.
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("ChecklistEvaluator: cannot open %s" % path)
|
||||
return {}
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
return parse_checklist_yaml(text)
|
||||
|
||||
|
||||
static func parse_checklist_yaml(text: String) -> Dictionary:
|
||||
var result := {}
|
||||
var conditions: Array = []
|
||||
var current_item: Dictionary = {}
|
||||
var in_conditions := false
|
||||
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
|
||||
# Detect conditions: array header
|
||||
if content == "conditions:":
|
||||
in_conditions = true
|
||||
continue
|
||||
|
||||
if not in_conditions:
|
||||
# Top-level key: value
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
else:
|
||||
if content.begins_with("- "):
|
||||
# New array item — flush previous
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var rest := content.substr(2).strip_edges()
|
||||
var colon := rest.find(":")
|
||||
if colon >= 0:
|
||||
var key := rest.substr(0, colon).strip_edges()
|
||||
var val_str := rest.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent >= 2 and not current_item.is_empty():
|
||||
# Continuation of current array item
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent == 0:
|
||||
# Back to top level — shouldn't happen in valid checklist YAML
|
||||
in_conditions = false
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
|
||||
# Flush last item
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
|
||||
if not conditions.is_empty():
|
||||
result["conditions"] = conditions
|
||||
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
|
||||
# Strip inline comments (not inside quotes)
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
|
||||
# Quoted string
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
|
||||
# Boolean
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
|
||||
# Float (contains decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
|
||||
# Plain string
|
||||
return val
|
||||
@@ -13,12 +13,14 @@ extends Node2D
|
||||
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _flash_rect: ColorRect = null # #502: ephemeral screen flash overlay
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
@@ -98,6 +100,10 @@ func _process(_delta: float) -> void:
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# #503: Update checklist overlay (auto-checklist progress tracking)
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
@@ -159,6 +165,10 @@ func _consume_monologue() -> void:
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
@@ -218,3 +228,18 @@ func _on_dialogue_dismissed() -> void:
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
|
||||
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
||||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
$UILayer.add_child(_flash_rect)
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
@@ -5,14 +5,15 @@ extends TileMapLayer
|
||||
# Uses a programmatic TileSet with placeholder colored rectangles (D-014)
|
||||
#
|
||||
# Tile types (atlas coords in the programmatic source):
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
# (4,0) = reset_plate — amber (#502)
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 }
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
# Wire-format string to TileType mapping
|
||||
const TILE_TYPE_MAP: Dictionary = {
|
||||
@@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = {
|
||||
"wall": TileType.WALL,
|
||||
"door": TileType.DOOR,
|
||||
"object": TileType.OBJECT,
|
||||
"reset_plate": TileType.RESET_PLATE,
|
||||
}
|
||||
|
||||
var _initialized: bool = false
|
||||
@@ -36,7 +38,7 @@ func _setup_tileset() -> void:
|
||||
|
||||
# Create an atlas source backed by a programmatic image
|
||||
var source := TileSetAtlasSource.new()
|
||||
var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
|
||||
# Floor (0,0) — dark gray
|
||||
_fill_tile(img, 0, Color(0.18, 0.18, 0.22))
|
||||
@@ -46,13 +48,15 @@ func _setup_tileset() -> void:
|
||||
_fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15))
|
||||
# Object (3,0) — teal
|
||||
_fill_tile(img, 3, Color(0.2, 0.45, 0.45))
|
||||
# Reset plate (4,0) — amber (#502)
|
||||
_fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2))
|
||||
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
source.texture = tex
|
||||
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
# Create tile entries in the atlas
|
||||
for i in range(4):
|
||||
for i in range(5):
|
||||
source.create_tile(Vector2i(i, 0))
|
||||
|
||||
var source_id := ts.add_source(source)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,132 @@
|
||||
extends Control
|
||||
|
||||
## #503: Auto-checklist HUD overlay — shows condition progress in Gauntlet mode.
|
||||
## Renders below the GauntletHUD timer. Each condition shows a check/dash + description.
|
||||
## Only visible in gauntlet_mode. Latched conditions stay checked.
|
||||
##
|
||||
## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof.
|
||||
|
||||
const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
|
||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
||||
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
|
||||
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
|
||||
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
|
||||
const FONT_SIZE := 11
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(8, 6)
|
||||
const MAX_DESC_CHARS := 52 # Truncate long descriptions
|
||||
|
||||
var _evaluator = null # ChecklistEvaluator instance
|
||||
var _last_room_id: Variant = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
_evaluator = _ChecklistEvaluator.new()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not GameState.gauntlet_mode:
|
||||
if visible:
|
||||
visible = false
|
||||
return
|
||||
|
||||
if not visible:
|
||||
visible = true
|
||||
|
||||
var room_id: Variant = GameState.room_id
|
||||
if room_id == null:
|
||||
if _evaluator.is_loaded():
|
||||
_evaluator.reset()
|
||||
_last_room_id = null
|
||||
queue_redraw()
|
||||
return
|
||||
|
||||
# Load checklist on room change
|
||||
if room_id != _last_room_id:
|
||||
_evaluator.load_room(str(room_id))
|
||||
_last_room_id = room_id
|
||||
|
||||
# Evaluate conditions against current snapshot
|
||||
_evaluator.evaluate()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _evaluator == null or not _evaluator.is_loaded():
|
||||
return
|
||||
|
||||
var font := get_theme_default_font()
|
||||
var results: Array = _evaluator.get_results()
|
||||
if results.is_empty():
|
||||
return
|
||||
|
||||
var met_count: int = _evaluator.get_met_count()
|
||||
var total_count: int = _evaluator.get_total_count()
|
||||
var all_complete: bool = _evaluator.is_complete()
|
||||
|
||||
# Header line: "CHECK: 5/8"
|
||||
var header_text := "CHECK: %d/%d" % [met_count, total_count]
|
||||
var header_color: Color = COMPLETE_COLOR if all_complete else HEADER_COLOR
|
||||
|
||||
# Calculate box height: header + one line per condition + padding
|
||||
var line_count: int = 1 + results.size()
|
||||
var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT
|
||||
|
||||
# Calculate box width from longest line
|
||||
var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
for r in results:
|
||||
var desc: String = r.get("description", r.get("id", ""))
|
||||
if desc.length() > MAX_DESC_CHARS:
|
||||
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
|
||||
var prefix: String = "[x] " if r.get("met", false) else "[ ] "
|
||||
var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
if line_width > max_width:
|
||||
max_width = line_width
|
||||
|
||||
var box_width: float = max_width + PADDING.x * 2
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_width, box_height)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, header_color)
|
||||
|
||||
# Condition lines
|
||||
for r in results:
|
||||
y += LINE_HEIGHT
|
||||
var is_met: bool = r.get("met", false)
|
||||
var prefix: String = "[x] " if is_met else "[ ] "
|
||||
var desc: String = r.get("description", r.get("id", ""))
|
||||
if desc.length() > MAX_DESC_CHARS:
|
||||
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
|
||||
var color: Color = MET_COLOR if is_met else UNMET_COLOR
|
||||
draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
func get_evaluator():
|
||||
return _evaluator
|
||||
|
||||
|
||||
func get_met_count() -> int:
|
||||
if _evaluator == null:
|
||||
return 0
|
||||
return _evaluator.get_met_count()
|
||||
|
||||
|
||||
func get_total_count() -> int:
|
||||
if _evaluator == null:
|
||||
return 0
|
||||
return _evaluator.get_total_count()
|
||||
|
||||
|
||||
func is_complete() -> bool:
|
||||
if _evaluator == null:
|
||||
return false
|
||||
return _evaluator.is_complete()
|
||||
@@ -0,0 +1,17 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/checklist_overlay.gd" id="1_checklist"]
|
||||
|
||||
; #503: Auto-checklist overlay — below GauntletHUD timer, right-aligned
|
||||
[node name="ChecklistOverlay" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -420.0
|
||||
offset_top = 78.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 400.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_checklist")
|
||||
+12
-12
@@ -120,24 +120,24 @@ func _confirm_selection() -> void:
|
||||
|
||||
|
||||
func _activate_insert() -> void:
|
||||
# TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol
|
||||
# #518/D-058: Send PauseSimulation when insert opens. Idempotent —
|
||||
# if already paused (e.g. Gauntlet interlude), server ignores duplicate.
|
||||
if not _insert_active:
|
||||
_insert_active = true
|
||||
_send_pause()
|
||||
|
||||
|
||||
func _send_pause() -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
func deactivate_insert() -> void:
|
||||
# Called when closing insert view — send Pause again (toggle)
|
||||
# #518/D-058: Send ResumeSimulation when insert closes.
|
||||
if _insert_active:
|
||||
_insert_active = false
|
||||
_send_pause()
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
@@ -174,7 +174,7 @@ func _draw() -> void:
|
||||
|
||||
# Label
|
||||
var label: String = SPOKE_NAMES.get(spoke, "")
|
||||
var font := ThemeDB.fallback_font
|
||||
var font := get_theme_default_font()
|
||||
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
|
||||
var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0)
|
||||
draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
|
||||
|
||||
Reference in New Issue
Block a user