diff --git a/client/scripts/canon_log/canon_log.gd b/client/scripts/canon_log/canon_log.gd new file mode 100644 index 0000000..7fe7c9d --- /dev/null +++ b/client/scripts/canon_log/canon_log.gd @@ -0,0 +1,101 @@ +class_name CanonLog +extends RefCounted +## The §11 canon log: compact structured state code maintains and injects into +## every AI call. This container owns the container-level invariants; leaf rows +## own theirs. AI prose never writes here directly — mutators are called by code. + +const SCHEMA_VERSION := 1 +const MAX_RECENT_EVENTS := 5 + +var schema_version: int = SCHEMA_VERSION +var player: LogPlayer +var location: LogLocation +var party: Array[PartyMember] = [] +var recent_events: Array[String] = [] +var established_facts: Array[String] = [] +var active_quests: Array[Quest] = [] +var humiliations: Array[Humiliation] = [] + + +func _init() -> void: + player = LogPlayer.new() + location = LogLocation.new() + + +# ── maintenance mutators (charter §11 maintenance table) ───────────────────── + +func push_event(line: String) -> void: + recent_events.append(line) + while recent_events.size() > MAX_RECENT_EVENTS: + recent_events.pop_front() + + +func add_fact(text: String) -> void: + if text not in established_facts: + established_facts.append(text) + + +func add_humiliation(id: String, text: String, weight: int, turn: int) -> void: + humiliations.append(Humiliation.new(id, text, weight, turn)) + + +func set_location(id: String, name: String) -> void: + location = LogLocation.new(id, name) + + +func party_member(id: String) -> PartyMember: + for m in party: + if m.id == id: + return m + return null + + +func adjust_disposition(id: String, delta: int) -> bool: + var m := party_member(id) + if m == null: + return false + m.set_disposition(m.disposition + delta) + return true + + +func set_quest_status(id: String, status: String) -> bool: + for q in active_quests: + if q.id == id: + return q.set_status(status) + return false + + +# ── serialisation ──────────────────────────────────────────────────────────── + +func to_dict() -> Dictionary: + return { + "schema_version": schema_version, + "player": player.to_dict(), + "location": location.to_dict(), + "party": party.map(func(m): return m.to_dict()), + "recent_events": recent_events.duplicate(), + "established_facts": established_facts.duplicate(), + "active_quests": active_quests.map(func(q): return q.to_dict()), + "humiliations": humiliations.map(func(h): return h.to_dict()), + } + + +static func from_dict(d: Dictionary) -> CanonLog: + var v := int(d.get("schema_version", -1)) + if v != SCHEMA_VERSION: + push_error("unsupported canon_log schema_version: %s" % v) + return null + var log := CanonLog.new() + log.player = LogPlayer.from_dict(d.get("player", {})) + log.location = LogLocation.from_dict(d.get("location", {})) + for m in d.get("party", []): + log.party.append(PartyMember.from_dict(m)) + for e in d.get("recent_events", []): + log.push_event(e) + for f in d.get("established_facts", []): + log.add_fact(f) + for q in d.get("active_quests", []): + log.active_quests.append(Quest.from_dict(q)) + for h in d.get("humiliations", []): + log.humiliations.append(Humiliation.from_dict(h)) + return log diff --git a/client/scripts/canon_log/canon_log.gd.uid b/client/scripts/canon_log/canon_log.gd.uid new file mode 100644 index 0000000..b6e2548 --- /dev/null +++ b/client/scripts/canon_log/canon_log.gd.uid @@ -0,0 +1 @@ +uid://b1crmy8ma5ig4 diff --git a/client/tests/unit/test_canon_log.gd b/client/tests/unit/test_canon_log.gd new file mode 100644 index 0000000..aa7235c --- /dev/null +++ b/client/tests/unit/test_canon_log.gd @@ -0,0 +1,48 @@ +extends "res://addons/gut/test.gd" + +const CanonLog = preload("res://scripts/canon_log/canon_log.gd") +const PartyMember = preload("res://scripts/canon_log/party_member.gd") +const Quest = preload("res://scripts/canon_log/quest.gd") + + +func test_push_event_caps_at_five(): + var log = CanonLog.new() + for i in 7: + log.push_event("event %d" % i) + assert_eq(log.recent_events.size(), 5) + assert_eq(log.recent_events[0], "event 2") + assert_eq(log.recent_events[4], "event 6") + + +func test_add_fact_dedups(): + var log = CanonLog.new() + log.add_fact("the bridge is out") + log.add_fact("the bridge is out") + assert_eq(log.established_facts.size(), 1) + + +func test_humiliations_stack(): + var log = CanonLog.new() + log.add_humiliation("h_1", "vomited on a shrine", 7, 3) + log.add_humiliation("h_2", "vomited on a shrine", 7, 5) + assert_eq(log.humiliations.size(), 2) + + +func test_adjust_disposition_clamps_and_reports_missing(): + var log = CanonLog.new() + log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 98)) + assert_true(log.adjust_disposition("brannoc_thane", 50)) + assert_eq(log.party_member("brannoc_thane").disposition, 100) + assert_false(log.adjust_disposition("nobody", 5)) + + +func test_set_quest_status_rejects_bad_enum(): + var log = CanonLog.new() + log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find it")) + assert_false(log.set_quest_status("find_the_ledger", "abandoned")) + assert_true(log.set_quest_status("find_the_ledger", "complete")) + assert_false(log.set_quest_status("no_quest", "active")) + + +func test_unsupported_schema_version_returns_null(): + assert_null(CanonLog.from_dict({"schema_version": 2})) diff --git a/client/tests/unit/test_canon_log.gd.uid b/client/tests/unit/test_canon_log.gd.uid new file mode 100644 index 0000000..3c726e9 --- /dev/null +++ b/client/tests/unit/test_canon_log.gd.uid @@ -0,0 +1 @@ +uid://cc3ax10wjoqx6