diff --git a/client/scripts/autoloads/ui_strings.gd b/client/scripts/autoloads/ui_strings.gd index b7c2dd443..95dd4e6d5 100644 --- a/client/scripts/autoloads/ui_strings.gd +++ b/client/scripts/autoloads/ui_strings.gd @@ -49,44 +49,6 @@ func reload() -> void: ## Parse YAML with arbitrary nesting depth. ## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }. +## Delegates to YamlParser.parse_flat() (#560). static func _parse_yaml(text: String) -> Dictionary: - var strings := {} - var stack: Array = [] # [[indent, key], ...] - for line in text.split("\n"): - var stripped := line.strip_edges(false, true) - if stripped.is_empty() or stripped.begins_with("#"): - continue - var indent := line.length() - line.lstrip(" ").length() - var content := stripped.strip_edges() - var colon_pos := content.find(":") - if colon_pos < 0: - continue - var key := content.substr(0, colon_pos).strip_edges() - var val := content.substr(colon_pos + 1).strip_edges() - # Trailing comment without a value — treat as section header - if val.begins_with("#"): - val = "" - # Pop sections at same or deeper indent - while stack.size() > 0 and stack.back()[0] >= indent: - stack.pop_back() - if val.is_empty(): - # Section header — push onto stack - stack.push_back([indent, key]) - else: - # Leaf value — extract from quotes or strip inline comment - if val.begins_with("\""): - var end_quote := val.find("\"", 1) - if end_quote > 0: - val = val.substr(1, end_quote - 1) - else: - val = val.substr(1) - else: - var comment_pos := val.find(" #") - if comment_pos >= 0: - val = val.substr(0, comment_pos).strip_edges() - var dotted_key := "" - for entry in stack: - dotted_key += entry[1] + "." - dotted_key += key - strings[dotted_key] = val - return strings + return YamlParser.parse_flat(text) diff --git a/client/scripts/checklist/checklist_evaluator.gd b/client/scripts/checklist/checklist_evaluator.gd index 2f51187ca..2e2858721 100644 --- a/client/scripts/checklist/checklist_evaluator.gd +++ b/client/scripts/checklist/checklist_evaluator.gd @@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool: 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. +# -- YAML parsing -------------------------------------------------------------- func _load_checklist_file(path: String) -> Dictionary: if not FileAccess.file_exists(path): @@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary: return parse_checklist_yaml(text) +## Delegates to YamlParser.parse() (#560). 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 + return YamlParser.parse(text) diff --git a/client/scripts/util/yaml_parser.gd b/client/scripts/util/yaml_parser.gd new file mode 100644 index 000000000..cafec640e --- /dev/null +++ b/client/scripts/util/yaml_parser.gd @@ -0,0 +1,163 @@ +class_name YamlParser +## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd. +## +## Handles: nested sections (maps), arrays of dict items (- key: val), typed values. +## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format +## (as UIStrings._parse_yaml() requires). +## +## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax. +## String values: quotes stripped. Booleans, ints, and floats are type-inferred. +## +## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability). + + +## Parse YAML text into a hierarchical Dictionary. +## Nested sections become nested dicts. Array items (- key: val) become Arrays. +## Values are type-inferred: bool, int, float, or String. +static func parse(text: String) -> Dictionary: + var root: Dictionary = {} + # Stack: [{indent: int, key: String}] — path of open section headers + var stack: Array = [] + # Array state + var current_array: Variant = null # Array being built, or null + var current_item: Variant = null # Dict being built for current array item, or null + var array_parent_indent: int = -1 # indent of the "key:" line that owns the array + + for raw_line in text.split("\n"): + var stripped := raw_line.strip_edges(false, true) + if stripped.is_empty() or stripped.strip_edges().begins_with("#"): + continue + var indent: int = raw_line.length() - raw_line.lstrip(" ").length() + var content: String = stripped.strip_edges() + + # --- Array item (- key: value) --- + if content.begins_with("- "): + # First item: convert parent section's {} placeholder to [] + if current_array == null and stack.size() > 0: + var parent := _node_at(root, stack, true) + var arr_key: String = stack.back()["key"] + var new_arr: Array = [] + parent[arr_key] = new_arr + current_array = new_arr + array_parent_indent = stack.back()["indent"] + # Flush previous item and start a new one + if current_item != null: + current_array.append(current_item) + current_item = {} + var rest: String = content.substr(2).strip_edges() + var colon: int = rest.find(":") + if colon >= 0: + var k: String = rest.substr(0, colon).strip_edges() + var v: String = rest.substr(colon + 1).strip_edges() + current_item[k] = _parse_value(v) + continue + + # --- Continuation line within current array item --- + if current_array != null and indent > array_parent_indent: + var colon: int = content.find(":") + if colon >= 0 and current_item != null: + var k: String = content.substr(0, colon).strip_edges() + var v: String = content.substr(colon + 1).strip_edges() + current_item[k] = _parse_value(v) + continue + + # --- End of array (indent has returned to array level or above) --- + if current_array != null: + if current_item != null: + current_array.append(current_item) + current_item = null + current_array = null + array_parent_indent = -1 + if stack.size() > 0: + stack.pop_back() # pop the array-owning key + + # --- Regular key: value or section header --- + var colon: int = content.find(":") + if colon < 0: + continue + var key: String = content.substr(0, colon).strip_edges() + var val_str: String = content.substr(colon + 1).strip_edges() + + # Pop sections at the same or deeper indent (we're back at a shallower level) + while stack.size() > 0 and stack.back()["indent"] >= indent: + stack.pop_back() + + var node: Dictionary = _node_at(root, stack, false) + + if val_str.is_empty() or val_str.begins_with("#"): + # Section header — create nested dict (may become Array if - items follow) + node[key] = {} + stack.push_back({"indent": indent, "key": key}) + else: + node[key] = _parse_value(val_str) + + # Flush the last array item if the file ended inside an array + if current_array != null and current_item != null: + current_array.append(current_item) + + return root + + +## Convenience: parse text and flatten to dotted-key format in one call. +## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)). +static func parse_flat(text: String) -> Dictionary: + return flatten(parse(text)) + + +## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility). +## {"a": {"b": "v"}} → {"a.b": "v"} +## Arrays are skipped — dotted-key format does not represent them. +## All values are converted to String (UIStrings stores display text, not typed data). +static func flatten(d: Dictionary, prefix: String = "") -> Dictionary: + var result: Dictionary = {} + for k in d: + var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k) + var v = d[k] + if v is Dictionary: + result.merge(flatten(v, full_key)) + elif not v is Array: + result[full_key] = str(v) + return result + + +## Parse a single YAML value string into a typed GDScript value. +## Strips inline comments, handles quoted strings, infers bool/int/float/String. +static func _parse_value(val: String) -> Variant: + if val.is_empty(): + return "" + # Strip inline comment outside quotes + if not val.begins_with("\""): + var comment_pos: int = val.find(" #") + if comment_pos >= 0: + val = val.substr(0, comment_pos).strip_edges() + # Quoted string — extract content between quotes + if val.begins_with("\""): + var end_quote: int = 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 (must have 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 + + +## Navigate root following the stack key path. +## parent=true: navigate one level less (returns the parent node, not the leaf). +static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary: + var node: Dictionary = root + var depth: int = stack.size() - (1 if parent else 0) + for i in range(depth): + var k: String = stack[i]["key"] + if node.has(k) and node[k] is Dictionary: + node = node[k] + else: + break + return node diff --git a/client/tests/test_yaml_parser.gd b/client/tests/test_yaml_parser.gd new file mode 100644 index 000000000..6897b4da5 --- /dev/null +++ b/client/tests/test_yaml_parser.gd @@ -0,0 +1,261 @@ +## YamlParser unit tests (#560). +## Verifies parse() (nested, typed) and parse_flat() (dotted keys, string values). +## Covers: maps, nested maps, arrays of dicts, type conversion, comments, +## quoted strings, empty input, edge cases. +## +## D-030: fixture-based, server-free, no subprocess required. +class_name TestYamlParser +extends GdUnitTestSuite + + +# -- parse(): basic key-value pairs ------------------------------------------- + +func test_parse_simple_key_value() -> void: + var result := YamlParser.parse("key: value") + assert_that(result["key"]).is_equal("value") + + +func test_parse_quoted_string() -> void: + var result := YamlParser.parse('key: "hello world"') + assert_that(result["key"]).is_equal("hello world") + + +func test_parse_empty_quoted_string() -> void: + var result := YamlParser.parse('key: ""') + assert_that(result["key"]).is_equal("") + + +func test_parse_integer_value() -> void: + var result := YamlParser.parse("count: 42") + assert_that(result["count"]).is_equal(42) + assert_that(typeof(result["count"])).is_equal(TYPE_INT) + + +func test_parse_negative_integer() -> void: + var result := YamlParser.parse("offset: -3") + assert_that(result["offset"]).is_equal(-3) + + +func test_parse_float_value() -> void: + var result := YamlParser.parse("radius: 2.5") + assert_that(typeof(result["radius"])).is_equal(TYPE_FLOAT) + assert_float(result["radius"]).is_equal_approx(2.5, 0.001) + + +func test_parse_boolean_true() -> void: + var result := YamlParser.parse("enabled: true") + assert_that(result["enabled"]).is_equal(true) + assert_that(typeof(result["enabled"])).is_equal(TYPE_BOOL) + + +func test_parse_boolean_false() -> void: + var result := YamlParser.parse("enabled: false") + assert_that(result["enabled"]).is_equal(false) + + +func test_parse_empty_input() -> void: + var result := YamlParser.parse("") + assert_that(result.size()).is_equal(0) + + +func test_parse_only_comments() -> void: + var result := YamlParser.parse("# comment\n# another") + assert_that(result.size()).is_equal(0) + + +func test_parse_inline_comment_stripped() -> void: + var result := YamlParser.parse("room_id: test # this is a comment") + assert_that(result["room_id"]).is_equal("test") + + +func test_parse_quoted_value_with_hash() -> void: + ## Quoted strings preserve literal # characters. + var result := YamlParser.parse('color: "#e0e8ff"') + assert_that(result["color"]).is_equal("#e0e8ff") + + +func test_parse_value_with_colon() -> void: + var result := YamlParser.parse('time: "12:30"') + assert_that(result["time"]).is_equal("12:30") + + +# -- parse(): nested maps ----------------------------------------------------- + +func test_parse_nested_map() -> void: + var yaml := "section:\n key: value" + var result := YamlParser.parse(yaml) + assert_that(result.has("section")).is_true() + assert_that(result["section"] is Dictionary).is_true() + assert_that(result["section"]["key"]).is_equal("value") + + +func test_parse_deeply_nested() -> void: + var yaml := "a:\n b:\n c: deep" + var result := YamlParser.parse(yaml) + assert_that(result["a"]["b"]["c"]).is_equal("deep") + + +func test_parse_multiple_sections() -> void: + var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk" + var result := YamlParser.parse(yaml) + assert_that(result["hud"]["mode"]).is_equal("Mode") + assert_that(result["interaction"]["talk"]).is_equal("Talk") + + +func test_parse_multiple_keys_per_section() -> void: + var yaml := "hud:\n a: 1\n b: 2\n c: 3" + var result := YamlParser.parse(yaml) + assert_that(result["hud"]["a"]).is_equal(1) + assert_that(result["hud"]["b"]).is_equal(2) + assert_that(result["hud"]["c"]).is_equal(3) + + +func test_parse_sibling_subsections() -> void: + var yaml := "states:\n a:\n x: 1\n b:\n x: 2" + var result := YamlParser.parse(yaml) + assert_that(result["states"]["a"]["x"]).is_equal(1) + assert_that(result["states"]["b"]["x"]).is_equal(2) + + +func test_parse_section_with_trailing_comment() -> void: + ## "section: # comment" should be treated as a section header. + var yaml := "section: # comment\n key: value" + var result := YamlParser.parse(yaml) + assert_that(result["section"]["key"]).is_equal("value") + + +# -- parse(): arrays of dicts ------------------------------------------------- + +func test_parse_single_array_item() -> void: + var yaml := "conditions:\n - id: test-1\n type: near\n x: 10" + var result := YamlParser.parse(yaml) + assert_that(result.has("conditions")).is_true() + assert_that(result["conditions"] is Array).is_true() + assert_that(result["conditions"].size()).is_equal(1) + assert_that(result["conditions"][0]["id"]).is_equal("test-1") + assert_that(result["conditions"][0]["type"]).is_equal("near") + assert_that(result["conditions"][0]["x"]).is_equal(10) + + +func test_parse_multiple_array_items() -> void: + var yaml := "conditions:\n - id: a\n x: 1\n - id: b\n x: 2" + var result := YamlParser.parse(yaml) + assert_that(result["conditions"].size()).is_equal(2) + assert_that(result["conditions"][0]["id"]).is_equal("a") + assert_that(result["conditions"][1]["id"]).is_equal("b") + + +func test_parse_array_items_with_blank_lines() -> void: + var yaml := "conditions:\n - id: a\n x: 1\n\n - id: b\n x: 2" + var result := YamlParser.parse(yaml) + assert_that(result["conditions"].size()).is_equal(2) + + +func test_parse_top_level_plus_array() -> void: + ## Checklist format: top-level key-value pairs followed by a conditions array. + var yaml := "room_id: warehouse\nconditions:\n - id: c1\n type: near\n x: 5" + var result := YamlParser.parse(yaml) + assert_that(result["room_id"]).is_equal("warehouse") + assert_that(result["conditions"].size()).is_equal(1) + assert_that(result["conditions"][0]["id"]).is_equal("c1") + + +func test_parse_array_typed_values() -> void: + var yaml := "items:\n - id: t\n x: 42\n radius: 2.5\n active: true" + var result := YamlParser.parse(yaml) + var item: Dictionary = result["items"][0] + assert_that(item["x"]).is_equal(42) + assert_that(typeof(item["radius"])).is_equal(TYPE_FLOAT) + assert_that(item["active"]).is_equal(true) + + +# -- parse_flat(): dotted keys ------------------------------------------------- + +func test_flat_simple() -> void: + var yaml := "section:\n key: value" + var result := YamlParser.parse_flat(yaml) + assert_that(result.has("section.key")).is_true() + assert_that(result["section.key"]).is_equal("value") + + +func test_flat_deeply_nested() -> void: + var yaml := "a:\n b:\n c: deep" + var result := YamlParser.parse_flat(yaml) + assert_that(result["a.b.c"]).is_equal("deep") + + +func test_flat_multiple_sections() -> void: + var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk" + var result := YamlParser.parse_flat(yaml) + assert_that(result["hud.mode"]).is_equal("Mode") + assert_that(result["interaction.talk"]).is_equal("Talk") + + +func test_flat_values_are_strings() -> void: + ## parse_flat returns all values as strings, unlike parse() which returns typed. + var yaml := "section:\n count: 42\n rate: 0.9\n active: true" + var result := YamlParser.parse_flat(yaml) + assert_that(result["section.count"]).is_equal("42") + assert_that(result["section.rate"]).is_equal("0.9") + assert_that(result["section.active"]).is_equal("true") + + +func test_flat_quoted_value() -> void: + var yaml := 'section:\n key: "hello world"' + var result := YamlParser.parse_flat(yaml) + assert_that(result["section.key"]).is_equal("hello world") + + +func test_flat_inline_comment() -> void: + var yaml := "hud:\n mode: Standard # default" + var result := YamlParser.parse_flat(yaml) + assert_that(result["hud.mode"]).is_equal("Standard") + + +func test_flat_empty_quoted_value() -> void: + var yaml := 'hud:\n prefix: "" # No prefix' + var result := YamlParser.parse_flat(yaml) + assert_that(result.has("hud.prefix")).is_true() + assert_that(result["hud.prefix"]).is_equal("") + + +func test_flat_skips_arrays() -> void: + ## Arrays have no dotted-key representation — they are skipped in flat output. + var yaml := "room_id: test\nconditions:\n - id: c1\n x: 5" + var result := YamlParser.parse_flat(yaml) + assert_that(result.has("room_id")).is_true() + # No dotted keys for array contents + assert_that(result.has("conditions")).is_false() + assert_that(result.has("conditions.0")).is_false() + + +func test_flat_empty_input() -> void: + var result := YamlParser.parse_flat("") + assert_that(result.size()).is_equal(0) + + +# -- Dialogue theme format (regression) ---------------------------------------- + +func test_dialogue_theme_format() -> void: + ## dialogue-theme.yaml has top-level values and one nested map (npc_colors). + var yaml := "player_color: \"#e0e8ff\"\nnpc_colors:\n 0: \"#4a9ebb\"\n 1: \"#6bc9a6\"\npassive_opacity: 0.9" + var flat := YamlParser.parse_flat(yaml) + assert_that(flat["player_color"]).is_equal("#e0e8ff") + assert_that(flat["npc_colors.0"]).is_equal("#4a9ebb") + assert_that(flat["npc_colors.1"]).is_equal("#6bc9a6") + assert_that(flat["passive_opacity"]).is_equal("0.9") + + +# -- Checklist format (regression) --------------------------------------------- + +func test_checklist_format() -> void: + ## checklist.yaml: top-level kv + conditions array with typed values. + var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0" + var result := YamlParser.parse(yaml) + assert_that(result["room_id"]).is_equal("inventory_warehouse") + var cond: Dictionary = result["conditions"][0] + assert_that(cond["id"]).is_equal("test-1") + assert_that(cond["condition_type"]).is_equal("player_near") + assert_that(cond["x"]).is_equal(10) + assert_that(cond["y"]).is_equal(20) + assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)