From 5388979bf104fc2c40d66b5b2ee24352824a503b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 14:46:11 -0500 Subject: [PATCH] =?UTF-8?q?feat(client):=20TagExtractor=20=E2=80=94=20pure?= =?UTF-8?q?=20regex=20parse/strip=20of=20=C2=A712=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- client/scripts/text/tag_extractor.gd | 38 ++++++++++++++++++++ client/scripts/text/tag_extractor.gd.uid | 1 + client/tests/unit/test_tag_extractor.gd | 40 +++++++++++++++++++++ client/tests/unit/test_tag_extractor.gd.uid | 1 + 4 files changed, 80 insertions(+) create mode 100644 client/scripts/text/tag_extractor.gd create mode 100644 client/scripts/text/tag_extractor.gd.uid create mode 100644 client/tests/unit/test_tag_extractor.gd create mode 100644 client/tests/unit/test_tag_extractor.gd.uid diff --git a/client/scripts/text/tag_extractor.gd b/client/scripts/text/tag_extractor.gd new file mode 100644 index 0000000..1f710ce --- /dev/null +++ b/client/scripts/text/tag_extractor.gd @@ -0,0 +1,38 @@ +class_name TagExtractor +extends RefCounted +## Pure §12 tag parsing. Extracts [FACT:], [ADJUST_DISPOSITION:], [MOVE:] tags +## and returns the prose with all tags stripped. It never applies anything and +## never validates a move (that needs NPC context from /npc/speak — a later +## plan). Its outputs are exactly the inputs CanonLog's mutators consume. + +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 _any_re: RegEx = RegEx.create_from_string("\\[[A-Z_]+:[^\\]]*\\]") + + +static func extract(prose: String) -> Dictionary: + var facts: Array = [] + for m in _fact_re.search_all(prose): + facts.append(m.get_string(1).strip_edges()) + + var dispositions: Array = [] + for m in _dispo_re.search_all(prose): + dispositions.append(int(m.get_string(1))) + + var moves: Array = [] + for m in _move_re.search_all(prose): + var raw_args := m.get_string(2).strip_edges() + var args: Array = [] + if raw_args != "": + for a in raw_args.split(","): + args.append(a.strip_edges()) + moves.append({"name": m.get_string(1), "args": args}) + + var clean := _any_re.sub(prose, "", true) + # Collapse whitespace left where inline tags were removed. + while clean.contains(" "): + clean = clean.replace(" ", " ") + clean = clean.strip_edges() + + return {"clean_text": clean, "facts": facts, "dispositions": dispositions, "moves": moves} diff --git a/client/scripts/text/tag_extractor.gd.uid b/client/scripts/text/tag_extractor.gd.uid new file mode 100644 index 0000000..091fc85 --- /dev/null +++ b/client/scripts/text/tag_extractor.gd.uid @@ -0,0 +1 @@ +uid://cib5yw5k0kip4 diff --git a/client/tests/unit/test_tag_extractor.gd b/client/tests/unit/test_tag_extractor.gd new file mode 100644 index 0000000..f8da1a5 --- /dev/null +++ b/client/tests/unit/test_tag_extractor.gd @@ -0,0 +1,40 @@ +extends "res://addons/gut/test.gd" + +const TagExtractor = preload("res://scripts/text/tag_extractor.gd") + + +func test_fact_extracted_and_stripped(): + var r := TagExtractor.extract("A muddy road. [FACT: the eastern bridge is out]") + assert_eq(r["facts"], ["the eastern bridge is out"]) + assert_eq(r["clean_text"], "A muddy road.") + + +func test_positive_and_negative_disposition(): + assert_eq(TagExtractor.extract("\"Aye.\" [ADJUST_DISPOSITION: +5]")["dispositions"], [5]) + assert_eq(TagExtractor.extract("[ADJUST_DISPOSITION: -10]")["dispositions"], [-10]) + + +func test_move_parsed_with_args(): + var r := TagExtractor.extract("[MOVE: offer_quest(14)]") + assert_eq(r["moves"][0]["name"], "offer_quest") + assert_eq(r["moves"][0]["args"], ["14"]) + + +func test_move_with_no_args(): + var r := TagExtractor.extract("[MOVE: refuse()]") + assert_eq(r["moves"][0]["name"], "refuse") + assert_eq(r["moves"][0]["args"], []) + + +func test_multiple_facts_collected(): + var r := TagExtractor.extract("Hi [FACT: a] mid [FACT: b] end") + assert_eq(r["facts"], ["a", "b"]) + assert_eq(r["clean_text"], "Hi mid end") + + +func test_no_tags_is_clean_passthrough(): + var r := TagExtractor.extract("no tags here") + assert_eq(r["facts"], []) + assert_eq(r["dispositions"], []) + assert_eq(r["moves"], []) + assert_eq(r["clean_text"], "no tags here") diff --git a/client/tests/unit/test_tag_extractor.gd.uid b/client/tests/unit/test_tag_extractor.gd.uid new file mode 100644 index 0000000..90efbd7 --- /dev/null +++ b/client/tests/unit/test_tag_extractor.gd.uid @@ -0,0 +1 @@ +uid://piviwupch8rt