Eight TDD tasks off feature/client-canon-log-engine: GUT harness, leaf entities, CanonLog container + mutators, Luck+GameState, ContentDB, NewGame.construct, TagExtractor, round-trip/contract-invariant bridge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1414 lines
48 KiB
Markdown
1414 lines
48 KiB
Markdown
# Client Canon Log Engine (Plan B) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the client-side GDScript engine that constructs the canon log at new-game (origin + world + character creation) and maintains its invariants turn to turn — a headless, GUT-tested logic layer that binds to the contract Plan A shipped.
|
||
|
||
**Architecture:** RefCounted typed entity classes each own the invariant guarding their own data (fat-but-decomposed model). A `NewGame` builder reads three immutable inputs (origin dict, `ContentDB`, character-creation dict) and writes two products (`CanonLog`, `GameState`); numeric Luck lives only in `GameState`, never the log. A pure `TagExtractor` parses §12 tags. No UI, no HTTP, no save/load. The api remains the authoritative schema gate; the client validates only the invariants it actively maintains.
|
||
|
||
**Tech Stack:** Godot 4.7, GDScript, GUT v9.7.1 (headless test runner). JSON everywhere; the client models the log as typed objects and serialises with `to_dict()`/`from_dict()`.
|
||
|
||
## Global Constraints
|
||
|
||
- **Engine:** Godot 4.7 (`/home/ptarrant/.local/bin/godot`, `4.7.stable`). GDScript only (charter §16).
|
||
- **Test framework:** GUT **v9.7.1**, vendored into `client/addons/gut`, run headless via `bash client/run_tests.sh`.
|
||
- **Model:** RefCounted typed classes with `to_dict()`/`from_dict()`. No `Resource`/`.tres`, no bare dictionaries for log entities (spec decision 3).
|
||
- **Validation:** invariants enforced **in the model** (correct-by-construction). No client-side JSON-Schema validator — the api is the schema authority (spec decision 4).
|
||
- **§7 Luck boundary:** numeric Luck, stats, HP/MP, inventory contents **never** appear in the canon log. Only `luck_descriptor` crosses. Enforced by keeping numbers in `GameState` and the log's `player` dict carrying exactly `{name, class_id, luck_descriptor}`.
|
||
- **`recent_events` capped at 5** (charter §11) — enforced by `CanonLog.push_event`.
|
||
- **POC classes:** `sellsword` | `assassin` | `priest` only (charter §8).
|
||
- **All string ids match `^[a-z0-9_]+$`** (via `Ids.is_valid`).
|
||
- **Test convention:** test files `extends "res://addons/gut/test.gd"` (path form, cache-independent) and `preload` the script-under-test by explicit `res://` path (a missing file is a clean red). Production code cross-references by `class_name`; `run_tests.sh` runs `godot --headless --import` first so those global names resolve.
|
||
- **Content root:** `ContentDB.default_content_root()` = `res://../content` globalized (dev reads the repo `/content`). Export packaging is a later plan.
|
||
- **Execution branch:** run this plan on `feature/client-canon-log-engine` branched off `dev` (CLAUDE.md §18). Per-task commits land there; the human merges to `dev`.
|
||
|
||
---
|
||
|
||
### Task 1: GUT harness — vendored, headless, smoke-tested
|
||
|
||
**Files:**
|
||
- Create: `client/addons/gut/` (vendored GUT v9.7.1)
|
||
- Create: `client/.gutconfig.json`
|
||
- Create: `client/run_tests.sh`
|
||
- Test: `client/tests/unit/test_smoke.gd`
|
||
|
||
**Interfaces:**
|
||
- Produces: `bash client/run_tests.sh` — imports the project then runs every `res://tests/unit/**/*.gd` GUT test; prints a summary containing `Failing: 0` when green. All later tasks invoke this exact command.
|
||
|
||
- [ ] **Step 1: Create the feature branch**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git checkout dev && git checkout -b feature/client-canon-log-engine
|
||
```
|
||
|
||
- [ ] **Step 2: Vendor GUT v9.7.1 into the client**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
tmp=$(mktemp -d)
|
||
git clone --depth 1 --branch v9.7.1 https://github.com/bitwes/Gut.git "$tmp"
|
||
mkdir -p client/addons
|
||
cp -r "$tmp/addons/gut" client/addons/gut
|
||
rm -rf "$tmp"
|
||
ls client/addons/gut/gut_cmdln.gd
|
||
```
|
||
Expected: prints `client/addons/gut/gut_cmdln.gd` (the headless runner is present).
|
||
|
||
- [ ] **Step 3: Create the GUT config**
|
||
|
||
Create `client/.gutconfig.json`:
|
||
|
||
```json
|
||
{
|
||
"dirs": ["res://tests/unit"],
|
||
"include_subdirs": true,
|
||
"log_level": 1,
|
||
"should_exit": true
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create the headless test runner**
|
||
|
||
Create `client/run_tests.sh`:
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# Headless GUT runner. Imports the project first so global class_name refs
|
||
# resolve on a fresh checkout / after new class_name scripts are added.
|
||
# Fallback if --import misbehaves on some builds: godot --headless --editor --quit
|
||
set -uo pipefail
|
||
cd "$(dirname "$0")"
|
||
godot --headless --import >/dev/null 2>&1 || true
|
||
godot --headless -s res://addons/gut/gut_cmdln.gd -gconfig=res://.gutconfig.json "$@"
|
||
```
|
||
|
||
Make it executable:
|
||
|
||
```bash
|
||
chmod +x /home/ptarrant/repos/ptarrant/coc-rpg/client/run_tests.sh
|
||
```
|
||
|
||
- [ ] **Step 5: Write the smoke test**
|
||
|
||
Create `client/tests/unit/test_smoke.gd`:
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
|
||
|
||
func test_harness_runs():
|
||
assert_eq(1, 1, "GUT harness executes a passing assertion")
|
||
```
|
||
|
||
- [ ] **Step 6: Run the harness**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: output contains `Passing: 1` and `Failing: 0`. This proves GUT runs headless against `tests/unit`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git add client/addons/gut client/.gutconfig.json client/run_tests.sh client/tests/unit/test_smoke.gd
|
||
git commit -m "chore(client): vendor GUT 9.7.1 + headless test harness"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Id helper + leaf canon-log entities
|
||
|
||
**Files:**
|
||
- Create: `client/scripts/ids.gd`
|
||
- Create: `client/scripts/canon_log/log_player.gd`
|
||
- Create: `client/scripts/canon_log/log_location.gd`
|
||
- Create: `client/scripts/canon_log/party_member.gd`
|
||
- Create: `client/scripts/canon_log/quest.gd`
|
||
- Create: `client/scripts/canon_log/humiliation.gd`
|
||
- Test: `client/tests/unit/test_entities.gd`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `Ids.is_valid(id: String) -> bool`
|
||
- `LogPlayer.new(name := "", class_id := "", luck_descriptor := "")`; `set_class_id(v) -> bool` (rejects non-POC classes); `to_dict() -> Dictionary` (exactly `{name, class_id, luck_descriptor}`); `LogPlayer.from_dict(d) -> LogPlayer`
|
||
- `LogLocation.new(id := "", name := "")`; `to_dict()`; `LogLocation.from_dict(d) -> LogLocation`
|
||
- `PartyMember.new(id := "", name := "", disposition := 0)`; `set_disposition(v)` (clamps −100..100); `to_dict()`; `PartyMember.from_dict(d) -> PartyMember`
|
||
- `Quest.new(id := "", name := "", status := "active", objective := "")`; `set_status(v) -> bool` (enum); `to_dict()`; `Quest.from_dict(d) -> Quest`
|
||
- `Humiliation.new(id := "", text := "", weight := 1, turn := 0)` (weight clamped 1..10, turn ≥ 0); `to_dict()`; `Humiliation.from_dict(d) -> Humiliation`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `client/tests/unit/test_entities.gd`:
|
||
|
||
```gdscript
|
||
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())
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: FAIL — the test script fails to load (`Could not load ... res://scripts/ids.gd`) or reports errors; `Failing`/errored count is non-zero.
|
||
|
||
- [ ] **Step 3: Write the Id helper**
|
||
|
||
Create `client/scripts/ids.gd`:
|
||
|
||
```gdscript
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 4: Write `LogPlayer`**
|
||
|
||
Create `client/scripts/canon_log/log_player.gd`:
|
||
|
||
```gdscript
|
||
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", ""))
|
||
```
|
||
|
||
- [ ] **Step 5: Write `LogLocation`**
|
||
|
||
Create `client/scripts/canon_log/log_location.gd`:
|
||
|
||
```gdscript
|
||
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", ""))
|
||
```
|
||
|
||
- [ ] **Step 6: Write `PartyMember`**
|
||
|
||
Create `client/scripts/canon_log/party_member.gd`:
|
||
|
||
```gdscript
|
||
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)))
|
||
```
|
||
|
||
- [ ] **Step 7: Write `Quest`**
|
||
|
||
Create `client/scripts/canon_log/quest.gd`:
|
||
|
||
```gdscript
|
||
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", ""))
|
||
```
|
||
|
||
- [ ] **Step 8: Write `Humiliation`**
|
||
|
||
Create `client/scripts/canon_log/humiliation.gd`:
|
||
|
||
```gdscript
|
||
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)))
|
||
```
|
||
|
||
- [ ] **Step 9: Run test to verify it passes**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: PASS — output contains `Failing: 0`; the 7 `test_entities` tests all pass.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git add client/scripts/ids.gd client/scripts/canon_log client/tests/unit/test_entities.gd
|
||
git commit -m "feat(client): id helper + leaf canon-log entities with owned invariants"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `CanonLog` container + maintenance mutators
|
||
|
||
**Files:**
|
||
- Create: `client/scripts/canon_log/canon_log.gd`
|
||
- Test: `client/tests/unit/test_canon_log.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LogPlayer`, `LogLocation`, `PartyMember`, `Quest`, `Humiliation` (Task 2).
|
||
- Produces:
|
||
- `CanonLog.new()` — starts with empty `LogPlayer`/`LogLocation`, empty arrays, `schema_version = 1`.
|
||
- Mutators: `push_event(line)` (cap 5, drop oldest); `add_fact(text)` (dedup); `add_humiliation(id, text, weight, turn)` (append-only); `set_location(id, name)`; `party_member(id) -> PartyMember`; `adjust_disposition(id, delta) -> bool`; `set_quest_status(id, status) -> bool`.
|
||
- `to_dict() -> Dictionary`; `CanonLog.from_dict(d) -> CanonLog` (returns `null` on unsupported `schema_version`).
|
||
- Typed fields: `party: Array[PartyMember]`, `active_quests: Array[Quest]`, `humiliations: Array[Humiliation]`, `recent_events: Array[String]`, `established_facts: Array[String]`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `client/tests/unit/test_canon_log.gd`:
|
||
|
||
```gdscript
|
||
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}))
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: FAIL — `Could not load ... res://scripts/canon_log/canon_log.gd`; non-zero failing/errored.
|
||
|
||
- [ ] **Step 3: Write `CanonLog`**
|
||
|
||
Create `client/scripts/canon_log/canon_log.gd`:
|
||
|
||
```gdscript
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: PASS — `Failing: 0`; the 6 `test_canon_log` tests pass. (The `push_error` on the unsupported-version test prints a red line but does not fail the test — GUT does not fail on `push_error` by default.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git add client/scripts/canon_log/canon_log.gd client/tests/unit/test_canon_log.gd
|
||
git commit -m "feat(client): CanonLog container + maintenance mutators (cap 5, dedup, stack)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `Luck` + `GameState` (the §7 numeric store)
|
||
|
||
**Files:**
|
||
- Create: `client/scripts/state/luck.gd`
|
||
- Create: `client/scripts/state/game_state.gd`
|
||
- Test: `client/tests/unit/test_luck.gd`, `client/tests/unit/test_game_state.gd`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `Luck.MIN`(1), `Luck.MAX`(20), `Luck.DRIFT`(3); `Luck.roll_base(rng: RandomNumberGenerator, modifier: int) -> int`; `Luck.drift(current: int, base: int, delta: int) -> int`; `Luck.descriptor(luck: int) -> String`.
|
||
- `GameState.new()` with fields `luck: int`, `luck_base: int`, `stats: Dictionary`, `npc_dispositions: Dictionary`, `inventory: Dictionary`; methods `set_luck(v)`, `drift_luck(delta)`, `luck_descriptor() -> String`, `set_npc_disposition(id, v)` (clamp −100..100), `add_item(item_id, qty)`.
|
||
|
||
- [ ] **Step 1: Write the failing Luck test**
|
||
|
||
Create `client/tests/unit/test_luck.gd`:
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
|
||
const Luck = preload("res://scripts/state/luck.gd")
|
||
|
||
|
||
func _rng(s: int) -> RandomNumberGenerator:
|
||
var r := RandomNumberGenerator.new()
|
||
r.seed = s
|
||
return r
|
||
|
||
|
||
func test_roll_base_is_deterministic_for_a_seed():
|
||
assert_eq(Luck.roll_base(_rng(42), 0), Luck.roll_base(_rng(42), 0))
|
||
|
||
|
||
func test_roll_base_stays_in_range():
|
||
for s in range(1, 30):
|
||
var v := Luck.roll_base(_rng(s), 0)
|
||
assert_true(v >= Luck.MIN and v <= Luck.MAX, "luck %d out of range" % v)
|
||
|
||
|
||
func test_modifier_shifts_result():
|
||
# +100 modifier clamps to MAX regardless of rolls.
|
||
assert_eq(Luck.roll_base(_rng(5), 100), Luck.MAX)
|
||
|
||
|
||
func test_drift_stays_within_base_band_and_range():
|
||
assert_eq(Luck.drift(10, 10, 100), 13) # capped at base+3
|
||
assert_eq(Luck.drift(10, 10, -100), 7) # capped at base-3
|
||
assert_eq(Luck.drift(20, 19, 100), 20) # base+3=22 -> clamped to MAX
|
||
|
||
|
||
func test_descriptor_bands():
|
||
assert_eq(Luck.descriptor(4), "Fortune spits on you")
|
||
assert_eq(Luck.descriptor(5), "The world is not on your side")
|
||
assert_eq(Luck.descriptor(20), "The dice are kind today")
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: FAIL — `Could not load ... res://scripts/state/luck.gd`.
|
||
|
||
- [ ] **Step 3: Write `Luck`**
|
||
|
||
Create `client/scripts/state/luck.gd`:
|
||
|
||
```gdscript
|
||
class_name Luck
|
||
extends RefCounted
|
||
## Numeric Luck (charter §7). Lives in GameState; only descriptor() reaches the
|
||
## log. Deterministic given a seeded RNG (charter §10). Die/range/bands are
|
||
## TUNABLE constants — not final values.
|
||
|
||
const MIN := 1
|
||
const MAX := 20
|
||
const DRIFT := 3
|
||
|
||
const BANDS := [
|
||
{"max": 4, "text": "Fortune spits on you"},
|
||
{"max": 8, "text": "The world is not on your side"},
|
||
{"max": 12, "text": "The dice are indifferent"},
|
||
{"max": 16, "text": "Luck rides with you"},
|
||
{"max": 20, "text": "The dice are kind today"},
|
||
]
|
||
|
||
|
||
static func roll_base(rng: RandomNumberGenerator, modifier: int) -> int:
|
||
var total := 0
|
||
for i in 5:
|
||
total += rng.randi_range(1, 20)
|
||
return clampi(roundi(total / 5.0) + modifier, MIN, MAX)
|
||
|
||
|
||
static func drift(current: int, base: int, delta: int) -> int:
|
||
return clampi(clampi(current + delta, base - DRIFT, base + DRIFT), MIN, MAX)
|
||
|
||
|
||
static func descriptor(luck: int) -> String:
|
||
for band in BANDS:
|
||
if luck <= band["max"]:
|
||
return band["text"]
|
||
return BANDS[-1]["text"]
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: PASS — the 5 `test_luck` tests pass, `Failing: 0`.
|
||
|
||
- [ ] **Step 5: Write the failing GameState test**
|
||
|
||
Create `client/tests/unit/test_game_state.gd`:
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
|
||
const GameState = preload("res://scripts/state/game_state.gd")
|
||
|
||
|
||
func test_add_item_accumulates():
|
||
var s = GameState.new()
|
||
s.add_item("coin", 3)
|
||
s.add_item("coin", 2)
|
||
assert_eq(s.inventory["coin"], 5)
|
||
|
||
|
||
func test_npc_disposition_clamps():
|
||
var s = GameState.new()
|
||
s.set_npc_disposition("oda_fenn", 250)
|
||
assert_eq(s.npc_dispositions["oda_fenn"], 100)
|
||
|
||
|
||
func test_drift_luck_respects_base():
|
||
var s = GameState.new()
|
||
s.luck_base = 10
|
||
s.luck = 10
|
||
s.drift_luck(100)
|
||
assert_eq(s.luck, 13)
|
||
|
||
|
||
func test_luck_descriptor_delegates():
|
||
var s = GameState.new()
|
||
s.luck = 4
|
||
assert_eq(s.luck_descriptor(), "Fortune spits on you")
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it fails**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: FAIL — `Could not load ... res://scripts/state/game_state.gd`.
|
||
|
||
- [ ] **Step 7: Write `GameState`**
|
||
|
||
Create `client/scripts/state/game_state.gd`:
|
||
|
||
```gdscript
|
||
class_name GameState
|
||
extends RefCounted
|
||
## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck,
|
||
## stats, world-NPC dispositions, and inventory — none of which enter the canon
|
||
## log (charter §7/§2). The log reads only luck_descriptor() from here.
|
||
|
||
var luck: int = 0
|
||
var luck_base: int = 0
|
||
var stats: Dictionary = {} # {str, dex, con, fth, mag}
|
||
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
||
var inventory: Dictionary = {} # item_id -> qty
|
||
|
||
|
||
func set_luck(v: int) -> void:
|
||
luck = clampi(v, Luck.MIN, Luck.MAX)
|
||
|
||
|
||
func drift_luck(delta: int) -> void:
|
||
luck = Luck.drift(luck, luck_base, delta)
|
||
|
||
|
||
func luck_descriptor() -> String:
|
||
return Luck.descriptor(luck)
|
||
|
||
|
||
func set_npc_disposition(id: String, v: int) -> void:
|
||
npc_dispositions[id] = clampi(v, -100, 100)
|
||
|
||
|
||
func add_item(item_id: String, qty: int) -> void:
|
||
inventory[item_id] = int(inventory.get(item_id, 0)) + qty
|
||
```
|
||
|
||
- [ ] **Step 8: Run test to verify it passes**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: PASS — the 4 `test_game_state` tests pass, `Failing: 0`.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git add client/scripts/state client/tests/unit/test_luck.gd client/tests/unit/test_game_state.gd
|
||
git commit -m "feat(client): Luck (deterministic gen/drift/descriptor) + GameState store"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `ContentDB` — load `/content`, resolve origin refs
|
||
|
||
**Files:**
|
||
- Create: `client/scripts/content/content_db.gd`
|
||
- Test: `client/tests/unit/test_content_db.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the authored fixtures under `/content` (Plan A).
|
||
- Produces:
|
||
- `ContentDB.default_content_root() -> String` (globalized `res://../content`)
|
||
- `ContentDB.origin_path(id: String) -> String`
|
||
- `ContentDB.load_json(abs_path: String) -> Variant`
|
||
- `ContentDB.new()`; `load_from(content_root: String) -> void`
|
||
- Lookups returning the raw content dict (or `{}`): `location(id)`, `npc(id)`, `quest(id)`, `item(id)`; predicates `has_location/has_npc/has_quest/has_item(id) -> bool`
|
||
- `companions() -> Array` (NPC dicts with `role == "companion"`)
|
||
- `unresolved_refs(origin: Dictionary) -> Array` (e.g. `["location:nowhere", "item:ghost_blade"]`; empty = clean) — the GDScript twin of `api/app/content.py::unresolved_refs`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `client/tests/unit/test_content_db.gd`:
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
|
||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||
|
||
var db
|
||
|
||
|
||
func before_each():
|
||
db = ContentDB.new()
|
||
db.load_from(ContentDB.default_content_root())
|
||
|
||
|
||
func _deserter() -> Dictionary:
|
||
return ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||
|
||
|
||
func test_loads_world_ids():
|
||
assert_true(db.has_location("greywater_docks"))
|
||
assert_true(db.has_quest("find_the_ledger"))
|
||
assert_true(db.has_item("worn_shortsword"))
|
||
assert_true(db.has_item("coin"))
|
||
|
||
|
||
func test_companions_are_brannoc_and_cadwyn():
|
||
var ids := []
|
||
for n in db.companions():
|
||
ids.append(n["id"])
|
||
ids.sort()
|
||
assert_eq(ids, ["brannoc_thane", "cadwyn_vell"])
|
||
|
||
|
||
func test_good_origin_has_no_unresolved_refs():
|
||
assert_eq(db.unresolved_refs(_deserter()), [])
|
||
|
||
|
||
func test_broken_location_ref_detected():
|
||
var o := _deserter()
|
||
o["start_location_id"] = "nowhere"
|
||
assert_true("location:nowhere" in db.unresolved_refs(o))
|
||
|
||
|
||
func test_broken_item_ref_detected():
|
||
var o := _deserter()
|
||
o["inventory_grants"] = o["inventory_grants"].duplicate(true)
|
||
o["inventory_grants"].append({"item_id": "ghost_blade", "qty": 1})
|
||
assert_true("item:ghost_blade" in db.unresolved_refs(o))
|
||
|
||
|
||
func test_null_quest_ref_resolves():
|
||
var o := _deserter()
|
||
o["start_quest_id"] = null
|
||
assert_eq(db.unresolved_refs(o), [])
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: FAIL — `Could not load ... res://scripts/content/content_db.gd`.
|
||
|
||
- [ ] **Step 3: Write `ContentDB`**
|
||
|
||
Create `client/scripts/content/content_db.gd`:
|
||
|
||
```gdscript
|
||
class_name ContentDB
|
||
extends RefCounted
|
||
## Loads authored world content (/content) and cross-checks origin references.
|
||
## Mirrors api/app/content.py so client and api resolve ids identically. This is
|
||
## content integrity, distinct from JSON-schema validation.
|
||
|
||
var locations: Dictionary = {}
|
||
var npcs: Dictionary = {}
|
||
var quests: Dictionary = {}
|
||
var items: Dictionary = {}
|
||
|
||
|
||
static func default_content_root() -> String:
|
||
return ProjectSettings.globalize_path("res://").path_join("../content").simplify_path()
|
||
|
||
|
||
static func origin_path(id: String) -> String:
|
||
return default_content_root().path_join("origins").path_join("%s.json" % id)
|
||
|
||
|
||
static func load_json(abs_path: String) -> Variant:
|
||
var text := FileAccess.get_file_as_string(abs_path)
|
||
return JSON.parse_string(text)
|
||
|
||
|
||
func load_from(content_root: String) -> void:
|
||
var world := content_root.path_join("world")
|
||
locations = _load_dir(world.path_join("locations"))
|
||
npcs = _load_dir(world.path_join("npcs"))
|
||
quests = _load_dir(world.path_join("quests"))
|
||
items = _load_dir(world.path_join("items"))
|
||
|
||
|
||
func _load_dir(dir_path: String) -> Dictionary:
|
||
var out: Dictionary = {}
|
||
var dir := DirAccess.open(dir_path)
|
||
if dir == null:
|
||
push_error("content dir not found: %s" % dir_path)
|
||
return out
|
||
for file in dir.get_files():
|
||
if not file.ends_with(".json"):
|
||
continue
|
||
var data: Variant = load_json(dir_path.path_join(file))
|
||
if typeof(data) != TYPE_DICTIONARY or not data.has("id"):
|
||
push_error("bad content file: %s" % file)
|
||
continue
|
||
out[data["id"]] = data
|
||
return out
|
||
|
||
|
||
func location(id: String) -> Dictionary: return locations.get(id, {})
|
||
func npc(id: String) -> Dictionary: return npcs.get(id, {})
|
||
func quest(id: String) -> Dictionary: return quests.get(id, {})
|
||
func item(id: String) -> Dictionary: return items.get(id, {})
|
||
func has_location(id: String) -> bool: return locations.has(id)
|
||
func has_npc(id: String) -> bool: return npcs.has(id)
|
||
func has_quest(id: String) -> bool: return quests.has(id)
|
||
func has_item(id: String) -> bool: return items.has(id)
|
||
|
||
|
||
func companions() -> Array:
|
||
var out: Array = []
|
||
for id in npcs:
|
||
if npcs[id].get("role", "") == "companion":
|
||
out.append(npcs[id])
|
||
return out
|
||
|
||
|
||
func unresolved_refs(origin: Dictionary) -> Array:
|
||
var missing: Array = []
|
||
var loc: String = origin.get("start_location_id", "")
|
||
if not has_location(loc):
|
||
missing.append("location:%s" % loc)
|
||
var q: Variant = origin.get("start_quest_id", null)
|
||
if q != null and not has_quest(q):
|
||
missing.append("quest:%s" % q)
|
||
for grant in origin.get("inventory_grants", []):
|
||
if not has_item(grant.get("item_id", "")):
|
||
missing.append("item:%s" % grant.get("item_id", ""))
|
||
for npc_id in origin.get("disposition_overrides", {}):
|
||
if not has_npc(npc_id):
|
||
missing.append("npc:%s" % npc_id)
|
||
return missing
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: PASS — the 6 `test_content_db` tests pass, `Failing: 0`. (Proves the globalized `res://../content` path reaches the real repo fixtures headless.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git add client/scripts/content/content_db.gd client/tests/unit/test_content_db.gd
|
||
git commit -m "feat(client): ContentDB loads /content + mirrors the api id-resolution check"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `NewGame.construct` — origin + world + creation → {log, state}
|
||
|
||
**Files:**
|
||
- Create: `client/scripts/newgame/new_game.gd`
|
||
- Test: `client/tests/unit/test_new_game.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CanonLog`, `LogPlayer`, `PartyMember`, `Quest` (Tasks 2–3); `Luck`, `GameState` (Task 4); `ContentDB` (Task 5).
|
||
- Produces: `NewGame.construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary`, returning `{ ok: bool, errors: Array, log: CanonLog, state: GameState }`. `creation` = `{name, class_id}`. On any content-integrity or build-constraint failure, `ok` is `false`, `errors` is non-empty, and `log`/`state` are `null`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `client/tests/unit/test_new_game.gd`:
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
|
||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||
|
||
var world
|
||
|
||
|
||
func before_each():
|
||
world = ContentDB.new()
|
||
world.load_from(ContentDB.default_content_root())
|
||
|
||
|
||
func _deserter() -> Dictionary:
|
||
return ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||
|
||
|
||
func _rng() -> RandomNumberGenerator:
|
||
var r := RandomNumberGenerator.new()
|
||
r.seed = 7
|
||
return r
|
||
|
||
|
||
func _build(creation: Dictionary) -> Dictionary:
|
||
return NewGame.construct(_deserter(), world, creation, _rng())
|
||
|
||
|
||
func test_construct_succeeds():
|
||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||
assert_true(res["ok"], str(res["errors"]))
|
||
|
||
|
||
func test_numeric_luck_absent_from_log():
|
||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||
var pd: Dictionary = res["log"].player.to_dict()
|
||
assert_false("luck" in pd)
|
||
assert_eq(pd.keys().size(), 3)
|
||
assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX)
|
||
|
||
|
||
func test_inventory_and_dispo_land_in_state_not_log():
|
||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||
assert_eq(res["state"].inventory.get("coin", 0), 3)
|
||
assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1)
|
||
assert_false("inventory" in res["log"].to_dict())
|
||
|
||
|
||
func test_companion_dispositions_from_overrides():
|
||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
|
||
assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15)
|
||
|
||
|
||
func test_situation_and_facts_seeded():
|
||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||
assert_eq(res["log"].recent_events.size(), 2)
|
||
assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts)
|
||
assert_eq(res["log"].humiliations.size(), 0)
|
||
|
||
|
||
func test_active_quest_resolved_from_world():
|
||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||
assert_eq(res["log"].active_quests.size(), 1)
|
||
assert_eq(res["log"].active_quests[0].id, "find_the_ledger")
|
||
assert_eq(res["log"].active_quests[0].status, "active")
|
||
|
||
|
||
func test_null_start_quest_yields_no_quest():
|
||
var o := _deserter()
|
||
o["start_quest_id"] = null
|
||
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
|
||
assert_eq(res["log"].active_quests.size(), 0)
|
||
|
||
|
||
func test_class_not_allowed_is_rejected():
|
||
var res := _build({"name": "X", "class_id": "berserker"})
|
||
assert_false(res["ok"])
|
||
assert_true(res["errors"].size() > 0)
|
||
assert_null(res["log"])
|
||
|
||
|
||
func test_unresolved_origin_is_rejected():
|
||
var o := _deserter()
|
||
o["start_location_id"] = "nowhere"
|
||
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
|
||
assert_false(res["ok"])
|
||
assert_true("unresolved ref: location:nowhere" in res["errors"])
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: FAIL — `Could not load ... res://scripts/newgame/new_game.gd`.
|
||
|
||
- [ ] **Step 3: Write `NewGame`**
|
||
|
||
Create `client/scripts/newgame/new_game.gd`:
|
||
|
||
```gdscript
|
||
class_name NewGame
|
||
extends RefCounted
|
||
## New-game construction (spec "New-game construction"). Reads three immutable
|
||
## inputs (origin, world, creation) and writes two products (CanonLog, GameState).
|
||
## Nothing flows back up — charter §2 on the client. Validation is front-loaded:
|
||
## a broken origin or illegal class fails here, loudly, not three scenes later.
|
||
|
||
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary:
|
||
var errors: Array = []
|
||
|
||
# 1. content integrity — every referenced id must resolve in world content.
|
||
for ref in world.unresolved_refs(origin):
|
||
errors.append("unresolved ref: %s" % ref)
|
||
|
||
# 2. build constraints — chosen class must be allowed by the origin.
|
||
var bc: Dictionary = origin.get("build_constraints", {})
|
||
var allowed: Array = bc.get("allowed_classes", [])
|
||
var class_id: String = creation.get("class_id", "")
|
||
if class_id not in allowed:
|
||
errors.append("class not allowed by origin: %s" % class_id)
|
||
|
||
if not errors.is_empty():
|
||
return {"ok": false, "errors": errors, "log": null, "state": null}
|
||
|
||
# 3. roll Luck (numeric -> state only) and 4. the stat block (data only).
|
||
var state := GameState.new()
|
||
state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
|
||
state.luck = state.luck_base
|
||
state.stats = {
|
||
"str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng),
|
||
"fth": _roll_stat(rng), "mag": _roll_stat(rng),
|
||
}
|
||
|
||
# 5. assemble the canon log.
|
||
var log := CanonLog.new()
|
||
log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor())
|
||
|
||
var loc: Dictionary = world.location(origin["start_location_id"])
|
||
log.set_location(loc.get("id", ""), loc.get("name", ""))
|
||
|
||
var overrides: Dictionary = origin.get("disposition_overrides", {})
|
||
var companion_ids := {}
|
||
for c in world.companions():
|
||
companion_ids[c["id"]] = true
|
||
log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
|
||
# non-companion overrides go to game state, never the log (spec §5 / charter §6).
|
||
for npc_id in overrides:
|
||
if not companion_ids.has(npc_id):
|
||
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
|
||
|
||
for line in origin.get("situation", []):
|
||
log.push_event(line)
|
||
for fact in origin.get("opening_facts", []):
|
||
log.add_fact(fact)
|
||
|
||
var quest_id: Variant = origin.get("start_quest_id", null)
|
||
if quest_id != null:
|
||
var qd: Dictionary = world.quest(quest_id)
|
||
log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", qd.get("objective", "")))
|
||
|
||
# inventory grants -> game state (NOT the log). Charter §2.
|
||
for grant in origin.get("inventory_grants", []):
|
||
state.add_item(grant.get("item_id", ""), int(grant.get("qty", 0)))
|
||
|
||
return {"ok": true, "errors": [], "log": log, "state": state}
|
||
|
||
|
||
static func _roll_stat(rng: RandomNumberGenerator) -> int:
|
||
# 3d6 placeholder; the stat block is data-only in this plan. TUNABLE.
|
||
return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: PASS — the 9 `test_new_game` tests pass, `Failing: 0`. (End-to-end construction from the real `deserter.json` produces a correct log + state.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git add client/scripts/newgame/new_game.gd client/tests/unit/test_new_game.gd
|
||
git commit -m "feat(client): NewGame.construct — origin+world+creation -> {canon log, state}"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `TagExtractor` — pure §12 tag parsing
|
||
|
||
**Files:**
|
||
- Create: `client/scripts/text/tag_extractor.gd`
|
||
- Test: `client/tests/unit/test_tag_extractor.gd`
|
||
|
||
**Interfaces:**
|
||
- Produces: `TagExtractor.extract(prose: String) -> Dictionary` returning `{ clean_text: String, facts: Array, dispositions: Array, moves: Array }`, where each `moves` entry is `{name: String, args: Array}`. Extracts and strips only — no state mutation, no move validation (deferred to the HTTP plan).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `client/tests/unit/test_tag_extractor.gd`:
|
||
|
||
```gdscript
|
||
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")
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: FAIL — `Could not load ... res://scripts/text/tag_extractor.gd`.
|
||
|
||
- [ ] **Step 3: Write `TagExtractor`**
|
||
|
||
Create `client/scripts/text/tag_extractor.gd`:
|
||
|
||
```gdscript
|
||
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}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: PASS — the 6 `test_tag_extractor` tests pass, `Failing: 0`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git add client/scripts/text/tag_extractor.gd client/tests/unit/test_tag_extractor.gd
|
||
git commit -m "feat(client): TagExtractor — pure regex parse/strip of §12 tags"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Round-trip + integration — the bridge to the api contract
|
||
|
||
**Files:**
|
||
- Test: `client/tests/unit/test_round_trip.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `NewGame` (Task 6), `ContentDB` (Task 5), `CanonLog` (Task 3).
|
||
- Produces: no new production code — this task proves the client's product survives `to_dict → from_dict` unchanged and satisfies every contract invariant the api enforces (spec decision 4: parity without duplicating the schema).
|
||
|
||
- [ ] **Step 1: Write the integration test**
|
||
|
||
Create `client/tests/unit/test_round_trip.gd`:
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
|
||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||
const CanonLog = preload("res://scripts/canon_log/canon_log.gd")
|
||
|
||
|
||
func _built_log():
|
||
var world = ContentDB.new()
|
||
world.load_from(ContentDB.default_content_root())
|
||
var rng := RandomNumberGenerator.new()
|
||
rng.seed = 3
|
||
var origin := ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||
var res := NewGame.construct(origin, world, {"name": "Aldric", "class_id": "priest"}, rng)
|
||
assert_true(res["ok"], str(res["errors"]))
|
||
return res["log"]
|
||
|
||
|
||
func test_to_from_dict_is_identity():
|
||
var log = _built_log()
|
||
var d1: Dictionary = log.to_dict()
|
||
var log2 = CanonLog.from_dict(d1)
|
||
assert_not_null(log2)
|
||
assert_eq(log2.to_dict(), d1)
|
||
|
||
|
||
func test_constructed_log_satisfies_contract_invariants():
|
||
var d: Dictionary = _built_log().to_dict()
|
||
assert_eq(d["schema_version"], 1)
|
||
assert_true(d["recent_events"].size() <= 5)
|
||
assert_eq(d["player"].keys().size(), 3) # exactly name/class_id/luck_descriptor
|
||
assert_false("luck" in d["player"]) # §7: no numeric luck
|
||
assert_true(d["player"]["class_id"] in ["sellsword", "assassin", "priest"])
|
||
for m in d["party"]:
|
||
assert_true(m["disposition"] >= -100 and m["disposition"] <= 100)
|
||
for q in d["active_quests"]:
|
||
assert_true(q["status"] in ["active", "complete", "failed"])
|
||
for h in d["humiliations"]:
|
||
assert_true(h["weight"] >= 1 and h["weight"] <= 10)
|
||
```
|
||
|
||
- [ ] **Step 2: Run the full suite**
|
||
|
||
Run: `bash client/run_tests.sh`
|
||
Expected: PASS — every test from Tasks 1–8 green, `Failing: 0`. The `test_round_trip` pair proves the client's canon log conforms to the same contract the api validates, with no schema re-implementation.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/ptarrant/repos/ptarrant/coc-rpg
|
||
git add client/tests/unit/test_round_trip.gd
|
||
git commit -m "test(client): round-trip + contract-invariant integration on a constructed log"
|
||
```
|
||
|
||
- [ ] **Step 4: Hand off for merge**
|
||
|
||
The feature branch `feature/client-canon-log-engine` is complete and green. Per CLAUDE.md §18, the human smoke-tests and merges `--no-ff` into `dev`, then deletes the branch. Claude does not merge or push here.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:**
|
||
- Decision 1 (GUT, headless): Task 1 vendors GUT v9.7.1 + `run_tests.sh`. ✔
|
||
- Decision 2 (logic layer only): no UI/HTTP/save-load task exists; noted as out-of-scope follow-ups. ✔
|
||
- Decision 3 (RefCounted typed classes, `to_dict`/`from_dict`): Tasks 2–3. ✔
|
||
- Decision 4 (invariants in the model; no client schema validator; api is authority): each entity owns its invariant (Task 2), container owns cap/dedup/stack (Task 3); Task 8 proves parity via invariant checks, not a schema port. ✔
|
||
- Decision 5 (Luck-centric game state; stat block data-only): Task 4 (`GameState`, `Luck`) + Task 6 rolls stats into state. ✔
|
||
- Decision 6 (mutators + tag extractor; no move validation): mutators Task 3, `TagExtractor` Task 7, move validation explicitly excluded. ✔
|
||
- Decision 7 (fat-but-decomposed): leaf entities + container + separate `NewGame`/`ContentDB`/`TagExtractor`. ✔
|
||
- §7 boundary (numeric luck never in log): enforced by `LogPlayer.to_dict` shape + `GameState` ownership; asserted in Tasks 2, 6, 8. ✔
|
||
- `recent_events` cap 5: `CanonLog.push_event` (Task 3), asserted Task 3 + 8. ✔
|
||
- Content resolution mirrors `api/app/content.py`: `ContentDB.unresolved_refs` (Task 5), same `"kind:id"` string format. ✔
|
||
- New-game construction ordering (resolve → constraints → roll → assemble): Task 6 mirrors the spec routine, incl. companion vs non-companion override split, situation→push_event trimming, inventory→state. ✔
|
||
- Deterministic Luck via injected RNG (§10): Task 4 tests, seeded RNG throughout Task 6/8. ✔
|
||
|
||
**Placeholder scan:** No TBD/TODO. The two `TUNABLE` markers (Luck bands/die, `_roll_stat`) are deliberate, flagged tuning constants with real working defaults — not unfinished code. ✔
|
||
|
||
**Type consistency:** `construct` returns `{ok, errors, log, state}` — consumed with those keys in Tasks 6 and 8. `unresolved_refs` returns `"location:…"`/`"item:…"` strings — asserted with those exact prefixes (Tasks 5, 6). `load_from`/`default_content_root`/`origin_path`/`load_json` names match across Tasks 5, 6, 8. `Luck.MIN/MAX/DRIFT`, `roll_base`, `drift`, `descriptor` consistent across Tasks 4, 6, 8. `to_dict`/`from_dict` present on every entity and consumed in round-trip (Task 8). ✔
|
||
|
||
**Deviations from spec (flag for reviewer):**
|
||
- `construct` returns a `{ok, errors, …}` result dict rather than raising — GDScript has no exceptions; this keeps failures testable. The spec described "fail loudly"; a populated `errors` array + null products is the loud, testable form.
|
||
- `LogLocation`/`Quest` id-regex validation `push_error`s but still assigns (does not hard-reject). The meaningful mutation invariants (clamp/cap/dedup/enum) are hard-enforced; id validity is a warning surface since ids originate from already-resolved content.
|
||
|
||
**Out of scope (later plans):** UI scenes, HTTP client + degraded-DM fallback, save/load + `schema_version` migration, move validation & apply, combat/stat consumers, content packaging for export, final Luck die/range + descriptor copy.
|