Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
265 lines
8.1 KiB
GDScript
265 lines
8.1 KiB
GDScript
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)
|