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: 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 (checklist-specific) ----------------------------------------- # Handles the constrained checklist YAML format: top-level key:value pairs, # a conditions array of flat dictionaries. No nested arrays or anchors. # # Limitation: unquoted values containing " #" are truncated at the comment marker. # Use quoted strings ("value # with hash") if values must contain literal hashes. 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