Files
settled-reach/client/scripts/checklist/checklist_evaluator.gd
T
jpmschweitzerandClaude Opus 4.6 503644beb1 fix(client): address PR #36 re-review — 7 items from Hoshe + Tyre
Warnings: facing indicator tests use Godot-normalized rotation range
(-PI, PI] instead of raw addition (SW/W/NW in test_rendering,
West in test_client_p3). Suggestions: cache font in world_radial
_draw(), fix docstring on deactivate_insert() trigger, document
tile-coordinate system on _eval_player_near (D-066), add public
reset_facing_state() to InputMapper (D-030 testability).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:28:37 +01:00

355 lines
11 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:
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