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: # Content directory lives at repo root (content/), one level above the Godot # project (client/). In editor/dev mode we resolve via the project path. # In exported builds, content is expected at res://content/ (copied by export # preset) — the globalize fallback won't exist, so check res:// first. if DirAccess.dir_exists_absolute("res://content"): _content_base = ProjectSettings.globalize_path("res://content") else: 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"] _warn_empty_ids(_room_conditions, room_path) # 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"] _warn_empty_ids(_cross_conditions, cross_path) _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. ## Conditions with empty id are excluded (invalid, cannot be latched). func get_results() -> Array: var results: Array = [] for cond in _room_conditions + _cross_conditions: var cid: String = cond.get("id", "") if cid.is_empty(): continue ( 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 (excludes conditions with empty id). func get_total_count() -> int: var count: int = 0 for cond in _room_conditions + _cross_conditions: if not cond.get("id", "").is_empty(): count += 1 return count ## 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 static func _warn_empty_ids(conditions: Array, path: String) -> void: for i in conditions.size(): if conditions[i].get("id", "").is_empty(): push_warning( ( "ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path] ) ) # -- Condition evaluation ------------------------------------------------------ func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns 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 ## x/y and radius are in tile coordinates (matching GameState.player_position), ## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion ## happens only at render time. 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 -------------------------------------------------------------- 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) ## Delegates to YamlParser.parse() (#560). static func parse_checklist_yaml(text: String) -> Dictionary: return YamlParser.parse(text)