From 2b1c11b1e6830dd0bca456bdbadcba319f146e44 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 13:15:18 -0500 Subject: [PATCH] fix(client): TagExtractor tolerates paren-less moves + scrubs misformatted move tags (live-review LF1/LF2) Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/text/tag_extractor.gd | 4 ++- client/tests/unit/test_tag_extractor.gd | 42 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/client/scripts/text/tag_extractor.gd b/client/scripts/text/tag_extractor.gd index 1f710ce..0efb4e8 100644 --- a/client/scripts/text/tag_extractor.gd +++ b/client/scripts/text/tag_extractor.gd @@ -7,8 +7,9 @@ extends RefCounted static var _fact_re: RegEx = RegEx.create_from_string("\\[FACT:\\s*(.*?)\\]") static var _dispo_re: RegEx = RegEx.create_from_string("\\[ADJUST_DISPOSITION:\\s*([+-]?\\d+)\\]") -static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)\\(([^)]*)\\)\\]") +static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)(?:\\(([^)]*)\\))?\\]") static var _any_re: RegEx = RegEx.create_from_string("\\[[A-Z_]+:[^\\]]*\\]") +static var _bare_move_re: RegEx = RegEx.create_from_string("\\[[a-z_]+\\([^\\]]*\\)\\]") static func extract(prose: String) -> Dictionary: @@ -30,6 +31,7 @@ static func extract(prose: String) -> Dictionary: moves.append({"name": m.get_string(1), "args": args}) var clean := _any_re.sub(prose, "", true) + clean = _bare_move_re.sub(clean, "", true) # Collapse whitespace left where inline tags were removed. while clean.contains(" "): clean = clean.replace(" ", " ") diff --git a/client/tests/unit/test_tag_extractor.gd b/client/tests/unit/test_tag_extractor.gd index f8da1a5..fafd71e 100644 --- a/client/tests/unit/test_tag_extractor.gd +++ b/client/tests/unit/test_tag_extractor.gd @@ -38,3 +38,45 @@ func test_no_tags_is_clean_passthrough(): assert_eq(r["dispositions"], []) assert_eq(r["moves"], []) assert_eq(r["clean_text"], "no tags here") + + +# LF1 — a live model emitted [MOVE: refuse] with no parens at all. The move +# must still be recognized so the legal move isn't silently dropped. +func test_move_without_parens_is_parsed(): + var r := TagExtractor.extract("[MOVE: refuse]") + assert_eq(r["moves"][0]["name"], "refuse") + assert_eq(r["moves"][0]["args"], []) + + +# LF1 regression guard — explicit empty parens must still work as before. +func test_move_with_explicit_empty_parens_still_parsed(): + var r := TagExtractor.extract("[MOVE: end_conversation()]") + assert_eq(r["moves"][0]["name"], "end_conversation") + assert_eq(r["moves"][0]["args"], []) + + +# LF1 regression guard — well-formed args must still parse unchanged. +func test_move_with_args_still_parsed(): + var r := TagExtractor.extract("[MOVE: reveal(varrell_twins)]") + assert_eq(r["moves"][0]["name"], "reveal") + assert_eq(r["moves"][0]["args"], ["varrell_twins"]) + + +# LF2 — a live model emitted [adjust_disposition(-5)] without the MOVE: +# prefix. It must not be parsed/applied (untrusted, malformed), but it must +# also not leak into player-visible prose as literal tag text. +func test_bare_lowercase_move_shape_is_scrubbed_not_parsed(): + var r := TagExtractor.extract("He glares. [adjust_disposition(-5)] Fine.") + assert_false(r["clean_text"].contains("[adjust_disposition(-5)]")) + assert_eq(r["moves"], []) + assert_eq(r["dispositions"], []) + + +# LF2 regression guard — a well-formed MOVE tag alongside a bare lowercase +# shape in the same prose must still be parsed and stripped correctly. +func test_wellformed_move_still_parsed_alongside_bare_lowercase_shape(): + var r := TagExtractor.extract("[MOVE: reveal(x)] He glares. [adjust_disposition(-5)] Fine.") + assert_eq(r["moves"][0]["name"], "reveal") + assert_eq(r["moves"][0]["args"], ["x"]) + assert_false(r["clean_text"].contains("[MOVE:")) + assert_false(r["clean_text"].contains("[adjust_disposition(-5)]"))