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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
|
||||
24
client/scripts/canon_log/humiliation.gd
Normal file
24
client/scripts/canon_log/humiliation.gd
Normal file
@@ -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)))
|
||||
1
client/scripts/canon_log/humiliation.gd.uid
Normal file
1
client/scripts/canon_log/humiliation.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b1rqyk7wndskx
|
||||
21
client/scripts/canon_log/log_location.gd
Normal file
21
client/scripts/canon_log/log_location.gd
Normal file
@@ -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", ""))
|
||||
1
client/scripts/canon_log/log_location.gd.uid
Normal file
1
client/scripts/canon_log/log_location.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cdn82yran4mjg
|
||||
33
client/scripts/canon_log/log_player.gd
Normal file
33
client/scripts/canon_log/log_player.gd
Normal file
@@ -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", ""))
|
||||
1
client/scripts/canon_log/log_player.gd.uid
Normal file
1
client/scripts/canon_log/log_player.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b1pvwqbhh4sfs
|
||||
25
client/scripts/canon_log/party_member.gd
Normal file
25
client/scripts/canon_log/party_member.gd
Normal file
@@ -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)))
|
||||
1
client/scripts/canon_log/party_member.gd.uid
Normal file
1
client/scripts/canon_log/party_member.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bxostgrkkj4px
|
||||
33
client/scripts/canon_log/quest.gd
Normal file
33
client/scripts/canon_log/quest.gd
Normal file
@@ -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", ""))
|
||||
1
client/scripts/canon_log/quest.gd.uid
Normal file
1
client/scripts/canon_log/quest.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dipm6exydkvhb
|
||||
9
client/scripts/ids.gd
Normal file
9
client/scripts/ids.gd
Normal file
@@ -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
|
||||
1
client/scripts/ids.gd.uid
Normal file
1
client/scripts/ids.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cbkjvby6t752k
|
||||
55
client/tests/unit/test_entities.gd
Normal file
55
client/tests/unit/test_entities.gd
Normal file
@@ -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())
|
||||
1
client/tests/unit/test_entities.gd.uid
Normal file
1
client/tests/unit/test_entities.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b4ggfq607h4bj
|
||||
Reference in New Issue
Block a user