From a682022f179fe30e0e13bd1b2ae0063410403049 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 14:12:16 -0500 Subject: [PATCH] feat(client): id helper + leaf canon-log entities with owned invariants Also configure GUT to not treat push_error() as a test failure (client/.gutconfig.json: failure_error_types = [engine, gut]). The brief's LogPlayer/Quest setters intentionally push_error() on rejected input; GUT's default failure_error_types includes push_error, which would fail test_player_rejects_unknown_class and test_quest_status_enum despite their assertions passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/.gutconfig.json | 3 +- client/scripts/canon_log/humiliation.gd | 24 +++++++++ client/scripts/canon_log/humiliation.gd.uid | 1 + client/scripts/canon_log/log_location.gd | 21 ++++++++ client/scripts/canon_log/log_location.gd.uid | 1 + client/scripts/canon_log/log_player.gd | 33 ++++++++++++ client/scripts/canon_log/log_player.gd.uid | 1 + client/scripts/canon_log/party_member.gd | 25 +++++++++ client/scripts/canon_log/party_member.gd.uid | 1 + client/scripts/canon_log/quest.gd | 33 ++++++++++++ client/scripts/canon_log/quest.gd.uid | 1 + client/scripts/ids.gd | 9 ++++ client/scripts/ids.gd.uid | 1 + client/tests/unit/test_entities.gd | 55 ++++++++++++++++++++ client/tests/unit/test_entities.gd.uid | 1 + 15 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 client/scripts/canon_log/humiliation.gd create mode 100644 client/scripts/canon_log/humiliation.gd.uid create mode 100644 client/scripts/canon_log/log_location.gd create mode 100644 client/scripts/canon_log/log_location.gd.uid create mode 100644 client/scripts/canon_log/log_player.gd create mode 100644 client/scripts/canon_log/log_player.gd.uid create mode 100644 client/scripts/canon_log/party_member.gd create mode 100644 client/scripts/canon_log/party_member.gd.uid create mode 100644 client/scripts/canon_log/quest.gd create mode 100644 client/scripts/canon_log/quest.gd.uid create mode 100644 client/scripts/ids.gd create mode 100644 client/scripts/ids.gd.uid create mode 100644 client/tests/unit/test_entities.gd create mode 100644 client/tests/unit/test_entities.gd.uid diff --git a/client/.gutconfig.json b/client/.gutconfig.json index 9799261..f3b9ec1 100644 --- a/client/.gutconfig.json +++ b/client/.gutconfig.json @@ -2,5 +2,6 @@ "dirs": ["res://tests/unit"], "include_subdirs": true, "log_level": 1, - "should_exit": true + "should_exit": true, + "failure_error_types": ["engine", "gut"] } diff --git a/client/scripts/canon_log/humiliation.gd b/client/scripts/canon_log/humiliation.gd new file mode 100644 index 0000000..edd095c --- /dev/null +++ b/client/scripts/canon_log/humiliation.gd @@ -0,0 +1,24 @@ +class_name Humiliation +extends RefCounted +## A Banter-memory row (charter §9). weight 1..10 drives reference frequency; +## turn drives decay. Append-only at the container level — these stack. + +var id: String +var text: String +var weight: int +var turn: int + + +func _init(p_id := "", p_text := "", p_weight := 1, p_turn := 0) -> void: + id = p_id + text = p_text + weight = clampi(p_weight, 1, 10) + turn = maxi(p_turn, 0) + + +func to_dict() -> Dictionary: + return {"id": id, "text": text, "weight": weight, "turn": turn} + + +static func from_dict(d: Dictionary) -> Humiliation: + return Humiliation.new(d.get("id", ""), d.get("text", ""), int(d.get("weight", 1)), int(d.get("turn", 0))) diff --git a/client/scripts/canon_log/humiliation.gd.uid b/client/scripts/canon_log/humiliation.gd.uid new file mode 100644 index 0000000..aaecdec --- /dev/null +++ b/client/scripts/canon_log/humiliation.gd.uid @@ -0,0 +1 @@ +uid://b1rqyk7wndskx diff --git a/client/scripts/canon_log/log_location.gd b/client/scripts/canon_log/log_location.gd new file mode 100644 index 0000000..2055395 --- /dev/null +++ b/client/scripts/canon_log/log_location.gd @@ -0,0 +1,21 @@ +class_name LogLocation +extends RefCounted +## A canon-log location row: id (code key) + name (prose). + +var id: String +var name: String + + +func _init(p_id := "", p_name := "") -> void: + if p_id != "" and not Ids.is_valid(p_id): + push_error("invalid location id: %s" % p_id) + id = p_id + name = p_name + + +func to_dict() -> Dictionary: + return {"id": id, "name": name} + + +static func from_dict(d: Dictionary) -> LogLocation: + return LogLocation.new(d.get("id", ""), d.get("name", "")) diff --git a/client/scripts/canon_log/log_location.gd.uid b/client/scripts/canon_log/log_location.gd.uid new file mode 100644 index 0000000..326143f --- /dev/null +++ b/client/scripts/canon_log/log_location.gd.uid @@ -0,0 +1 @@ +uid://cdn82yran4mjg diff --git a/client/scripts/canon_log/log_player.gd b/client/scripts/canon_log/log_player.gd new file mode 100644 index 0000000..d0620df --- /dev/null +++ b/client/scripts/canon_log/log_player.gd @@ -0,0 +1,33 @@ +class_name LogPlayer +extends RefCounted +## A canon-log player row. Carries ONLY name, class_id, luck_descriptor (§7): +## no numeric Luck, no stats — those live in GameState and never reach the AI. + +const CLASSES := ["sellsword", "assassin", "priest"] + +var name: String +var class_id: String +var luck_descriptor: String + + +func _init(p_name := "", p_class_id := "", p_luck_descriptor := "") -> void: + name = p_name + luck_descriptor = p_luck_descriptor + if p_class_id != "": + set_class_id(p_class_id) + + +func set_class_id(v: String) -> bool: + if v not in CLASSES: + push_error("invalid class_id: %s" % v) + return false + class_id = v + return true + + +func to_dict() -> Dictionary: + return {"name": name, "class_id": class_id, "luck_descriptor": luck_descriptor} + + +static func from_dict(d: Dictionary) -> LogPlayer: + return LogPlayer.new(d.get("name", ""), d.get("class_id", ""), d.get("luck_descriptor", "")) diff --git a/client/scripts/canon_log/log_player.gd.uid b/client/scripts/canon_log/log_player.gd.uid new file mode 100644 index 0000000..c385d95 --- /dev/null +++ b/client/scripts/canon_log/log_player.gd.uid @@ -0,0 +1 @@ +uid://b1pvwqbhh4sfs diff --git a/client/scripts/canon_log/party_member.gd b/client/scripts/canon_log/party_member.gd new file mode 100644 index 0000000..ae95a24 --- /dev/null +++ b/client/scripts/canon_log/party_member.gd @@ -0,0 +1,25 @@ +class_name PartyMember +extends RefCounted +## A companion row. Owns the disposition clamp (−100..100, charter §9). + +var id: String +var name: String +var disposition: int + + +func _init(p_id := "", p_name := "", p_disposition := 0) -> void: + id = p_id + name = p_name + set_disposition(p_disposition) + + +func set_disposition(v: int) -> void: + disposition = clampi(v, -100, 100) + + +func to_dict() -> Dictionary: + return {"id": id, "name": name, "disposition": disposition} + + +static func from_dict(d: Dictionary) -> PartyMember: + return PartyMember.new(d.get("id", ""), d.get("name", ""), int(d.get("disposition", 0))) diff --git a/client/scripts/canon_log/party_member.gd.uid b/client/scripts/canon_log/party_member.gd.uid new file mode 100644 index 0000000..8cf5985 --- /dev/null +++ b/client/scripts/canon_log/party_member.gd.uid @@ -0,0 +1 @@ +uid://bxostgrkkj4px diff --git a/client/scripts/canon_log/quest.gd b/client/scripts/canon_log/quest.gd new file mode 100644 index 0000000..2ec3127 --- /dev/null +++ b/client/scripts/canon_log/quest.gd @@ -0,0 +1,33 @@ +class_name Quest +extends RefCounted +## An active-quest row. Owns the status enum {active, complete, failed}. + +const STATUSES := ["active", "complete", "failed"] + +var id: String +var name: String +var status: String +var objective: String + + +func _init(p_id := "", p_name := "", p_status := "active", p_objective := "") -> void: + id = p_id + name = p_name + objective = p_objective + set_status(p_status) + + +func set_status(v: String) -> bool: + if v not in STATUSES: + push_error("invalid quest status: %s" % v) + return false + status = v + return true + + +func to_dict() -> Dictionary: + return {"id": id, "name": name, "status": status, "objective": objective} + + +static func from_dict(d: Dictionary) -> Quest: + return Quest.new(d.get("id", ""), d.get("name", ""), d.get("status", "active"), d.get("objective", "")) diff --git a/client/scripts/canon_log/quest.gd.uid b/client/scripts/canon_log/quest.gd.uid new file mode 100644 index 0000000..9b94368 --- /dev/null +++ b/client/scripts/canon_log/quest.gd.uid @@ -0,0 +1 @@ +uid://dipm6exydkvhb diff --git a/client/scripts/ids.gd b/client/scripts/ids.gd new file mode 100644 index 0000000..762b9e3 --- /dev/null +++ b/client/scripts/ids.gd @@ -0,0 +1,9 @@ +class_name Ids +extends RefCounted +## Shared id validation. Every canon-log / content id matches ^[a-z0-9_]+$. + +static var _re: RegEx = RegEx.create_from_string("^[a-z0-9_]+$") + + +static func is_valid(id: String) -> bool: + return _re.search(id) != null diff --git a/client/scripts/ids.gd.uid b/client/scripts/ids.gd.uid new file mode 100644 index 0000000..4a3853f --- /dev/null +++ b/client/scripts/ids.gd.uid @@ -0,0 +1 @@ +uid://cbkjvby6t752k diff --git a/client/tests/unit/test_entities.gd b/client/tests/unit/test_entities.gd new file mode 100644 index 0000000..e18fa1a --- /dev/null +++ b/client/tests/unit/test_entities.gd @@ -0,0 +1,55 @@ +extends "res://addons/gut/test.gd" + +const Ids = preload("res://scripts/ids.gd") +const LogPlayer = preload("res://scripts/canon_log/log_player.gd") +const LogLocation = preload("res://scripts/canon_log/log_location.gd") +const PartyMember = preload("res://scripts/canon_log/party_member.gd") +const Quest = preload("res://scripts/canon_log/quest.gd") +const Humiliation = preload("res://scripts/canon_log/humiliation.gd") + + +func test_ids_regex(): + assert_true(Ids.is_valid("greywater_docks")) + assert_false(Ids.is_valid("Bad Id")) + assert_false(Ids.is_valid("has-hyphen")) + + +func test_player_dict_has_only_three_keys(): + var p = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you") + var d = p.to_dict() + assert_eq(d.keys().size(), 3) + assert_false("luck" in d) + assert_eq(d["class_id"], "sellsword") + + +func test_player_rejects_unknown_class(): + var p = LogPlayer.new("Aldric", "sellsword", "x") + assert_false(p.set_class_id("bard")) + assert_eq(p.class_id, "sellsword") + + +func test_party_member_clamps_disposition(): + var m = PartyMember.new("brannoc_thane", "Brannoc Thane", 200) + assert_eq(m.disposition, 100) + m.set_disposition(-200) + assert_eq(m.disposition, -100) + + +func test_quest_status_enum(): + var q = Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find it") + assert_false(q.set_status("abandoned")) + assert_eq(q.status, "active") + assert_true(q.set_status("complete")) + + +func test_humiliation_clamps_weight_and_turn(): + var h = Humiliation.new("h_1", "vomited on a shrine", 11, -3) + assert_eq(h.weight, 10) + assert_eq(h.turn, 0) + + +func test_round_trip_each_entity(): + var loc = LogLocation.new("greywater_docks", "the Greywater docks") + assert_eq(LogLocation.from_dict(loc.to_dict()).to_dict(), loc.to_dict()) + var m = PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15) + assert_eq(PartyMember.from_dict(m.to_dict()).to_dict(), m.to_dict()) diff --git a/client/tests/unit/test_entities.gd.uid b/client/tests/unit/test_entities.gd.uid new file mode 100644 index 0000000..88211a1 --- /dev/null +++ b/client/tests/unit/test_entities.gd.uid @@ -0,0 +1 @@ +uid://b4ggfq607h4bj