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>
355 lines
11 KiB
GDScript
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
|