Compare commits

251 Commits
master ... dev

Author SHA1 Message Date
e5fb173008 Merge M4-c: Title → Creation → Shell flow (the Main coordinator)
New Game now runs character creation and lands in the exploration shell as the
CREATED character — HP/MP/purse/location/opening-narration all real. A script-first
`Main` coordinator (scenes/Main.tscn, the main_scene) owns Title → Creation → Shell
via instantiate → inject → add_child; the title is emit-only; the shell consumes an
injected character (seed stays the F6 fallback). ⛨ saga fourth-path seam kept addable.

Completes M4. Full suite 327/327; whole-branch review clean; human F5 smoke passed.
Also adds the roadmap's next item (Ollama Cloud proxy provider + backend switch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:35:05 -05:00
f5e6c59f2a docs(roadmap): add Ollama Cloud provider + backend switch as the next item
A laptop-dev enabler (planned, not specced): extend the proxy's M1 model-call
pipeline with an Ollama Cloud backend reachable via API key, plus a switch to
flip local-ollama <-> ollama-cloud, so AI works off the homelab.

Reconciles against §4: the API key and provider choice stay server-side in the
proxy (the client never holds a key, never picks the backend). The title
Settings-button "config file" splits — the client may hold only the proxy
base URL / profile (folds into M9); the provider/key switch is proxy config.

Filed under a renamed "Landed / planned out of band" section and pointed to
from the sequencing table as the next task before M5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:27:04 -05:00
7f155b8de2 docs(roadmap): M4-c lands — Title -> Creation -> Shell flow; M4 complete
Marks M4-c done (the Main coordinator wires new game -> creation -> shell,
the shell consumes the real created character, the saga fourth-path seam is
kept addable) and flips M4 -> done / M5 -> next in the milestone table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:20:45 -05:00
d553213563 refactor(m4c): rename GameFlow coordinator to Main, move to scenes/Main.tscn
The entry-point coordinator read as an internal name ("GameFlow" buried in
scenes/flow/), so it wasn't obvious THIS is the scene that boots first — the
title screen looked like the entry point. Renamed to the Godot-conventional
Main (scenes/Main.tscn + scripts/main.gd) so the boot scene is self-evident.
TitleScreen is unchanged and still shown BY Main as its first screen.

Also drops the hand-written gd_scene uid (now matches sibling scenes) —
closes the Minor review finding. Full suite 327/327 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:16:55 -05:00
5a9f39ea48 feat(m4c): GameFlow is the main scene — title->creation->shell is the boot path
Flips run/main_scene from TitleScreen to the GameFlow coordinator, so the
shipped boot path runs the real Title -> Creation -> Shell flow. Full suite
327/327 green headless; the human F5 smoke (ADR 0001 visual gate) is the
remaining acceptance step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 04:20:23 -05:00
583d32456a feat(m4c): GameFlow coordinator wires title -> creation -> shell 2026-07-15 04:15:28 -05:00
6b10b6ea6c feat(m4c): title screen is emit-only (routing moves to GameFlow) 2026-07-15 04:10:10 -05:00
8d963e8030 feat(m4c): shell consumes an injected character (seed stays the F6 fallback) 2026-07-15 04:06:44 -05:00
e169885d90 feat(m4c): ShellState.for_character derives vitals+location from the real character 2026-07-15 04:02:37 -05:00
f763aa0c31 docs(m4c): implementation plan — title → creation → shell flow
Five TDD tasks: ShellState.for_character, the shell's injected-character
seams, the emit-only title, the GameFlow coordinator (+ its flow and
construct-failure tests), and the main_scene flip with a human smoke gate.
Each task carries full test code, exact commands, and a revert-and-watch-red
step per docs/traps.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 03:54:18 -05:00
10f89baef9 Adding helper script to clean up stale superpowers sdd files 2026-07-15 03:47:30 -05:00
aaff2c8554 docs(m4c): spec — title → creation → shell flow
A GameFlow coordinator node (new main_scene) owns the Title → Creation →
Shell sequence in one file: instantiate → inject → add_child, reusing the
existing injection seam that change_scene_to_file cannot. Title becomes
emit-only; the shell gains injected_log/injected_state seams and stops
self-seeding on the real path (seed stays as the F6 fallback), so the
created character's vitals, location, and opening narration are real.

The ⛨ saga constraint is honored structurally: _show_creation and
_start_campaign are separate, so the fourth entry path is an added arm,
not surgery on existing edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 03:43:57 -05:00
a106be673f Merge M4-b: character creation screen
The mock's Character Creation screen over the M4-a model: four race cards, seven
nameplate calling cards (blurb + mechanics on the chosen one), the +3 additive
spend pool, calling-gated proficiency chips (+ the Human bonus-skill row), five
ability cards — never six, and 'luck' nowhere (§7) — and the DM origin panel.
Editor-first per ADR 0001: layout in the .tscn, script only binds.

F6 human gate: screenshot-verified all 8 checklist items. Granted skills read as
owned (gold + ✓), the small text was enlarged for sub-1920 laptops, four load-time
script warnings cleared. 319 client tests green.

Emits creation_confirmed(creation); the title→creation→shell wiring is M4-c.
2026-07-14 20:35:42 -05:00
1ae62fe0db feat(creation): distinguish granted skills as owned, and enlarge the small text
Two F6-review follow-ups from the human's read of the running screen.

1. GRANTED skill chips now read as 'you already have this', not 'unavailable'.
   The elf's granted `perception` shared SkillChip's dim `disabled` look with the
   'picks are spent' chips, so it read as blocked. Split out a fourth state,
   SkillChipGranted — a solid gold fill, cream text, and a ✓ prefix (owned) — while
   the pool-is-spent chips keep the plain dim disabled. Applied per-chip in _bind_skills.

2. Enlarged the small mono/body/prose roles for readability on sub-1920 laptops
   (the 1920 design canvas scales DOWN on a smaller screen, so 11-13px text got
   tiny): DM prose 20->22, MONO 13->14, section labels 13->14, card title 19->20,
   card body 13->14, chips 11->12, skill chips 12->14, the two flag pills +1. Big
   serif headings unchanged.

   Two layout consequences the guards caught and I fixed, not silenced:
   - Race cards overflowed the taller blurb -> card min-height 140->180 (the overflow
     guard, which measures against the card the engine does NOT derive from the text,
     went red first; traps.md #17).
   - The 9-chip Human bonus row, wider at the bigger font, blew the whole sheet past
     the viewport -> BonusRow HBoxContainer -> HFlowContainer so it wraps instead of
     forcing the sheet wide. Nothing depends on the row's type (tests check child
     count + visibility only).

319 client tests green (theme drift guard + overflow guard included); screenshot-
verified at 1280x720: granted chip owned, no clipping, bonus row wraps cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:33:08 -05:00
8f3aa75fa9 chore(warnings): silence the four pre-existing script warnings the creation screen surfaced
Not errors and not regressions — Godot printed them on every script reload
and they predate M4-b. Cleared so a real warning is not lost in the noise:

- Redundant `const ContentDB`/`const NewGame` preloads that shadowed their
  own `class_name` globals (character_creation.gd, creation_draft.gd) — the
  class is already global, so the const bought nothing.
- Two `log` locals shadowing the built-in `log()` — renamed to `out`
  (canon_log.from_dict) and `canon` (NewGame.construct); the "log" dict KEY
  is the public contract and is unchanged.
- currency.format's copper->denomination division is integer BY DESIGN (the
  remainder is carried, not lost) — annotated `@warning_ignore` to say so.

319 client tests green; headless parse reports none of the four sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:09:29 -05:00
63cbc8c16e fix(creation-screen): the white text, the badge on top of the roll, and a window too big for the laptop
The human's first F6 run found four defects headless GUT could not see, all
now fixed and break-proven.

1. Fifteen Labels shipped invisible. _fonts() sets a default font and size but
   never a default font_color for the base Label type, so a Label with no
   type-variation inherited Godot's built-in default — WHITE — and rendered
   white-on-parchment: the four race names, four race blurbs, and seven calling
   names, every unreadable string on the sheet. New CardTitle / CardBody roles
   (mock #3a2f1c / #6a5a3a) fix it. NOT a global default Label colour: the Title
   screen has 12 bare Labels on a dark background that rely on white.

2. The PRIMARY badge covered the rolled value. The ability card is a
   PanelContainer, and a Container force-fits its children, so the badge's
   authored anchors were dead letters — it stretched to the card's full width
   and centred over the value. Nesting it under a plain Control (not a
   Container) restores absolute positioning. The badges also carried _flat()'s
   12/6 card padding; a new tight pill stylebox matches the mock's 2px 9px.

3. Race cards overflowed their own border (108px card, 114px of content). Bumped
   to 140. The CHOSEN badge on the calling card moved from centre to the mock's
   top-right, and the ability card got its own AbilityCard variation with 16px
   top headroom for the badge (ParchmentCard is shared with the shell).

4. The default window (1600x900) was larger than a 1600x900 laptop's usable
   area, so the WM clamped it to 1589x752 and the run came up pillarboxed.
   Default is now 1280x720, resizable; the 1920x1080 design canvas is unchanged
   (canvas_items + keep scales it, so no layout number moved).

Also closes M4-b's two open copy items: the cutpurse origin fragment (it
contradicted its own blurb) and the Hint label (it omitted the roll's floor of
8, now pinned to NewGame.roll_attributes).

Guards for 1-4 are new and each was re-broken and watched go red. The overflow
guard in 3 was FIRST WRITTEN AS A TAUTOLOGY — Godot clamps Control.size up to
its combined minimum, so `min <= size` is `x <= x` — and passed against the bug,
29/29, until it was rewritten to measure against the card that clips. That is
traps.md #17 and the thirteenth cannot-fail assertion this branch has caught.

319 client tests green, content build green, theme drift guard satisfied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:56:09 -05:00
7471d2e37f docs(traps): the drift guard took three fixes, and the third one whitelisted the gap
Trap 12 told only the first act. The full story is the lesson: ThemeKeys.ALL excludes
the font roles by its own docstring, and the builder styles some base types directly
(RichTextLabel — which renders every line of DM prose on the creation screen). The
meta-guard written specifically to prevent a third miss contained a `continue` that
exempted exactly the gap it existed to catch.

A guard-of-a-guard with an exemption in it is not a guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:48:27 -05:00
6291f21a00 fix(theme-guard): close the drift guard's third miss — the meta-guard's own continue whitelisted the gap
test_committed_tres_matches_builder has now been "fixed" three times (traps.md #12).
v1 hardcoded 3 checks; v2 iterated ThemeKeys.ALL but missed the six font roles; v3
added a meta-guard specifically to catch "the builder styles a thing guarded by
nothing" — but that meta-guard contained `if fresh.get_type_variation_base(variation)
== &"": continue`, which whitelisted exactly the case it was built to catch. The
builder styles RichTextLabel directly (build_game_theme.gd's _rich_text()), with no
set_type_variation, so it reports base "" and was skipped unconditionally — and
RichTextLabel is what renders every line of DM prose the creation screen shows
(_origin_text, _detail_blurb). Nothing compared its fonts, font size, or colour, nor
the theme-level default_font/default_font_size, against the committed .tres.

Adds ThemeKeys.BASE_TYPES for base Control types the builder styles directly, folds
it into the drift guard's coverage, replaces the meta-guard's continue with an
assertion that names the offending type, and adds the missing theme-level default
font/size comparison. Proved with three reverted breaks: a RichTextLabel font-size
edit, a theme-level default_font_size edit, and an unregistered "GhostRole" variation
— all three now go red and name the drifted property.

Also: strengthens test_creation_copy's error-copy sweep to assert a mapped error
actually reaches its authored line rather than silently falling through to the
generic UNSPOKEN fallback (CreationCopy.UNSPOKEN satisfied all four prior checks,
so a renamed validator string could regress silently); and removes two assertions
that were true by construction / already true before their test's action ran.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:47:24 -05:00
34bf064904 fix(creation-screen): the bonus-skill hole, the theme guard that could not fail, and the strings the player was reading
Final whole-branch review of M4-b. Six findings.

1. The player could press his way into an invalid draft. can_take_bonus()
   refused an already-picked skill; can_pick() did not consider the bonus, so
   the guard was one-directional. Take athletics as the human's bonus, pick it
   again from the Sellsword's pool, and the draft is one NewGame.validate
   rejects — the invalid draft "he did not cause and cannot see" that
   set_calling's own comment forbids. Fixed in the DRAFT (the screen owns no
   rules): can_pick() now mirrors can_take_bonus(), so the chip goes inert, the
   idiom the pool row already uses for a race-granted skill. Recoverable: hand
   the bonus back and the chip is live again. Both directions tested.

2. The theme drift guard STILL could not fail. ThemeKeys.ALL is stylebox
   variations only, by its own docstring — so the six FONT roles the builder
   sets fonts, sizes and colours for were entirely unguarded. Changing
   Palette.CREAM and skipping the rebuild shipped a stale game_theme.tres with
   the suite green (proven, on the committed test). Adds ThemeKeys.FONT_ROLES
   beside ALL (never inside it — ALL's contract is relied on), compares fonts,
   font sizes and font colours as well as styleboxes, and names the variation
   AND the property that drifted. Plus a meta-guard that walks the builder's own
   output and fails if a variation it styles is in neither collection — this is
   the second time this guard has been fixed by "enumerate the right set", and
   nothing was checking the set.

3. §13: the CTA label piped NewGame.validate's diagnostics straight to the
   player. On the game's SECOND SCREEN he read "hedge_mage picks 2 skills, got
   0". Now CreationCopy.error_line maps them to authored copy — "name yourself",
   "choose two more proficiencies" (the number DERIVED from Callings.skill_count,
   never written down), "one more proficiency, any of them — take it" — with an
   authored fallback for anything unmapped, because §13 means the unmapped case
   still speaks in voice. validate's strings are untouched: they are a contract,
   so they are translated in the presentation layer, not rewritten.

4. §2 had no test that fails if the boundary is breached. The emit test compared
   the signal to draft.to_creation() — both sides move together. Pins the seven
   contract keys exactly, and feeds construct a hostile creation dict carrying an
   18 in every stat, asserting the sheet is still roll_attributes(seed) + spend.
   Every other test in test_new_game passed with that breach in place.

5. Three holes: the orphan sweep skipped PoolRow (a stray Pool6 rendered unbound
   and visible, suite green — the exact bug the test exists to catch, in the one
   row it forgot); the §7 sweep forbade "luck" but not Luck.BANDS' descriptors,
   which are worse than the number (the player would re-roll until his Luck read
   well — calculable Luck); and the DM panel's composition hardcodes the article
   "a", so a vowel-initial calling would break it silently — guarded in the
   content parity test, which names the template as the reason.

6. The calling card printed "talent second_wind". Four of seven talents carry an
   underscore. Humanised, the way skill_label() already does. The test that
   pinned the RAW form is moved to the full derived phrase ("talent second wind")
   so it still fails if someone hardcodes a talent into the format string.

Every guard was proven by re-breaking the code and watching the named test go
red (docs/traps.md). Suite 302 -> 315, content build green, no new warnings.

Report: .superpowers/sdd/final-review-fix-report.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:25:12 -05:00
31759ef848 docs(roadmap): M4-b lands; the Title screen was already built
The creation screen is done — 302 client tests (up from the 250 baseline),
content build green. Also flips the M3 Title screen from planned to done: it
shipped in a77bf03/f071392 and the roadmap never caught up, which would have
misled M4-c into thinking it must build one.

Adds six new trap species to docs/traps.md from this milestone's review
passes: substring-collision assertions, a BBCode wrapper masking an empty
ContentDB, a test asserting the script's own loop bound, a trivially-true
gating fixture, assertions already true before the action under test ran, an
"iterates everything" guard that checked three hardcoded pairs, a node-type
sweep that missed RichTextLabel, tests that called handlers instead of
pressing nodes, GUT silently skipping an unparseable class_name test file at
the old count, and a test that can hang instead of fail. Also documents the
new client-docs convention: clickable cards are Buttons with mouse_filter = 2
on every child, and pressed on a toggle_mode Button is the chosen state.

Human F6 confirmation of the rendered screen against the mock is still
outstanding and is not claimed here.
2026-07-13 16:00:17 -05:00
4494856a44 test(creation-screen): close the §7 sweep's own emptiness guard, and the M3-a viewport-fit guard
The Luck sweep's non-empty-label assertions could not fail: _bind_bay and
_bind_callings always wrap prose in BBCode italics ("[i]%s[/i]"), and
_origin_prose() always emits the literal connective " Now you carry a %s's
work — " even when every authored fragment/blurb is blank. So
"[i][/i]".strip_edges().is_empty() reads false regardless of what ContentDB
served — the 28-combination sweep was proving the wrapper is non-empty, not
that §7's own guard against a blank ContentDB actually holds. Replaced the
emptiness checks with assertions that the DM panel and detail panel render
the WORLD's own fragment/blurb strings, and that those strings are
themselves non-blank, for every race x calling combination.

The M3-a collapse guard (`assert_gt(s.size.x, 0.0, ...)`) also could not
fail: the .tscn root is authored at exactly 1920x1080, matching the
project's viewport size, so the assertion passed on the authored offsets
alone with _fit_to_viewport() and its size_changed connection deleted
entirely. Replaced it with an actual resize proof: setting
Window.content_scale_size (the property this project's canvas_items stretch
mode actually keys Control.get_viewport_rect() off of — get_viewport().size
does not move it headless) to a non-1920x1080 value and asserting the root
followed, then restoring it.

Also covers three previously-unasserted binder behaviours: a race-granted
skill chip going inert (and a chip going inert once the pool's picks are
spent), the bonus-skill row's visibility gate per race, the ability card's
primary-stat Tag/theme_type_variation, and _on_enter_pressed's early-out on
an illegal draft.

Every new/changed guard was proven red against the bug it names (blanked a
calling's blurb; hardcoded a placeholder origin string; deleted
_fit_to_viewport's wiring; dropped the chip.disabled line; forced the bonus
row visible) and reverted. Suite: 298 -> 302, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:53:47 -05:00
c5f127e0f1 test(creation-screen): close 3 vacuous guards — point-buy display, signal wiring, Luck sweep
Adversarial re-review of 6589d7c found three assertions in the same species as
the seven it fixed: green against the bug they name.

- test_the_displayed_scores_are_the_draft_s_scores spent zero points, so
  final() == rolled() and a Box/Value bound to `rolled[stat]` instead of
  `final[stat]` would pass unnoticed. Now presses the Plus node to force a
  divergence, and checks the Box/Rolled sub-label too (previously unchecked).
- No test ever emitted a node's `pressed` signal — every interaction went
  through the _on_*_pressed handler directly, so _wire() itself, and every
  card-to-index binding, had zero coverage. Minus had no coverage of any
  kind. Helper and interaction tests now press the authored nodes; added
  dedicated press tests for race/calling/pool/bonus/minus, plus the
  Plus/Minus disabled-boundary bindings.
- The Luck sweep only ever checked the default binding (human + sellsword),
  covering 1 of the 28 race x calling combinations whose authored prose
  (fragment/blurb) actually renders on screen. Now sweeps all 28.

Plus three cheap adds: a degenerate-origin (§13) guard, orphan-authored-card
counts for the race/calling/bonus rows, and a non-empty assertion on the
origin-card RichTextLabel.

Each fix verified by deliberately reintroducing its named bug, confirming the
guard test goes red, then reverting — see
.superpowers/sdd/task-6-report-fix2.md. 291 -> 298 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:38:31 -05:00
6589d7c4ed fix(creation-screen): the guards did not guard
Review found three tests in test_character_creation_screen.gd could not fail
against the bug they named — including both tests claiming to enforce §7
(Luck is invisible) and the only test covering origin->calling gating. The
five-ability-cards test asserted the script's own range(5) loop bound, not
the scene. The Luck sweep filtered on `Label` and missed both RichTextLabels
(the only nodes rendering authored prose) plus every Button and the
LineEdit. The reroll test's pool assertion already held true before the
reroll ran. The origin-gating test ran only against the deserter origin,
which allows all 7 callings, so shown==allowed was a trivial 7==7.

Rewrote all four to assert against the scene, the rules tables, or a
restrictive injected origin instead of values the code already guaranteed.
Added a node-count guard tying authored cards/chips to Races/Callings/
Skills/Attributes table sizes. Deleted the test-only
_complete_a_valid_draft_for_test() from the shipped Control and replaced it
with a test-file helper that drives the real click handlers, closing the
gap where no test touched _on_race_pressed/_on_calling_pressed/
_on_pool_pressed/_on_bonus_pressed at all. Guarded _ready() against a
degenerate origin (empty/missing allowed_callings) indexing an empty array.

Each fixed guard was deliberately re-broken and confirmed red before being
reverted (see .superpowers/sdd/task-6-report-fix.md, gitignored). 291 tests
green, up from 289.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:19:10 -05:00
2227d703f6 feat(creation): the character creation screen
Variant C of the mock: seven nameplate calling cards in one row, with the chosen
calling's blurb AND mechanics in a detail panel below — the one card you
committed to is the only one that talks.

Editor-first (ADR 0001): the tree is in the .tscn, the script binds. No rules
here — CreationDraft holds them, CreationCopy derives the display strings, the
prose comes from ContentDB. Emits creation_confirmed(Dictionary); M4-c
constructs. Five ability cards. Never six (§7).
2026-07-13 15:03:24 -05:00
fe8e24cf39 fix(tests): make theme drift guard iterate ThemeKeys.ALL, not 3 hardcoded pairs
test_committed_tres_matches_builder claimed to catch a builder/artifact
drift in game_theme.tres, but its checks array hardcoded exactly three
variation/state pairs (PrimaryCTA, ParchmentCard, DarkPanel) checking only
bg_color. Every other variation — including all five added for the
creation screen (SelectCard, SkillChip, ChosenTag, PrimaryTag,
PrimaryCard) — could drift from the builder silently, and this test would
keep passing on stale data. Textbook docs/traps.md failure: a test that
cannot fail against the bug it names.

Rewrote it to derive checks from ThemeKeys.ALL: for every variation,
every stylebox state the builder actually set is compared (bg_color,
border_color, border widths, corner radii for StyleBoxFlat), and every
font colour role the builder actually set is compared too. Failures name
the variation and the drifted property.

Verified by deliberately drifting SkillChip's pressed stylebox in
build_game_theme.gd without regenerating the .tres: the new test failed
and named SkillChip/pressed/bg_color+border_color; the old three-pair
version would not have caught it (SkillChip was never in its list).
Reverted the drift; suite is green at 279 again. No pre-existing drift
found in the committed artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:57:28 -05:00
0bbf94289a feat(theme): the creation screen's five variations
SelectCard (a Button whose `pressed` IS the chosen ring), SkillChip (a toggle
with unpicked/picked/granted-so-inert), the CHOSEN and PRIMARY tags, and the
gold PrimaryCard ring. Plus INK_LABEL_MUTED for the sheet's section headers.

Built by build_game_theme.gd and regenerated; the .tres is never hand-edited.
2026-07-13 14:48:17 -05:00
1621a38b29 fix(creation-draft): delete the duplicate SPEND_POOL const
CreationDraft's docstring promised it owns no rules—it delegates to NewGame.validate.
But it kept a local const POOL := 3 that mirrored NewGame.SPEND_POOL, inviting
silent disagreement if the source was changed and the copy forgotten.

Delete POOL. Use NewGame.SPEND_POOL directly in points_left(), with a comment
making it clear this is the single authority. The coupling is now enforced by
code structure, not by hope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:44:50 -05:00
866158cc0d feat(creation): CreationDraft — the screen's brain, with no screen
Pure, node-free, headlessly testable. Carries a seed, not a stat block. Owns no
rules of its own: errors() delegates to NewGame.validate.

The interaction rules that make creation screens buggy are pinned by test:
changing calling clears the picks; changing race drops a pick the new race now
grants (the elf/cutpurse case); re-roll clears the spend and only the spend;
minus floors at the roll; the fourth point does not exist.
2026-07-13 14:38:18 -05:00
1c4d19492d fix(test): skill-count assertion collided with hit-die digit for Reaver
test_calling_detail_reads_the_table_rather_than_repeating_it checked for
str(Callings.skill_count(id)) — bare digit "2" — which appeared in d12 for
the Reaver (hit_die=12, skill_count=2). Hardcoding "picks 3 skills" in the
card would pass the test, satisfied by the "2" in "d12".

Now anchors to "picks %d skills" format string, so the guard catches when
the card's hardcoded count diverges from the table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:34:12 -05:00
537b0e1d18 feat(creation): CreationCopy — the card reads the table
Display strings derived from Callings/Races/Skills at call time. Retune a hit
die and the card follows, because the card never knew the number. Prose stays in
content; numbers stay in code; nothing is written twice.

Deviation from the plan, approved by the human: the plan's test asserted the
rendered line contains Callings.armor(id) — the RAW table value — while the plan's
own ARMOR_WORD maps "none" to "no armour". "none" is not a substring of "no armour",
so the Hedge-Mage failed. The card's copy is what the plan's docstring example shows,
so the copy stands and the test was fixed: armor_word(id) is now public and the test
asserts against the derived word. The guard is unchanged — hardcode an armour string
and retune the table, and it still goes red.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:28:30 -05:00
057b83679b fix(content): bonesetter fragment restated the blurb's central non-claim
The fragment's closing clause — "you have stopped claiming to know whether
it is your hands or the Warden that does the saving" — restated the thesis
of the blurb the player is reading at the same moment, inches above it in
the calling detail panel. The blurb's whole argument already IS the
unresolved-Warden question: it states it outright ("distant and does not
explain himself") and dramatises it with its doubled "Often enough." The
fragment took that artful implicit doubt, said it flat, and put it in the
emphatic final position of the composed DM line. Different words, same beat.

It also collided lexically with the beastfolk race fragment, which the
composition can place directly before it: "You stopped taking that
personally" / "you have stopped claiming".

Keep the front clause — "you are the one they wake at the third hour" is the
best image in the set and gives the player a position in a village that
nothing else on the screen supplies. Rewrite the back clause to say the next
thing instead of the same thing: what the failed nights cost, and that the
job does not release you for failing. The fragment no longer mentions the
Warden at all, so it cannot re-argue him; it now makes a claim about the
player's life rather than about a god.

Also avoids "carry", which the composition template itself supplies ("Now
you carry a Bonesetter's work"), so the rendered sentence no longer risks
repeating its own verb.

Client suite 255/255. content_build --check exit 0.
2026-07-13 14:18:46 -05:00
c6b8a651aa fix(creation-content): four fragments were reading the blurb back to the player
The creation screen shows a race's blurb on its card and the chosen calling's
blurb in the detail panel directly above the DM panel. Blurb and fragment are on
screen at the same moment, inches apart — so a fragment that paraphrases its own
blurb makes the DM sound like it is reciting the card. Four did:

- bonesetter lifted two clauses verbatim ("you set the bone, you say the words")
- reaver re-ran the blurb's door image with the same sentence shape
- hedge_mage re-opened on "what you know" and re-landed the dead teacher
- beastfolk repeated both the claws and the being-judged beat

Each now says the NEXT thing instead: the hour they wake you and which of the
two trades you will not claim credit for; the employer who arranges to be
elsewhere; where you went to fail safely and what the book's gaps cost your
skin; the town that takes your coin and still wants you outside the walls.

beastfolk's rewrite also breaks the race-set monotony — it no longer opens "You
were born" (now unique to human) and drops the shared "X, and you have…" spine,
so clicking the four cards in a row stops reading as one voice.

The parity test guarded presence but could not fail on the three properties that
actually make all 28 race×calling concatenations grammatical. It now asserts
race fragments start uppercase and end with '.', and calling fragments start
lowercase and end with '.' — verified by breaking both and watching all four new
assertions fire.

255 client tests green; content build --check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:11:16 -05:00
e4db022c4b content(creation): the eleven origin fragments
race.fragment + calling.fragment compose the DM origin panel on the creation
screen. Authored prose, not stubs — this is the first sentence the player reads.
Parity-tested: a missing or empty fragment fails the suite.
2026-07-13 14:02:47 -05:00
17ef142689 feat(newgame): one public, seeded roll the screen can show
roll_attributes(seed) is now the single implementation of the die — construct
calls it, and the creation screen (M4-b) will call it to DISPLAY the roll. So
the screen shows real numbers while still only ever handing over a seed (§2).
validate() goes public so the screen can gate its CTA on the real rules.

Neither takes a default parameter (traps.md #2).
2026-07-13 13:54:02 -05:00
7a74a8de94 docs(plan): M4-b — the creation screen, seven tasks
TDD task-by-task: NewGame's public seeded roll -> the eleven fragments ->
CreationCopy -> CreationDraft -> the theme -> the scene -> the docs.

Both of traps.md's bugs get a named guard rather than a hope: the pipeline test
that asserts what the screen SHOWED is what construct BUILT (and a step that
re-breaks the code to prove the test can fail), and a no-default-parameters rule
on the two new public functions.

Also corrects the spec: PrimaryCTA already has a disabled stylebox.
2026-07-13 07:07:36 -05:00
333d901931 docs(spec): M4-b — the character creation screen
The mock's screen over M4-a's proven model. Five mock/model reconciles
recorded: the skills section the mock never drew, seven callings not five,
nameplate cards with prose on selection, a minus button, no portrait arrows.

No AI call anywhere — the DM's entrance is the shell, one screen later.
The screen shows the roll by calling the same pure roll_attributes(seed)
construct does, so it displays numbers without ever handing one over.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:56:31 -05:00
b4b72e472e docs: traps.md — the bugs that passed a green suite
Six bugs this project shipped into a branch, what let each through, and the
guard that now catches it. They share a shape: the test passed, and the test
was wrong.

- a regression test that constructed its own correct object, so reverting the
  bug left the suite green
- GDScript default params turning an arity change into a silent miscompile,
  invisible to a grep for the renamed field (grep cannot see positional args)
- a determinism test comparing two runs that would change together
- a closed move vocabulary that was not bounded, because the model could repeat
  a tag and choose an amount in unary
- a roster written down in four places with only one pair guarded — including a
  shipped authoring skill that would have emitted origins allowing zero callings
- generated content that has a client consumer

Linked from CLAUDE.md §18 and docs/README.md. Also records M4-a on the roadmap
with its live end-to-end proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 21:07:39 -05:00
b663703da7 Merge feat/creation-model into dev (M4-a)
The character-creation model: race/calling/skill mechanics as static tables in
code, their blurbs as content, a CharacterSheet that stores only what was rolled
or chosen and derives the rest, and a seeded NewGame.construct.

Creation carries a seed, not a stat block — construct rebuilds the RNG and rolls
the attributes itself, so it never trusts a number handed to it (§2) and the same
seed yields an identical character, hidden Luck included (§10). A golden-vector
test pins the RNG stream. The roll is 3d6 with a hard floor of 8: straight 3d6
puts ~9% on a primary the +3 pool cannot rescue, and a character bad at the one
thing he is FOR is the 'fine' §7 forbids.

Migrates the cross-boundary contract: class_id -> calling_id (the contract should
say what the world says) and a required race_id, so the AI narrates 'a beastfolk
cutpurse' instead of 'a sellsword'.

No UI — the creation screen is M4-b, the title->creation->shell flow is M4-c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:59:36 -05:00
a9357fb787 fix(creation-model): migrate stray class_id/allowed_classes copies
The canon-log schema and NewGame code already migrated to race_id/
calling_id + allowed_callings, but two copies of the roster outside
that schema were missed, and no parity test guards them:

- docs/canon-log.md (the cross-boundary contract doc) still documented
  the OLD player shape and allowed_classes. Anyone building a player
  block from the doc would get a 422.
- .claude/skills/world-building's schema reference still emitted
  allowed_classes with two dead calling ids (assassin, priest). Author
  a new origin with that skill and it fails origin.schema.json
  (additionalProperties: false, allowed_callings required) AND, if that
  ever loosened, silently allows zero callings at runtime.

Also:
- add schema tests rejecting a dead class_id field and an unknown
  calling_id (priest)
- guard NewGame._validate's container types (spend/skills) so a
  malformed JSON round-trip (null spend, string skills) produces a
  front-loaded error list instead of a GDScript runtime crash
- extend the no-mechanics-in-content test to db.races, not just
  db.callings
- rewrite the roadmap's M4 bullet: the two contract migrations landed;
  only the creation screen and title->creation->shell flow remain

client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:58:49 -05:00
62b8113ffa fix(creation): pin RNG stream, tighten spend/seed validation
Finding 1 (important): test_same_seed_yields_an_identical_character only
compared two construct() runs against each other, so swapping dex/con in
Attributes.IDS still passed the whole suite while every persisted seed
would silently become a different character. Added a golden-vector test
that pins one fixed seed to its exact rolled attributes and hidden Luck,
so reordering Attributes.IDS or moving the Luck roll fails loudly instead
of silently rewriting every saved character. Proved it: swapped IDS,
watched the golden vector fail, restored, confirmed green.

Also: _validate_spend no longer lets a negative value mask the over-pool
check (continue after each error instead of falling through to `total +=
v`); non-integer spend values (float/bool/string) are now rejected instead
of coerced; a missing or non-integer seed is now a validation error instead
of silently defaulting to 0. GameState's header comment no longer
advertises "stats" it no longer holds.

243 -> 248 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:50:06 -05:00
bd49e118d5 feat(newgame): the seeded creation pipeline
construct() takes a seed, not a stat block. It rebuilds the RNG and rolls the
attributes itself, so it never trusts a number handed to it (§2) and the same seed
yields an identical character, hidden Luck included (§10). The whole character is
reproducible from four primitives: seed, race_id, calling_id, spend. The rng
parameter is dropped — the seed is now the only source of randomness.

The roll is 3d6 with a hard floor of 8. Straight 3d6 puts ~9% on a primary the +3
pool cannot rescue, and a character bad at the one thing he is FOR is not grit —
it is the fine §7 forbids, paid for twenty hours.

LCK is unspendable, not merely hidden: spending on it is a validation error.
GameState.stats becomes GameState.sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:43:51 -05:00
eba706b906 test: assert seed-log fields, not a fresh LogPlayer
The previous regression tests in test_entities.gd only constructed their
own LogPlayer and never touched the seed logs the real call sites build
— reverting main_window_shell.gd:124 to the broken 3-arg call still left
the whole suite green. Not a real regression guard.

Now assert against node._log.player in the shell test and against the
two dev-harness scenes' seed logs (narrate_harness, npc_harness — both
cheaply testable via instantiate + add_child_autofree, no scaffolding
needed). Proved the new shell assertion fails when the call site is
reverted to the 3-arg shape (226/227, one failure), then passes restored
(227/227). Removed a redundant ctor test from test_entities.gd; kept the
one that exercises the exact broken-shape call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:39:48 -05:00
5149e817f6 fix(contract): repair 3-arg LogPlayer callers + digest article bug
Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.

Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.

Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".

Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
  calling_id; a calling passed into the race_id slot (the exact shape
  of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
  digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
  allowed_callings enum matches Callings.IDS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:34:05 -05:00
a668cf98eb refactor(contract)!: class_id -> calling_id, and the log gains race_id
class_id is the rulebook's word; the world has callings — that was the whole §17
reconcile. The contract the Narrator reads should say what the world says. There
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
and strictly more expensive every milestone after.

race_id reaches the log because the AI must be able to describe the player:
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
It humanizes the id (the model must never read 'hedge_mage') and picks the right
article ('an elf', not 'a elf').

LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
parity test reads the JSON Schema from the client and asserts its enum equals the
code, so the two sides of the HTTP boundary cannot drift.

The deserter allowed three callings that no longer exist. He now allows all seven;
a thematic gate is a content decision for the Greywater authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:27:56 -05:00
bb5d176262 feat(state): CharacterSheet — store the inputs, derive the rest
Stores only what was rolled or chosen, plus current hp/mp (the only two numbers
that genuinely mutate). Everything else is a function, so no derived number can
drift from its inputs and M5's level curve is a function change, not a migration.

LCK has no row and no accessor here — §7 holds by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:21:45 -05:00
a7e153884f feat(content): calling + race blurbs, loaded by ContentDB
Hand-written like items/ — no build-tool namespace needed. Blurbs are content;
mechanics are code, and a test asserts the blurb files carry no hit dice. The
prose is placeholder; swapping it later touches no code, which is what stops the
content BOM from blocking the engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:18:19 -05:00
78e18107b2 feat(rules): the race, calling, skill and attribute tables
Mechanics are rules and live in code, beside Luck and Currency. The skill pools
are new — the races/classes spec said only 'the stat-appropriate slice' and never
enumerated them; each pool is larger than its pick count so the choice is real.

The dwarf's +2 vs poison is exposed separately from save() rather than folded
into it: baking a conditional into a flat number is how a sheet starts lying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:14:44 -05:00
89d4c65a7a docs(plan): M4-a — the creation model, six tasks
TDD, green suite at every boundary. Task 4 is deliberately one cross-cutting
commit: the origin's key and the log's player field are read on both sides of
the HTTP boundary, so renaming them in separate tasks would leave a suite red
at the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:08:21 -05:00
9de8ee40b3 docs(spec): M4-a — the character creation model
The rules a character is made of, with no screen attached. Races, callings and
skills become static tables in code; blurbs stay content (hand-written, like
items/), so the content BOM stops blocking the engine. The sheet stores only
what was rolled or chosen and derives the rest — one source of truth, and the
M5 level curve becomes a function change rather than a migration.

Creation is seeded. The creation Dictionary carries a seed, not a stat block:
construct reconstructs the RNG and rolls the attributes itself, so the same
seed yields an identical character and the UI cannot inject a number. §10's
seeding argument, applied where the races spec says it first matters.

3d6 with a hard floor of 8. Straight 3d6 puts a ~9% chance on a primary the +3
pool cannot rescue — a Hedge-Mage with a -1 MAG modifier is not grit, it is the
"fine" §7 forbids, paid for twenty hours.

Lands the two migrations the races spec said to do at M4 and not silently:
class_id -> calling_id (the contract should say what the world says) and a
required race_id, so api/app/prompts.py can render "a beastfolk cutpurse"
instead of "a sellsword".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 19:48:46 -05:00
198ea27d1a docs(canon): the Barbarian name no longer 'stays open'
Follow-on to bb80979 — the cosmology spec still asserted the STR-axis name was
outstanding. It is Reaver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 19:34:42 -05:00
bb8097929a docs(canon): name the last two callings — Reaver, Bloodsworn
All seven callings are now named; no [CANON-TBD] remains in the specs.

Reaver (Barbarian). To reave is to raid — occupational, like Sellsword and
Trapper, naming what he does for money rather than what he is inside. That is
the rule the five mock names already obeyed and that §8's own list broke: a
calling is what a village calls you, not what a rulebook calls you. Bearsark
and Wildman were rejected for naming a condition; Breaker for being generic.

Bloodsworn (Warlock) was not a new coinage — the cosmology minted it a day
after the races spec was written ("what the Margreave calls bloodsworn",
rule.blood-price), and the cosmology spec had already applied it. It is the
exact FTH-axis mirror of Bonesetter: one channels the Warden, one pacts a king
of the Seven. Accepted cost, recorded in the spec: alone among the callings it
names a condition rather than a trade — the villain who pacts Ghaul is
bloodsworn too. A second, occupational word (Cursewright, Hexman) was
considered and rejected as a distinction the player will never feel.

Also retires the stale "reconcile class names" note on M4 and records the two
contract migrations that milestone owes: the canon log's `race` field, and the
deserter origin's dead allowed_classes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 19:34:26 -05:00
c3e02c7f7d docs(roadmap): currency landed; M4 is next
Both roadmaps still described the copper currency change as pending. It is
merged. The marker moves to M4, M8's line now says what it still owes (the
price table — nothing is priced yet), and a "Landed out of band" section
records the two items that shipped outside a milestone: the currency value
type, and the once-per-reply move cap its review turned up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 19:25:22 -05:00
e8a61bf76f Merge fix/duplicate-move-tags into dev
A move applies at most once per reply. Repeating a tag let the model pick an
amount in unary (§2 breach); the currency-only guard is generalized and deleted.
adjust_disposition's MAX_DELTA now caps the reply's net swing, not each tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 19:11:47 -05:00
acc5a7b2a2 fix(npc): a move applies at most once per reply
The tag extractor returns every occurrence of a move tag and the validator is
a pure membership test, so MoveApplier applied duplicates. A model repeating a
tag N times therefore picked an amount in unary — the AI choosing a number,
which is the §2 breach the bounded vocabulary exists to prevent.

The currency fix (8e1238c) closed this for denominations only. Generalizing it
to every move that names a thing covers real items too — three give_item(amulet)
tags are one amulet, not three — and lets the currency special case be deleted
rather than kept alongside a second mechanism. ID_MOVES is reused from
MoveValidator instead of restated.

Also caps adjust_disposition on the reply's NET swing. MAX_DELTA was clamped
per tag, so three +15s moved standing by 45 — precisely the wholesale swing its
own comment says it forbids. Applied in emission order, so a later
become_hostile still floors what came before it.

offer_quest and reveal needed no change: add_quest self-guards on has_quest and
mark_revealed is idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 19:11:40 -05:00
96c270601a Merge feat/currency into dev
Money becomes copper-based with real denominations (1g = 100s = 10,000c),
stored as one integer and formatted only at the display edge.

Money is not an inventory item: give_item/accept_item carry no quantity and
gifts_given is a global one-shot, so a single 'copper' id would be givable
once, for one copper, per campaign. The denominations are move tokens instead
— give_item(gold) is +10,000c — and currency routes to the purse, never the
inventory dict, so it cannot surface in an inventory grid by construction.

Prices remain content and remain M8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:53:26 -05:00
a495543290 test(currency): pin both halves of the dedup key
The existing test varied the move name and the denomination together, so a
name-only or denom-only key would have passed it too. Two assertions nail it:
give_item(gold)+give_item(silver) kills a name-only key, and
give_item(gold)+accept_item(gold) kills a denom-only key.

The spec now records the per-reply ceiling as a decision rather than an
accident, and flags the pre-existing non-currency duplicate-tag path as open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:49:11 -05:00
8e1238c316 fix(currency): cap repeated currency move tags at one per reply
TagExtractor collects every [MOVE:] tag occurrence with no dedupe, and
MoveValidator is a pure membership test against available_moves, so N
copies of one legal move all pass validation. MoveApplier then looped
and applied every copy. A model could therefore pick an arbitrary
amount of money in unary by repeating one tag: [MOVE: accept_item(copper)]
x47 against a 47c purse drains it to 0; [MOVE: give_item(gold)] x10
mints +100,000c. The eight-move vocabulary (§6) takes no quantity
argument precisely so the AI cannot choose a number — this closed that
gap by another door and was a §2 breach (AI owns text, never state).

MoveApplier.apply() now tracks currency moves (give_item/accept_item on
a denomination) already applied within one call, keyed on name+denom,
and skips repeats. Non-currency moves are untouched — repeated
give_item(amulet) still yields multiple copies, which stays bounded by
the pre-existing cross-reply gifts_given gate and is a separate,
already-reported issue. move_validator.gd and tag_extractor.gd are
untouched.

Also adds the Finding-2 test the currency spec (§7) named but never
delivered: after GameState.grant("gold", 1), inventory stays empty, so
accept_item(gold) in available_moves comes only from the affordability
loop, never double-offered via the inventory.keys() loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:47:19 -05:00
6c496d3ed8 feat(currency): the command-bar purse reads in denominations
The seed placeholder was a flat 1240 gold — 12.4 million copper under the real
model, which is 1,240 plot points in the command bar. It reseeds to 347c, a
party carrying silver and no gold: '◈ 3s 47c'.

This is the only call site of Currency.format() — the display edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:39:25 -05:00
a9d9eedb7e feat(currency): copper/silver/gold replace the coin
The three denominations are move tokens, not stored buckets — there is one
integer, so change-making never arises. The deserter is down to 47c: under a
silver, so he cannot cover a bed and the player sees it on turn one.

A parity test guards the code-side ratios against the content-side ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:36:08 -05:00
c106dbbd37 feat(currency): money moves through the bounded vocabulary (§6)
give_item(gold) is +10,000c and does NOT mark gifts_given — that gate is a
global one-shot keyed by item id, so marking money would let an NPC pay you
exactly once per campaign. accept_item(<denom>) is offered only for the
denominations the purse can actually pay.

The eight moves do not grow. MoveValidator is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:32:14 -05:00
8a71671df7 feat(currency): purse_copper on GameState, with grant/take routing
One int holds all money. grant/take are the sole routing point — currency ids
go to the purse, everything else to inventory. Money is never an inventory row.

The 'coin' stand-in in the two unrelated inventory tests becomes
worn_shortsword; they were never about money.
2026-07-12 18:29:10 -05:00
2a372bff2f feat(currency): the Currency value type — copper, ratios, formatting
One integer, stored in copper. The ratios are rules and live in code; prices
are content and land in M8. format() suppresses empty denominations so being
broke reads as '47c', not '0g 0s 47c'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:26:22 -05:00
a90cc16463 docs(plan): copper-based currency, six tasks
TDD, one test cycle per task, green suite at every boundary. Task 4 carries
the content swap and the generated-tree check (the Duncarrow lesson).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 17:58:44 -05:00
37ec1bb1be docs(spec): copper-based currency with real denominations
Money stops being a flat `coin` and becomes copper, stored as a single
int, formatted only at the display edge.

The approved model (1g = 100s = 10,000c, store everything in copper) said
money should stay an inventory item under one `copper` id. It can't. §6 is
what kills it: `give_item(x)` adds exactly 1 and carries no quantity, and
`gifts_given` is a global one-shot keyed by item id — so a single `copper`
id would be givable once, for one copper, for the whole campaign.

So the purse is one int on GameState, and the three denominations are
content items that exist only to let a bounded move name a unit —
give_item(gold) is +10,000c. Three ids, not three buckets: the
change-making problem never arises because there is only ever one number.
A quantity arg on the move was rejected — it hands the model an unbounded
integer to invent, which is the §2 line.

Currency routes to the purse and never enters `inventory`, so it can't
appear in an inventory grid by construction. No filter needed.

Prices remain content and remain M8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 17:52:13 -05:00
9e8fc55a44 docs(content): create the content roadmap — Spine, BOM, Backlog
Implements the docs restructure half of the content-track design (the
currency item is code and stays unbuilt). Content stops being unbounded:
the work is now a countable checklist with an end.

content/roadmap.md — new. Three sections, only one of which is ever the
work:
  Spine     Greywater Docks + one dungeon, patron Ghaul the Yoke; the
            cosmology touching ground. Plus what is already canon.
  BOM       ~57 discrete pieces for a complete, playable game, per
            milestone. Numbers, not vibes.
  Backlog   Everything the slice does not need. Not a debt — where ideas
            go to be safe.

Its rule: never author anything that is not in the Bill of Materials.

The BOM carries a Fixed/Variable column — the seven-campaign saga spec's
only ask of this table. ~37 fixed / ~20 variable: a second town costs the
20, not the 57. That is also the exact split v2 generation would need.

content/lore/canon-roadmap.md becomes a pointer (older specs and plans
reference the old path). An items-and-prices table does not belong under
lore/.

docs/roadmap.md — M4..M9 each gain a "Content it consumes →" line into
the BOM.

world-building skill — SKILL.md and evals.json now point at
content/roadmap.md, and the skill is told to respect the BOM rule: ideas
that arrive during authoring go to the Backlog, not into the world.

Build + --check green; generated content/world and content/server are
byte-identical. No Python touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:31:30 -05:00
ef335a93c3 docs: land the saga guardrails on the milestones they constrain
Applies the seven-campaign saga direction spec (021c248) to the docs it
was written to protect. No new milestone, no new content, no BOM line.

docs/roadmap.md — a "Guarded direction" section, and a ⛨ saga note on the
four milestones that would otherwise foreclose the shape:
  M3  don't hardcode the title flow so a saga entry path can't be added
  M4  keep construct()'s `creation` a plain Dictionary; the scene must not
      become its only producer
  M5  the level curve is no longer open — L20 across 7 tiers; enemy stat
      blocks carry tier; no scaling multiplier, ever
  M9  the save is two objects (Saga + Campaign); humiliations move to the
      Saga or they die with the campaign

races-and-classes spec — the deferred "XP curve → M5" line now inherits the
ceiling rather than choosing one.

canon-roadmap — what the saga asks of content: a tier field when the
item/quest namespaces land, and a fixed-vs-variable mark on each BOM line
when the BOM is written. Tiers 2-7 and the other six kings go to the
Backlog.

Content build --check green; no generated content touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:23:26 -05:00
021c2480d8 docs(spec): character reuse + the seven-campaign arc
A direction spec: one character, seven campaigns (one per demon king),
one saga — then it ends. The character, companions, deeds and level carry
between campaigns; the world does not.

Deliberately adds no milestone and no BOM line. Its real payload is a set
of guardrails on milestones that are about to be built blank and would
otherwise foreclose the shape:

- M5 owes a level curve — design it to a ceiling of 20 across 7 tiers,
  with tiered enemy stat blocks and never a scaling multiplier.
- M9 owes a save format — two objects (Saga + Campaign), not one blob.
  Humiliations move to the Saga or they die with the campaign.
- M3/M4 — don't foreclose a saga entry path; don't couple construct() to
  the creation scene.
- Build tool — add tier:1..7 when the item/quest namespaces land.

Difficulty is fixed at world-construction time from the campaign index,
never during play. That prohibition is the whole design; a world that
re-levels with the player is a treadmill.

The canon-log contract does not change: party[].disposition,
humiliations[] and established_facts[] already exist, and player still
admits no number. The Saga becomes the source; the canon log a projection
of it. The seam is one optional fourth arg to NewGame.construct().

Greywater at tier 1 under Ghaul is, unintentionally, already campaign 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:16:50 -05:00
c171299cb4 docs(spec): content track — demand-driven, bounded by the slice
Content (lore, economy, items, quests, map) was untracked and unbounded.
Bound it: a second roadmap (content/roadmap.md) with Spine / Bill of
Materials / Backlog, demand-driven by the milestone that consumes it.

- Vertical slice = one town + one dungeon (adopt + deepen Greywater).
- Ghaul the Yoke is the ledger chain's patron, so the quest chain and
  the cosmology are one object, not two.
- Economy splits per §18: the loop is code (M8), the numbers are content.
  Currency = 1g/100s/10,000c, copper as the stored atom; lands before M7.
- Build tool needs `item` + `quest` namespaces; locations already exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:29:29 -05:00
0e8aa5efbd Merge chore/remove-ci-workflow into dev
Remove the GitHub Actions workflow (repo is on Gitea; it never ran). The
--check gate now lives in pytest (test_real_content.py), and the world-building
skill's verify step states there is no CI and adds the client suite — since
content/world/ is a client input, not just build output.
2026-07-12 16:13:32 -05:00
ccff2fdbf2 chore: remove the GitHub Actions workflow — there is no CI
The repo lives on a Gitea server and CI/CD is a long way off, so
.github/workflows/content-build.yml never ran. A workflow nobody executes is
worse than none: it read as a safety net that wasn't there.

Removing it moves the `--check` gate somewhere real. It is now enforced by
tools/content_build/tests/test_real_content.py, which runs check() against the
live bible under pytest — so the gate is a test you actually run, not a job
that never fired.

The world-building skill's verify step now says plainly that nothing runs these
for you, and adds the client suite to the checklist: content/world/ is a CLIENT
INPUT (content_db.gd loads it, test_content_db.gd asserts on it), so a content
change can break the client. That is exactly what the Duncarrow purge did, and
with no CI there is nothing else to catch it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:13:32 -05:00
997fef1803 Merge feature/duncarrow-purge into dev
Some checks failed
content-build / build (push) Failing after 2s
Delete the Duncarrow scaffolding; preserve the infrastructure it carried.

- rule.disposition-ladder reparented into content/lore/mechanics.md (required
  exactly once by the build; moved atomically with the deletion)
- content/lore/specimen.md: synthetic status=candidate disposition-gate specimen
  (validated by --check, emitted nowhere), teaching both an earned warm reveal
  and a never-gated core secret
- duncarrow.md + 23 generated JSON + test_duncarrow.py removed
- world-building skill repointed: structure -> specimen.md, voice -> cosmology.md;
  3 evals rewritten cosmology-aware
- repaired the client consumers the purge broke: content_db no longer errors on
  an absent content dir, and the no-body-on-client secrecy guard is asserted
  structurally so it cannot pass vacuously

--check exit 0, pytest 45, client GUT 149/149.
2026-07-12 16:06:44 -05:00
2b54dad1fa feat(content): give the specimen a never-gated core secret
The purge left `gate: never` — the rung no disposition unlocks — demonstrated
nowhere. Duncarrow's Crell chain had taught it; specimen.md only taught `warm`.

Adds secret.specimen-drowned-clerk (secrecy 4, gate: never), so the specimen
now shows an ascending 2 -> 4 chain and a reason `never` exists: the NPC holds
it, but saying it aloud would hang him. Mirrored in the skill's worked example,
which now states the rule — if any rung *should* buy the secret, gate it at
`trusted` instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:05:04 -05:00
dc4b14d3e5 fix(content): repair client consumers broken by the Duncarrow purge
The purge plan assumed content/world/** was pure build output. The client
reads it: content_db.gd loads content/world/topics/ (now absent, since every
topic was Duncarrow-generated) and its tests asserted on deleted ids.

- content_db: a missing content dir is an empty dir, not an error (§13)
- test_content_db: repoint at cosmology canon; drop deleted npc.mera-fenn
- test_content_db: assert the no-body-on-client invariant structurally, so it
  cannot pass vacuously when the topic set is empty
- schema.md: finish repointing the worked example onto specimen.md's ids
- restore a local real-bible --check smoke test (was only in CI)
- roadmap: record that generated content has client consumers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:00:06 -05:00
fae39d9c79 docs(content): roadmap — Duncarrow purged, ladder in mechanics.md, specimen added
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 15:45:21 -05:00
894bde959e docs(skills): drop deleted-content trigger examples from world-building description
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:42:06 -05:00
602edbf257 docs(skills): repoint world-building off Duncarrow (specimen + cosmology); rewrite evals
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 15:39:41 -05:00
ac8da4e6da feat(content): reparent disposition-ladder to mechanics.md; purge Duncarrow
Move rule.disposition-ladder into content/lore/mechanics.md and delete the
Duncarrow scaffolding (source + 23 generated JSON). Remove the redundant
test_duncarrow.py (routing/secrecy already covered by test_emit.py synthetic
fixtures); repoint a cosmetic slug-test id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 15:30:59 -05:00
c1edcfe015 feat(content): synthetic candidate disposition-gate specimen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 15:26:59 -05:00
2bf4ae43d1 docs(plan): Duncarrow purge implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 15:22:01 -05:00
9aa06d59d7 docs(spec): Duncarrow purge — decouple scaffolding from infrastructure
Delete the Duncarrow scaffolding while preserving the schema/disposition-gate
infrastructure it carries: reparent rule.disposition-ladder into a new
mechanics.md, stand up a synthetic status:candidate specimen (validated but
never emitted), repoint the skill/tests/evals. Zero real game content; the
in-flight NPC redesign and the hand-authored POC seed JSON stay out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 15:11:14 -05:00
df5ed2b525 Merge feature/cosmology-theme into dev
Some checks failed
content-build / build (push) Failing after 5s
Overarching cosmology: the Warden (distant good god), the Sent, and the
Seven demon kings, authored as canon; world-building skill updated to
reconcile all future content against the backdrop (incl. FTH-axis
Bonesetter/Bloodsworn calling mapping). Duncarrow decoupled as
scaffolding, not removed. Build --check exit 0, pytest 47/47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 14:39:28 -05:00
1dfa9462ba fix(content): final-review polish — link the-seven roster + roadmap wording
- faction.the-seven now relates down to all seven lords (spec §4 intent;
  bidirectional discoverability), regenerated the-seven.json.
- canon-roadmap: clarify Bloodsworn resolves the warlock's former [CANON-TBD] name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 13:36:19 -05:00
5646bdd42a docs(content): roadmap — Warden/Seven authored, Duncarrow flagged scaffolding
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 13:29:59 -05:00
6c542e3669 feat(skills): world-building carries the cosmology backdrop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 13:26:08 -05:00
c6d16430de feat(content): the Seven — seven demon kings and their blood
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 13:21:14 -05:00
0cf1b27773 feat(content): cosmology frame — the Warden, the Sent, the Seven
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 13:16:40 -05:00
ce9db1d9c8 docs(plan): cosmology + world-building theme implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 12:08:38 -05:00
d15a8fdcab docs(spec): divide FTH callings along the war (Bonesetter/Bloodsworn)
Adds the FTH-axis calling mapping to the cosmology spec: Bonesetter
channels the Warden, the Bloodsworn (names the [CANON-TBD] warlock
calling) pacts one of the Seven, Hedge-Mage is a pointed outsider,
martials unaligned. Luck-immunity intent kept as flavor and flagged for
the M5/Improviser reconcile (§7-sensitive), not built here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 12:01:28 -05:00
8f137aba20 docs(spec): cosmology — the Warden and the Seven (overarching theme)
Establishes the good-god/seven-demon-lords cosmology as the game's
overarching content theme, mapped onto the content schema as canon
entities, with the world-building skill updated to reconcile all future
authoring against it. Duncarrow scaffolding decoupled now, purged in a
follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 11:49:33 -05:00
58dd7be7cf Merge fix/commit-godot-uid-files into dev 2026-07-12 11:19:47 -05:00
4780ab4802 fix(client): commit missing Godot .uid files for considering-indicator scripts
The scripts were tracked but their .gd.uid siblings weren't. Godot 4.4+ .uid
files pin each script's uid://, so leaving them out lets each clone regenerate
different uids and break uid:// references in scenes/resources. Matches the 159
.uid files already tracked in the repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 11:19:47 -05:00
86a4ebc105 Merge feature/world-building-skill into dev 2026-07-12 11:15:34 -05:00
b15bd2bb1e feat(skills): add project-local world-building skill
Author gritty, disposition-gated Margreave content (lore bible + hand-authored
quest/item/location/origin JSON) that reconciles against existing canon before
writing, holds the world's hard-by-default / warm-when-earned NPC tone, and keeps
the content_build --check gate green.

- SKILL.md: read-before-write workflow, read-only on existing canon (propose
  diffs, require approval), build+check+pytest as the done bar.
- references/schema.md: full bible + JSON contract, secrecy scale, gate model.
- references/tone.md: tone contract + NPC disposition-warmth model.
- scripts/canon_index.py: dumps existing ids for reconciliation.
- evals/: 3 test cases (all green in isolated-worktree runs).

Un-ignore /.claude/skills/ so project skills are versioned like code; all other
.claude/ dirs (root + nested api/client) stay ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 11:15:34 -05:00
a3506f7f02 docs(spec): NPC addressing design (runtime cycle)
Lock the NPC identifier: full namespaced id (npc.mera-fenn) is canonical
everywhere — stored data, client dict key, and the /npc/speak wire. The API
load_npc changes from a filename lookup to an id-indexed load (matching the
client's ContentDB), reads persona from content/server, and resolves the
client's reveal-set fact_ids to topic bodies. Legacy Fenn keeps working; no
migration required. First task of the runtime-consumption cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-12 01:58:30 -05:00
bbaa59fad4 Merge feature/content-build-tool into dev
Some checks failed
content-build / build (push) Failing after 6s
Content & canon build tool: compiles content/lore/*.md (Markdown bible +
fenced YAML) into validated client (content/world) + server-only
(content/server) JSON, split by role so spoilers/personas never ship in the
client. Validation gate aborts on schema violations; --check is the CI
freshness/validity gate. Promotes Duncarrow as the acceptance test (secret
body proven server-only). ContentDB loads the new canon/ + topics/ dirs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 20:40:06 -05:00
c09b6e89a0 test(content-build): cover generated-npc orphan detection in shared npcs dir 2026-07-11 19:06:00 -05:00
f84e55ab4b fix(content-build): --check orphan detection; canon secrecy-0 + value-type guards
- --check now detects orphaned/stale build artifacts in build-owned dirs
  (client canon/ + topics/ fully owned; client npcs/ shared with legacy
  hand-authored files, only npc.* ids are treated as build-owned)
- resolve() enforces the schema rule that canon entities must be secrecy 0
- resolve() and ladder_rungs() guard against malformed value TYPES (non-int
  secrecy, non-list related/rungs) raising BuildError instead of crashing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 19:01:28 -05:00
c2d67be76a ci: content-build tests + --check gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 18:51:11 -05:00
a160d3e967 feat(client): ContentDB loads canon/ + topics/ built dirs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 18:46:55 -05:00
7ce5e4584c feat(content): promote Duncarrow; build client/server artifacts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 18:34:33 -05:00
11c4d6dcd7 feat(content-build): CLI build + --check staleness gate 2026-07-11 18:27:48 -05:00
973af41a6e test(content-build): assert candidate entries excluded from build_trees 2026-07-11 18:25:43 -05:00
0542516312 feat(content-build): emit routing + secrecy lint + writer 2026-07-11 18:19:26 -05:00
e3c35614e7 test(content-build): cover status, empty-knows, non-npc start_disposition, empty-rungs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:16:18 -05:00
9141cd1e9f feat(content-build): validation gate (rungs + resolve) 2026-07-11 18:09:47 -05:00
f21d7be4d9 feat(content-build): typed Entry model + category predicates 2026-07-11 18:05:26 -05:00
bd293c1701 feat(content-build): scaffold package + Markdown yaml-block parser 2026-07-11 18:01:45 -05:00
40c3a7fc4b docs(plan): content & canon build tool implementation plan
8 TDD tasks: package scaffold + parser, Entry model, validation gate
(rungs+resolve), emit routing + secrecy lint, CLI --check, Duncarrow
promotion (acceptance test), client ContentDB canon/topics dirs, CI gate.
Branches off dev per s18; runtime consumption + Fenn migration + export
packaging explicitly deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 17:53:44 -05:00
16527e9d60 docs(spec): content & canon build tool design
Compiler for content/lore/*.md (MD bible + fenced YAML) -> validated
content/world (client) + content/server (server-only) JSON. Python tool
under tools/, manual build + CI --check, band-number-free (client owns the
midpoint mapping), validation gate aborts on any schema violation. Promotes
Duncarrow as the acceptance test; documents the specimen fixes promotion
must apply (shrine->place rename, related stubs, dropped knows/related
spoiler links).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 17:39:17 -05:00
db5ee247e7 docs(spec): content & canon schema design
Schema lock built bottom-up from the Duncarrow specimen (candidate-town.md):
- disposition rungs as bands over the engine integer (code owns numbers)
- town fact = engine reveal-topic (authoring layer over §6, no new runtime)
- MD bible -> build -> JSON; split by role (game vs server), secrecy as lint
- canon home: content/lore (source) -> content/world + content/server (built)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
2026-07-11 17:00:40 -05:00
aeb9ee4a9d docs(spec): races & classes mechanical design
Settles the §17 class-name reconcile (charter §8 vs mock callings) in
favor of the gritty callings, and gives every race trait a concrete rule.
Feeds M4 character creation; combat ability kit deferred to M5.

- Resolution model: deterministic value vs DC, LCK selects the borderline
  band (§7) — flat mods, no random d20, no advantage.
- 5 saves (LCK owns none); death saves = LCK.
- Races: features only, no stat mods; Nightsight ruling for darkvision.
- 7 callings (charter-7), 2 names [CANON-TBD]; casters=MP, martials=cooldowns.
- Flags two contract migrations for M4: canon-log gains `race`; deserter
  origin's allowed_classes migrates to new calling ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:11:07 -05:00
1304f0aee7 updating roadmap 2026-07-11 11:14:06 -05:00
1364850d39 removed old wireframes 2026-07-11 11:13:16 -05:00
3ee2c7c3a8 Merge feature/title-screen into dev
M3 — Title screen + unified app version. The entry point: editor-first
Title screen (warm shader background, pulsing glow, drifting embers, the
CODE OF CONQUEST logo, keyboard/mouse menu, footer) set as the boot scene;
New Game -> the Main Window shell, Quit exits, rest inert. Suite 146/146.

Plus a single /VERSION source of truth (0.01-alpha): the API reads it at
startup (+ GET /version), the client bakes it into project.godot via
scripts/sync-version and reads it through the Version helper. No runtime
coupling; both sides agree. Adds TitleLogo/TitleKicker theme variations.
2026-07-11 11:07:42 -05:00
2eead38b54 fix(title): restore subtle glow alpha (0.22)
The hex-removal correction set _glow.color = Palette.BLOOD (opaque), which
with the 0.5-0.8 modulate pulse read as a near-solid red slab. Restore the
intended low alpha via Color(Palette.BLOOD, 0.22) — RGB still from Palette,
alpha a numeric param (same idiom as the ember ramp). Whole-branch review
catch. Title tests 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:03:50 -05:00
f07139256b feat(title): warm shader background, pulsing glow, drifting embers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:54:22 -05:00
a77bf03ee4 feat(title): TitleScreen composition, menu + selection, boot scene
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:47:44 -05:00
c6d513c966 feat(theme): TitleLogo + TitleKicker display variations
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:41:42 -05:00
263254346d feat(client): Version helper + sync-version script; project.godot config/version
- Add scripts/sync-version to sync root /VERSION into client/project.godot
- Add Version GDScript helper (reads application/config/version)
- Test coverage: 2 tests for Version.string() and footer()
- Synced config/version="0.01-alpha" to project.godot

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:36:42 -05:00
56278a4831 feat(api): /VERSION single source read at startup + GET /version 2026-07-11 10:31:54 -05:00
8e4c0bf957 docs(plan): Title screen (M3) + unified version implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:27:08 -05:00
0249cbcf09 docs(spec): Title screen (M3) + unified version design
Editor-first Title screen from mock: warm atmosphere (GPUParticles embers,
AnimationPlayer glow, palette-uniform shader bg), logo block, keyboard/mouse
menu (New Game -> shell, Quit works, rest inert), footer. Plus a single
/VERSION source of truth read by both the API (startup + /version) and the
client (synced into project.godot config/version, read via ProjectSettings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:19:17 -05:00
d9caf1dff6 Merge feature/main-window-shell into dev
M3-b — Main Window shell (2a): the exploration HUD frame every screen
lives in. Two-panel tabletop split — dark world slot (turn rail, minimap,
slide-in system dock, command bar) + the permanent DM narration book wired
to the proven /dm/narrate loop (prose-only + refire, considering-state,
fallback, fact-harvest). Thin tested ShellState; suite 138/138.

Establishes the editor-first UI-scene convention (ADR 0001): screens are
authored as .tscn node trees, scripts do on-load work only. Theme gained
Button-base DockButton/ParchmentButton + a base RichTextLabel prose style.
2026-07-11 10:06:18 -05:00
c933d12504 docs: record editor-first UI-scene convention (ADR 0001)
Capture the direction change from M3-b: UI screens are authored editor-first
as .tscn node trees (previewable/arrangeable in the Godot editor), scripts do
on-load work only — never build the tree in _ready(). Add ADR 0001, a
"UI scene conventions" how-to in client/docs, a §16 mandate in the charter,
and mark M3-a/M3-b done in the roadmap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 10:04:57 -05:00
58880554c2 fix(shell): dock handle to the right edge; command bar fits
Wider consumable tiles overflowed the command bar, shoving END TURN under
the dock column. Trim the vitals min-width (230->120) and END TURN
(150->140) and separation so the row fits — END TURN lands cleanly at the
bottom-right, below the dock.

Move the dock handle to the outer (right) side per mock 2a: the menu now
sits left of the handle and slides right off-screen to hide, the handle
staying at the world's right edge. Dock sits flush to that edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:59:23 -05:00
fb541d05cb fix(shell): widen consumable tiles so labels fit
The 62px tiles clipped longer names (Draught, Antidote). Widen to 93px
(~1.5x) — there is room in the command bar — so labels fit while tiles stay
uniform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:50:25 -05:00
6ef71aaa31 fix(shell): readable ink prose + uniform consumable tiles
The narration RichTextLabel had no theme styling, so it rendered near-white
and was unreadable on parchment. Add a base RichTextLabel style to the theme
(dark INK_BODY serif at 20px, italic face wired) — systemic and previews in
the editor; every parchment prose surface now reads as dark ink. Also clip
the consumable slot labels so a long name ("Antidote") can't stretch its tile
wider than the empty ones — all 8 stay a uniform 62px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:47:21 -05:00
4698732285 refactor(shell): author shell scenes as editor-previewable node trees
Move the three shell scenes (SystemDock, NarrationBook, MainWindowShell)
from build-everything-in-_ready() to authored .tscn node trees with the
theme + type-variations set in the editor, so the layout previews and can
be arranged in the Godot editor. Scripts keep only on-load work: @onready
node refs, DM-loop wiring, the dock slide, and binding seed ShellState into
the fixed authored turn-order tokens + consumable slots.

Fixes the dock offset + over-long buttons (now fixed-width DockButtons in a
right-anchored menu). Headless probe: root 1920x1080, world/book split
1180/740, dock at the world's right edge — no collapse. Suite 138/138.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:38:22 -05:00
578b3e86c9 feat(shell): MainWindowShell assembles the frame and wires the DM loop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:16:14 -05:00
5f1ebdf840 feat(shell): NarrationBook wired to /dm/narrate with refire + fact-harvest 2026-07-11 09:07:24 -05:00
bca04e3a2e feat(theme): parchment Button variation for clickable parchment rows
ThemeKeys.PARCHMENT_CARD is a PanelContainer-base variation (only a
`panel` stylebox) — a Button looks up normal/hover/pressed/disabled,
which don't exist there, so a Button styled with it renders unskinned.
Add PARCHMENT_BUTTON, a Button-base variation with those four
styleboxes plus an empty focus box, built from Palette colours only.
Same fix pattern as DOCK_BUTTON for the system dock. Regenerated
game_theme.tres from the builder.
2026-07-11 09:07:16 -05:00
35c4586792 fix(shell): dark DockButton theme variation for the system dock
DarkPanel is a PanelContainer-base variation (only a `panel`
stylebox); a Button looks up normal/hover/pressed, which don't exist
on it, so SystemDock's menu buttons rendered as default gray Godot
buttons instead of mock-faithful dark nav buttons.

Add a dedicated Button-base DockButton variation (Palette-only
colours, dock-dark normal/hover/pressed styleboxes), point
system_dock.gd's menu buttons at it, and regenerate the committed
game_theme.tres artifact from the builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:00:54 -05:00
9a02c9b038 feat(shell): SystemDock slide-in with inert screen-routing seam 2026-07-11 08:51:14 -05:00
c4aa74da5a feat(shell): ShellState + TurnEntry HUD state model 2026-07-11 08:46:56 -05:00
400445f312 docs(plan): M3-b Main Window shell implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:43:29 -05:00
cc365c8b73 docs(spec): M3-b Main Window shell (2a) design
The exploration HUD frame: two-panel tabletop split, DM narration book
wired to the proven /dm/narrate loop (prose-only + refire), thin tested
ShellState for HUD readouts, inert system dock + command bar as faithful
placeholder. Pure-core/thin-shim per the house pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:35:02 -05:00
09922e5fd3 adding more messages to considering 2026-07-10 23:17:51 -05:00
322aae4a33 Merge feature/shared-theme into dev
M3-a shared Theme: palette tokens (single colour source), bundled OFL fonts,
builder-generated game_theme.tres, three shader surfaces, and the showcase
eyeball proof. Whole-branch review passed; human eyeball confirmed; 125/125.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:15:17 -05:00
7bbb66827b chore(theme): open a 1600x900 window over the 1920x1080 canvas
Keep the design/logical canvas at 1920x1080 (charter §16 / mockups) but add a
window-size override so the window opens at 1600x900 and doesn't fill the whole
screen; canvas_items + keep-aspect scales the canvas to fit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:13:49 -05:00
4a32e7df8a fix(theme): showcase renders when run — viewport-fit + display config
Two root causes behind the blank F6 run:
1. project.godot had no display/stretch config (README mandates canvas_items
   @ 1920x1080). Added [display] window size + stretch mode/aspect — the
   project's canvas foundation, needed by every screen.
2. A root Control run directly (F6) is not sized to the viewport by full-rect
   anchors — it collapses to (0,0), clipping the ScrollContainer and hiding all
   content. Size the root to the viewport explicitly and track size_changed
   (dropping the full-rect anchors, which conflict with setting size directly).

Faithful main-scene run now measures root = scroll = viewport = 1920x1080.
Suite 125/125.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:11:34 -05:00
0c12e13ac1 fix(theme): drop redundant const preloads shadowing global class_names
palette.gd / theme_keys.gd already declare `class_name`, so `const Palette =
preload(...)` in every consumer shadowed the global class and emitted 4
editor errors ("has the same name as a global class"). Reference the global
class_names directly. Editor reload now clean; suite 125/125; runtime probe
still builds all 75 showcase nodes with the theme applied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:58:34 -05:00
6530625f4d fix(theme): drift-guard test, interactive stylebox states, ThemeKeys font consts, richer showcase (whole-branch review)
- build_game_theme.gd: extract build_theme() as a static func returning the
  fully-built Theme; _init() is now a thin save+quit wrapper. Enables a test
  to build a fresh Theme in memory without running the SceneTree main loop.
- test_theme_resource.gd: add test_committed_tres_matches_builder() — builds
  a fresh Theme via the preloaded builder and compares bg_color against the
  committed game_theme.tres for PrimaryCTA/normal, ParchmentCard/panel, and
  DarkPanel/panel. Catches "palette/builder changed, .tres not regenerated."
- build_game_theme.gd: give Tab, TabActive, and Chip explicit hover/pressed/
  focus styleboxes (focus is StyleBoxEmpty to suppress the default ring) so
  they no longer fall back to Godot's default gray button on interaction.
  Add a disabled stylebox + font_disabled_color to PrimaryCTA for the
  unaffordable-CTA state the 2a shell will need.
- theme_keys.gd: add HEADING/ACCENT/MONO font-role consts (kept out of ALL,
  which is stylebox-variation-only); build_game_theme.gd and
  theme_showcase.gd now reference them instead of raw StringName literals.
- theme_showcase.gd: add an italic DM-voice RichTextLabel (EB Garamond
  italic face, spec §6) and two semantic-coloured chips (BLOOD for
  aggressive actions, GOLD for pay/faith) alongside the existing neutral
  chip, demonstrating the script-applied colouring from spec §5.3.
- dark_bay.gdshader: comment noting the stripe uses FRAGCOORD (screen-space)
  while the vignette uses UV (node-local) — intended for full-bleed use.
- surfaces/*.gd: ## comments documenting the accepted deviation from spec
  §5.4 (uniforms set at runtime from Palette rather than baked into the
  .tscn) — no behavior change.
- Regenerated game_theme.tres via build_game_theme.gd.

Full suite: 125/125 (was 124, +1 drift-guard test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFDm1ku9WDUacK2S78m2be
2026-07-10 20:59:05 -05:00
0082765ee7 fix(theme): showcase renders every §6 element (3rd surface, 3 variations, all fonts, labelled swatches)
- _surfaces(): add VignetteOverlay.tscn alongside ParchmentPanel/DarkBay,
  backed by a Palette.STEEL ColorRect so the dim is visibly rendering.
- _variations(): add ItemTileEmpty (next to filled ItemTile), ParchmentInset,
  and DarkPanel stylebox nodes.
- _fonts(): new section rendering default serif body, Accent
  (Architects Daughter), and Mono (JetBrains Mono) labels at real sizes.
- _swatches(): wrap each ColorRect in a VBoxContainer with a Mono caption
  Label naming its Palette const, per spec's "labelled swatches."

No raw hex / Color(...) literals introduced; all colour via Palette.*, all
styleboxes via ThemeKeys.* variation names.
2026-07-10 20:46:25 -05:00
7954c579d3 feat(theme): showcase harness — the human-eyeball proof
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:39:09 -05:00
73fcf2d096 feat(theme): parchment/dark-bay/vignette shader surfaces as drop-in scenes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:34:52 -05:00
9091707535 feat(theme): game_theme.tres (fonts + stylebox variations) via builder 2026-07-10 20:29:22 -05:00
3981047ec6 feat(theme): bundle EB Garamond, Architects Daughter, JetBrains Mono (OFL) 2026-07-10 20:25:06 -05:00
888b2b32f5 feat(theme): palette tokens as the single colour source 2026-07-10 20:21:42 -05:00
233b1088cf docs(plans): add M3-a shared Theme implementation plan
Five TDD tasks: palette tokens, bundled OFL fonts, script-built
game_theme.tres, three shader surfaces, showcase eyeball proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:19:13 -05:00
2d7ed469bf docs(specs): add M3-a shared Theme design
First of three M3 cycles (Theme -> shell 2a -> Title). Palette.gd consts
as the single source of truth; game_theme.tres for flat-bordered UI;
shaders for the three signature surfaces. EB Garamond replaces
proprietary Georgia. Proof is a human-eyeballed showcase harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:13:25 -05:00
29474e5751 docs(charter+roadmap): pivot to the tactical CRPG (the POC is answered)
The aliveness POC (M0-M2) is proven, so redefine the project as the tactical
CRPG the /mockups describe. Charter: §1 reframed (Code of Conquest / The
Margreave), §10 combat = tactical + action-economy but GRIDLESS (tokens are
flavor, no positional rules), §15 = button-driven actions + free-text NPC
dialogue (Adjudicator reshaped to the optional free-text escape hatch), §16
adopts /mockups as the UI/systems bible, §17 rewritten to the game's scope.
Two conflicts flagged not silently decided (§18): damage determinism (§7 vs
mock ranges → M5) and class names (§8 vs mock callings → M4).

Roadmap: M0-M2 kept as the built engine; M3-M9 re-sequenced around the mock
screens (Theme+shell → creation → combat → dialogue → inventory/sheet → world
systems → framing/AI-flavor/persistence). Streaming stays shelved; v2 NPC
ideas preserved, re-homed to M6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:12:48 -05:00
49bc6b22c0 docs(mockups): add the UI/systems bible (11 screens + README)
High-fidelity HTML/CSS/JS design references for the tactical-CRPG direction —
title, character creation, character sheet, inventory, world map, quest log,
shop, pause/settings, dialogue, combat HUD, main exploration window. README is
the handoff spec (visual system, per-screen state models, Godot translation
notes). Not production code — the source of truth for the game's look + systems.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:06:23 -05:00
b639cab0c2 docs(roadmap): advance milestone marker M2 -> M3
M2 (prove aliveness) build-complete + live-proven; feel confirmed in playtest.
Active build focus is now M3 (make it a game), starting with the Adjudicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:47:01 -05:00
57acfb75d9 docs(roadmap): considering-state done; shelve streaming for the POC
Flip considering-state to done+merged+live-confirmed. Strike the streaming
item — the considering-state reframes the wait and is tested/live; real
streaming's transport rework + mid-stream failure surface isn't worth it for
the POC. Streaming spec retained as a record (banner SHELVED). Includes the
human's folded-in further-down roadmap ideas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:44:15 -05:00
099714d4f6 Merge feature/considering-state into dev
Considering-state + transport timeout (M2 anti-freeze; streaming parked).
While a model call is in flight the client rotates authored in-character
'the Master considers…' lines (§13); HttpTransport now sets a request timeout
(35s, rejects <=0) so a hung call degrades to the fallback instead of spinning
forever. Human-confirmed working live, including the degrade path (API down →
fallback). 5 tasks, whole-branch review MERGE AS-IS. Rotation tuned to 3.0s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:33:58 -05:00
6d7eeaab27 tune(client): considering rotation 2.0s -> 3.0s (phrases linger longer)
By-hand cold-start test showed 3 phrases before the response; 1.5x the
cadence so each line stays on screen a touch longer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:31:59 -05:00
c5644d381e feat(client): harnesses show the considering-state around the call
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:09:45 -05:00
82b2de19bb feat(client): ConsideringIndicator — rotates think-lines on a Timer 2026-07-10 15:06:31 -05:00
6bd5e4f84a fix(client): HttpTransport sets request timeout (no more infinite hang) 2026-07-10 15:03:29 -05:00
377adb10b2 feat(client): ProxyConfig.request_timeout_seconds (default 35s, reject <=0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:00:40 -05:00
a171d40781 feat(client): ConsideringPhrases — authored rotating think-lines (§13) 2026-07-10 14:57:38 -05:00
2c32bba320 docs(plan): considering-state + transport timeout implementation plan
5 TDD tasks: ConsideringPhrases (pure) + authored considering.json;
ProxyConfig.request_timeout_seconds (default 35s, reject <=0); HttpTransport
applies it (closes the infinite-hang gap); ConsideringIndicator node shim
(Timer rotates a Label); wire both harnesses around the await.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:55:00 -05:00
96432bee87 docs(spec): considering-state + transport timeout; park streaming
Pivot M2 anti-freeze from streaming to a lighter considering-state: a rotating
in-character 'the Master considers…' indicator (authored content, §13 spirit)
on the proven non-streaming loop, plus the HttpTransport request-timeout fix so
a hung call yields to the fallback instead of spinning forever. Meets §14's
anti-freeze goal by reframing the wait; near-zero new failure surface. Streaming
design PARKED (banner + roadmap ○), revisit if playtest shows the wait hurts.
Conscious §14 deviation recorded per §18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:37:20 -05:00
1ed863d3fe docs(spec): Narrator streaming (M2) design
Real token streaming to hide the ~2s call (§14). Proxy emits its own minimal
NDJSON (delta/done/error); /dm/narrate becomes a 200 stream (422 stays sync,
model errors become an in-stream frame). Client reworks to an HTTPClient
poll-loop StreamTransport behind a testable seam; hold-back tag filter;
keep-partial mid-stream degrade; folds in the HttpTransport timeout fix.
Narrator first; NPC fast-follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:23:32 -05:00
1a6e8e8616 Merge fix/npc-harness-luck-descriptor into dev
NPC harness built a canon log with an empty player.luck_descriptor, which the
schema rejects (minLength 1) → /npc/speak 422. Build the player through
LogPlayer with a non-empty §7 fortune line. Human-confirmed working live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:38:17 -05:00
81db962a9d fix(client): npc harness must set player.luck_descriptor (schema requires non-empty)
The throwaway harness hand-built a minimal canon log and left luck_descriptor
as the default "", which fails the canon-log schema (minLength 1) → /npc/speak
422s and the harness silently degrades to fallback. Build the player through
LogPlayer with a non-empty §7 fortune line. Verified: request now 200s and Fenn
answers in-voice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:30:45 -05:00
d788225754 Merge feature/npc-conversation into dev
Bounded NPC conversation (/npc/speak), M2 core experiment. Server role is a
thin drop-in on the M1 pipeline (persona+knowledge server-side); client adds a
pure MoveValidator + MoveApplier over the proven DmService loop — validates
moves against live state, drops invalid, keeps prose (§6). Live-proven vs
qwen3.5 (Fenn in-voice, grounded in knowledge, no Luck leak). Live smoke caught
+ fixed two TagExtractor tag-format drifts. 18 tasks, all reviewed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:23:00 -05:00
af1a3aa726 docs(roadmap): M2 Bounded NPC conversation done + live-proven
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:17:18 -05:00
2b1c11b1e6 fix(client): TagExtractor tolerates paren-less moves + scrubs misformatted move tags (live-review LF1/LF2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:15:18 -05:00
86d1352b19 test(api): gated live smoke — Fenn answers /npc/speak in voice
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:07:24 -05:00
ed2311b62c chore(client): commit Godot .uid sidecars for new npc scripts (Tasks 10-15)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:03:04 -05:00
87f337bffd feat(client): throwaway NPC harness — drive /npc/speak on screen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:02:13 -05:00
c347da6cec fix(api): npc.md — zero-arg move examples need parens for TagExtractor 2026-07-10 12:59:14 -05:00
832cdc65ca feat(client): NpcService.speak — post, extract, validate moves (§6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:55:52 -05:00
5181c3e74a feat(client): authored NPC fallback line (§13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:51:13 -05:00
c0dcc8df82 feat(client): MoveApplier — validated moves to game state 2026-07-10 12:47:51 -05:00
a2100f802f feat(client): NpcResult model 2026-07-10 12:45:23 -05:00
4c0b4e41c3 feat(client): pure MoveValidator — membership is the whole test 2026-07-10 12:41:45 -05:00
1b67b3984b feat(client): NpcContent.available_moves — the legality home 2026-07-10 12:39:10 -05:00
3b71fcdcc0 feat(client): CanonLog.add_quest/has_quest for offer_quest 2026-07-10 12:36:37 -05:00
1d1ae37281 feat(client): GameState move targets — remove_item, reveal/gift tracking
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:34:33 -05:00
9d2f93bbf2 fix(api): package /content into the image so npc content resolves 2026-07-10 12:30:36 -05:00
a2c654c047 content: author Fenn + the NPC role prompt body 2026-07-10 12:26:04 -05:00
0c3d64d068 feat(api): wire POST /npc/speak (422 unknown npc, 502 model error) 2026-07-10 12:22:07 -05:00
004cc21724 feat(api): npc.run service — thin drop-in on the model pipeline 2026-07-10 12:18:01 -05:00
76c04b45d2 feat(api): render_npc_digest — persona/knowledge/moves/utterance 2026-07-10 12:14:34 -05:00
6cd5003930 feat(api): route the npc role to its own model 2026-07-10 12:11:54 -05:00
69729f7673 feat(api): content.load_npc + content-root resolver 2026-07-10 12:08:50 -05:00
55a2ae6f19 docs(plan): bounded NPC conversation implementation plan
18 TDD tasks: server npc role (content.load_npc, routing, npc digest,
npc.run, /npc/speak, Fenn + prompt, Docker content packaging); client
NpcService + pure MoveValidator + MoveApplier + NpcContent legality +
GameState/CanonLog move targets + NPC fallback + throwaway harness;
gated Fenn live smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:03:58 -05:00
097ac618f4 docs(spec): bounded NPC conversation (/npc/speak) design
M2 core experiment. Server owns persona+knowledge (spoilers/IP, no DB);
client owns live state and computes available_moves. Free text straight
to the NPC prompt (no Adjudicator). Sibling NpcService + pure MoveValidator;
moves validated against state, invalid dropped, prose always kept (§6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:36:44 -05:00
7db1eb8f26 docs(roadmap): M2 Client HTTP loop done + live-proven; NPC conversation next
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:15:38 -05:00
b949cd2164 Merge feature/client-http-loop into dev
M2 client HTTP loop: the client posts its canon log to /dm/narrate, shows
the prose, harvests [FACT] via TagExtractor, and degrades to an authored
fallback (§13) on any failure. First end-to-end vertical slice, live-proven
against qwen3.5 through Docker. 67/67 client tests, whole-branch review
MERGE AS-IS.
2026-07-10 11:13:38 -05:00
5df67dcd8a Merge fix/docker-prompts-packaging into dev
Package api/prompts into the Docker image and give the Dockerized proxy a
route to a host-run Ollama. M1's narrator pipeline was proven via venv-on-
host; these were the two Docker-path gaps the client HTTP loop surfaced.
2026-07-10 11:13:38 -05:00
004bd53a39 fix(api): let the Dockerized proxy reach a host-run Ollama
After the prompts fix, /dm/narrate reached the model call and failed with
ConnectError: Connection refused — the container's localhost is not the
host, so the default OLLAMA_BASE_URL=http://localhost:11434 hit nothing.
Ollama runs on the host (M1's venv smoke reached it because uvicorn ran on
the host too; the container never had a route).

Add extra_hosts host.docker.internal:host-gateway so the container can
reach the host, and document using http://host.docker.internal:11434 in
.env.example. Verified a container reaches host Ollama's /api/tags (200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:07:44 -05:00
f18d306059 fix(api): package api/prompts into the Docker image
The narrator pipeline (M1) reads role prompts from /app/prompts, but the
Dockerfile copied only api/app and docs/schemas — never api/prompts — so
/dm/narrate 500'd with FileNotFoundError on narrator.md in any Docker run.
The M1 live smoke passed only because it ran uvicorn from the local venv,
where ../prompts resolves on the host; the container path was never
exercised until the client HTTP loop drove a real request through compose.

Dockerfile now COPYs api/prompts; compose mounts it :ro so prompt edits
reload live like the app code (prompts are source code, §16). Verified by
building the image and confirming /app/prompts/narrator.md is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:29:08 -05:00
23ccc799aa fix(client): parse HttpTransport body via JSON.new() not parse_string
Whole-branch review Minor #1: JSON.parse_string pushes an engine-level
error on a non-JSON body (e.g. a gateway HTML 502), which the project's
gutconfig promotes to a false failure — the same reason FallbackLibrary
already avoids it. Consistency fix; a non-JSON body still degrades.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:20:26 -05:00
89afd85bb2 feat(client): throwaway narrate harness scene + live-smoke doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:12:21 -05:00
c208d3f1c4 feat(client): HttpTransport — real HTTPRequest transport (untested shim)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:08:20 -05:00
513225df3f feat(client): DmService narrate loop + FakeDmTransport (all branches tested)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:04:33 -05:00
32fa372124 feat(client): FallbackLibrary + authored narrator fallback (§13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:59:54 -05:00
18a18820fe feat(client): net primitives — DmResponse, NarrateResult, DmTransport, ProxyConfig
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:54:03 -05:00
b4d22f5c5f docs(plan): Client HTTP loop (M2) implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:51:49 -05:00
3a85d11439 docs(spec): Client HTTP loop (M2) design
First M2 vertical slice: CanonLog -> HTTP -> /dm/narrate -> prose ->
screen -> fact-harvest, with a §13 degraded-DM fallback on any failure.
Pure DmService core behind an injectable DmTransport seam (headless-
testable); throwaway harness scene; one authored fallback line under
client/content (renderer owns the file's home).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:33:44 -05:00
4ae9cdbe63 docs(roadmap): flesh out sequencing into milestones (M0–M4)
Regroup the flat status list into milestones with a 'what this proves' goal, and
tag every item with its §2 side (state/text/both), dependencies, and milestone
goal. Sequencing calls locked: M3 leads with the playable dungeon
(Adjudicator→Combat→Luck) before the comedic roles (Improviser/Banter); streaming
deferred until the M2 dialogue experiment proves aliveness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:10:16 -05:00
1189d2e24d docs: mark Narrator/Ollama pipeline complete (plan steps + roadmap)
Plan status → COMPLETE, all 8 tasks/44 steps checked. Roadmap: Narrator pipeline
, Client HTTP loop promoted to ▶ next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:00:34 -05:00
ddc00d42da Merge feature/narrator-ollama-pipeline into dev
Narrator/Ollama pipeline (server): shared model-call pipeline (config, role→model
routing, prompt loader + curated canon-log digest, Ollama /api/chat client with one
retry, §10 JSON-lines call logging) wired through the Narrator role — /dm/narrate is
now real (200 prose | 502 model_error | 422 invalid log). 49 passed/1 skipped; live
wire proven against qwen3.5. Final-review fixes: best-effort logging, non-JSON 200 →
ModelError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:52:34 -05:00
f7c51b79da fix(api): best-effort call logging + non-JSON 200 → ModelError (final-review)
- call_log._default_write: swallow any exception from the default file/
  stdout sink and report to stderr, so a log-write failure (disk full,
  bad permissions, misconfigured CALL_LOG_PATH) never turns a successful
  narration into a 500 (charter §13). Injected write= sinks (used by
  tests) are left to surface their own errors.
- ollama_client.chat: catch ValueError alongside httpx.HTTPError in the
  retry loop so a 200 response with a non-JSON body (JSONDecodeError is
  a ValueError subclass) counts as a failed attempt and falls through to
  ModelError after the one retry, instead of escaping chat() uncaught
  (charter §12 — one retry, then ModelError, nothing else escapes).
- main.py: update stale docstrings — /dm/narrate is now fully wired
  (routing + model call + logging); the other four roles remain stubs.

Regression tests added for both fixes (TDD: watched RED, then GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:45:39 -05:00
470ccb2935 test(api): gated live Ollama smoke (--run-live) + env docs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:36:56 -05:00
45d5f0360b test(api): keep test_endpoints hermetic after /dm/narrate wiring
/dm/narrate now makes a real Ollama call (Task 7). The role-endpoint
loop tests in test_endpoints.py had no mock, so every default pytest
run was silently hitting a live Ollama instance on localhost:11434 —
breaking the hermetic-suite constraint. Add an autouse fixture that
stubs narrate.ollama_client.chat and narrate.call_log.record so all
five endpoints stay hermetic; assertions (200 on valid, 422 on
invalid) are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:31:42 -05:00
5a35802075 feat(api): wire /dm/narrate — 200 prose | 502 model_error | 422 invalid log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:31:38 -05:00
2027e7a9b9 feat(api): narrate service — render→route→call→log, returns raw prose
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:27:36 -05:00
2558e2a2a0 feat(api): author narrator system prompt (voice, [FACT] rule, no-numbers, no player agency) 2026-07-10 08:23:31 -05:00
15995c57ee feat(api): §10 JSON-lines call logging (full prompt + seed, injectable sink)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:20:00 -05:00
be133c4f3a feat(api): Ollama /api/chat client with one retry + typed ModelError
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:15:45 -05:00
4d8a19353e feat(api): prompt loader (header split) + curated canon-log digest
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:11:57 -05:00
42b3720721 feat(api): config + role→model routing (qwen3.5 narrator default, think off)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:08:56 -05:00
ea2cfafd34 docs(plan): Narrator/Ollama pipeline implementation plan
Phase 1 (config, routing, prompts+digest, ollama_client, call_log) + Phase 2
(author narrator.md, narrate service, wire /dm/narrate, gated live smoke).
Mock boundary via httpx.MockTransport; qwen3.5 default with think:false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:32:08 -05:00
ec01ce5c9b docs(spec): qwen3.5 narrator default + think:false; add model probe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:25:17 -05:00
959abc79eb docs(spec): Narrator/Ollama pipeline (server) design
Shared server-side model-call pipeline (Ollama client, role->model routing,
prompt loader + canon-log digest, §10 call logging, retry->typed 502) wired
through /dm/narrate as the first role. Non-streaming; server returns raw
prose, client extracts tags. Two phases: pipeline foundation, Narrator online.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:58:03 -05:00
b15e81e242 docs(roadmap): fill in sequencing — Narrator/Ollama pipeline next
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:56:42 -05:00
1f39ec709e Merge client canon log engine (Plan B)
Client-side GDScript engine that constructs the canon log at new-game
(origin + world + character creation) and maintains its invariants turn
to turn. Headless GUT-tested logic layer (48 tests): typed RefCounted
model with invariants-in-the-model, Luck-centric GameState, ContentDB
mirroring the api id-resolver, NewGame.construct, and a pure §12 tag
extractor. Binds to the Plan A canon log contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:34:41 -05:00
bc83e92f44 chore(client): Godot rewrote project.godot header on open
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:34:41 -05:00
63ce3efb74 fix(client): reject empty player name in NewGame.construct (contract minLength:1)
Whole-branch review flagged that NewGame.construct validated class and
origin refs but not player.name. The canon-log schema requires
player.name with minLength:1, so a missing/blank name previously
returned ok:true and produced a log the API would reject with 422.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:03:18 -05:00
2d52d74963 chore(client): track generated .uid for test_round_trip.gd
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:51:48 -05:00
2673126003 test(client): round-trip + contract-invariant integration on a constructed log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:51:42 -05:00
5388979bf1 feat(client): TagExtractor — pure regex parse/strip of §12 tags
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:46:11 -05:00
7c5eaf3ba3 test(client): cover non-companion disposition routing (§6/§7 boundary) in NewGame
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:42:12 -05:00
8ca89a1ab2 feat(client): NewGame.construct — origin+world+creation -> {canon log, state}
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:36:50 -05:00
fe03d1f787 feat(client): ContentDB loads /content + mirrors the api id-resolution check
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:30:48 -05:00
0a9742a984 feat(client): Luck (deterministic gen/drift/descriptor) + GameState store
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:24:59 -05:00
4166370f9d feat(client): CanonLog container + maintenance mutators (cap 5, dedup, stack)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:19:55 -05:00
a682022f17 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>
2026-07-09 14:12:16 -05:00
5486ed00fa chore(client): vendor GUT 9.7.1 + headless test harness 2026-07-09 14:05:20 -05:00
1c5f08c5e7 docs(plan): client canon log engine (Plan B) implementation plan
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>
2026-07-09 13:58:21 -05:00
5a1f1ac0ca docs(spec): client canon log engine (Plan B) design
The client half of the canon log contract: typed RefCounted model with
invariants-in-the-model, Luck-centric game state, new-game construction
from origin+world+creation, turn-to-turn mutators, and a pure regex tag
extractor. Headless GUT-tested logic layer; UI/HTTP/save-load deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:46:53 -05:00
272995fb6c Merge canon log follow-ups
Sort-key hardening, unified 422 error envelope, starlette warning filter, and
the docs/canon-log.md living contract for the Plan B client.
2026-07-09 13:09:32 -05:00
7f1d9fa39a chore(api): canon log follow-ups — hardening, unified 422, contract doc
- canon_log.py: stringify jsonschema error-path segments before sorting so a
  future schema mixing array indices and object keys can't TypeError (#1)
- main.py: RequestValidationError handler reshapes pydantic body failures into
  the same {"detail": {"canon_log_errors": [...]}} envelope as schema failures,
  so the client parses ONE 422 shape (#2a) + tests
- pytest.ini: filter the starlette/httpx TestClient deprecation → pristine output (#4)
- docs/canon-log.md: living contract for the client (Plan B) — schemas, the §7
  boundary, the unified error envelope, per-role injection (#3)

Full suite 26 passing, 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:03:59 -05:00
0e9d1b2a43 Merge canon log contract & api validation (Plan A)
JSON Schema contract for canon log + origin seeds (single source of truth),
validator module, POC deserter origin + minimal world fixtures with
id-resolution, canon-log validation wired into all five role endpoints
(422 on invalid), and schemas bundled into the Docker image with a passing
runtime smoke test. Full suite 25 passing.
2026-07-09 12:58:44 -05:00
e39d1a446e fix(api): constrain disposition_overrides keys; close docker paper cuts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:54:01 -05:00
4ab0e564ef chore(api): bundle canon log schemas into image; repo-root build context
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:45:49 -05:00
6d5510773a feat(api): validate canon log at every role endpoint (422 on invalid) 2026-07-09 12:42:03 -05:00
4aa65c9d4c feat(content): POC deserter origin + world fixtures with id-resolution check 2026-07-09 12:37:29 -05:00
e80a4071f6 fix(api): pattern-constrain humiliations id; broaden canon log boundary tests 2026-07-09 12:34:31 -05:00
843ab11a5c feat(api): canon log JSON Schema + validator, enforce §7 luck boundary
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:31:10 -05:00
710745e548 feat(api): origin seed JSON Schema + validator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:26:39 -05:00
38c659cbfe Merge canon log schema design + implementation plan
Design spec + Plan A (contract & api validation) + content/world reorg.
2026-07-09 12:22:01 -05:00
87c5748295 docs: implementation plan for canon log contract + api validation
Plan A of two: the JSON Schema contract, authored POC fixtures (deserter
origin + minimal world), and the api's runtime validation of every canon log
(422 on invalid, enforcing the §7 luck boundary). Five TDD tasks, all runnable
with pytest + Docker in-repo. Plan B (GDScript client construction/maintenance)
deferred until we're in Godot.

Plan: docs/superpowers/plans/2026-07-09-canon-log-contract-and-api-validation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:18:41 -05:00
800811dac2 docs: canon log schema + origin seed design
Design spec for the §11 canon log, plus origin seeds as thin initial-state
overlays over a single static world. Three layers (world content / origin seed /
runtime canon log), the in/out boundary (numeric Luck never enters the AI
payload, §7), field-level schemas, new-game construction order, turn-to-turn
maintenance, and JSON storage validated on both client and api.

Folds content/npcs and content/quests under content/world/; adds
content/world/{locations,items} and content/origins. fallback/ kept outside
world/ (authored prose, not id-referenced).

Spec: docs/superpowers/specs/2026-07-09-canon-log-schema-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:03:37 -05:00
d340e06dad docs: add git workflow to working agreement (§18)
Long-lived master + dev; all non-doc work on branches off dev; doc-only
edits go straight to dev; human owns the dev->master merge and pushes
master; Claude pushes only dev, only after a --no-ff merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:27:04 -05:00
c5eccbb2a5 chore: gitignore and untrack .claude harness files
These are Claude Code session/state files, not project code. They were
swept into the first commit; remove from the index (kept on disk) and
ignore going forward so they stop dirtying status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:24:46 -05:00
d4ada9bedb chore: dockerize api proxy and add gitignore
Add a Docker-based FastAPI proxy skeleton (charter §4) for parity across
dev / homelab VPS / fly.io, plus root .gitignore for Godot 4.7 and Python.

- api/Dockerfile: python:3.12-slim, non-root, PORT-aware CMD
- api/app/main.py: /health + five role endpoint stubs (§4)
- api/requirements.txt: fastapi/uvicorn/httpx/pydantic, pinned
- docker-compose.yml: local dev with live reload
- api/fly.toml: prod deploy stub with /health check
- api/.env.example: key lives in the proxy, never the client (§4)
- .gitignore: ignores .env, whitelists .env.example

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:23:27 -05:00
654 changed files with 65966 additions and 117 deletions

View File

@@ -1,46 +0,0 @@
[2026-07-09 10:54:03.926] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:54:03.929] Processing: hook_event=UserPromptSubmit, tool=
[2026-07-09 10:56:09.126] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:09.128] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:18.474] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:18.476] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:21.852] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:21.855] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:25.336] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:25.338] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:28.741] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:28.744] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:32.070] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:32.073] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:35.419] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:35.422] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:39.157] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:39.159] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:42.516] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:42.518] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:48.542] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:48.545] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:51.814] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:51.816] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:55.095] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:55.098] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:56:59.075] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:56:59.077] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:57:02.336] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:57:02.338] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:57:05.616] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:57:05.618] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:57:08.886] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:57:08.888] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:57:12.207] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:57:12.209] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:57:16.216] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:57:16.218] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:57:19.540] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:57:19.542] Processing: hook_event=PostToolUse, tool=Write
[2026-07-09 10:57:31.383] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:57:31.386] Processing: hook_event=PostToolUse, tool=Edit
[2026-07-09 10:57:51.755] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
[2026-07-09 10:57:51.758] Processing: hook_event=Stop, tool=
[2026-07-09 10:57:51.761] _git_status_porcelain error: [Errno 138] emscripten does not support processes.
[2026-07-09 10:57:51.761] Stop hook: empty review set

View File

@@ -1 +0,0 @@
{"shown_warnings": [], "touched_paths": []}

View File

@@ -0,0 +1,168 @@
---
name: world-building
description: >
Author gritty, dark, disposition-gated world content for Code of Conquest (the
Margreave). Use this WHENEVER the user wants to write, expand, or add lore,
storylines, plot threads, secrets, factions, regions, towns, dungeons, NPCs and
their knowledge/dialogue, quests, items, locations, or starting origins — even
if they don't say "world-building" or name the content/ folder. Triggers on
"write an NPC", "add a town/region/faction", "new quest", "a secret the party
can uncover", "expand a region", "flesh out a town", "someone the
player meets on the road", "cursed item", "give me a storyline", and the like.
The skill reconciles new content against existing canon (never contradicting or
silently changing it), writes it in the exact source format the content build
tool consumes, keeps NPCs true to the world's hard, uncaring tone (warm only
when disposition is earned), and verifies the build stays green. Do NOT use it
for engine/UI code, prompts (/api/prompts), or combat rules.
---
# World-building — the Margreave
Author new content for **Code of Conquest**, a gritty single-player CRPG. Read
`CLAUDE.md` (repo root) once per session for the charter — this skill assumes it.
## The one rule that governs everything you write
> **Code owns state. AI owns text.** (charter §2)
You are authoring *static content*, not runtime state. Facts, personas, places,
and knowledge chains are text the engine references by stable **id**. You never
write initial disposition numbers into world content, never gameplay logic. A
starting *value* (where the player begins, a seeded disposition) belongs to an
**origin**, never to a town or NPC.
## The two things that make this hard
1. **New content must not contradict old content.** The build tool checks
structure (ids resolve, secrecy routes correctly) but it CANNOT tell that you
just described the north road as untraveled when canon says elves walk it. A
contradiction ships silently and the DM narrates a broken world. So:
**read the existing canon before you write a word**, and treat every existing
file as read-only unless the user explicitly approves an edit (see below).
2. **The tone is specific and easy to get wrong.** Gritty, not grim. The world
is hard and does not care about the player — but an NPC who has come to trust
them becomes genuinely warm. Getting either pole wrong (grimdark misery, or
friendly-NPC-from-hello) breaks the world. See `references/tone.md`.
## Workflow — follow in order
Make a todo per step.
### 1. Understand what's being asked, and which layer it lives in
| Content kind | Format | Lives in |
|---|---|---|
| People, places, regions, factions, world rules, knowledge (rumor/fact/secret), NPC dialogue layers | **Markdown bible**` ```yaml ` blocks | `content/lore/*.md` (source) |
| Quests / story skeletons | Hand-authored JSON | `content/world/quests/` |
| Items (incl. cursed/blessed) | Hand-authored JSON | `content/world/items/` |
| Locations (map nodes) | Hand-authored JSON | `content/world/locations/` |
| Origins (starting seeds) | Hand-authored JSON | `content/origins/` |
| Degraded-DM fallback prose | Markdown/text | `content/fallback/` |
The **lore bible is the heart** — it produces the disposition-gated dialogue that
makes NPCs feel alive. `content/world/` and `content/server/` under `canon/`,
`topics/`, and `npcs/` are **BUILT from the bible — never hand-edit them.** The
JSON under `quests/`, `items/`, `locations/`, and `origins/` is hand-authored and
loosely schema'd (POC seeds); edit those directly.
If a storyline you're writing implies a quest, item, or location that doesn't
exist yet, say so and author the stub, or flag it — don't leave a dangling id.
### 2. Load and reconcile against existing canon — before writing
Run the canon index to see every id, type, secrecy, and one-line gist already
authored:
```
.venv/bin/python .claude/skills/world-building/scripts/canon_index.py
```
Read the entries your new content touches (people it references, the region it
sits in, secrets it brushes against). New content must slot into this without
contradiction. Reuse existing ids in `related:` rather than inventing parallel
ones. If your idea only works by *changing* something already canon — a fact's
body, a disposition, who knows what — **STOP.** Do not edit. Present the exact
before/after change and the reason, and get explicit approval first. New files
and new entries need no approval; edits to existing canon always do.
New content must also slot against the world's overarching cosmology — the good
god (the Warden), his angels (the Sent), and the seven demon kings who work
against him through influence, possession, and blood sacrifice. Read
`references/cosmology.md` before authoring; it is the backdrop nothing may
contradict, and it carries the authoring rules that keep the war in tone
(distant, worked through people, never epic, blessing/curse read through Luck).
### 3. Draft in the correct format
- **Bible entries:** see `references/schema.md` for the full field-by-field
contract, the id/namespace/type rules, the secrecy scale, and the knowledge-gate
model. Match the **structure** of `content/lore/specimen.md` (the synthetic
disposition-gate specimen — a guarded-then-warm NPC with one reveal earned at
`warm` and a `never`-gated core no disposition unlocks) and the **voice** of the
canon bodies in `content/lore/cosmology.md`.
- **Hand-authored JSON:** mirror the shape of the existing file in that folder
(`references/schema.md` documents each). Keep ids stable and lowercase-snake.
Write NPCs to the tone and disposition-warmth model in `references/tone.md`. A
hard NPC starts guarded (`cold`/`neutral`, sometimes reachable-`hostile`),
withholds more than a stranger would expect, and both *reveals more* and *softens
in manner* as the player climbs their ladder. That warmth is earned, never
default — it's what the five-rung gate system exists to deliver.
### 4. Build and verify — not done until green
The build regenerates `content/world/` + `content/server/` from the bible, and the
`--check` gate is what proves it stayed valid. **There is no CI — nothing runs
these for you.** You are the gate; run them, from repo root, with the repo venv
active (`source .venv/bin/activate`). The `content_build` package lives under
`tools/`, so `PYTHONPATH=tools` is required:
```
PYTHONPATH=tools python -m content_build # regenerate world/ + server/ from lore/
PYTHONPATH=tools python -m content_build --check # must exit 0: fresh, valid, no orphans, no secrecy leaks
PYTHONPATH=tools python -m pytest tools/content_build/tests -q # schema + secrecy + cli coverage
```
**If the build changed anything under `content/world/`, also run the client
suite** — `bash client/run_tests.sh` (must be fully green):
```
bash client/run_tests.sh # Godot/GUT; content/world/ is a CLIENT INPUT, not just build output
```
That last one is not optional and not paranoia. `client/scripts/content/content_db.gd`
loads `content/world/`, and `client/tests/unit/test_content_db.gd` asserts on it — so
**deleting or renaming generated content is a client change.** A content purge once
left the client erroring on a vanished directory and turned a secrecy assertion into a
test that passed while checking nothing. Nothing else will catch that for you.
(If `python` isn't found, the venv isn't active — use `.venv/bin/python`.)
If any step fails, read the `BUILD FAILED` / `STALE:` message — it names the
source file and line — fix the bible, rebuild, recheck. Never hand-edit the
generated JSON to make `--check` pass; fix the source. (Hand-authored JSON under
quests/items/locations/origins isn't built — the build tool ignores it. If you
touched those, just confirm they're valid JSON.)
### 5. Report
Summarize: new ids added (by layer), any existing id you reused in `related`,
anything you flagged for approval, and confirmation the build + check + tests are
green. Update [`content/roadmap.md`](../../../content/roadmap.md) if you added a new
bible file or closed out a thread — its Spine list, or the Bill of Materials line
you just satisfied.
**Respect its rule: never author anything that is not in the Bill of Materials.**
An idea that arrives during authoring goes to that doc's Backlog, not into the
world.
## Reference files
- `references/cosmology.md` — the overarching theme (the Warden and the Seven)
every piece of content reconciles against, plus the rules that keep it in tone.
Read before authoring any lore, NPC, quest, or item.
- `references/schema.md` — the complete bible + JSON schema, all validation rules,
the secrecy scale, the knowledge-gate model, worked examples. Read before
authoring bible entries.
- `references/tone.md` — the Margreave's tone contract and the NPC
disposition-warmth model. Read before writing any NPC or narrative body.

View File

@@ -0,0 +1,48 @@
{
"skill_name": "world-building",
"evals": [
{
"id": 1,
"name": "new-npc-reconciled",
"prompt": "Add a new NPC on the road: a caravan guard who's cagey with strangers but opens up once you've earned it. Give them something personal they only share when they trust you. Put it in a new lore file.",
"expected_output": "New npc.* dialogue-layer entry (+ its person/place/knowledge entries) authored in a NEW content/lore file; start_disposition cold or neutral; at least one fact gated at warm/trusted; every related resolves; no existing lore file edited; PYTHONPATH=tools content_build --check exits 0; tone dry and guarded-then-warm; nothing contradicts the cosmology backdrop.",
"assertions": [
"Created at least one new npc.* entry in content/lore with start_disposition of cold or neutral",
"The NPC's knows list gates a personal fact at warm or trusted (earned reveal)",
"Every related id resolves to a real entry",
"No pre-existing content/lore/*.md file was modified (or any edit was surfaced for approval, not silent)",
"python -m content_build --check exits 0 after the change",
"Persona/body prose is dry and in-tone; no narrator winking or 'hilarious' register",
"Nothing authored contradicts the cosmology canon (the Warden / the Seven)"
],
"files": []
},
{
"id": 2,
"name": "contradiction-approval-gate",
"prompt": "Write an NPC priest who tells the player the Warden's true name and proves the god is real and good.",
"expected_output": "Skill recognizes this contradicts locked cosmology (the Warden is nameless BY DESIGN; his existence and goodness are 'distant & contested', never confirmed by the narrator), STOPS instead of authoring it, and either explains the conflict or proposes an in-tone alternative (an NPC who CLAIMS a name or believes, with the game confirming nothing) and asks before writing.",
"assertions": [
"Did NOT author content that states the Warden's true name or confirms the god is real/good",
"Surfaced the contradiction explicitly, naming the cosmology rule (nameless by design; distant & contested)",
"Proposed an in-tone alternative and/or asked for approval before writing"
],
"files": []
},
{
"id": 3,
"name": "new-storyline-file",
"prompt": "Start a new storyline about a roadside cult bleeding travelers to feed one of the Seven: who they are, and a rumor the player might hear. Make it its own file.",
"expected_output": "A new content/lore/*.md file with canon entities and at least one knowledge entry; related wired to a real cosmology id (faction.the-seven or a specific lord); knowledge with correct ascending secrecy; build + --check green; content/roadmap.md updated. The demon tie is handled in-tone: a gated secret (hidden engine), not stated in the open.",
"assertions": [
"Authored a new content/lore/*.md file (its own file)",
"Wired at least one new entry's related to a real cosmology id (faction.the-seven or a lord)",
"Includes at least one knowledge entry (rumor/fact/secret) with correct ascending secrecy",
"python -m content_build --check exits 0 after the change",
"content/roadmap.md updated to reflect the new content",
"The demonic connection is a gated secret, not open surface text"
],
"files": []
}
]
}

View File

@@ -0,0 +1,57 @@
# Cosmology — the Warden and the Seven
The overarching theme of the Margreave. Every new piece of content sits inside
this hidden war and must not contradict it. The canon lives in
`content/lore/cosmology.md` and `content/lore/the-seven.md`; this file is the
author-facing summary + the rules that keep it in tone. Read it before writing
lore, NPCs, quests, or items.
## The frame
- **The Warden** (`person.the-warden`) — a genuinely good, *distant* god. Made
the races, loves them, wards the world against the Seven. Answers worship with
rare blessing and does not otherwise intervene. **Nameless by design** — no one
living speaks his name; never invent one. Whether he is good, absent, or dead
is the world's oldest argument, never settled by the narrator.
- **The Sent** (`faction.the-sent`) — his angels. Indirect: a Sent one *asks* a
mortal to do a deed in his name; blessing follows the deed. Rare — most people
never meet one. This is the cosmic-flavored quest-giver vein.
- **The Seven** (`faction.the-seven`) — seven demon kings + armies, the Warden's
enemy. Want the races dead or enslaved. Run on **blood** (`rule.blood-price`):
sacrifice buys their power. The roster (`content/lore/the-seven.md`): Kareth
(slaughter), Vael (betrayal), Morren (plague/mercy-kill), Ghaul (bondage),
Ishri (appetite), Nuun (madness — chief possessor), Draeth (tyranny).
- **Possession** (`rule.possession`) — how the Seven reach mortals: mostly
*influence* (a hand on the scale of what a person already wants), rarely
outright seizing a body (Nuun's specialty). A possessed mortal is a victim; the
thing inside is the enemy.
- **The two economies** — worship→blessing (`rule.the-pact`) vs
blood→power (`rule.blood-price`). Both express through Luck's *flavor*, never
numbers (see below).
## The FTH-axis callings (from the races/classes spec)
The two divine callings draw power from opposite sides; the rest are unaligned.
- **Bonesetter** (Cleric, FTH) — channels the Warden; healing *is* his blessing.
- **Bloodsworn** (Warlock, FTH) — pacted to one of the Seven; the "Pact Mark"
talent spends a thread of a lord's blood-price power.
- **Hedge-Mage** (Wizard, MAG) — outsider; arcane is *studied*, serves neither.
- Martials (Sellsword/Trapper/Barbarian) — unaligned; the war only echoes.
Callings are code-owned state — reference them in prose, never author a "class"
entity. (A Bloodsworn's patron choice and any Luck effect are M4/M5, not content.)
## Authoring rules — keep it in tone
1. **Distant.** The cosmology is the hidden engine under local grime, not the
surface text. A corrupt mayor can just be a corrupt mayor.
2. **Through people.** Demons work by influence; possession is rare and quiet.
No manifesting monsters in the square, no preaching narrator, god unconfirmed.
3. **Not epic.** Keep the gritty, indifferent register (see `tone.md`). Cosmic
stakes stay implied, argued-over, never announced.
4. **Blessing/curse ↔ Luck.** Express which side has hold of someone through
Luck's *flavor* ("fortune spits on you") — never a number, never something the
player can calculate. A cursed item (+STR / LCK) is a small bargain with the
Seven.
5. **Victims are gated secrets.** WHO is possessed, WHICH road is bled, WHICH
shrine is a front — author as per-town `rumor`/`fact`/`secret` with disposition
gates, not stated in the open. The frame gives you hooks; you place the wounds.

View File

@@ -0,0 +1,260 @@
# Content schema — the exact contract
Everything the build tool (`tools/content_build/`) enforces, plus the loose JSON
formats it doesn't build. When in doubt, the validator in `resolve.py` is the
source of truth; this file is its plain-language mirror.
## Table of contents
- [The bible file (`content/lore/*.md`)](#the-bible-file)
- [Common fields (every bible entry)](#common-fields)
- [Entry kinds](#entry-kinds)
- [The secrecy scale](#the-secrecy-scale)
- [The knowledge-gate model](#the-knowledge-gate-model)
- [Client vs server routing](#client-vs-server-routing)
- [Worked example](#worked-example)
- [Hand-authored JSON (not built)](#hand-authored-json)
---
## The bible file
A bible file is Markdown. Prose between blocks is for human authors — the build
tool ignores it. Content lives in fenced blocks opened by a line that is exactly
` ```yaml ` and closed by ` ``` `. Each block is one YAML **mapping** = one entry.
Organize a file with `##` headings and `>` notes like `content/lore/specimen.md`.
Every `*.md` in `content/lore/` is parsed; a new region/storyline can be its own
file (e.g. `content/lore/the-tallow-reach.md`).
## Common fields
Every entry requires these five (missing any → build fails):
| Field | Rule |
|---|---|
| `id` | `namespace.slug`, lowercase-kebab. Namespace is one of the legal set below. **Globally unique** — duplicate id fails the build. |
| `type` | Must equal the id's namespace, EXCEPT `npc.*` whose type is always `person`. |
| `status` | `candidate` or `canon`. Only `canon` entries emit to world/server; `candidate` is validated but not shipped — use it for work-in-progress. |
| `secrecy` | Integer (not bool). See the scale below. |
| `related` | List of ids (may be empty `[]`). **Every id must resolve** to another entry, or the build fails. This is the web that keeps the world coherent — wire new entries into it. |
`body: >` — the prose. A YAML folded scalar. This is what the DM/NPC pipeline
reads. For canon entities it ships to the client; for knowledge it's server-only.
## Entry kinds
Two families, distinguished by **id namespace** (not by any `kind` field):
### Canon entities — the world's furniture
Namespaces `town`, `place`, `region`, `faction`, `person`, `rule`.
- `type` == namespace.
- **Must be `secrecy: 0`** (they're public; their bodies ship to the client).
- `body` is public description.
- The special entry `rule.disposition-ladder` carries a `rungs:` list and is
**required exactly once** across all bibles — it defines the gate vocabulary.
Don't duplicate or remove it; it lives in `mechanics.md`.
### Knowledge — the atoms NPCs reveal
Namespaces `rumor`, `fact`, `secret` (these three id-prefixes; `type` matches).
- `secrecy` 14 (author-facing sensitivity — see scale).
- `body` is the thing known. **Routes to the server**, never the client.
- One atomic idea per entry, so different NPCs can hold the same fact at
different gates. Chain them by `related:` (a secret relates to the facts that
circle it).
### NPC dialogue layer — `npc.*`
The interactive layer of a person. `id: npc.<slug>`, `type: person`. This is a
SEPARATE entry from the `person.<slug>` world-record; the namespace prefix is the
only thing distinguishing them (a person may have both — see the mayor). Extra
required/optional fields:
| Field | Rule |
|---|---|
| `start_disposition` | **Required.** One of the ladder rungs (`hostile`/`cold`/`neutral`/`warm`/`trusted`). Where this NPC meets a stranger. |
| `knows` | **Required, non-empty.** List of `{fact: <knowledge-id>, gate: <rung-or-never>}`. Each `fact` must resolve to a `rumor`/`fact`/`secret`. Each `gate` is a rung or `never`. |
| `body` | Here it's the NPC's **persona** — voice, manner, what they want, why they withhold. Server-only. |
| `disposition_notes` | Optional. Author guidance on how they move up/down the ladder and what each pole means. Server-only. |
A non-npc entry must NOT carry `knows` or `start_disposition`. An `npc.*` entry
MUST carry both.
## The secrecy scale
`secrecy` is **author-facing and static** — a mis-authoring guard, not a runtime
lever. It answers "how sensitive is this text, where may it live?"
| secrecy | Meaning | Routing |
|---|---|---|
| 0 | Public. Canon entities only. | Body ships client-side. |
| 1 | Observable rumor; freely-ish known. | Body server-only. |
| 2 | Known locally, not discussed. | Body server-only. |
| 3 | Protected; a real tell. | **Body must never reach the client** (build hard-fails if it does). |
| 4 | The core secret. | Server-only. |
Keep the secrecy of a knowledge chain *ascending* from freely-observed to the
`never`-gated core — a chain climbs by secrecy, e.g. 1→2→2→3→4 from an
observable rumor to the core secret.
## The knowledge-gate model
**Two independent axes — never conflate them:**
- **secrecy** (above) lives on the *fact* — static, author-facing.
- **gate** lives on the *knowledge link* (`knows[].gate`) — runtime,
character-facing. It's the disposition rung at which THIS NPC will reveal THIS
fact.
Rungs, low → high: `hostile < cold < neutral < warm < trusted`. Plus the special
gate **`never`**: the NPC *knows* the fact (the Improviser can reason with it) but
no disposition ever unlocks it in dialogue. `never` is how a locked-door NPC (the
mayor) holds the secret without leaking it.
Design a chain so climbing an NPC's disposition unlocks successively deeper facts.
The reachable ceiling is a design choice: an NPC can *witness* enough to imply a
secret without holding the secret entry itself: its top rung names what it
witnessed, not the whole truth, and the player infers the rest. Understanding
earned beats understanding handed over.
## Client vs server routing
The build splits each canon entry (`emit.py`) — you don't do this, but author with
it in mind:
- **Canon entity** → `content/world/canon/<slug>.json` (id, type, related, public
body). Nothing server-side.
- **Knowledge** → client `content/world/topics/<slug>.json` (id, type, related —
**no body**); server `content/server/topics/<slug>.json` (id, body, secrecy).
- **npc.\*** → client `content/world/npcs/<slug>.json` (id, type,
start_disposition, related, knows+gates — the gate logic the client needs);
server `content/server/npcs/<slug>.json` (id, persona, disposition_notes — the
voice, hidden).
The guarantee: a player who reads the client bundle sees NPC *gates* but never NPC
*personas* or *secret bodies*. That's why secrecy≥3 bodies client-side is a hard
build failure.
## Worked example
A guarded fisherman who warms up and names the coin — and who holds one thing no
disposition ever buys.
```yaml
id: person.specimen-teague
type: person
status: canon
secrecy: 0
related: [place.specimen-wharf]
body: >
Teague, oldest hand on the Greywater wharf. Mends nets he no longer sails with.
Watches everything off the water and says almost none of it.
```
```yaml
id: fact.specimen-foreign-coin
type: fact
status: canon
secrecy: 2
related: [place.specimen-wharf, person.specimen-teague]
body: >
The dock hands are paid in pass-country coin, not town mint. Someone outside the
charter is buying the wharf's silence, a little at a time.
```
```yaml
id: secret.specimen-drowned-clerk
type: secret
status: canon
secrecy: 4
related: [place.specimen-wharf, person.specimen-teague, fact.specimen-foreign-coin]
body: >
The charter clerk who came asking after the coin went into the river in the
spring. Teague held his legs. He has not been out on the water since and tells
anyone who asks that it is his knees.
```
```yaml
id: npc.specimen-teague
type: person
status: canon
start_disposition: cold
related:
[person.specimen-teague, fact.specimen-foreign-coin,
secret.specimen-drowned-clerk]
body: >
Same man as person.specimen-teague; his interactive layer. Curt with strangers, not
from malice but from a lifetime of watching talkers end up face-down in the
river. Warms slowly, and when he does the dryness turns to something almost
fond.
knows:
- {fact: fact.specimen-foreign-coin, gate: warm}
- {fact: secret.specimen-drowned-clerk, gate: never}
disposition_notes: >
Starts cold. Buying his catch, not his story, is what moves him. At warm he
names the coin; he never speculates aloud about who mints it. The clerk is the
one thing no rung buys — saying it aloud would hang him, and he knows it. He
reasons around it, steers off it, and takes it to the ground.
```
Note: `person.specimen-teague` (secrecy 0, public) and `npc.specimen-teague` (the layer,
persona server-only) are two entries. The chain ascends 2 → 4: the coin is gated at
`warm` and the player earns it; the clerk is gated `never` and no disposition unlocks
it. **That is what `never` is for.** Teague holds it — the Improviser may reason with
it, and the player may infer it from what he *will* say — but he cannot be talked into
saying it, because saying it would hang him. A `never` gate needs a reason like that:
if any rung *should* buy the secret, gate it at `trusted` instead.
## Hand-authored JSON
Not built from the bible — edit these files directly. Loosely schema'd POC seeds;
mirror the existing file in the folder. Keep ids stable, lowercase-snake.
**Quest**`content/world/quests/<id>.json`
```json
{ "id": "find_the_ledger", "name": "The Missing Ledger",
"objective": "Find who took Fenn's ledger" }
```
**Item**`content/world/items/<id>.json`. Cursed/blessed items move Luck (§7):
the STR/LCK split is the point. Numeric effects live in game state; keep the JSON
to identity/slot and let narrative-worthy items surface as canon-log facts.
```json
{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }
```
**Location**`content/world/locations/<id>.json`. Map nodes; an origin's
`start_location_id` resolves here.
```json
{ "id": "greywater_docks", "name": "the Greywater docks",
"description": "A rot-black wharf where the river meets the sea trade." }
```
**Origin**`content/origins/<id>.json`. Thin starting seed — where the player
begins and the situation. This is the ONLY place initial *state* (seeded
dispositions, granted items) belongs. Every referenced id
(`start_location_id`, `inventory_grants[].item_id`, `start_quest_id`,
`disposition_overrides` keys) must resolve to real content, or new-game
construction fails loudly.
```json
{
"schema_version": 1,
"id": "deserter",
"display_name": "The Deserter",
"description": "You walked away from a company that doesn't allow walking away.",
"start_location_id": "greywater_docks",
"situation": ["Arrived by barge before dawn, hood up"],
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
"start_quest_id": "find_the_ledger",
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
}
```
**Fallback**`content/fallback/`. Degraded-DM prose (charter §13), written as
in-voice content, not error text. Every AI surface needs one before it ships.

View File

@@ -0,0 +1,121 @@
# Tone — the Margreave
The world's voice. Distilled from charter §3 (tone), §7 (Luck), §9 (companions).
Read before writing any `body`, persona, or narrative prose. Getting this right is
most of the job — the schema is easy; the voice is the product.
## The core stance
**Gritty, not grim.** The reference is Batman in the DC comics — not Warhammer,
not Care Bears. The world has real problems and does not care about the player.
People get hangovers, vomit, curse, get infected wounds. But it is not a misery
engine. Grimdark is as wrong as cozy. The world is *indifferent*, and indifference
is harder and more interesting than cruelty.
**The world is played straight.** Comedy emerges from situation, never from the
narrator. Nothing is ever "hilariously" anything. The narrator is dry and does not
wink. When you're tempted to make prose funny, make the *situation* absurd and
describe it flatly instead.
## Register
- **Profanity** is permitted and should feel *earned*, not decorative.
- **Violence has weight.** Don't gloss it and don't wallow.
- **Sex exists** and is discussed the way adults discuss it — bluntly, and mostly
as a source of trouble.
- **Bodies are real.** Fatigue, hunger, cold, drink, injury. The world touches the
characters physically.
## The narrator voice
Dry. Economical. Observational. States what is, lets the reader feel the weight.
It never editorializes, never reassures, never jokes. A good body reads like
someone who has seen a lot and is not impressed, telling you the truth plainly.
Look at the cosmology bodies: "Most of the Margreave believes the Seven are real.
Most of the Margreave is right." The weight is in the plain statement, not in
adjectives.
## NPCs: hard by default, warm when earned
This is the load-bearing pattern for the user's world. **The world doesn't care
about you — but a person who comes to trust you does.** That arc is what the
disposition ladder exists to deliver, and it's what makes the coldness bearable.
### Default posture: guarded
A Margreave NPC meets a stranger with wariness, not hostility — they have their
own troubles and no reason to spend them on you. Mechanically:
- `start_disposition` is usually `cold` or `neutral`, rarely `warm`.
- A frightened or burned NPC withholds **more** than a stranger would expect, not
less (fear reads as prickliness). `hostile` is a
reachable floor: push them, threaten, side against them, and they close entirely.
- What they reveal is gated. Low rungs get you surface; the personal and the
dangerous sit higher up.
### The warm turn: real, specific, earned
When the player climbs the ladder, two things change together:
1. **They reveal more** — deeper `knows` gates unlock.
2. **Their manner softens** — write the persona and `disposition_notes` so that at
`warm`/`trusted` the dryness turns to something human: fondness, relief at being
heard, blunt loyalty — the specimen NPC's "dryness turns to something almost
fond."
The warmth is never sycophantic and never a personality transplant — a hard person
stays recognizably themselves, just no longer guarded against *you*. That's more
affecting than a switch from mean to nice.
**Don't** write an NPC who is friendly from hello (breaks the world) or one who
stays cold no matter what (makes the ladder pointless and the world hopeless). The
range between those poles is the whole design.
## The companions set the tone (charter §9)
If you write companion content, hold these exactly — they carry the register:
- **Cadwyn Vell (Bard, NPC-only):** genuinely talented, reflexively lies about
small things and truthfully about large ones, does not know he is the problem and
never becomes self-aware. Florid performing, clipped when scared. The source of
chaos.
- **Brannoc Thane (Sellsword):** dry, warm, economical, twenty years past his
prime and at peace with it. Says devastating things in the tone of a man
discussing weather. Holds the humiliation log — the callback engine. Not a grump;
*fond* of you, which is what makes it land.
- The pair dislike each other mildly and permanently, and neither will leave the
other. Cadwyn generates, Brannoc annotates. Don't add a third to the loop.
## The cosmic backdrop (the Warden and the Seven)
The world sits inside a hidden war — a distant good god and seven demon kings
(see `references/cosmology.md`). It colors tone but must never break the world's
indifference:
- The cosmology is the **hidden engine**, not the surface text. Day-to-day the
world is still taxes, mud, and bad men; the war is what's *underneath* them.
- Demons work **through people** — influence, rarely possession. Write the
wreckage and the person going wrong, not a monster in the square. The narrator
never confirms the god, never preaches, never goes epic.
- Which side has hold of someone reads through **Luck's flavor, never a number**
(a blessing is a run of fortune; a curse is the trap door opening under you).
- The good/evil is real but **argued-over** — most folk dispute whether any of it
is true. Certainty is the enemy of this tone as much as of §7's Luck.
## Luck's fingerprint (charter §7)
If content touches Luck: it is **visible in prose, never in numbers.** Never state
a Luck value; render a descriptor ("Fortune spits on you"). Bad luck costs
**dignity, not progress** — embarrassment, inconvenience, property damage, social
catastrophe, minor injury. Never quest failure, permanent loss, or death. A cursed
item that grants power at a Luck cost (+STR / LCK) is the most interesting kind of
item — lean into that.
## Quick self-check before you ship a body
- Did the narrator stay dry and out of the joke?
- Is the hardness *indifference*, not sadism?
- Does the NPC start guarded, with a real warm turn available up the ladder?
- Would profanity/violence/sex here feel earned, not decorative?
- Does anything contradict an existing canon body? (If yes → reconcile, don't ship.)

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Print an index of every authored entry across the lore bible + hand-authored
JSON, so new content can be reconciled against existing canon BEFORE writing.
Reads the same ```yaml blocks the content build tool parses (no dependency on the
build package — pure stdlib + PyYAML, which the repo already needs). Run from the
repo root:
python .claude/skills/world-building/scripts/canon_index.py
Output groups bible entries by kind (canon entity / knowledge / npc layer) with
id, status, secrecy, and a one-line gist, then lists hand-authored quest/item/
location/origin ids. Use it to spot id collisions and facts your new content must
not contradict."""
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.exit("PyYAML not installed; run: pip install pyyaml")
REPO = Path(__file__).resolve().parents[4]
LORE = REPO / "content" / "lore"
WORLD = REPO / "content" / "world"
ORIGINS = REPO / "content" / "origins"
CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"}
KNOWLEDGE_TYPES = {"rumor", "fact", "secret"}
def parse_bible(path: Path):
"""Yield yaml-block mappings, mirroring tools/content_build/parse.py."""
lines = path.read_text().splitlines()
i, n = 0, len(lines)
while i < n:
if lines[i].strip() == "```yaml":
i += 1
block = []
while i < n and lines[i].strip() != "```":
block.append(lines[i])
i += 1
data = yaml.safe_load("\n".join(block))
if isinstance(data, dict):
yield data, path.name
i += 1
def gist(text) -> str:
if not text:
return ""
one = " ".join(str(text).split())
return one[:100] + ("" if len(one) > 100 else "")
def main() -> int:
if not LORE.is_dir():
return print(f"no lore dir at {LORE}") or 1
entities, knowledge, npcs = [], [], []
for md in sorted(LORE.glob("*.md")):
for d, src in parse_bible(md):
eid = d.get("id", "?")
ns = str(eid).split(".", 1)[0]
row = (eid, d.get("status", "?"), d.get("secrecy", "?"),
gist(d.get("body")), src)
if ns == "npc":
gates = ", ".join(f"{k.get('fact')}@{k.get('gate')}"
for k in d.get("knows", []))
npcs.append(row + (d.get("start_disposition", "?"), gates))
elif ns in KNOWLEDGE_TYPES:
knowledge.append(row)
else:
entities.append(row)
print("=== CANON ENTITIES (public; body ships client) ===")
for eid, st, sec, g, src in sorted(entities):
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
print("\n=== KNOWLEDGE (rumor/fact/secret; body server-only) ===")
for eid, st, sec, g, src in sorted(knowledge):
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
print("\n=== NPC DIALOGUE LAYERS ===")
for eid, st, sec, g, src, start, gates in sorted(npcs):
print(f" {eid:34} [{st} start:{start}] {src}\n persona: {g}\n"
f" knows: {gates}")
def list_json(label, d):
print(f"\n=== {label} (hand-authored JSON) ===")
if not d.is_dir():
print(" (none)")
return
for f in sorted(d.glob("*.json")):
try:
o = json.loads(f.read_text())
name = o.get("name") or o.get("display_name") or ""
except (OSError, ValueError):
name = "(unreadable)"
print(f" {o.get('id', f.stem):28} {name}")
list_json("QUESTS", WORLD / "quests")
list_json("ITEMS", WORLD / "items")
list_json("LOCATIONS", WORLD / "locations")
list_json("ORIGINS", ORIGINS)
return 0
if __name__ == "__main__":
raise SystemExit(main())

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.claude
client
content
!content
docs/*
!docs/schemas
**/__pycache__
**/*.py[cod]
.venv
venv
**/.pytest_cache
api/tests
api/.env
api/.env.*

76
.gitignore vendored Normal file
View File

@@ -0,0 +1,76 @@
# ─────────────────────────────────────────────
# Local scratch — never versioned
# ─────────────────────────────────────────────
# Ad-hoc F6 / smoke-test captures. Kept for the working session, not the repo.
/screenshots/
# ─────────────────────────────────────────────
# Godot 4.7 (client/)
# ─────────────────────────────────────────────
# Editor + import cache (4.x replaced .import/ with .godot/)
.godot/
# Exported builds
/client/build/
export.cfg
# export_presets.cfg holds keystore paths / signing config — keep secrets out of history
export_presets.cfg
# Mono/C# (only if we drop to C# per charter §16)
.mono/
data_*/
*.mono/
# ─────────────────────────────────────────────
# Python / FastAPI (api/)
# ─────────────────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.eggs/
build/
dist/
# Virtual envs
.venv/
venv/
env/
ENV/
# Tooling caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
.tox/
# ─────────────────────────────────────────────
# Secrets — the API key lives here in dev, NEVER in the client (charter §4)
# ─────────────────────────────────────────────
.env
.env.*
!.env.example
# ─────────────────────────────────────────────
# Editors / OS
# ─────────────────────────────────────────────
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# ─────────────────────────────────────────────
# Logs (charter §4/§10 logs go to a store, not the repo)
# ─────────────────────────────────────────────
*.log
# ─────────────────────────────────────────────
# Claude Code harness session/state files
# ─────────────────────────────────────────────
# Ignore every .claude/ dir anywhere in the tree...
**/.claude/
# ...but keep the repo-root .claude/skills/ dir: project-local skills are
# checked-in tooling, versioned like code. (Re-include the root dir, drop its
# other contents, then re-include skills/.)
!/.claude/
/.claude/*
!/.claude/skills/

115
CLAUDE.md
View File

@@ -8,13 +8,13 @@ Read this before writing code. When a request conflicts with something written h
## 1. What this is
A single-player, party-based fantasy RPG. Combat is deterministic JRPG-style. The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
**Code of Conquest** — a single-player, party-based **tactical CRPG** in a gritty high-fantasy world ("The Margreave"). Turn-based, D&D-flavored combat is deterministic (§7/§10). The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
**Not multiplayer.** Not now, not later. Companions are code-controlled NPCs. Do not introduce networking, lockstep, or authority abstractions "in case."
**Target:** Desktop first (Windows/Linux). Mobile is a later consideration and should not constrain POC decisions.
**Target:** Desktop first (Windows/Linux), **1920×1080 (16:9), Godot 4.7.** Mobile is a later consideration and should not constrain design.
**Current phase:** POC. The question the POC answers is *does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?* Everything else is scaffolding for that experiment.
**Phase.** The original POC question — *does bounded AI dialogue feel alive, or a chatbot in a costume?* — is **answered.** Bounded NPC dialogue, validated moves, and the DM loop are built and live-proven (M0M2). The project is now **building the game the mockups describe** (§16, `/mockups`): the tactical CRPG across the 11 screens of that UI bible. The AI's job is unchanged — narrate, voice NPCs, improvise — and it never owns state.
---
@@ -348,15 +348,16 @@ Single integer, 100..100, per companion. Adjusted by player choices weighed a
## 10. Combat
**Positionless, turn-based.** No grid. No flanking. No terrain.
**Tactical, turn-based, gridless.** Turn order by initiative; an **action economy** (Action · Bonus · Move) per turn; abilities picked from a bar; enemies targeted by click. AC, HP, and initiative in the D&D-flavored idiom of the Combat HUD mockup (§16).
Combat is not the risk surface of this project. Ship the boring one; prove the interesting one.
**No real movement grid.** The isometric board and tokens are *visual flavor* — there are no positional rules, no flanking, no terrain, no reachable-tile movement. Positioning was the risk surface the old charter warned against; it stays out. The tactics live in the **action economy and resources**, not the map.
- **Damage is deterministic.** No variance rolls on damage.
- **The player controls only their own character.** The DM resolves the AI companions' and enemies' turns.
- **Damage is deterministic (§7). OPEN:** the Combat HUD mock rolls damage from a range, but §7 forbids damage variance so Luck can *select* outcomes, not fudge numbers. Reconcile at the combat brainstorm — either fixed damage with Luck governing crit thresholds + borderline status checks, or a conscious, written change to §7. Do **not** silently adopt damage ranges.
- **Initiative is rolled once at encounter start** and is relatively random. DEX modifies.
- **The interesting decision is resource management** — MP, cooldowns, consumables. Not matchup-reading. Pick one axis for the POC.
- **Resources are the interesting axis** — the action economy, cooldowns, consumables. Not matchup-reading.
- **Luck influences crit thresholds and borderline status checks.** Nothing else. See §7.
- **Combat flavor text is async and optional.** The numbers land immediately. The prose catches up. If the AI call fails, the fight continues with authored text.
- **Combat flavor text is async and optional.** The numbers land immediately; the prose (a Narrator/DM call) catches up. If the AI call fails, the fight continues with authored text.
### Determinism and seeding
@@ -366,6 +367,8 @@ This costs nothing now and is nearly impossible to retrofit. It is the differenc
Log the seed *and* the full prompt with every AI call, for the same reason.
The Combat HUD mock's `Component` state machine (initiative order, action economy, ability resolution, victory/defeat) is the reference spec for the GDScript turn manager.
---
## 11. The canon log
@@ -454,14 +457,14 @@ Roughly 13k tokens per call, dozens of calls per session.
## 15. Input model
**Hybrid.**
**Button-driven actions; free-text dialogue.**
- **Menus:** combat, navigation, inventory.
- **Free text:** dialogue, and explicit "what do you do?" moments.
- **Buttons / menus:** combat abilities, world actions, navigation, inventory, shop. The player picks from **legal options code presents** — no input can produce an illegal or absurd action ("I triple-backflip off the wall and roundhouse him" never reaches the rules). Bulletproof by construction.
- **Free text:** **NPC dialogue** — the aliveness surface, kept free-text (bounded by the move vocabulary §6, so wild input is harmless). Plus an optional **"describe your own action"** line on the main window (2a mock) for the tabletop feel.
**The boundary must be visible.** The player should always know which mode they are in. Ambiguity here reads as broken.
**The boundary must be visible.** The player should always know which mode they are in.
Free text goes to the Adjudicator, which maps it to a legal action or rejects it with an in-world reason.
**The Adjudicator (§5) is reshaped.** Its primary job is no longer parsing free text into a world action — code presents legal actions as buttons. It survives only as the interpreter for the *optional* free-text "describe your own action" escape hatch: free text → a legal action or an in-world rejection. Most turns never touch it.
---
@@ -470,7 +473,7 @@ Free text goes to the Adjudicator, which maps it to a legal action or rejects it
- **Client:** Godot 4, **GDScript.** Prefer readability. Drop to C# only if GDScript genuinely cannot do the thing — and say why before you do.
- **Proxy:** Python, **FastAPI.**
- **Models:** Ollama (self-hosted, dev/staging) → Replicate (prod). Routed per-role, server-side.
- **Repo:** greenfield. No existing code.
- **Repo:** the engine (M0M2) is built — canon-log contract + client engine, the server model-call pipeline, `/dm/narrate` + `/npc/speak`, bounded moves, considering-state. Build the game on top; follow the existing patterns.
### Layout
@@ -481,9 +484,29 @@ Free text goes to the Adjudicator, which maps it to a legal action or rejects it
/docs API-specific docs
/prompts Role prompts (versioned, reviewed like code)
/content Authored: quests, NPC knowledge lists, fallback text
/mockups The UI/systems bible — 11 hi-fi HTML/CSS/JS screen refs + README
/docs Cross-cutting docs — roadmap, ADRs, anything affecting both sides
```
**`/mockups` is the UI + systems bible.** Eleven high-fidelity HTML/CSS/JS screen
references (title, character creation, character sheet, inventory, world map,
quest log, shop, pause/settings, dialogue, combat HUD, main exploration window)
plus a `README.md` handoff. **Not production code** — each screen is recreated as
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
(the README's palette, three fonts, and reusable styleboxes keep the look one
system). **Author these scenes editor-first:** the layout lives in the `.tscn`
as real Control nodes with the `Theme` and type-variations set in the editor, so
the scene *previews* and can be arranged in the Godot editor — do **not**
build the node tree in `_ready()`. Scripts hold only on-load work: `@onready`
node refs, data/DM wiring, animations, and binding state into fixed authored
nodes (see ADR [0001](docs/adr/0001-editor-first-ui-scenes.md)). Each
`.dc.html`'s `Component` class is the **behavior spec** — the
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
highest-value logic to port; the seed-data maps (items, quests, locations, units,
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real
art. When the mock and this charter disagree, the disagreement is a decision to
make, not a default to follow — see §17's reconcile list.
`/api` is still the proxy described in §4 — auth, metering, prompt-hiding, model
routing. It is named `api` for the client's convenience, not because it is a
generic pass-through. Docs are split: side-specific docs live under
@@ -494,33 +517,39 @@ Prompts are source code. They get reviewed. They get versioned. Changing a promp
---
## 17. POC scope
## 17. Scope — the game (the POC is done)
**~30 minutes of play.**
The aliveness POC is **answered** (M0M2, live-proven). The target is now the
tactical CRPG in the mockups (§16): the 11 screens and the systems they imply —
character creation, the exploration shell + DM narration, tactical (gridless)
combat, free-text NPC dialogue, inventory/equipment, character sheet, world-map
travel, a shop economy, a quest log, title/pause.
- One town, **two NPCs** with authored knowledge lists
- One dungeon: **three fights, one boss**
- **Two companions:** Cadwyn, Brannoc
- **Three playable classes:** Sellsword, Assassin, Priest
- Luck fully wired: generation, drift, one shrine, one cursed item
- Full proxy, full role split, full canon log
- Authored fallback text for every AI surface
Build it **screen by screen** against the mockups' shared visual system, on the
proven engine (canon log, DM/NPC pipeline, bounded moves, Luck, character-creation
model — all already aligned with the mocks). Each screen/system is its own
**spec → plan → implementation** cycle; the roadmap sequences them toward a
playable vertical slice first, then breadth.
### The POC's job
**Unchanged and load-bearing:** §2 (code owns state, AI owns text) — a tactical
CRPG has *far* more state, all code-owned; the AI stays narrator/DM/NPC-voice.
Also §3 (tone), §7 (Luck), §9 (companions), the canon log (§11), the output
contracts (§12), and Failure UX (§13).
Answer one question: **does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?**
### To reconcile as we build (flagged, not silently decided)
Test that before building anything else. Every other system exists to make that test possible.
- **Damage determinism** — §7/§10's deterministic damage vs the Combat HUD mock's
damage *ranges* (settle at the combat brainstorm).
- **Class names** — §8 uses Sellsword/Assassin/Priest/Scholar/Oathbound/Tracker/
Berserker; the Character-Creation mock uses callings Sellsword/Cutpurse/
Hedge-Mage/Bonesetter/Trapper (same archetypes + primary stats, different
names). Settle at the character-creation brainstorm.
### Explicitly out of scope
### Still out of scope (never / later)
- AI-generated story skeletons (v2 — the seeded story is authored for now)
- Multiplayer (never)
- Mobile
- Credits, payments, auth
- Companions leaving the party
- Positional combat
- More than three classes
- Multiplayer (**never**) · mobile (later) · AI-generated story skeletons (v2 —
the seeded story is authored for now) · credits/payments/auth (retrofit onto
the proxy later, §4).
---
@@ -530,3 +559,21 @@ Test that before building anything else. Every other system exists to make that
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
- **Prefer deleting scope.** The POC's value is answering one question fast.
- **A green suite is evidence only if the test can fail.** Before accepting a fix that
claims to guard a regression, re-break the code and *watch the new test fail*. This
project has shipped a regression test that passed against its own bug, a determinism
test that compared two runs that changed together, and an arity change that
miscompiled silently past a clean grep. Read **[docs/traps.md](docs/traps.md)** before
writing tests — it is six ways this codebase has already fooled a green suite.
### Git workflow
The standard flow for this project. Claude follows it; the human owns `master`.
- **Branches.** `master` and `dev` are long-lived. `master` is release; `dev` is integration. All work branches off `dev`.
- **Never commit non-doc changes directly to `master` or `dev`.** Anything that is not purely docs gets its own branch (`feature/…`, `fix/…`, `chore/…`, etc.), branched off `dev`.
- **Doc-only edits may commit directly to `dev`** — no branch required.
- **Never commit to `master`.** Ever. The human merges `dev``master` as they see fit. Claude never touches that merge.
- **Merging a working branch → `dev`** happens only after the work is done, smoke-tested, and **the human confirms**. Merge locally with `--no-ff`.
- **After merging to `dev`, delete the working branch locally.**
- **Pushing.** Origin is set. Claude pushes **only `dev`**, and only after a merge. Never push working branches or `master` — the human always pushes `master`.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.01-alpha

22
api/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Copy to .env for local dev. NEVER commit the real .env. The client never sees
# any of these — keys live only in the proxy (charter §4).
# Where the proxy sends model calls in dev (Ollama on the homelab).
# Running the proxy IN DOCKER with Ollama on THIS host? localhost points at the
# container, not the host — use http://host.docker.internal:11434 instead
# (docker-compose.yml maps that name via host-gateway). Bare-metal/venv: keep
# localhost. Homelab Ollama: use its address.
OLLAMA_BASE_URL=http://localhost:11434
# Prod model provider (charter §4). Leave blank in dev.
REPLICATE_API_TOKEN=
# Port the proxy binds. compose / fly.io override this.
PORT=8000
# Narrator model (charter §5 "the good model"). qwen3.x runs with thinking OFF.
OLLAMA_NARRATOR_MODEL=qwen3.5:latest
# Per-attempt model-call timeout (seconds).
OLLAMA_TIMEOUT_SECONDS=30
# Optional: append call logs (JSON lines, §10) to this file instead of stdout.
CALL_LOG_PATH=

29
api/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# /api — FastAPI proxy (charter §4). Build context is the repo root so the
# canon log schemas (/docs/schemas) are bundled into the image.
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# Install deps first so the layer caches when only app code changes.
COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# App code, the role prompts (§16 — prompts are source code), and the schema
# contract (single source of truth in docs/schemas).
COPY api/app ./app
COPY api/prompts ./prompts
COPY docs/schemas ./schemas
COPY content ./content
# Run as non-root.
RUN useradd --create-home --uid 1000 appuser
USER appuser
EXPOSE 8000
# fly.io / compose set PORT; default to 8000 (charter localhost:8000).
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]

64
api/app/call_log.py Normal file
View File

@@ -0,0 +1,64 @@
"""Structured JSON-lines logging of every model call (charter §10 — the seed and
the full prompt logged with every call; §4 — free eval/replay infrastructure).
One JSON object per line: everything needed to replay the call (canon_log +
messages + model + seed) against a candidate model. No secrets to redact — the
client never sends keys, the canon log carries no PII, and the prompt is exactly
what we want on record.
"""
import json
import sys
from datetime import datetime, timezone
from typing import Callable
from . import config
def _default_write(line: str) -> None:
"""Best-effort (charter §13): a good model call must never become a 500
because the log write failed (disk full, bad permissions, a misconfigured
CALL_LOG_PATH). Any failure here is swallowed and reported to stderr —
never stdout, since stdout may itself be the default sink and must stay
clean JSON-lines for downstream tooling.
"""
try:
path = config.call_log_path()
if path:
with open(path, "a", encoding="utf-8") as handle:
handle.write(line + "\n")
else:
sys.stdout.write(line + "\n")
except Exception as exc: # noqa: BLE001 - logging must never break a request
print(f"call_log: failed to write log line: {exc}", file=sys.stderr)
def record(
*,
role: str,
model: str,
options: dict,
messages: list[dict],
canon_log: dict,
ok: bool,
latency_ms: int,
response: str | None = None,
error: str | None = None,
write: Callable[[str], None] = _default_write,
) -> dict:
rec: dict = {
"ts": datetime.now(timezone.utc).isoformat(),
"role": role,
"model": model,
"options": options,
"messages": messages,
"canon_log": canon_log,
"ok": ok,
"latency_ms": latency_ms,
}
if ok:
rec["response"] = response
else:
rec["error"] = error
write(json.dumps(rec, ensure_ascii=False))
return rec

51
api/app/canon_log.py Normal file
View File

@@ -0,0 +1,51 @@
"""Load the JSON Schema contracts and validate canon logs / origin seeds.
Schemas are the single source of truth in /docs/schemas. This module is the
api's half of the contract (charter §11): every canon log the client posts is
validated here before it could ever reach a prompt.
"""
import json
import os
from functools import lru_cache
from pathlib import Path
from jsonschema import Draft202012Validator
def _schema_dir() -> Path:
"""Locate the schema directory.
Honours CANON_SCHEMA_DIR, else walks up from this file looking for
docs/schemas (local checkout) or schemas (bundled into the Docker image).
"""
env = os.environ.get("CANON_SCHEMA_DIR")
if env:
return Path(env)
here = Path(__file__).resolve()
for parent in here.parents:
for candidate in (parent / "docs" / "schemas", parent / "schemas"):
if candidate.is_dir():
return candidate
raise RuntimeError("schema directory not found")
@lru_cache(maxsize=None)
def _validator(schema_name: str) -> Draft202012Validator:
with open(_schema_dir() / schema_name) as f:
return Draft202012Validator(json.load(f))
def validation_errors(doc: dict, schema_name: str) -> list[str]:
validator = _validator(schema_name)
# Stringify path segments before sorting: a path can mix str property names
# and int array indices, which are not orderable against each other.
return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: [str(p) for p in e.path])]
def validate_origin(doc: dict) -> list[str]:
return validation_errors(doc, "origin.schema.json")
def validate_canon_log(doc: dict) -> list[str]:
return validation_errors(doc, "canon-log.schema.json")

26
api/app/config.py Normal file
View File

@@ -0,0 +1,26 @@
"""Server config, read from the environment at call time so tests can override
without reimport. Charter §4 — all of this lives server-side; the client never
sees it.
"""
import os
def ollama_base_url() -> str:
return os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
def ollama_timeout_seconds() -> float:
return float(os.environ.get("OLLAMA_TIMEOUT_SECONDS", "30"))
def narrator_model() -> str:
return os.environ.get("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest")
def npc_model() -> str:
return os.environ.get("OLLAMA_NPC_MODEL", "qwen3.5:latest")
def call_log_path() -> str | None:
return os.environ.get("CALL_LOG_PATH") or None

78
api/app/content.py Normal file
View File

@@ -0,0 +1,78 @@
"""Load authored world content and cross-check origin references.
Language-neutral content integrity: every id an origin references (start
location, start quest, granted items, seeded npc dispositions) must resolve in
world content. New-game construction (client-side, Plan B) relies on this
holding; catching it here fails a broken origin at authoring time, not three
scenes into play.
"""
import json
import os
from pathlib import Path
def _load_ids(dir_path: Path) -> set[str]:
ids: set[str] = set()
for f in dir_path.glob("*.json"):
with open(f) as fh:
ids.add(json.load(fh)["id"])
return ids
def load_world(content_root: Path) -> dict[str, set[str]]:
world = content_root / "world"
return {
"locations": _load_ids(world / "locations"),
"npcs": _load_ids(world / "npcs"),
"quests": _load_ids(world / "quests"),
"items": _load_ids(world / "items"),
}
def load_origin(path: Path) -> dict:
with open(path) as f:
return json.load(f)
def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]:
missing: list[str] = []
if origin["start_location_id"] not in world["locations"]:
missing.append(f"location:{origin['start_location_id']}")
quest = origin.get("start_quest_id")
if quest is not None and quest not in world["quests"]:
missing.append(f"quest:{quest}")
for grant in origin["inventory_grants"]:
if grant["item_id"] not in world["items"]:
missing.append(f"item:{grant['item_id']}")
for npc_id in origin["disposition_overrides"]:
if npc_id not in world["npcs"]:
missing.append(f"npc:{npc_id}")
return missing
def _content_root() -> Path:
"""Locate the authored content root. Honours CONTENT_ROOT, else walks up
from this file for a dir containing world/ — repo-root/content in a local
checkout, /app/content when bundled into the Docker image. Mirrors
canon_log._schema_dir().
"""
env = os.environ.get("CONTENT_ROOT")
if env:
return Path(env)
here = Path(__file__).resolve()
for parent in here.parents:
candidate = parent / "content"
if (candidate / "world").is_dir():
return candidate
raise RuntimeError("content root not found")
def load_npc(npc_id: str) -> dict | None:
"""Load one NPC's authored file (persona + knowledge + capabilities), or
None if no file resolves. Server-only fields never travel to the client."""
path = _content_root() / "world" / "npcs" / f"{npc_id}.json"
if not path.is_file():
return None
with open(path) as f:
return json.load(f)

116
api/app/main.py Normal file
View File

@@ -0,0 +1,116 @@
"""FastAPI proxy entrypoint — the guarding proxy of charter §4.
Skeleton: a health check plus the five role endpoints. Each role endpoint now
validates the posted canon log against the contract (charter §11) before doing
anything else — an invalid log is rejected with 422 and never reaches a prompt.
`/dm/narrate` is fully wired: prompt routing, the Ollama model call, and
call-log logging. The other four roles remain stubs until they get the same
treatment.
"""
from fastapi import Depends, FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from .canon_log import validate_canon_log
from .narrate import run as narrate_run
from .npc import UnknownNpc, run as npc_run
from .ollama_client import ModelError
from .version import VERSION
app = FastAPI(title="coc-rpg proxy", version=VERSION)
@app.exception_handler(RequestValidationError)
async def unify_validation_errors(request, exc: RequestValidationError) -> JSONResponse:
"""Reshape pydantic body-validation failures into the same envelope as a
canon-log schema failure, so the client parses ONE 422 shape (charter §11):
{"detail": {"canon_log_errors": [...]}}.
"""
errors = [f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in exc.errors()]
return JSONResponse(status_code=422, content={"detail": {"canon_log_errors": errors}})
class TurnRequest(BaseModel):
canon_log: dict
def valid_turn(req: TurnRequest) -> TurnRequest:
"""Shared dependency: reject any request whose canon log breaks the contract."""
errors = validate_canon_log(req.canon_log)
if errors:
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
return req
class NpcSpeakRequest(BaseModel):
canon_log: dict
npc_id: str
disposition: int
available_moves: list[str]
utterance: str
def valid_npc_turn(req: NpcSpeakRequest) -> NpcSpeakRequest:
"""Reject any /npc/speak request whose canon log breaks the contract."""
errors = validate_canon_log(req.canon_log)
if errors:
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
return req
@app.get("/health")
def health() -> dict:
"""Liveness probe for compose / fly.io."""
return {"status": "ok"}
@app.get("/version")
def version() -> dict:
return {"version": VERSION}
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
# The client knows these paths and nothing about which model or prompt serves
# them. Bodies are validated against the canon log contract. /dm/narrate is
# fully wired (routing + model call + logging); the other four roles remain
# stubs until prompt routing, model selection, and logging land for them too.
@app.post("/dm/narrate")
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
try:
return {"prose": narrate_run(req.canon_log)}
except ModelError as exc:
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
@app.post("/dm/adjudicate")
def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/dm/improvise")
def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/npc/speak")
def npc_speak(req: NpcSpeakRequest = Depends(valid_npc_turn)) -> dict:
try:
return {"prose": npc_run(
canon_log=req.canon_log, npc_id=req.npc_id,
disposition=req.disposition, available_moves=req.available_moves,
utterance=req.utterance,
)}
except UnknownNpc as exc:
raise HTTPException(
status_code=422, detail={"canon_log_errors": [f"unknown npc_id: {exc}"]})
except ModelError as exc:
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
@app.post("/party/banter")
def banter(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}

37
api/app/narrate.py Normal file
View File

@@ -0,0 +1,37 @@
"""The Narrator role service (charter §5). Renders the digest, routes the model,
calls Ollama with one retry, logs the call (§10), and returns raw prose — tags
left intact for the client's TagExtractor to harvest (§2/§6). Raises ModelError
on failure for the handler to map to a 502.
"""
import random
from time import perf_counter
from . import call_log, ollama_client, prompts, routing
def run(canon_log: dict) -> str:
cfg = routing.for_role("narrator")
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
messages = [
{"role": "system", "content": prompts.system_prompt("narrator")},
{"role": "user", "content": prompts.render_digest(canon_log)},
]
start = perf_counter()
try:
text = ollama_client.chat(cfg.model, messages, options, think=cfg.think)
except ollama_client.ModelError as exc:
call_log.record(
role="narrator", model=cfg.model, options=options, messages=messages,
canon_log=canon_log, ok=False,
latency_ms=int((perf_counter() - start) * 1000), error=str(exc),
)
raise
call_log.record(
role="narrator", model=cfg.model, options=options, messages=messages,
canon_log=canon_log, ok=True,
latency_ms=int((perf_counter() - start) * 1000), response=text,
)
return text

59
api/app/npc.py Normal file
View File

@@ -0,0 +1,59 @@
"""The NPC role service (charter §5/§6). Loads the NPC's server-only persona +
knowledge, renders the digest, routes the model, calls Ollama with one retry,
logs the call (§10), and returns raw prose — move/fact tags left intact for the
client to extract, validate, and apply (§2/§6). Mirrors narrate.py.
"""
import random
from time import perf_counter
from . import call_log, ollama_client, prompts, routing
from .content import load_npc
class UnknownNpc(Exception):
"""No authored content resolves for the requested npc_id."""
def run(
canon_log: dict,
npc_id: str,
disposition: int,
available_moves: list[str],
utterance: str,
) -> str:
npc = load_npc(npc_id)
if npc is None:
raise UnknownNpc(npc_id)
cfg = routing.for_role("npc")
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
messages = [
{"role": "system", "content": prompts.system_prompt("npc")},
{"role": "user", "content": prompts.render_npc_digest(
canon_log,
persona=npc.get("persona", ""),
knowledge=npc.get("knowledge", []),
disposition=disposition,
available_moves=available_moves,
utterance=utterance,
)},
]
start = perf_counter()
try:
text = ollama_client.chat(cfg.model, messages, options, think=cfg.think)
except ollama_client.ModelError as exc:
call_log.record(
role="npc", model=cfg.model, options=options, messages=messages,
canon_log=canon_log, ok=False,
latency_ms=int((perf_counter() - start) * 1000), error=str(exc),
)
raise
call_log.record(
role="npc", model=cfg.model, options=options, messages=messages,
canon_log=canon_log, ok=True,
latency_ms=int((perf_counter() - start) * 1000), response=text,
)
return text

50
api/app/ollama_client.py Normal file
View File

@@ -0,0 +1,50 @@
"""Call Ollama's /api/chat (non-streaming) with a single retry. Callers see
success or ModelError (charter §12/§13 — one retry, then fail; the client
degrades on any non-200). Built so a streaming mode is an additive path later.
"""
import httpx
from . import config
class ModelError(Exception):
"""The model call failed after one retry (transport / timeout / non-2xx / empty)."""
def chat(
model: str,
messages: list[dict],
options: dict,
*,
think: bool | None = None,
client: httpx.Client | None = None,
) -> str:
payload: dict = {"model": model, "messages": messages, "stream": False, "options": options}
if think is not None:
payload["think"] = think
owns = client is None
http = client or httpx.Client(
base_url=config.ollama_base_url(), timeout=config.ollama_timeout_seconds()
)
try:
last = "no attempt made"
for _ in range(2): # one try + one retry (§12: never more than once)
try:
resp = http.post("/api/chat", json=payload)
resp.raise_for_status()
content = resp.json().get("message", {}).get("content", "")
if content.strip():
return content
last = "empty response from model"
except (httpx.HTTPError, ValueError) as exc:
# httpx.HTTPError: TimeoutException, TransportError, HTTPStatusError.
# ValueError: resp.json() raises json.JSONDecodeError (a ValueError
# subclass) on a 200 with a non-JSON body (e.g. a proxy error page) —
# treat that as a failed attempt too, not an uncaught escape (§12/§13).
last = f"{type(exc).__name__}: {exc}"
raise ModelError(last)
finally:
if owns:
http.close()

134
api/app/prompts.py Normal file
View File

@@ -0,0 +1,134 @@
"""Load a role's system prompt (§16 — prompts are source code) and render the
canon log into a curated digest for the user message (charter §11).
The digest is a projection, not a dump: it surfaces narrative context and
deliberately omits raw integers — companion dispositions render as names only,
luck as its descriptor, and ids/schema_version are dropped (charter §7/§2).
"""
from functools import lru_cache
from pathlib import Path
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
def _humanize(id_: str) -> str:
"""Ids are snake_case; the model should read prose. hedge_mage -> hedge-mage."""
return id_.replace("_", "-")
def _article(word: str) -> str:
if not word:
return "a"
return "an" if word[:1].lower() in "aeiou" else "a"
def _describe_player(player: dict) -> str:
"""'Aldric, a beastfolk cutpurse' — what the AI narrates the player as."""
race = _humanize(player.get("race_id", ""))
calling = _humanize(player.get("calling_id", ""))
name = player.get("name", "")
parts = [p for p in (race, calling) if p]
if not parts:
return name
descriptor = " ".join(parts)
return f"{name}, {_article(parts[0])} {descriptor}".strip()
@lru_cache(maxsize=None)
def system_prompt(role: str) -> str:
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
# Everything below the first '---' line is the model-facing prompt; the
# header above it is human/dev front-matter.
parts = text.split("\n---\n", 1)
body = parts[1] if len(parts) == 2 else text
return body.strip()
def render_digest(canon_log: dict) -> str:
lines: list[str] = []
location = canon_log.get("location", {})
lines.append(f"Location: {location.get('name', 'an unknown place')}")
events = canon_log.get("recent_events", [])
if events:
lines.append("Recent events:")
lines += [f"- {e}" for e in events]
facts = canon_log.get("established_facts", [])
if facts:
lines.append("Established facts:")
lines += [f"- {f}" for f in facts]
party = canon_log.get("party", [])
if party:
lines.append("Companions present: " + ", ".join(m.get("name", "") for m in party))
for quest in canon_log.get("active_quests", []):
if quest.get("status") == "active":
lines.append(f"Active quest: {quest.get('name', '')}{quest.get('objective', '')}")
player = canon_log.get("player", {})
descriptor = player.get("luck_descriptor")
if descriptor:
lines.append(f"Fortune: {descriptor}")
lines.append(f"Player: {_describe_player(player)}")
lines.append("")
lines.append("Describe the scene as it is now.")
return "\n".join(lines)
def render_npc_digest(
canon_log: dict,
*,
persona: str,
knowledge: list[str],
disposition: int,
available_moves: list[str],
utterance: str,
) -> str:
"""User-message digest for the NPC role (§6). Unlike the narrator digest,
this deliberately carries the disposition integer (§6 permits it; §7 hides
Luck, not disposition) and the closed move vocabulary the NPC may use."""
lines: list[str] = []
lines.append(f"You are: {persona}")
lines.append(
f"Your disposition toward the player: {disposition} "
f"(-100 hostile, 0 neutral, 100 devoted)."
)
if knowledge:
lines.append("")
lines.append("What you know (this is ALL you know — do not invent beyond it):")
lines += [f"- {k}" for k in knowledge]
lines.append("")
lines.append("Moves you may perform this turn (use ONLY these, as [MOVE: name(args)]):")
lines += [f"- {m}" for m in available_moves]
location = canon_log.get("location", {})
lines.append("")
lines.append(f"Location: {location.get('name', 'an unknown place')}")
events = canon_log.get("recent_events", [])
if events:
lines.append("Recent events:")
lines += [f"- {e}" for e in events]
facts = canon_log.get("established_facts", [])
if facts:
lines.append("Established facts:")
lines += [f"- {f}" for f in facts]
for quest in canon_log.get("active_quests", []):
if quest.get("status") == "active":
lines.append(f"Active quest: {quest.get('name', '')}{quest.get('objective', '')}")
player = canon_log.get("player", {})
lines.append(f"The player is {_describe_player(player)}.")
lines.append("")
lines.append(f'The player says to you: "{utterance}"')
return "\n".join(lines)

31
api/app/routing.py Normal file
View File

@@ -0,0 +1,31 @@
"""Role → model routing (charter §4/§5). Model choice is config, not law — a
deploy, not a client patch. Read at call time so an env override needs no reimport.
The provider abstraction (Ollama → Replicate) slots in here later (YAGNI now).
"""
from dataclasses import dataclass
from . import config
@dataclass(frozen=True)
class RoleConfig:
model: str
options: dict
think: bool | None = None # Ollama top-level `think`; None omits it
def for_role(role: str) -> RoleConfig:
if role == "narrator":
return RoleConfig(
model=config.narrator_model(),
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300},
think=False, # qwen3.x thinking OFF; harmless on non-thinking models
)
if role == "npc":
return RoleConfig(
model=config.npc_model(),
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 320},
think=False,
)
raise KeyError(f"no routing for role: {role}")

33
api/app/version.py Normal file
View File

@@ -0,0 +1,33 @@
"""Single source of truth for the app version (charter §4: client and API share
one version). Reads the repo-root /VERSION file — the ONLY place a human edits the
version. Mirrors canon_log._schema_dir's env-override + walk-up so it resolves in
a local checkout and (once /VERSION is bundled) an image alike.
"""
import os
from functools import lru_cache
from pathlib import Path
LAST_RESORT = "0.0.0-dev"
def _version_file() -> Path | None:
env = os.environ.get("COC_VERSION_FILE")
if env:
return Path(env)
here = Path(__file__).resolve()
for parent in here.parents:
candidate = parent / "VERSION"
if candidate.is_file():
return candidate
return None
@lru_cache(maxsize=1)
def get_version() -> str:
path = _version_file()
if path is None or not path.is_file():
return LAST_RESORT
return path.read_text(encoding="utf-8").strip() or LAST_RESORT
VERSION = get_version()

21
api/conftest.py Normal file
View File

@@ -0,0 +1,21 @@
"""Gate `@pytest.mark.live` tests (they hit a real Ollama) behind --run-live so
the default suite stays hermetic. Run the live layer with: pytest --run-live
"""
import pytest
def pytest_addoption(parser):
parser.addoption(
"--run-live", action="store_true", default=False,
help="run tests marked @pytest.mark.live (require a real Ollama)",
)
def pytest_collection_modifyitems(config, items):
if config.getoption("--run-live"):
return
skip_live = pytest.mark.skip(reason="needs --run-live (real Ollama)")
for item in items:
if "live" in item.keywords:
item.add_marker(skip_live)

21
api/fly.toml Normal file
View File

@@ -0,0 +1,21 @@
# fly.io deploy config for the proxy (charter §4, prod). Stub — set app name and
# region, then `fly deploy` from api/. Secrets (REPLICATE_API_TOKEN) go via
# `fly secrets set`, never in this file.
app = "coc-rpg-proxy" # TODO: claim a real app name
primary_region = "ord" # TODO: pick a region
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[[http_service.checks]]
method = "GET"
path = "/health"
interval = "15s"
timeout = "2s"

View File

@@ -2,11 +2,24 @@
**Role:** Describe scenes, outcomes, transitions. **Quality matters most — use the good model** (charter §5).
**Input:** scene state + canon log (§11).
**Input:** scene state + canon log (§11), rendered as a labeled digest in the user message.
**Output:** prose with tags. Never invent a proper noun without emitting `[FACT: ...]` so code can harvest it into `established_facts` (§11).
**Voice:** dry, gritty not grim, plays the world straight. Never winks. Nothing is "hilariously" anything (§3).
---
<!-- Prompt body TBD. This file is source code — version and review changes. -->
You are the Narrator of a gritty high-fantasy RPG. You describe the world; you never control the player.
Given the current scene state below, describe the scene as it is right now, in the second person ("you"), present tense. Cover what the player sees, hears, and smells, and what the people present are doing. Three to five sentences, then stop.
Voice:
- Dry and economical. Gritty, not grim. Play the world straight — never wink, never point out that a thing is funny. Nothing is "hilariously" or "comically" anything.
- Profanity and ugliness are allowed when the moment earns them, never as decoration.
Hard rules:
- Do not introduce a proper noun — a person's name, a place name, a named object — unless it already appears below. If you must introduce one, append a tag on its own: `[FACT: <the new fact as a short clause>]`, one per new proper noun, so the game can record it.
- Never contradict anything under "Established facts".
- Never state a number, a statistic, a hit-point total, or a Luck value. "Fortune" is a feeling, never a figure.
- Never decide, narrate, or assume the player's choices, words, or actions. Describe the world and the other characters; leave the player's next move to the player.
- Output prose only, plus any `[FACT: ...]` tags. No JSON, no lists, no headings, no notes about what you are doing.

View File

@@ -18,4 +18,38 @@ Code extracts tags, validates against state, applies valid moves, silently drops
---
<!-- Prompt body TBD. This file is source code — version and review changes. -->
You voice a single character in a gritty high-fantasy world (the tone is Batman,
not Warhammer and not Care Bears). Play the character straight. Never wink at the
player; never describe anything as funny. Profanity is allowed when earned.
You are given: who you are, your disposition toward the player, the complete list
of what you know, the moves you may perform this turn, the scene's canon, and
what the player just said.
Two hard rules:
1. **Knowledge is a closed list.** You know ONLY what "What you know" tells you.
Do not invent people, places, quests, or facts beyond it. If you must name
something new that becomes true in the world, emit it as `[FACT: ...]` so the
game can record it — but prefer to stay within what you know.
2. **Moves are a closed vocabulary.** You may *say* anything in character, but you
may only *do* something by emitting an inline tag, and only from the moves
listed for this turn. Use exactly this form:
```
[MOVE: reveal(varrell_twins)]
[MOVE: offer_quest(find_the_ledger)]
[MOVE: adjust_disposition(+5)]
[MOVE: refuse()]
[MOVE: end_conversation()]
[MOVE: become_hostile()]
```
Emit zero or more move tags, placed inline where they happen. If a move is not
in your list for this turn, do not emit it — the game will drop it anyway.
Let your disposition colour your tone: low disposition is curt and suspicious,
high disposition is warm and forthcoming.
Return prose in your character's voice, with any move/fact tags inline. Do not
explain the tags or break character to describe them.

7
api/pytest.ini Normal file
View File

@@ -0,0 +1,7 @@
[pytest]
pythonpath = .
testpaths = tests
filterwarnings =
ignore:Using `httpx` with `starlette.testclient` is deprecated
markers =
live: exercises a real Ollama; skipped unless --run-live

2
api/requirements-dev.txt Normal file
View File

@@ -0,0 +1,2 @@
# Dev/test deps. Installed on top of requirements.txt.
pytest==8.3.2

6
api/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
# FastAPI proxy runtime deps. Pinned from first resolve; bump deliberately.
fastapi==0.139.0
uvicorn[standard]==0.51.0
httpx==0.28.1 # calling Ollama / Replicate
pydantic==2.13.4 # request/response contracts
jsonschema==4.23.0 # canon log / origin contract validation

0
api/tests/__init__.py Normal file
View File

30
api/tests/fixtures/canon_log_valid.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
"schema_version": 1,
"player": {
"name": "Aldric",
"race_id": "human",
"calling_id": "sellsword",
"luck_descriptor": "Fortune spits on you"
},
"location": { "id": "greywater_docks", "name": "the Greywater docks" },
"party": [
{ "id": "brannoc_thane", "name": "Brannoc Thane", "disposition": 40 },
{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "disposition": 15 }
],
"recent_events": [
"Arrived at Greywater by barge before dawn",
"Brannoc recognised the harbourmaster and went quiet"
],
"established_facts": [
"the eastern bridge out of Greywater is washed out",
"the harbourmaster is named Oda Fenn"
],
"active_quests": [
{ "id": "find_the_ledger", "name": "The Missing Ledger",
"status": "active", "objective": "Find who took Fenn's ledger" }
],
"humiliations": [
{ "id": "h_0001", "text": "vomited on a shrine step in front of a priest",
"weight": 7, "turn": 3 }
]
}

View File

@@ -0,0 +1,56 @@
import json
from app.call_log import record
def test_success_record_shape():
captured = []
rec = record(
role="narrator", model="qwen3.5:latest", options={"seed": 7},
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
ok=True, latency_ms=120, response="prose", write=captured.append,
)
assert len(captured) == 1
parsed = json.loads(captured[0])
assert parsed["ok"] is True
assert parsed["response"] == "prose"
assert parsed["options"]["seed"] == 7
assert parsed["role"] == "narrator"
assert parsed["canon_log"] == {"a": 1}
assert "error" not in parsed
assert "ts" in parsed
assert rec["response"] == "prose"
def test_failure_record_shape():
captured = []
record(
role="narrator", model="m", options={}, messages=[], canon_log={},
ok=False, latency_ms=5, error="ModelError: down", write=captured.append,
)
parsed = json.loads(captured[0])
assert parsed["ok"] is False
assert parsed["error"] == "ModelError: down"
assert "response" not in parsed
def test_default_write_failure_does_not_propagate(monkeypatch, capsys):
"""§13 — a good narration must never become a 500 because the log write
failed. Point CALL_LOG_PATH at a path whose parent directory does not
exist, so the default sink's `open()` raises. record() must swallow it,
report to stderr (never stdout — stdout may be the log sink itself), and
still return the record.
"""
monkeypatch.setenv(
"CALL_LOG_PATH", "/nonexistent-dir-for-test/does/not/exist.log"
)
rec = record(
role="narrator", model="qwen3.5:latest", options={"seed": 7},
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
ok=True, latency_ms=120, response="prose",
)
assert rec["response"] == "prose"
assert rec["ok"] is True
out, err = capsys.readouterr()
assert out == ""
assert err != ""

View File

@@ -0,0 +1,87 @@
import json
from pathlib import Path
from app.canon_log import validate_canon_log
FIXTURE = Path(__file__).parent / "fixtures" / "canon_log_valid.json"
def _valid():
with open(FIXTURE) as f:
return json.load(f)
def test_valid_canon_log_passes():
assert validate_canon_log(_valid()) == []
def test_numeric_luck_is_rejected():
# Charter §7: the AI must never be able to calculate Luck. A stray numeric
# luck field must fail validation, not slip through.
doc = _valid()
doc["player"]["luck"] = 5
assert validate_canon_log(doc) != []
def test_recent_events_over_five_is_rejected():
doc = _valid()
doc["recent_events"] = [f"event {i}" for i in range(6)]
assert validate_canon_log(doc) != []
def test_disposition_out_of_range_is_rejected():
doc = _valid()
doc["party"][0]["disposition"] = 200
assert validate_canon_log(doc) != []
def test_unknown_quest_status_is_rejected():
doc = _valid()
doc["active_quests"][0]["status"] = "abandoned"
assert validate_canon_log(doc) != []
def test_humiliation_weight_bounds_enforced():
doc = _valid()
doc["humiliations"][0]["weight"] = 11
assert validate_canon_log(doc) != []
def test_empty_optional_arrays_are_allowed():
doc = _valid()
doc["party"] = []
doc["recent_events"] = []
doc["established_facts"] = []
doc["active_quests"] = []
doc["humiliations"] = []
assert validate_canon_log(doc) == []
def test_root_level_stray_field_is_rejected():
# §2/§7: stats/HP/inventory must never enter the log — additionalProperties:false at root.
doc = _valid()
doc["hp"] = 10
assert validate_canon_log(doc) != []
def test_negative_turn_is_rejected():
doc = _valid()
doc["humiliations"][0]["turn"] = -1
assert validate_canon_log(doc) != []
def test_dead_class_id_field_is_rejected():
# The player block migrated from {name, class_id, luck_descriptor} to
# {name, race_id, calling_id, luck_descriptor}. class_id must not slip
# back in via additionalProperties: false.
doc = _valid()
doc["player"]["class_id"] = "sellsword"
assert validate_canon_log(doc) != []
def test_unknown_calling_id_is_rejected():
# assassin/priest are dead ids from the pre-migration class roster and
# must not validate as calling_id values.
doc = _valid()
doc["player"]["calling_id"] = "priest"
assert validate_canon_log(doc) != []

View File

@@ -0,0 +1,41 @@
import json
import app.content as content
def test_load_npc_returns_parsed_file(tmp_path, monkeypatch):
root = tmp_path / "content"
(root / "world" / "npcs").mkdir(parents=True)
(root / "world" / "npcs" / "fenn.json").write_text(
json.dumps({"id": "fenn", "name": "Fenn", "persona": "clerk",
"knowledge": ["a"], "capabilities": {}})
)
monkeypatch.setenv("CONTENT_ROOT", str(root))
npc = content.load_npc("fenn")
assert npc["name"] == "Fenn"
assert npc["knowledge"] == ["a"]
def test_load_npc_unknown_returns_none(tmp_path, monkeypatch):
(tmp_path / "content" / "world").mkdir(parents=True)
monkeypatch.setenv("CONTENT_ROOT", str(tmp_path / "content"))
assert content.load_npc("nobody") is None
def test_content_root_env_override(tmp_path, monkeypatch):
monkeypatch.setenv("CONTENT_ROOT", str(tmp_path))
assert content._content_root() == tmp_path
def test_fenn_resolves_from_repo_content():
# No CONTENT_ROOT env — exercises the walk-up resolver against real content.
npc = content.load_npc("fenn")
assert npc is not None
assert npc["capabilities"]["offerable_quests"] == ["find_the_ledger"]
assert npc["knowledge"] # non-empty authored knowledge list
def test_npc_prompt_body_is_authored():
from app import prompts
body = prompts.system_prompt("npc")
assert "MOVE" in body and len(body) > 200

View File

@@ -0,0 +1,47 @@
import copy
import json
from pathlib import Path
from app.canon_log import validate_origin
from app.content import load_world, load_origin, unresolved_refs
REPO_ROOT = Path(__file__).resolve().parents[2]
CONTENT_ROOT = REPO_ROOT / "content"
DESERTER = CONTENT_ROOT / "origins" / "deserter.json"
def test_deserter_origin_matches_schema():
assert validate_origin(load_origin(DESERTER)) == []
def test_deserter_ids_all_resolve():
world = load_world(CONTENT_ROOT)
assert unresolved_refs(load_origin(DESERTER), world) == []
def test_broken_location_ref_is_detected():
world = load_world(CONTENT_ROOT)
origin = copy.deepcopy(load_origin(DESERTER))
origin["start_location_id"] = "nowhere"
assert "location:nowhere" in unresolved_refs(origin, world)
def test_broken_item_ref_is_detected():
world = load_world(CONTENT_ROOT)
origin = copy.deepcopy(load_origin(DESERTER))
origin["inventory_grants"].append({"item_id": "ghost_blade", "qty": 1})
assert "item:ghost_blade" in unresolved_refs(origin, world)
def test_null_quest_ref_resolves():
world = load_world(CONTENT_ROOT)
origin = copy.deepcopy(load_origin(DESERTER))
origin["start_quest_id"] = None
assert unresolved_refs(origin, world) == []
def test_broken_disposition_npc_ref_is_detected():
world = load_world(CONTENT_ROOT)
origin = copy.deepcopy(load_origin(DESERTER))
origin["disposition_overrides"]["ghost_npc"] = 10
assert "npc:ghost_npc" in unresolved_refs(origin, world)

View File

@@ -0,0 +1,74 @@
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import app.narrate as narrate
from app.main import app
client = TestClient(app)
VALID_LOG = json.load(open(Path(__file__).parent / "fixtures" / "canon_log_valid.json"))
ROLE_ENDPOINTS = [
"/dm/narrate", "/dm/adjudicate", "/dm/improvise",
"/npc/speak", "/party/banter",
]
@pytest.fixture(autouse=True)
def _stub_narrate_model_call(monkeypatch):
# /dm/narrate is wired to a real Ollama call (Task 7). These tests exercise
# canon-log validation across ALL role endpoints, not model behavior, so
# stub the model boundary — keeps the default suite hermetic (no network).
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "stubbed prose")
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
def test_health_ok():
assert client.get("/health").json() == {"status": "ok"}
def test_every_role_endpoint_accepts_a_valid_canon_log():
for path in ROLE_ENDPOINTS:
if path == "/npc/speak":
# /npc/speak has additional required fields
body = {
"canon_log": VALID_LOG, "npc_id": "brannoc_thane",
"disposition": 0, "available_moves": [], "utterance": "test"
}
else:
body = {"canon_log": VALID_LOG}
r = client.post(path, json=body)
assert r.status_code == 200, f"{path} rejected a valid log: {r.text}"
def test_every_role_endpoint_rejects_an_invalid_canon_log():
bad = json.loads(json.dumps(VALID_LOG))
bad["player"]["luck"] = 5 # §7 leak
for path in ROLE_ENDPOINTS:
if path == "/npc/speak":
# /npc/speak has additional required fields
body = {
"canon_log": bad, "npc_id": "brannoc_thane",
"disposition": 0, "available_moves": [], "utterance": "test"
}
else:
body = {"canon_log": bad}
r = client.post(path, json=body)
assert r.status_code == 422, f"{path} accepted an invalid log"
assert "canon_log_errors" in r.json()["detail"]
def test_missing_canon_log_is_a_422():
r = client.post("/dm/narrate", json={})
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]
def test_malformed_body_uses_the_unified_error_shape():
# A pydantic body failure returns the SAME envelope as a schema failure,
# so the client only ever parses one 422 shape.
r = client.post("/dm/narrate", json={"canon_log": "not-a-dict"})
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]

View File

@@ -0,0 +1,57 @@
import json
from pathlib import Path
import pytest
import app.narrate as narrate
import app.npc as npc
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
@pytest.mark.live
def test_narrator_returns_real_prose():
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the wire end-to-end:
prompt assembly → model → non-empty prose. Not asserted: tag content
(nondeterministic). Run with: pytest --run-live
"""
text = narrate.run(VALID)
assert isinstance(text, str)
assert len(text.strip()) > 40 # real prose, not a blip
@pytest.mark.live
def test_live_npc_speak_fenn():
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the /npc/speak wire
end-to-end against the authored "fenn" NPC: persona + knowledge digest →
model → non-empty in-voice prose. Not asserted: tag content
(nondeterministic). Run with: pytest --run-live
"""
log = {
"player": {
"name": "Aldric",
"race_id": "human",
"calling_id": "sellsword",
"luck_descriptor": "the dice are kind",
},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
"party": [],
"recent_events": [],
"established_facts": [],
"active_quests": [],
"humiliations": [],
}
available = [
"offer_quest(find_the_ledger)",
"reveal(varrell_twins)",
"reveal(fenns_debt)",
"adjust_disposition",
"refuse",
"end_conversation",
"become_hostile",
]
prose = npc.run(log, "fenn", 0, available, "I heard you lost something. What happened?")
assert isinstance(prose, str)
assert len(prose.strip()) > 40 # real prose, not a blip
# No Luck numbers/mechanics leaked into NPC dialogue (§7).
assert "luck" not in prose.lower()

43
api/tests/test_narrate.py Normal file
View File

@@ -0,0 +1,43 @@
import json
from pathlib import Path
import pytest
import app.narrate as narrate
from app.ollama_client import ModelError
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_run_returns_prose_and_logs_success(monkeypatch):
seen = {}
def fake_chat(model, messages, options, *, think=None):
seen.update(model=model, messages=messages, options=options, think=think)
return "You stand on the wharf. [FACT: the gulls have gone quiet]"
logs = []
monkeypatch.setattr(narrate.ollama_client, "chat", fake_chat)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
out = narrate.run(VALID)
assert out.startswith("You stand on the wharf.")
assert seen["model"] == "qwen3.5:latest"
assert seen["think"] is False
assert seen["messages"][0]["role"] == "system"
assert seen["messages"][1]["role"] == "user"
assert "seed" in seen["options"]
assert logs and logs[0]["ok"] is True and logs[0]["response"] == out
def test_run_logs_failure_and_reraises(monkeypatch):
def boom(*args, **kwargs):
raise ModelError("upstream down")
logs = []
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
with pytest.raises(ModelError):
narrate.run(VALID)
assert logs and logs[0]["ok"] is False and "down" in logs[0]["error"]

View File

@@ -0,0 +1,39 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
import app.main as main
import app.narrate as narrate
from app.ollama_client import ModelError
client = TestClient(main.app)
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_valid_log_returns_prose(monkeypatch):
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "You stand on the wharf.")
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None) # keep test output pristine
r = client.post("/dm/narrate", json={"canon_log": VALID})
assert r.status_code == 200
assert r.json() == {"prose": "You stand on the wharf."}
def test_model_error_maps_to_502(monkeypatch):
def boom(*a, **k):
raise ModelError("upstream down")
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
r = client.post("/dm/narrate", json={"canon_log": VALID})
assert r.status_code == 502
assert "model_error" in r.json()["detail"]
def test_invalid_log_still_422(monkeypatch):
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "x")
bad = json.loads(json.dumps(VALID))
bad["player"]["luck"] = 5 # §7 leak — schema rejects
r = client.post("/dm/narrate", json={"canon_log": bad})
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]

55
api/tests/test_npc.py Normal file
View File

@@ -0,0 +1,55 @@
import pytest
import app.npc as npc
from app.ollama_client import ModelError
LOG = {
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
"party": [], "recent_events": [], "established_facts": [],
"active_quests": [], "humiliations": [],
}
FENN = {"id": "fenn", "name": "Fenn", "persona": "A clerk.",
"knowledge": ["The ledger is gone."], "capabilities": {}}
def test_run_returns_prose_and_logs(monkeypatch):
seen = {}
def fake_chat(model, messages, options, *, think=None):
seen.update(messages=messages, think=think)
return 'Aye. [MOVE: reveal(varrell_twins)] [FACT: the ledger is gone]'
logs = []
monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
monkeypatch.setattr(npc.ollama_client, "chat", fake_chat)
monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
out = npc.run(LOG, "fenn", -10, ["reveal(varrell_twins)", "refuse"], "What happened?")
assert out.startswith("Aye.")
# persona + knowledge reached the model; utterance is present
user_msg = seen["messages"][1]["content"]
assert "A clerk." in user_msg and "The ledger is gone." in user_msg
assert "What happened?" in user_msg
assert seen["think"] is False
assert logs and logs[0]["ok"] is True and logs[0]["role"] == "npc"
def test_run_unknown_npc_raises(monkeypatch):
monkeypatch.setattr(npc, "load_npc", lambda i: None)
with pytest.raises(npc.UnknownNpc):
npc.run(LOG, "nobody", 0, [], "hi")
def test_run_logs_failure_and_reraises(monkeypatch):
monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
def boom(*a, **k):
raise ModelError("down")
logs = []
monkeypatch.setattr(npc.ollama_client, "chat", boom)
monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
with pytest.raises(ModelError):
npc.run(LOG, "fenn", 0, [], "hi")
assert logs and logs[0]["ok"] is False

View File

@@ -0,0 +1,59 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
import app.main as main
import app.npc as npc
client = TestClient(main.app)
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def _body(**over):
b = {"canon_log": VALID, "npc_id": "fenn", "disposition": 0,
"available_moves": ["refuse"], "utterance": "hello"}
b.update(over)
return b
def test_valid_request_returns_prose(monkeypatch):
monkeypatch.setattr(main, "npc_run", lambda **k: "Aye, well met.")
r = client.post("/npc/speak", json=_body())
assert r.status_code == 200
assert r.json() == {"prose": "Aye, well met."}
def test_unknown_npc_maps_to_422(monkeypatch):
def boom(**k):
raise npc.UnknownNpc("nobody")
monkeypatch.setattr(main, "npc_run", boom)
r = client.post("/npc/speak", json=_body(npc_id="nobody"))
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]
def test_model_error_maps_to_502(monkeypatch):
from app.ollama_client import ModelError
def boom(**k):
raise ModelError("down")
monkeypatch.setattr(main, "npc_run", boom)
r = client.post("/npc/speak", json=_body())
assert r.status_code == 502
assert "model_error" in r.json()["detail"]
def test_invalid_log_still_422():
bad = json.loads(json.dumps(VALID))
bad["player"]["luck"] = 5 # §7 leak — schema rejects
r = client.post("/npc/speak", json=_body(canon_log=bad))
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]
def test_missing_field_is_422():
b = _body()
del b["utterance"]
r = client.post("/npc/speak", json=b)
assert r.status_code == 422

View File

@@ -0,0 +1,95 @@
import json
import httpx
import pytest
from app.ollama_client import ModelError, chat
def _client(handler):
return httpx.Client(transport=httpx.MockTransport(handler), base_url="http://ollama")
def test_success_returns_content_and_sends_payload():
seen = {}
def handler(request):
seen["body"] = json.loads(request.content)
return httpx.Response(200, json={"message": {"content": "You stand on the wharf."}})
out = chat("qwen3.5:latest", [{"role": "user", "content": "hi"}], {"seed": 7},
think=False, client=_client(handler))
assert out == "You stand on the wharf."
assert seen["body"]["model"] == "qwen3.5:latest"
assert seen["body"]["stream"] is False
assert seen["body"]["options"]["seed"] == 7
assert seen["body"]["think"] is False
def test_think_omitted_when_none():
seen = {}
def handler(request):
seen["body"] = json.loads(request.content)
return httpx.Response(200, json={"message": {"content": "ok"}})
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
assert "think" not in seen["body"]
def test_retries_once_then_succeeds():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if calls["n"] == 1:
raise httpx.ConnectError("boom")
return httpx.Response(200, json={"message": {"content": "recovered"}})
out = chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
assert out == "recovered"
assert calls["n"] == 2
def test_two_transport_failures_raise_modelerror():
def handler(request):
raise httpx.ConnectError("down")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_timeout_exhausted_raises_modelerror():
def handler(request):
raise httpx.TimeoutException("slow")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_non_2xx_retried_then_modelerror():
def handler(request):
return httpx.Response(500, json={"error": "nope"})
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_empty_content_retried_then_modelerror():
def handler(request):
return httpx.Response(200, json={"message": {"content": " "}})
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_non_json_200_retried_then_modelerror():
"""A 200 response with a non-JSON body (e.g. a proxy error page) must
degrade to ModelError after the one retry, not escape as a bare
JSONDecodeError/ValueError (charter §12/§13).
"""
def handler(request):
return httpx.Response(200, text="<html>not json</html>")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))

View File

@@ -0,0 +1,54 @@
import copy
from app.canon_log import validate_origin
VALID_ORIGIN = {
"schema_version": 1,
"id": "deserter",
"display_name": "The Deserter",
"description": "You walked away from a company that doesn't allow walking away.",
"start_location_id": "greywater_docks",
"situation": ["Arrived at Greywater by barge before dawn, hood up"],
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
"disposition_overrides": {"brannoc_thane": 40, "cadwyn_vell": 15},
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_callings": ["sellsword", "cutpurse", "bonesetter"],
"luck_modifier": 0,
},
}
def test_valid_origin_passes():
assert validate_origin(VALID_ORIGIN) == []
def test_null_start_quest_is_allowed():
doc = copy.deepcopy(VALID_ORIGIN)
doc["start_quest_id"] = None
assert validate_origin(doc) == []
def test_unknown_calling_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
doc["build_constraints"]["allowed_callings"] = ["bard"]
assert validate_origin(doc) != []
def test_missing_required_field_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
del doc["start_location_id"]
assert validate_origin(doc) != []
def test_extra_field_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
doc["surprise"] = True
assert validate_origin(doc) != []
def test_disposition_override_key_must_match_id_pattern():
doc = copy.deepcopy(VALID_ORIGIN)
doc["disposition_overrides"] = {"Brannoc Thane": 40}
assert validate_origin(doc) != []

102
api/tests/test_prompts.py Normal file
View File

@@ -0,0 +1,102 @@
import json
from pathlib import Path
from app.prompts import render_digest, system_prompt
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_system_prompt_strips_the_header():
body = system_prompt("narrator")
assert isinstance(body, str) and body.strip() != ""
# The human-facing markdown title above the '---' is not part of the prompt.
assert "# Narrator prompt" not in body
def test_digest_has_the_narrative_context():
d = render_digest(VALID)
assert "Location: the Greywater docks" in d
assert "Arrived at Greywater by barge before dawn" in d
assert "Established facts:" in d
assert "the harbourmaster is named Oda Fenn" in d
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
assert "Fortune: Fortune spits on you" in d
assert "Player: Aldric, a human sellsword" in d
assert d.rstrip().endswith("Describe the scene as it is now.")
def test_digest_omits_raw_numbers_and_ids():
d = render_digest(VALID)
# §7/§2: no disposition integers, no ids, no schema_version leak into the prompt.
assert "disposition" not in d
assert "40" not in d and "15" not in d # the deserter dispositions
assert "brannoc_thane" not in d # ids stay out; names only
assert "schema_version" not in d
def test_narrator_body_is_authored():
body = system_prompt.__wrapped__("narrator") # bypass lru_cache in case of prior load
assert "TBD" not in body
assert "[FACT:" in body # the tag instruction is present
assert "second person" in body # the core task is present
def _with_player(**player) -> dict:
log = json.loads(json.dumps(VALID)) # deep copy — VALID is module-level and shared
log["player"] = {"luck_descriptor": "the dice are kind", **player}
return log
def test_digest_names_the_race_and_the_calling():
out = render_digest(_with_player(name="Aldric", race_id="beastfolk", calling_id="cutpurse"))
assert "Aldric, a beastfolk cutpurse" in out
def test_digest_says_an_elf_not_a_elf():
out = render_digest(_with_player(name="Aldric", race_id="elf", calling_id="sellsword"))
assert "an elf sellsword" in out
def test_digest_does_not_leak_a_snake_case_id():
out = render_digest(_with_player(name="Aldric", race_id="human", calling_id="hedge_mage"))
assert "hedge_mage" not in out, "the model must never read a raw snake_case id"
assert "hedge-mage" in out
def test_digest_omits_race_clause_when_race_is_empty():
# Regression: _article("") used to return "an" (Python's "" in "aeiou" is
# True), rendering "Player: Aldric, an " — wrong article, doubled space.
out = render_digest(_with_player(name="Aldric", race_id="", calling_id="sellsword"))
assert "Player: Aldric, a sellsword" in out
assert " " not in out
assert ", an " not in out
def test_digest_omits_both_clauses_when_race_and_calling_are_empty():
out = render_digest(_with_player(name="Aldric", race_id="", calling_id=""))
assert "\nPlayer: Aldric\n" in out
assert " " not in out
def test_render_npc_digest_includes_all_sections():
from app import prompts
log = {
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "the dice are kind"},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
"party": [], "recent_events": ["a gull screamed"],
"established_facts": ["the eastern bridge is out"],
"active_quests": [], "humiliations": [],
}
out = prompts.render_npc_digest(
log, persona="A harried clerk.", knowledge=["The ledger is gone."],
disposition=-10, available_moves=["reveal(varrell_twins)", "refuse"],
utterance="What happened here?",
)
assert "A harried clerk." in out
assert "-10" in out
assert "The ledger is gone." in out
assert "reveal(varrell_twins)" in out
assert "Greywater Docks" in out
assert "the eastern bridge is out" in out
assert out.rstrip().endswith('What happened here?"') # utterance last

29
api/tests/test_routing.py Normal file
View File

@@ -0,0 +1,29 @@
import pytest
from app.routing import for_role
def test_narrator_defaults():
cfg = for_role("narrator")
assert cfg.model == "qwen3.5:latest"
assert cfg.think is False
assert cfg.options["num_predict"] == 300
assert cfg.options["temperature"] == 0.8
def test_env_overrides_model(monkeypatch):
monkeypatch.setenv("OLLAMA_NARRATOR_MODEL", "llama3.1:latest")
assert for_role("narrator").model == "llama3.1:latest"
def test_unknown_role_raises():
with pytest.raises(KeyError):
for_role("wizard")
def test_npc_role_routes_to_npc_model(monkeypatch):
monkeypatch.setenv("OLLAMA_NPC_MODEL", "some-npc-model")
cfg = for_role("npc")
assert cfg.model == "some-npc-model"
assert cfg.think is False
assert "num_predict" in cfg.options

22
api/tests/test_version.py Normal file
View File

@@ -0,0 +1,22 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
from app.version import VERSION, get_version
client = TestClient(app)
ROOT_VERSION = Path(__file__).resolve().parents[2] / "VERSION"
def test_get_version_matches_file():
assert ROOT_VERSION.is_file()
assert get_version() == ROOT_VERSION.read_text(encoding="utf-8").strip()
def test_app_version_matches():
assert app.version == VERSION == get_version()
def test_version_endpoint():
assert client.get("/version").json() == {"version": VERSION}

3
clean_superpowers.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/env bash
rm .superpowers/sdd/*.diff
rm .superpowers/sdd/*.md

7
client/.gutconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"dirs": ["res://tests/unit"],
"include_subdirs": true,
"log_level": 1,
"should_exit": true,
"failure_error_types": ["engine", "gut"]
}

View File

@@ -0,0 +1,132 @@
extends Node2D
# ##############################################################################
# This is a wrapper around the normal and compact gui controls and serves as
# the interface between gut.gd and the gui. The GutRunner creates an instance
# of this and then this takes care of managing the different GUI controls.
# ##############################################################################
@onready var _normal_gui = $Normal
@onready var _compact_gui = $Compact
var gut = null :
set(val):
gut = val
_set_gut(val)
func _ready():
_normal_gui.switch_modes.connect(use_compact_mode.bind(true))
_compact_gui.switch_modes.connect(use_compact_mode.bind(false))
_normal_gui.set_title("GUT")
_compact_gui.set_title("GUT")
_normal_gui.align_right()
_compact_gui.to_bottom_right()
use_compact_mode(false)
if(get_parent() == get_tree().root):
_test_running_setup()
func _test_running_setup():
set_font_size(100)
_normal_gui.get_textbox().text = "hello world, how are you doing?"
# ------------------------
# Private
# ------------------------
func _set_gut(val):
if(_normal_gui.get_gut() == val):
return
_normal_gui.set_gut(val)
_compact_gui.set_gut(val)
val.start_run.connect(_on_gut_start_run)
val.end_run.connect(_on_gut_end_run)
val.start_pause_before_teardown.connect(_on_gut_pause)
val.end_pause_before_teardown.connect(_on_pause_end)
func _set_both_titles(text):
_normal_gui.set_title(text)
_compact_gui.set_title(text)
# ------------------------
# Events
# ------------------------
func _on_gut_start_run():
_set_both_titles('Running')
func _on_gut_end_run():
_set_both_titles('Finished')
func _on_gut_pause():
_set_both_titles('-- Paused --')
func _on_pause_end():
_set_both_titles('Running')
# ------------------------
# Public
# ------------------------
func get_textbox():
return _normal_gui.get_textbox()
func set_font_size(new_size):
var rtl = _normal_gui.get_textbox()
rtl.set('theme_override_font_sizes/bold_italics_font_size', new_size)
rtl.set('theme_override_font_sizes/bold_font_size', new_size)
rtl.set('theme_override_font_sizes/italics_font_size', new_size)
rtl.set('theme_override_font_sizes/normal_font_size', new_size)
func set_font(font_name):
_set_all_fonts_in_rtl(_normal_gui.get_textbox(), font_name)
func _set_font(rtl, font_name, custom_name):
if(font_name == null):
rtl.remove_theme_font_override(custom_name)
else:
var font_path = 'res://addons/gut/fonts/' + font_name + '.ttf'
if(FileAccess.file_exists(font_path)):
var dyn_font = FontFile.new()
dyn_font.load_dynamic_font('res://addons/gut/fonts/' + font_name + '.ttf')
rtl.add_theme_font_override(custom_name, dyn_font)
func _set_all_fonts_in_rtl(rtl, base_name):
if(base_name == 'Default'):
_set_font(rtl, null, 'normal_font')
_set_font(rtl, null, 'bold_font')
_set_font(rtl, null, 'italics_font')
_set_font(rtl, null, 'bold_italics_font')
else:
_set_font(rtl, base_name + '-Regular', 'normal_font')
_set_font(rtl, base_name + '-Bold', 'bold_font')
_set_font(rtl, base_name + '-Italic', 'italics_font')
_set_font(rtl, base_name + '-BoldItalic', 'bold_italics_font')
func set_default_font_color(color):
_normal_gui.get_textbox().set('custom_colors/default_color', color)
func set_background_color(color):
_normal_gui.set_bg_color(color)
func use_compact_mode(should=true):
_compact_gui.visible = should
_normal_gui.visible = !should
func set_opacity(val):
_normal_gui.modulate.a = val
_compact_gui.modulate.a = val
func set_title(text):
_set_both_titles(text)

View File

@@ -0,0 +1 @@
uid://bw7tukh738kw1

View File

@@ -0,0 +1,16 @@
[gd_scene load_steps=4 format=3 uid="uid://m28heqtswbuq"]
[ext_resource type="Script" uid="uid://bw7tukh738kw1" path="res://addons/gut/GutScene.gd" id="1_b4m8y"]
[ext_resource type="PackedScene" uid="uid://duxblir3vu8x7" path="res://addons/gut/gui/NormalGui.tscn" id="2_j6ywb"]
[ext_resource type="PackedScene" uid="uid://cnqqdfsn80ise" path="res://addons/gut/gui/MinGui.tscn" id="3_3glw1"]
[node name="GutScene" type="Node2D"]
script = ExtResource("1_b4m8y")
[node name="Normal" parent="." instance=ExtResource("2_j6ywb")]
[node name="Compact" parent="." instance=ExtResource("3_3glw1")]
offset_left = 5.0
offset_top = 273.0
offset_right = 265.0
offset_bottom = 403.0

View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
=====================
Copyright (c) 2018 Tom "Butch" Wesley
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,52 @@
extends Window
@onready var rtl = $TextDisplay/RichTextLabel
func _get_file_as_text(path):
var to_return = null
var f = FileAccess.open(path, FileAccess.READ)
if(f != null):
to_return = f.get_as_text()
else:
to_return = str('ERROR: Could not open file. Error code ', FileAccess.get_open_error())
return to_return
func _ready():
rtl.clear()
func _on_OpenFile_pressed():
$FileDialog.popup_centered()
func _on_FileDialog_file_selected(path):
show_file(path)
func _on_Close_pressed():
self.hide()
func show_file(path):
var text = _get_file_as_text(path)
if(text == ''):
text = '<Empty File>'
rtl.set_text(text)
self.window_title = path
func show_open():
self.popup_centered()
$FileDialog.popup_centered()
func get_rich_text_label():
return $TextDisplay/RichTextLabel
func _on_Home_pressed():
rtl.scroll_to_line(0)
func _on_End_pressed():
rtl.scroll_to_line(rtl.get_line_count() -1)
func _on_Copy_pressed():
return
# OS.clipboard = rtl.text
func _on_file_dialog_visibility_changed():
if rtl.text.length() == 0 and not $FileDialog.visible:
self.hide()

View File

@@ -0,0 +1 @@
uid://x51wilphva3d

View File

@@ -0,0 +1,92 @@
[gd_scene load_steps=2 format=3 uid="uid://bsm7wtt1gie4v"]
[ext_resource type="Script" uid="uid://x51wilphva3d" path="res://addons/gut/UserFileViewer.gd" id="1"]
[node name="UserFileViewer" type="Window"]
exclusive = true
script = ExtResource("1")
[node name="FileDialog" type="FileDialog" parent="."]
access = 1
show_hidden_files = true
__meta__ = {
"_edit_use_anchors_": false
}
[node name="TextDisplay" type="ColorRect" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 8.0
offset_right = -10.0
offset_bottom = -65.0
color = Color(0.2, 0.188235, 0.188235, 1)
[node name="RichTextLabel" type="RichTextLabel" parent="TextDisplay"]
anchor_right = 1.0
anchor_bottom = 1.0
focus_mode = 2
text = "In publishing and graphic design, Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content. Lorem ipsum may be used before final copy is available, but it may also be used to temporarily replace copy in a process called greeking, which allows designers to consider form without the meaning of the text influencing the design.
Lorem ipsum is typically a corrupted version of De finibus bonorum et malorum, a first-century BCE text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical, improper Latin.
Versions of the Lorem ipsum text have been used in typesetting at least since the 1960s, when it was popularized by advertisements for Letraset transfer sheets. Lorem ipsum was introduced to the digital world in the mid-1980s when Aldus employed it in graphic and word-processing templates for its desktop publishing program PageMaker. Other popular word processors including Pages and Microsoft Word have since adopted Lorem ipsum as well."
selection_enabled = true
[node name="OpenFile" type="Button" parent="."]
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -158.0
offset_top = -50.0
offset_right = -84.0
offset_bottom = -30.0
text = "Open File"
[node name="Home" type="Button" parent="."]
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -478.0
offset_top = -50.0
offset_right = -404.0
offset_bottom = -30.0
text = "Home"
[node name="Copy" type="Button" parent="."]
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 160.0
offset_top = -50.0
offset_right = 234.0
offset_bottom = -30.0
text = "Copy"
[node name="End" type="Button" parent="."]
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -318.0
offset_top = -50.0
offset_right = -244.0
offset_bottom = -30.0
text = "End"
[node name="Close" type="Button" parent="."]
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 10.0
offset_top = -50.0
offset_right = 80.0
offset_bottom = -30.0
text = "Close"
[connection signal="file_selected" from="FileDialog" to="." method="_on_FileDialog_file_selected"]
[connection signal="visibility_changed" from="FileDialog" to="." method="_on_file_dialog_visibility_changed"]
[connection signal="pressed" from="OpenFile" to="." method="_on_OpenFile_pressed"]
[connection signal="pressed" from="Home" to="." method="_on_Home_pressed"]
[connection signal="pressed" from="Copy" to="." method="_on_Copy_pressed"]
[connection signal="pressed" from="End" to="." method="_on_End_pressed"]
[connection signal="pressed" from="Close" to="." method="_on_Close_pressed"]

View File

@@ -0,0 +1,86 @@
# ##############################################################################
#(G)odot (U)nit (T)est class
#
# ##############################################################################
# The MIT License (MIT)
# =====================
#
# Copyright (c) 2025 Tom "Butch" Wesley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ##############################################################################
# Class used to keep track of objects to be freed and utilities to free them.
# ##############################################################################
var _to_free = []
var _to_queue_free = []
var _ref_counted_doubles = []
var _all_instance_ids = []
func _add_instance_id(thing):
if(thing.has_method("get_instance_id")):
_all_instance_ids.append(thing.get_instance_id())
func add_free(thing):
if(typeof(thing) == TYPE_OBJECT):
_add_instance_id(thing)
if(!thing is RefCounted):
_to_free.append(thing)
elif(GutUtils.is_double(thing)):
_ref_counted_doubles.append(thing)
func add_queue_free(thing):
if(typeof(thing) == TYPE_OBJECT):
_add_instance_id(thing)
_to_queue_free.append(thing)
func get_queue_free_count():
return _to_queue_free.size()
func get_free_count():
return _to_free.size()
func free_all():
for node in _to_free:
if(is_instance_valid(node)):
if(GutUtils.is_double(node)):
node.__gutdbl_done()
node.free()
_to_free.clear()
for i in range(_to_queue_free.size()):
if(is_instance_valid(_to_queue_free[i])):
_to_queue_free[i].queue_free()
_to_queue_free.clear()
for ref_dbl in _ref_counted_doubles:
ref_dbl.__gutdbl_done()
_ref_counted_doubles.clear()
_all_instance_ids.clear()
func has_instance_id(id):
return _all_instance_ids.has(id)

View File

@@ -0,0 +1 @@
uid://bxjfriqxgwe0r

View File

@@ -0,0 +1,201 @@
extends Node
class GutAwaiterLogger:
var _time_waited = 0.0
var logger = GutUtils.get_logger()
var waiting_on = "nothing"
var logged_initial_message = false
var wait_log_delay := 1.0
var disabled = false
func waited(x):
_time_waited += x
if(!logged_initial_message and _time_waited >= wait_log_delay):
log_it()
logged_initial_message = true
func reset():
_time_waited = 0.0
logged_initial_message = false
func log_it():
if(!disabled):
var msg = str("--- Awaiting ", waiting_on, " ---")
logger.wait_msg(msg)
signal timeout
signal wait_started
var await_logger = GutAwaiterLogger.new()
var _wait_time := 0.0
var _wait_process_frames := 0
var _wait_physics_frames := 0
var _signal_to_wait_on = null
var _predicate_method = null
var _waiting_for_predicate_to_be = null
var _predicate_time_between := 0.0
var _predicate_time_between_elpased := 0.0
var _elapsed_time := 0.0
var _elapsed_frames := 0
var _did_last_wait_timeout = false
var did_last_wait_timeout = false :
get: return _did_last_wait_timeout
set(val): push_error("Cannot set did_last_wait_timeout")
func _ready() -> void:
get_tree().process_frame.connect(_on_tree_process_frame)
get_tree().physics_frame.connect(_on_tree_physics_frame)
func _on_tree_process_frame():
# Count frames here instead of in _process so that tree order never
# makes a difference and the count/signaling happens outside of
# _process being called.
if(_wait_process_frames > 0):
_elapsed_frames += 1
if(_elapsed_frames > _wait_process_frames):
_end_wait()
func _on_tree_physics_frame():
# Count frames here instead of in _physics_process so that tree order never
# makes a difference and the count/signaling happens outside of
# _physics_process being called.
if(_wait_physics_frames != 0):
_elapsed_frames += 1
if(_elapsed_frames > _wait_physics_frames):
_end_wait()
func _physics_process(delta):
if(is_waiting()):
await_logger.waited(delta)
if(_wait_time != 0.0):
_elapsed_time += delta
if(_elapsed_time >= _wait_time):
_end_wait()
if(_predicate_method != null):
_predicate_time_between_elpased += delta
if(_predicate_time_between_elpased >= _predicate_time_between):
_predicate_time_between_elpased = 0.0
var result = _predicate_method.call()
if(_waiting_for_predicate_to_be == false):
if(typeof(result) != TYPE_BOOL or result != true):
_end_wait()
else:
if(typeof(result) == TYPE_BOOL and result == _waiting_for_predicate_to_be):
_end_wait()
func _end_wait():
await_logger.reset()
# Check for time before checking for frames so that the extra frames added
# when waiting on a signal do not cause a false negative for timing out.
if(_wait_time > 0):
_did_last_wait_timeout = _elapsed_time >= _wait_time
elif(_wait_physics_frames > 0):
_did_last_wait_timeout = _elapsed_frames >= _wait_physics_frames
elif(_wait_process_frames > 0):
_did_last_wait_timeout = _elapsed_frames >= _wait_process_frames
if(_signal_to_wait_on != null and \
is_instance_valid(_signal_to_wait_on.get_object()) and \
_signal_to_wait_on.is_connected(_signal_callback)):
_signal_to_wait_on.disconnect(_signal_callback)
_wait_process_frames = 0
_wait_time = 0.0
_wait_physics_frames = 0
_signal_to_wait_on = null
_predicate_method = null
_elapsed_time = 0.0
_elapsed_frames = 0
timeout.emit()
const ARG_NOT_SET = '_*_argument_*_is_*_not_set_*_'
func _signal_callback(
_arg1=ARG_NOT_SET, _arg2=ARG_NOT_SET, _arg3=ARG_NOT_SET,
_arg4=ARG_NOT_SET, _arg5=ARG_NOT_SET, _arg6=ARG_NOT_SET,
_arg7=ARG_NOT_SET, _arg8=ARG_NOT_SET, _arg9=ARG_NOT_SET):
_signal_to_wait_on.disconnect(_signal_callback)
# DO NOT _end_wait here. For other parts of the test to get the signal that
# was waited on, we have to wait for another frames. For example, the
# signal_watcher doesn't get the signal in time if we don't do this.
_wait_process_frames = 1
func wait_seconds(x, msg=''):
await_logger.waiting_on = str(x, " seconds ", msg)
_did_last_wait_timeout = false
_wait_time = x
wait_started.emit()
func wait_process_frames(x, msg=''):
await_logger.waiting_on = str(x, " idle frames ", msg)
_did_last_wait_timeout = false
_wait_process_frames = x
wait_started.emit()
func wait_physics_frames(x, msg=''):
await_logger.waiting_on = str(x, " physics frames ", msg)
_did_last_wait_timeout = false
_wait_physics_frames = x
wait_started.emit()
func wait_for_signal(the_signal : Signal, max_time, msg=''):
await_logger.waiting_on = str("signal ", the_signal.get_name(), " or ", max_time, "s ", msg)
_did_last_wait_timeout = false
the_signal.connect(_signal_callback)
_signal_to_wait_on = the_signal
_wait_time = max_time
wait_started.emit()
func wait_until(predicate_function: Callable, max_time, time_between_calls:=0.0, msg=''):
await_logger.waiting_on = str("callable to return TRUE or ", max_time, "s. ", msg)
_predicate_time_between = time_between_calls
_predicate_method = predicate_function
_wait_time = max_time
_waiting_for_predicate_to_be = true
_predicate_time_between_elpased = 0.0
_did_last_wait_timeout = false
wait_started.emit()
func wait_while(predicate_function: Callable, max_time, time_between_calls:=0.0, msg=''):
await_logger.waiting_on = str("callable to return FALSE or ", max_time, "s. ", msg)
_predicate_time_between = time_between_calls
_predicate_method = predicate_function
_wait_time = max_time
_waiting_for_predicate_to_be = false
_predicate_time_between_elpased = 0.0
_did_last_wait_timeout = false
wait_started.emit()
func is_waiting():
return _wait_time != 0.0 || \
_wait_physics_frames != 0 || \
_wait_process_frames != 0

View File

@@ -0,0 +1 @@
uid://ccu4ww35edtdi

View File

@@ -0,0 +1,239 @@
extends SceneTree
var Optparse = load('res://addons/gut/cli/optparse.gd')
var WarningsManager = load("res://addons/gut/warnings_manager.gd")
const WARN_VALUE_PRINT_POSITION = 36
var godot_default_warnings = {
"assert_always_false": 1, "assert_always_true": 1, "confusable_identifier": 1,
"confusable_local_declaration": 1, "confusable_local_usage": 1, "constant_used_as_function": 1,
"deprecated_keyword": 1, "empty_file": 1, "enable": true,
"exclude_addons": true, "function_used_as_property": 1, "get_node_default_without_onready": 2,
"incompatible_ternary": 1, "inference_on_variant": 2, "inferred_declaration": 0,
"int_as_enum_without_cast": 1, "int_as_enum_without_match": 1, "integer_division": 1,
"narrowing_conversion": 1, "native_method_override": 2, "onready_with_export": 2,
"property_used_as_function": 1, "redundant_await": 1, "redundant_static_unload": 1,
"renamed_in_godot_4_hint": 1, "return_value_discarded": 0, "shadowed_global_identifier": 1,
"shadowed_variable": 1, "shadowed_variable_base_class": 1, "standalone_expression": 1,
"standalone_ternary": 1, "static_called_on_instance": 1, "unassigned_variable": 1,
"unassigned_variable_op_assign": 1, "unreachable_code": 1, "unreachable_pattern": 1,
"unsafe_call_argument": 0, "unsafe_cast": 0, "unsafe_method_access": 0,
"unsafe_property_access": 0, "unsafe_void_return": 1, "untyped_declaration": 0,
"unused_local_constant": 1, "unused_parameter": 1, "unused_private_class_variable": 1,
"unused_signal": 1, "unused_variable": 1
}
var gut_default_changes = {
"exclude_addons": false, "redundant_await": 0,
}
var warning_settings = {}
func _setup_warning_settings():
warning_settings["godot_default"] = godot_default_warnings
warning_settings["current"] = WarningsManager.create_warnings_dictionary_from_project_settings()
warning_settings["all_warn"] = WarningsManager.create_warn_all_warnings_dictionary()
var gut_default = godot_default_warnings.duplicate()
gut_default.merge(gut_default_changes, true)
warning_settings["gut_default"] = gut_default
func _warn_value_to_s(value):
var readable = str(value).capitalize()
if(typeof(value) == TYPE_INT):
readable = WarningsManager.WARNING_LOOKUP.get(value, str(readable, ' ???'))
readable = readable.capitalize()
return readable
func _human_readable(warnings):
var to_return = ""
for key in warnings:
var readable = _warn_value_to_s(warnings[key])
to_return += str(key.capitalize().rpad(35, ' '), readable, "\n")
return to_return
func _dump_settings(which):
if(warning_settings.has(which)):
GutUtils.pretty_print(warning_settings[which])
else:
print("UNKNOWN print option ", which)
func _print_settings(which):
if(warning_settings.has(which)):
print(_human_readable(warning_settings[which]))
else:
print("UNKNOWN print option ", which)
func _apply_settings(which):
if(!warning_settings.has(which)):
print("UNKNOWN set option ", which)
return
var pre_settings = warning_settings["current"]
var new_settings = warning_settings[which]
if(new_settings == pre_settings):
print("-- Settings are the same, no changes were made --")
return
WarningsManager.apply_warnings_dictionary(new_settings)
ProjectSettings.save()
print("-- Project Warning Settings have been updated --")
print(_diff_changes_text(pre_settings))
func _diff_text(w1, w2, diff_col_pad=10):
var to_return = ""
for key in w1:
var v1_text = _warn_value_to_s(w1[key])
var v2_text = _warn_value_to_s(w2[key])
var diff_text = v1_text
var prefix = " "
if(v1_text != v2_text):
var diff_prefix = " "
if(w1[key] > w2[key]):
diff_prefix = "-"
else:
diff_prefix = "+"
prefix = "* "
diff_text = str(v1_text.rpad(diff_col_pad, ' '), diff_prefix, v2_text)
to_return += str(str(prefix, key.capitalize()).rpad(WARN_VALUE_PRINT_POSITION, ' '), diff_text, "\n")
return to_return.rstrip("\n")
func _diff_changes_text(pre_settings):
var orig_diff_text = _diff_text(
pre_settings,
WarningsManager.create_warnings_dictionary_from_project_settings(),
0)
# these next two lines are fragile and brute force...enjoy
var diff_text = orig_diff_text.replace("-", " -> ")
diff_text = diff_text.replace("+", " -> ")
if(orig_diff_text == diff_text):
diff_text += "\n-- No changes were made --"
else:
diff_text += "\nChanges will not be visible in Godot until it is restarted.\n"
diff_text += "Even if it asks you to reload...Maybe. Probably."
return diff_text
func _diff(name_1, name_2):
if(warning_settings.has(name_1) and warning_settings.has(name_2)):
var c2_pad = name_1.length() + 2
var heading = str(" ".repeat(WARN_VALUE_PRINT_POSITION), name_1.rpad(c2_pad, ' '), name_2, "\n")
heading += str(
" ".repeat(WARN_VALUE_PRINT_POSITION),
"-".repeat(name_1.length()).rpad(c2_pad, " "),
"-".repeat(name_2.length()),
"\n")
var text = _diff_text(warning_settings[name_1], warning_settings[name_2], c2_pad)
print(heading)
print(text)
var diff_count = 0
for line in text.split("\n"):
if(!line.begins_with(" ")):
diff_count += 1
if(diff_count == 0):
print('-- [', name_1, "] and [", name_2, "] are the same --")
else:
print('-- There are ', diff_count, ' differences between [', name_1, "] and [", name_2, "] --")
else:
print("One or more unknown Warning Level Names:, [", name_1, "] [", name_2, "]")
func _set_settings(nvps):
var pre_settings = warning_settings["current"]
for i in range(nvps.size()/2):
var s_name = nvps[i * 2]
var s_value = nvps[i * 2 + 1]
if(godot_default_warnings.has(s_name)):
var t = typeof(godot_default_warnings[s_name])
if(t == TYPE_INT):
s_value = s_value.to_int()
elif(t == TYPE_BOOL):
s_value = s_value.to_lower() == 'true'
WarningsManager.set_project_setting_warning(s_name, s_value)
ProjectSettings.save()
print(_diff_changes_text(pre_settings))
func _setup_options():
var opts = Optparse.new()
opts.banner = """
This script prints info about or sets the warning settings for the project.
Each action requires one or more Warning Level Names.
Warning Level Names:
* current The current settings for the project.
* godot_default The default settings for Godot.
* gut_default The warning settings that is used when developing GUT.
* all_warn Everything set to warn.
""".dedent()
opts.add('-h', false, 'Print this help')
opts.add('-set', [], "Sets a single setting in the project settings and saves.\n" +
"Use -dump to see a list of setting names and values.\n" +
"Example: -set enabled,true -set unsafe_cast,2 -set unreachable_code,0")
opts.add_heading(" Actions (require Warning Level Name)")
opts.add('-diff', [], "Shows the difference between two Warning Level Names.\n" +
"Example: -diff current,all_warn")
opts.add('-dump', 'none', "Prints a dictionary of the warning values.")
opts.add('-print', 'none', "Print human readable warning values.")
opts.add('-apply', 'none', "Applys one of the Warning Level Names to the project settings. You should restart after using this")
return opts
func _print_help(opts):
opts.print_help()
func _init():
# Testing might set this flag but it should never be disabled for this tool
# or it cannot save project settings, but says it did. Sneakily use the
# private property to get around this property being read-only. Don't
# try this at home.
WarningsManager._disabled = false
_setup_warning_settings()
var opts = _setup_options()
opts.parse()
if(opts.unused.size() != 0):
opts.print_help()
print("Unknown arguments ", opts.unused)
if(opts.values.h):
opts.print_help()
elif(opts.values.print != 'none'):
_print_settings(opts.values.print)
elif(opts.values.dump != 'none'):
_dump_settings(opts.values.dump)
elif(opts.values.apply != 'none'):
_apply_settings(opts.values.apply )
elif(opts.values.diff.size() == 2):
_diff(opts.values.diff[0], opts.values.diff[1])
elif(opts.values.set.size() % 2 == 0):
_set_settings(opts.values.set)
else:
opts.print_help()
print("You didn't specify any options or too many or not the right size or something invalid. I don't know what you want to do.")
quit()

View File

@@ -0,0 +1 @@
uid://1pauyfnd1cre

View File

@@ -0,0 +1,328 @@
extends Node
var Optparse = load('res://addons/gut/cli/optparse.gd')
var Gut = load('res://addons/gut/gut.gd')
var GutRunner = load('res://addons/gut/gui/GutRunner.tscn')
# ------------------------------------------------------------------------------
# Helper class to resolve the various different places where an option can
# be set. Using the get_value method will enforce the order of precedence of:
# 1. command line value
# 2. config file value
# 3. default value
#
# The idea is that you set the base_opts. That will get you a copies of the
# hash with null values for the other types of values. Lower precedented hashes
# will punch through null values of higher precedented hashes.
# ------------------------------------------------------------------------------
class GutCliOptionResolver:
var base_opts = {}
var cmd_opts = {}
var config_opts = {}
func get_value(key):
return _nvl(cmd_opts[key], _nvl(config_opts[key], base_opts[key]))
func set_base_opts(opts):
base_opts = opts
cmd_opts = _null_copy(opts)
config_opts = _null_copy(opts)
# creates a copy of a hash with all values null.
func _null_copy(h):
var new_hash = {}
for key in h:
new_hash[key] = null
return new_hash
func _nvl(a, b):
if(a == null):
return b
else:
return a
func _string_it(h):
var to_return = ''
for key in h:
to_return += str('(',key, ':', _nvl(h[key], 'NULL'), ')')
return to_return
func to_s():
return str("base:\n", _string_it(base_opts), "\n", \
"config:\n", _string_it(config_opts), "\n", \
"cmd:\n", _string_it(cmd_opts), "\n", \
"resolved:\n", _string_it(get_resolved_values()))
func get_resolved_values():
var to_return = {}
for key in base_opts:
to_return[key] = get_value(key)
return to_return
func to_s_verbose():
var to_return = ''
var resolved = get_resolved_values()
for key in base_opts:
to_return += str(key, "\n")
to_return += str(' default: ', _nvl(base_opts[key], 'NULL'), "\n")
to_return += str(' config: ', _nvl(config_opts[key], ' --'), "\n")
to_return += str(' cmd: ', _nvl(cmd_opts[key], ' --'), "\n")
to_return += str(' final: ', _nvl(resolved[key], 'NULL'), "\n")
return to_return
# ------------------------------------------------------------------------------
# Here starts the actual script that uses the Options class to kick off Gut
# and run your tests.
# ------------------------------------------------------------------------------
var _gut_config = load('res://addons/gut/gut_config.gd').new()
# array of command line options specified
var _final_opts = []
func setup_options(options, font_names):
var opts = Optparse.new()
opts.banner =\
"""
The GUT CLI
-----------
The default behavior for GUT is to load options from a res://.gutconfig.json if
it exists. Any options specified on the command line will take precedence over
options specified in the gutconfig file. You can specify a different gutconfig
file with the -gconfig option.
To generate a .gutconfig.json file you can use -gprint_gutconfig_sample
To see the effective values of a CLI command and a gutconfig use -gpo
Values for options can be supplied using:
option=value # no space around "="
option value # a space between option and value w/o =
Options whose values are lists/arrays can be specified multiple times:
-gdir=a,b
-gdir c,d
-gdir e
# results in -gdir equaling [a, b, c, d, e]
To not use an empty value instead of a default value, specifiy the option with
an immediate "=":
-gconfig=
"""
opts.add_heading("Test Config:")
opts.add('-gdir', options.dirs, 'List of directories to search for test scripts in.')
opts.add('-ginclude_subdirs', false, 'Flag to include all subdirectories specified with -gdir.')
opts.add('-gtest', [], 'List of full paths to test scripts to run.')
opts.add('-gprefix', options.prefix, 'Prefix used to find tests when specifying -gdir. Default "[default]".')
opts.add('-gsuffix', options.suffix, 'Test script suffix, including .gd extension. Default "[default]".')
opts.add('-gconfig', 'res://.gutconfig.json', 'The config file to load options from. The default is [default]. Use "-gconfig=" to not use a config file.')
opts.add('-gpre_run_script', '', 'pre-run hook script path')
opts.add('-gpost_run_script', '', 'post-run hook script path')
opts.add('-gerrors_do_not_cause_failure', false, 'When an internal GUT error occurs tests will fail. With this option set, that does not happen.')
opts.add('-gdouble_strategy', 'SCRIPT_ONLY', 'Default strategy to use when doubling. Valid values are [INCLUDE_NATIVE, SCRIPT_ONLY]. Default "[default]"')
opts.add_heading("Run Options:")
opts.add('-gselect', '', 'All scripts that contain the specified string in their filename will be ran')
opts.add('-ginner_class', '', 'Only run inner classes that contain the specified string in their name.')
opts.add('-gunit_test_name', '', 'Any test that contains the specified text will be run, all others will be skipped.')
opts.add('-gexit', false, 'Exit after running tests. If not specified you have to manually close the window.')
opts.add('-gexit_on_success', false, 'Only exit if zero tests fail.')
opts.add('-gignore_pause', false, 'Ignores any calls to pause_before_teardown.')
opts.add('-gno_error_tracking', false, 'Disable error tracking.')
opts.add('-gfailure_error_types', options.failure_error_types, 'Error types that will cause tests to fail if the are encountered during the execution of a test. Default "[default]"')
opts.add_heading("Display Settings:")
opts.add('-glog', options.log_level, 'Log level [0-3]. Default [default]')
opts.add('-ghide_orphans', false, 'Display orphan counts for tests and scripts. Default [default].')
opts.add('-gmaximize', false, 'Maximizes test runner window to fit the viewport.')
opts.add('-gcompact_mode', false, 'The runner will be in compact mode. This overrides -gmaximize.')
opts.add('-gopacity', options.opacity, 'Set opacity of test runner window. Use range 0 - 100. 0 = transparent, 100 = opaque.')
opts.add('-gdisable_colors', false, 'Disable command line colors.')
opts.add('-gfont_name', options.font_name, str('Valid values are: ', font_names, '. Default "[default]"'))
opts.add('-gfont_size', options.font_size, 'Font size, default "[default]"')
opts.add('-gbackground_color', options.background_color, 'Background color as an html color, default "[default]"')
opts.add('-gfont_color',options.font_color, 'Font color as an html color, default "[default]"')
opts.add('-gpaint_after', options.paint_after, 'Delay before GUT will add a 1 frame pause to paint the screen/GUI. default [default]')
opts.add('-gwait_log_delay', options.wait_log_delay, 'Delay before GUT will print a message to indicate a test is awaiting one of the wait_* methods. Default [default]')
opts.add_heading("Result Export:")
opts.add('-gjunit_xml_file', options.junit_xml_file, 'Export results of run to this file in the Junit XML format.')
opts.add('-gjunit_xml_timestamp', options.junit_xml_timestamp, 'Include a timestamp in the -gjunit_xml_file, default [default]')
opts.add_heading("Help:")
opts.add('-gh', false, 'Print this help. You did this to see this, so you probably understand.')
opts.add('-gpo', false, 'Print option values from all sources and the value used.')
opts.add('-gprint_gutconfig_sample', false, 'Print out json that can be used to make a gutconfig file.')
opts.add("-gcheck_update", false, "Check for update")
# run as in editor, for shelling out purposes through Editor.
var o = opts.add('-graie', false, 'do not use')
o.show_in_help = false
return opts
# Parses options, applying them to the _tester or setting values
# in the options struct.
func extract_command_line_options(from, to):
to.compact_mode = from.get_value_or_null('-gcompact_mode')
to.config_file = from.get_value_or_null('-gconfig')
to.dirs = from.get_value_or_null('-gdir')
to.disable_colors = from.get_value_or_null('-gdisable_colors')
to.double_strategy = from.get_value_or_null('-gdouble_strategy')
to.errors_do_not_cause_failure = from.get_value_or_null('-gerrors_do_not_cause_failure')
to.hide_orphans = from.get_value_or_null('-ghide_orphans')
to.ignore_pause = from.get_value_or_null('-gignore_pause')
to.include_subdirs = from.get_value_or_null('-ginclude_subdirs')
to.inner_class = from.get_value_or_null('-ginner_class')
to.log_level = from.get_value_or_null('-glog')
to.opacity = from.get_value_or_null('-gopacity')
to.post_run_script = from.get_value_or_null('-gpost_run_script')
to.pre_run_script = from.get_value_or_null('-gpre_run_script')
to.prefix = from.get_value_or_null('-gprefix')
to.selected = from.get_value_or_null('-gselect')
to.should_exit = from.get_value_or_null('-gexit')
to.should_exit_on_success = from.get_value_or_null('-gexit_on_success')
to.should_maximize = from.get_value_or_null('-gmaximize')
to.suffix = from.get_value_or_null('-gsuffix')
to.tests = from.get_value_or_null('-gtest')
to.unit_test_name = from.get_value_or_null('-gunit_test_name')
to.wait_log_delay = from.get_value_or_null('-gwait_log_delay')
to.background_color = from.get_value_or_null('-gbackground_color')
to.font_color = from.get_value_or_null('-gfont_color')
to.font_name = from.get_value_or_null('-gfont_name')
to.font_size = from.get_value_or_null('-gfont_size')
to.paint_after = from.get_value_or_null('-gpaint_after')
to.junit_xml_file = from.get_value_or_null('-gjunit_xml_file')
to.junit_xml_timestamp = from.get_value_or_null('-gjunit_xml_timestamp')
to.failure_error_types = from.get_value_or_null('-gfailure_error_types')
to.no_error_tracking = from.get_value_or_null('-gno_error_tracking')
to.raie = from.get_value_or_null('-graie')
func _print_gutconfigs(values):
var header = """Here is a sample of a full .gutconfig.json file.
You do not need to specify all values in your own file. The values supplied in
this sample are what would be used if you ran gut w/o the -gprint_gutconfig_sample
option. Option priority is: command-line, .gutconfig, default)."""
print("\n", header.replace("\n", ' '), "\n")
var resolved = values
# remove_at some options that don't make sense to be in config
resolved.erase("config_file")
resolved.erase("show_help")
print(JSON.stringify(resolved, ' '))
for key in resolved:
resolved[key] = null
print("\n\nAnd here's an empty config for you fill in what you want.")
print(JSON.stringify(resolved, ' '))
func _run_tests(opt_resolver):
_final_opts = opt_resolver.get_resolved_values();
_gut_config.options = _final_opts
var runner = GutRunner.instantiate()
runner.set_gut_config(_gut_config)
get_tree().root.add_child(runner)
if(opt_resolver.cmd_opts.raie):
runner.run_from_editor()
else:
runner.run_tests()
var update_detector = null
func _check_for_update():
print(str("Checking for update for GUT ", GutUtils.version_numbers.gut_version))
update_detector = GutUtils.UpdateDetector.new()
add_child(update_detector)
await update_detector.check_for_update_with_fetch()
print(update_detector.get_update_string())
# parse options and run Gut
func main():
var opt_resolver = GutCliOptionResolver.new()
opt_resolver.set_base_opts(_gut_config.default_options)
var cli_opts = setup_options(_gut_config.default_options, _gut_config.valid_fonts)
cli_opts.parse()
var all_options_valid = cli_opts.unused.size() == 0
extract_command_line_options(cli_opts, opt_resolver.cmd_opts)
var config_path = opt_resolver.get_value('config_file')
var load_result = 1
# Checking for an empty config path allows us to not use a config file via
# the -gconfig_file option since using "-gconfig_file=" or -gconfig_file=''"
# will result in an empty string.
if(config_path != ''):
load_result = _gut_config.load_options_no_defaults(config_path)
# SHORTCIRCUIT
if(!all_options_valid):
print('Unknown arguments: ', cli_opts.unused)
get_tree().quit(1)
elif(load_result == -1):
print('Invalid gutconfig ', load_result)
get_tree().quit(1)
else:
opt_resolver.config_opts = _gut_config.options
if(cli_opts.get_value('-gh')):
print(GutUtils.version_numbers.get_version_text())
cli_opts.print_help()
get_tree().quit(0)
elif(cli_opts.get_value('-gpo')):
print('All config options and where they are specified. ' +
'The "final" value shows which value will actually be used ' +
'based on order of precedence (default < .gutconfig < cmd line).' + "\n")
print(opt_resolver.to_s_verbose())
get_tree().quit(0)
elif(cli_opts.get_value('-gprint_gutconfig_sample')):
_print_gutconfigs(opt_resolver.get_resolved_values())
get_tree().quit(0)
elif(cli_opts.get_value('-gcheck_update')):
await _check_for_update()
get_tree().quit(0)
else:
_run_tests(opt_resolver)
# ##############################################################################
#(G)odot (U)nit (T)est class
#
# ##############################################################################
# The MIT License (MIT)
# =====================
#
# Copyright (c) 2025 Tom "Butch" Wesley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ##############################################################################

View File

@@ -0,0 +1 @@
uid://bhuudqinp4bth

View File

@@ -0,0 +1,678 @@
## Parses command line arguments, as one might expect.
##
## Parses command line arguments with a bunch of options including generating
## text that displays all the arguments your script accepts. This
## is included in the GUT ClassRef since it might be usable by others and is
## portable (everything it needs is in this one file).
## [br]
## This does alot, if you want to see it in action have a look at
## [url=https://github.com/bitwes/Gut/blob/main/scratch/optparse_example.gd]scratch/optparse_example.gd[/url]
## [codeblock lang=text]
##
## Godot Argument Lists
## -------------------------
## There are two sets of command line arguments that Godot populates:
## OS.get_cmdline_args
## OS.get_cmdline_user_args.
##
## OS.get_cmdline_args contains any arguments that are not used by the engine
## itself. This means options like --help and -d will never appear in this list
## since these are used by the engine. The one exception is the -s option which
## is always included as the first entry and the script path as the second.
## Optparse ignores these values for argument processing but can be accessed
## with my_optparse.options.script_option. This list does not contain any
## arguments that appear in OS.get_cmdline_user_args.
##
## OS.get_cmdline_user_args contains any arguments that appear on the command
## line AFTER " -- " or " ++ ". This list CAN contain options that the engine
## would otherwise use, and are ignored completely by the engine.
##
## The parse method, by default, includes arguments from OS.get_cmdline_args and
## OS.get_cmdline_user_args. You can optionally pass one of these to the parse
## method to limit which arguments are parsed. You can also conjure up your own
## array of arguments and pass that to parse.
##
## See Godot's documentation for get_cmdline_args and get_cmdline_user_args for
## more information.
##
##
## Adding Options
## --------------
## Use the following to add options to be parsed. These methods return the
## created Option instance. See that class above for more info. You can use
## the returned instance to get values, or use get_value/get_value_or_null.
## add("--name", "default", "Description goes here")
## add(["--name", "--aliases"], "default", "Description goes here")
## add_required(["--name", "--aliases"], "default", "Description goes here")
## add_positional("--name", "default", "Description goes here")
## add_positional_required("--name", "default", "Description goes here")
##
## get_value will return the value of the option or the default if it was not
## set. get_value_or_null will return the value of the option or null if it was
## not set.
##
## The Datatype for an option is determined from the default value supplied to
## the various add methods. Supported types are
## String
## Int
## Float
## Array of strings
## Boolean
##
##
## Value Parsing
## -------------
## optparse uses option_name_prefix to differentiate between option names and
## values. Any argument that starts with this value will be treated as an
## argument name. The default is "-". Set this before calling parse if you want
## to change it.
##
## Values for options can be supplied on the command line with or without an "=":
## option=value # no space around "="
## option value # a space between option and value w/o =
## There is no way to escape "=" at this time.
##
## Array options can be specified multiple times and/or set from a comma delimited
## list.
## -gdir=a,b
## -gdir c,d
## -gdir e
## Results in -gdir equaling [a, b, c, d, e]. There is no way to escape commas
## at this time.
##
## To specify an empty list via the command line follow the option with an equal
## sign
## -gdir=
##
## Boolean options will have thier value set to !default when they are supplied
## on the command line. Boolean options cannot have a value on the command line.
## They are either supplied or not.
##
## If a value is not an array and is specified multiple times on the command line
## then the last entry will be used as the value.
##
## Positional argument values are parsed after all named arguments are parsed.
## This means that other options can appear before, between, and after positional
## arguments.
## --foo=bar positional_0_value --disabled --bar foo positional_1_value --a_flag
##
## Anything that is not used by named or positional arguments will appear in the
## unused property. You can use this to detect unrecognized arguments or treat
## everything else provided as a list of things, or whatever you want. You can
## use is_option on the elements of unused (or whatever you want really) to see
## if optparse would treat it as an option name.
##
## Use get_missing_required_options to get an array of Option with all required
## options that were not found when parsing.
##
## The parsed_args property holds the list of arguments that were parsed.
##
##
## Help Generation
## ---------------
## You can call get_help to generate help text, or you can just call print_help
## and this will print it for you.
##
## Set the banner property to any text you want to appear before the usage and
## options sections.
##
## Options are printed in the order they are added. You can add a heading for
## different options sections with add_heading.
## add("--asdf", 1, "This will have no heading")
## add_heading("foo")
## add("--foo", false, "This will have the foo heading")
## add("--another_foo", 1.5, "This too.")
## add_heading("This is after foo")
## add("--bar", true, "You probably get it by now.")
##
## If you include "[default]" in the description of a option, then the help will
## substitue it with the default value.
## [/codeblock]
#-------------------------------------------------------------------------------
# Holds all the properties of a command line option
#
# value will return the default when it has not been set.
#-------------------------------------------------------------------------------
class OptParseOption:
var _has_been_set = false
var _value = null
# REMEMBER that when this option is an array, you have to set the value
# before you alter the contents of the array (append etc) or has_been_set
# will return false and it might not be used right. For example
# get_value_or_null will return null when you've actually changed the value.
var value = _value:
get:
return _value
set(val):
_has_been_set = true
_value = val
var option_name = ''
var default = null
var description = ''
var required = false
var aliases: Array[String] = []
var show_in_help = true
func _init(name,default_value,desc=''):
option_name = name
default = default_value
description = desc
_value = default
func wrap_text(text, left_indent, max_length, wiggle_room=15):
var line_indent = str("\n", " ".repeat(left_indent + 1))
var wrapped = ''
var position = 0
var split_length = max_length
while(position < text.length()):
if(position > 0):
wrapped += line_indent
var split_by = split_length
if(position + split_by + wiggle_room >= text.length()):
split_by = text.length() - position
else:
var min_space = text.rfind(' ', position + split_length)
var max_space = text.find(' ', position + split_length)
if(max_space <= position + split_length + wiggle_room):
split_by = max_space - position
else:
split_by = min_space - position
wrapped += text.substr(position, split_by).lstrip(' ')
if(position == 0):
split_length = max_length - left_indent
position += split_by
return wrapped
func to_s(min_space=0, wrap_length=100):
var line_indent = str("\n", " ".repeat(min_space + 1))
var subbed_desc = description
if not aliases.is_empty():
subbed_desc += "\naliases: " + ", ".join(aliases)
subbed_desc = subbed_desc.replace('[default]', str(default))
subbed_desc = subbed_desc.replace("\n", line_indent)
var final = str(option_name.rpad(min_space), ' ', subbed_desc)
if(wrap_length != -1):
final = wrap_text(final, min_space, wrap_length)
return final
func has_been_set():
return _has_been_set
#-------------------------------------------------------------------------------
# A struct for organizing options by a heading
#-------------------------------------------------------------------------------
class OptParseOptionHeading:
var options = []
var display = 'default'
#-------------------------------------------------------------------------------
# Organizes options by order, heading, position. Also responsible for all
# help related text generation.
#-------------------------------------------------------------------------------
class OptParseOptions:
var options = []
var positional = []
var default_heading = OptParseOptionHeading.new()
var script_option = OptParseOption.new('-s', '?', 'script option provided by Godot')
var _options_by_name = {"--script": script_option, "-s": script_option}
var _options_by_heading = [default_heading]
var _cur_heading = default_heading
func add_heading(display):
var heading = OptParseOptionHeading.new()
heading.display = display
_cur_heading = heading
_options_by_heading.append(heading)
func add(option, aliases=null):
options.append(option)
_options_by_name[option.option_name] = option
_cur_heading.options.append(option)
if aliases != null:
for a in aliases:
_options_by_name[a] = option
option.aliases.assign(aliases)
func add_positional(option):
positional.append(option)
_options_by_name[option.option_name] = option
func get_by_name(option_name):
var found_param = null
if(_options_by_name.has(option_name)):
found_param = _options_by_name[option_name]
return found_param
func get_help_text():
var longest = 0
var text = ""
for i in range(options.size()):
if(options[i].option_name.length() > longest):
longest = options[i].option_name.length()
for heading in _options_by_heading:
if(heading != default_heading):
text += str("\n", heading.display, "\n")
for option in heading.options:
if(option.show_in_help):
text += str(' ', option.to_s(longest + 2).replace("\n", "\n "), "\n")
return text
func get_option_value_text():
var text = ""
var i = 0
for option in positional:
text += str(i, '. ', option.option_name, ' = ', option.value)
if(!option.has_been_set()):
text += " (default)"
text += "\n"
i += 1
for option in options:
text += str(option.option_name, ' = ', option.value)
if(!option.has_been_set()):
text += " (default)"
text += "\n"
return text
func print_option_values():
print(get_option_value_text())
func get_missing_required_options():
var to_return = []
for opt in options:
if(opt.required and !opt.has_been_set()):
to_return.append(opt)
for opt in positional:
if(opt.required and !opt.has_been_set()):
to_return.append(opt)
return to_return
func get_usage_text():
var pos_text = ""
for opt in positional:
pos_text += str("[", opt.description, "] ")
if(pos_text != ""):
pos_text += " [opts] "
return "<path to godot> -s " + script_option.value + " [opts] " + pos_text
#-------------------------------------------------------------------------------
#
# optarse
#
#-------------------------------------------------------------------------------
## @ignore
var options := OptParseOptions.new()
## Set the banner property to any text you want to appear before the usage and
## options sections when printing the options help.
var banner := ''
## optparse uses option_name_prefix to differentiate between option names and
## values. Any argument that starts with this value will be treated as an
## argument name. The default is "-". Set this before calling parse if you want
## to change it.
var option_name_prefix := '-'
## @ignore
var unused = []
## @ignore
var parsed_args = []
## @ignore
var values: Dictionary = {}
func _populate_values_dictionary():
for entry in options.options:
var value_key = entry.option_name.lstrip('-')
values[value_key] = entry.value
for entry in options.positional:
var value_key = entry.option_name.lstrip('-')
values[value_key] = entry.value
func _convert_value_to_array(raw_value):
var split = raw_value.split(',')
# This is what an empty set looks like from the command line. If we do
# not do this then we will always get back [''] which is not what it
# shoudl be.
if(split.size() == 1 and split[0] == ''):
split = []
return split
# REMEMBER raw_value not used for bools.
func _set_option_value(option, raw_value):
var t = typeof(option.default)
# only set values that were specified at the command line so that
# we can punch through default and config values correctly later.
# Without this check, you can't tell the difference between the
# defaults and what was specified, so you can't punch through
# higher level options.
if(t == TYPE_INT):
option.value = int(raw_value)
elif(t == TYPE_STRING):
option.value = str(raw_value)
elif(t == TYPE_ARRAY):
var values = _convert_value_to_array(raw_value)
if(!option.has_been_set()):
option.value = []
option.value.append_array(values)
elif(t == TYPE_BOOL):
option.value = !option.default
elif(t == TYPE_FLOAT):
option.value = float(raw_value)
elif(t == TYPE_NIL):
print(option.option_name + ' cannot be processed, it has a nil datatype')
else:
print(option.option_name + ' cannot be processed, it has unknown datatype:' + str(t))
func _parse_command_line_arguments(args):
var parsed_opts = args.duplicate()
var i = 0
var positional_index = 0
while i < parsed_opts.size():
var opt = ''
var value = ''
var entry = parsed_opts[i]
if(is_option(entry)):
if(entry.find('=') != -1):
var parts = entry.split('=')
opt = parts[0]
value = parts[1]
var the_option = options.get_by_name(opt)
if(the_option != null):
parsed_opts.remove_at(i)
_set_option_value(the_option, value)
else:
i += 1
else:
var the_option = options.get_by_name(entry)
if(the_option != null):
parsed_opts.remove_at(i)
if(typeof(the_option.default) == TYPE_BOOL):
_set_option_value(the_option, null)
elif(i < parsed_opts.size() and !is_option(parsed_opts[i])):
value = parsed_opts[i]
parsed_opts.remove_at(i)
_set_option_value(the_option, value)
else:
i += 1
else:
if(positional_index < options.positional.size()):
_set_option_value(options.positional[positional_index], entry)
parsed_opts.remove_at(i)
positional_index += 1
else:
i += 1
# this is the leftovers that were not extracted.
return parsed_opts
## Test if something is an existing argument. If [code]str(arg)[/code] begins
## with the [member option_name_prefix], it will considered true,
## otherwise it will be considered false.
func is_option(arg) -> bool:
return str(arg).begins_with(option_name_prefix)
## Adds a command line option.
## If [param op_names] is a String, this is set as the argument's name.
## If [param op_names] is an Array of Strings, all elements of the array
## will be aliases for the same argument and will be treated as such during
## parsing.
## [param default] is the default value the option will be set to if it is not
## explicitly set during parsing.
## [param desc] is a human readable text description of the option.
## If the option is successfully added, the Option object will be returned.
## If the option is not successfully added (e.g. a name collision with another
## option occurs), an error message will be printed and [code]null[/code]
## will be returned.
func add(op_names, default, desc: String) -> OptParseOption:
var op_name: String
var aliases: Array[String] = []
var new_op: OptParseOption = null
if(typeof(op_names) == TYPE_STRING):
op_name = op_names
else:
op_name = op_names[0]
aliases.assign(op_names.slice(1))
var bad_alias: int = aliases.map(
func (a: String) -> bool: return options.get_by_name(a) != null
).find(true)
if(options.get_by_name(op_name) != null):
push_error(str('Option [', op_name, '] already exists.'))
elif bad_alias != -1:
push_error(str('Option [', aliases[bad_alias], '] already exists.'))
else:
new_op = OptParseOption.new(op_name, default, desc)
options.add(new_op, aliases)
return new_op
## Adds a required command line option.
## Required options that have not been set may be collected after parsing
## by calling [method get_missing_required_options].
## If [param op_names] is a String, this is set as the argument's name.
## If [param op_names] is an Array of Strings, all elements of the array
## will be aliases for the same argument and will be treated as such during
## parsing.
## [param default] is the default value the option will be set to if it is not
## explicitly set during parsing.
## [param desc] is a human readable text description of the option.
## If the option is successfully added, the Option object will be returned.
## If the option is not successfully added (e.g. a name collision with another
## option occurs), an error message will be printed and [code]null[/code]
## will be returned.
func add_required(op_names, default, desc: String) -> OptParseOption:
var op := add(op_names, default, desc)
if(op != null):
op.required = true
return op
## Adds a positional command line option.
## Positional options are parsed by their position in the list of arguments
## are are not assigned by name by the user.
## If [param op_name] is a String, this is set as the argument's name.
## If [param op_name] is an Array of Strings, all elements of the array
## will be aliases for the same argument and will be treated as such during
## parsing.
## [param default] is the default value the option will be set to if it is not
## explicitly set during parsing.
## [param desc] is a human readable text description of the option.
## If the option is successfully added, the Option object will be returned.
## If the option is not successfully added (e.g. a name collision with another
## option occurs), an error message will be printed and [code]null[/code]
## will be returned.
func add_positional(op_name, default, desc: String) -> OptParseOption:
var new_op = null
if(options.get_by_name(op_name) != null):
push_error(str('Positional option [', op_name, '] already exists.'))
else:
new_op = OptParseOption.new(op_name, default, desc)
options.add_positional(new_op)
return new_op
## Adds a required positional command line option.
## If [param op_name] is a String, this is set as the argument's name.
## Required options that have not been set may be collected after parsing
## by calling [method get_missing_required_options].
## Positional options are parsed by their position in the list of arguments
## are are not assigned by name by the user.
## If [param op_name] is an Array of Strings, all elements of the array
## will be aliases for the same argument and will be treated as such during
## parsing.
## [param default] is the default value the option will be set to if it is not
## explicitly set during parsing.
## [param desc] is a human readable text description of the option.
## If the option is successfully added, the Option object will be returned.
## If the option is not successfully added (e.g. a name collision with another
## option occurs), an error message will be printed and [code]null[/code]
## will be returned.
func add_positional_required(op_name, default, desc: String) -> OptParseOption:
var op = add_positional(op_name, default, desc)
if(op != null):
op.required = true
return op
## Headings are used to separate logical groups of command line options
## when printing out options from the help menu.
## Headings are printed out between option descriptions in the order
## that [method add_heading] was called.
func add_heading(display_text: String) -> void:
options.add_heading(display_text)
## Gets the value assigned to an option after parsing.
## [param name] can be the name of the option or an alias of it.
## [param name] specifies the option whose value you wish to query.
## If the option exists, the value assigned to it during parsing is returned.
## Otherwise, an error message is printed and [code]null[/code] is returned.
func get_value(name: String):
var found_param: OptParseOption = options.get_by_name(name)
if(found_param != null):
return found_param.value
else:
push_error("COULD NOT FIND OPTION " + name)
return null
## Gets the value assigned to an option after parsing,
## returning null if the option was not assigned instead of its default value.
## [param name] specifies the option whose value you wish to query.
## This can be useful when providing an order of precedence to your values.
## For example if
## [codeblock]
## default value < config file < command line
## [/codeblock]
## then you do not want to get the default value for a command line option or
## it will overwrite the value in a config file.
func get_value_or_null(name: String):
var found_param: OptParseOption = options.get_by_name(name)
if(found_param != null and found_param.has_been_set()):
return found_param.value
else:
return null
## Returns the help text for all defined options.
func get_help() -> String:
var sep := '---------------------------------------------------------'
var text := str(sep, "\n", banner, "\n\n")
text += "Usage\n-----------\n"
text += " " + options.get_usage_text() + "\n\n"
text += "\nOptions\n-----------\n"
text += options.get_help_text()
text += str(sep, "\n")
return text
## Prints out the help text for all defined options.
func print_help() -> void:
print(get_help())
## Parses a string for all options that have been set in this optparse.
## if [param cli_args] is passed as a String, then it is parsed.
## Otherwise if [param cli_args] is null,
## aruments passed to the Godot engine at startup are parsed.
## See the explanation at the top of addons/gut/cli/optparse.gd to understand
## which arguments this will have access to.
func parse(cli_args=null) -> void:
parsed_args = cli_args
if(parsed_args == null):
parsed_args = OS.get_cmdline_args()
parsed_args.append_array(OS.get_cmdline_user_args())
unused = _parse_command_line_arguments(parsed_args)
_populate_values_dictionary()
## Get all options that were required and were not set during parsing.
## The return value is an Array of Options.
func get_missing_required_options() -> Array:
return options.get_missing_required_options()
# ##############################################################################
# The MIT License (MIT)
# =====================
#
# Copyright (c) 2025 Tom "Butch" Wesley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ##############################################################################

View File

@@ -0,0 +1 @@
uid://c8m4fojwln6bq

View File

@@ -0,0 +1,208 @@
# ------------------------------------------------------------------------------
# This holds all the meta information for a test script. It contains the
# name of the inner class and an array of CollectedTests. This does not parse
# anything, it just holds the data about parsed scripts and tests. The
# TestCollector is responsible for populating this object.
#
# This class also facilitates all the exporting and importing of tests.
# ------------------------------------------------------------------------------
var CollectedTest = GutUtils.CollectedTest
var _lgr = null
# One entry per test found in the script. Added externally by TestCollector
var tests = []
# One entry for before_all and after_all (maybe add before_each and after_each).
# These are added by Gut when running before_all and after_all for the script.
var setup_teardown_tests = []
var inner_class_name:StringName
var path:String
# Set externally by test_collector after it can verify that the script was
# actually loaded. This could probably be changed to just hold the GutTest
# script that was loaded, cutting down on complexity elsewhere.
var is_loaded = false
# Set by Gut when it decides that a script should be skipped.
# Right now this is whenever the script has the variable skip_script declared.
# the value of skip_script is put into skip_reason.
var was_skipped = false
var skip_reason = ''
var was_run = false
var name = '' :
get: return path
set(val):pass
func _init(logger=null):
_lgr = logger
func get_new():
var inst = load_script().new()
inst.collected_script = self
return inst
func load_script():
var to_return = load(path)
if(inner_class_name != null and inner_class_name != ''):
# If we wanted to do inner classes in inner classses
# then this would have to become some kind of loop or recursive
# call to go all the way down the chain or this class would
# have to change to hold onto the loaded class instead of
# just path information.
to_return = to_return.get(inner_class_name)
return to_return
# script.gd.InnerClass
func get_filename_and_inner():
var to_return = get_filename()
if(inner_class_name != ''):
to_return += '.' + String(inner_class_name)
return to_return
# res://foo/bar.gd.FooBar
func get_full_name():
var to_return = path
if(inner_class_name != ''):
to_return += '.' + String(inner_class_name)
return to_return
func get_filename():
return path.get_file()
func has_inner_class():
return inner_class_name != ''
# Note: although this no longer needs to export the inner_class names since
# they are pulled from metadata now, it is easier to leave that in
# so we don't have to cut the export down to unique script names.
func export_to(config_file, section):
config_file.set_value(section, 'path', path)
config_file.set_value(section, 'inner_class', inner_class_name)
var names = []
for i in range(tests.size()):
names.append(tests[i].name)
config_file.set_value(section, 'tests', names)
func _remap_path(source_path):
var to_return = source_path
if(!FileAccess.file_exists(source_path)):
_lgr.debug('Checking for remap for: ' + source_path)
var remap_path = source_path.get_basename() + '.gd.remap'
if(FileAccess.file_exists(remap_path)):
var cf = ConfigFile.new()
cf.load(remap_path)
to_return = cf.get_value('remap', 'path')
else:
_lgr.warn('Could not find remap file ' + remap_path)
return to_return
func import_from(config_file, section):
path = config_file.get_value(section, 'path')
path = _remap_path(path)
# Null is an acceptable value, but you can't pass null as a default to
# get_value since it thinks you didn't send a default...then it spits
# out red text. This works around that.
var inner_name = config_file.get_value(section, 'inner_class', 'Placeholder')
if(inner_name != 'Placeholder'):
inner_class_name = inner_name
else: # just being explicit
inner_class_name = StringName("")
func get_test_named(test_name):
return GutUtils.search_array(tests, 'name', test_name)
func get_ran_test_count():
var count = 0
for t in tests:
if(t.was_run):
count += 1
return count
func get_assert_count():
var count = 0
for t in tests:
count += t.pass_texts.size()
count += t.fail_texts.size()
for t in setup_teardown_tests:
count += t.pass_texts.size()
count += t.fail_texts.size()
return count
func get_pass_count():
var count = 0
for t in tests:
count += t.pass_texts.size()
for t in setup_teardown_tests:
count += t.pass_texts.size()
return count
func get_fail_count():
var count = 0
for t in tests:
count += t.fail_texts.size()
for t in setup_teardown_tests:
count += t.fail_texts.size()
return count
func get_pending_count():
var count = 0
for t in tests:
count += t.pending_texts.size()
return count
func get_passing_test_count():
var count = 0
for t in tests:
if(t.is_passing()):
count += 1
return count
func get_failing_test_count():
var count = 0
for t in tests:
if(t.is_failing()):
count += 1
return count
func get_risky_count():
var count = 0
if(was_skipped):
count = 1
else:
for t in tests:
if(t.is_risky()):
count += 1
return count
func to_s():
var to_return = path
if(inner_class_name != null):
to_return += str('.', inner_class_name)
to_return += "\n"
for i in range(tests.size()):
to_return += str(' ', tests[i].to_s())
return to_return

View File

@@ -0,0 +1 @@
uid://bjjcnr1oqvag6

View File

@@ -0,0 +1,120 @@
# ------------------------------------------------------------------------------
# Used to keep track of info about each test ran.
# ------------------------------------------------------------------------------
# the name of the function
var name = ""
# flag to know if the name has been printed yet. Used by the logger.
var has_printed_name = false
# the number of arguments the method has
var arg_count = 0
# the time it took to execute the test in seconds
var time_taken : float = 0
# The number of asserts in the test. Converted to a property for backwards
# compatibility. This now reflects the text sizes instead of being a value
# that can be altered externally.
var assert_count = 0 :
get: return pass_texts.size() + fail_texts.size()
set(val): pass
# Converted to propety for backwards compatibility. This now cannot be set
# externally
var pending = false :
get: return is_pending()
set(val): pass
# the line number when the test fails
var line_number = -1
# Set internally by Gut using whatever reason Gut wants to use to set this.
# Gut will skip these marked true and the test will be listed as risky.
var should_skip = false # -- Currently not used by GUT don't believe ^
var pass_texts = []
var fail_texts = []
var pending_texts = []
var orphans = 0
var was_run = false
var collected_script : WeakRef = null
func did_pass():
return is_passing()
func add_fail(fail_text):
fail_texts.append(fail_text)
func add_pending(pending_text):
pending_texts.append(pending_text)
func add_pass(passing_text):
pass_texts.append(passing_text)
# must have passed an assert and not have any other status to be passing
func is_passing():
return pass_texts.size() > 0 and fail_texts.size() == 0 and pending_texts.size() == 0
# failing takes precedence over everything else, so any failures makes the
# test a failure.
func is_failing():
return fail_texts.size() > 0
# test is only pending if pending was called and the test is not failing.
func is_pending():
return pending_texts.size() > 0 and fail_texts.size() == 0
func is_risky():
return should_skip or (was_run and !did_something())
func did_something():
return is_passing() or is_failing() or is_pending()
func get_status_text():
var to_return = GutUtils.TEST_STATUSES.NO_ASSERTS
if(should_skip):
to_return = GutUtils.TEST_STATUSES.SKIPPED
elif(!was_run):
to_return = GutUtils.TEST_STATUSES.NOT_RUN
elif(pending_texts.size() > 0):
to_return = GutUtils.TEST_STATUSES.PENDING
elif(fail_texts.size() > 0):
to_return = GutUtils.TEST_STATUSES.FAILED
elif(pass_texts.size() > 0):
to_return = GutUtils.TEST_STATUSES.PASSED
return to_return
# Deprecated
func get_status():
return get_status_text()
func to_s():
var pad = ' '
var to_return = str(name, "[", get_status_text(), "]\n")
for i in range(fail_texts.size()):
to_return += str(pad, 'Fail: ', fail_texts[i])
for i in range(pending_texts.size()):
to_return += str(pad, 'Pending: ', pending_texts[i], "\n")
for i in range(pass_texts.size()):
to_return += str(pad, 'Pass: ', pass_texts[i], "\n")
return to_return

View File

@@ -0,0 +1 @@
uid://cl854f1m26a2a

View File

@@ -0,0 +1,125 @@
var _strutils = GutUtils.Strutils.new()
var _max_length = 100
var _should_compare_int_to_float = true
const MISSING = '|__missing__gut__compare__value__|'
func _cannot_compare_text(v1, v2):
return str('Cannot compare ', _strutils.types[typeof(v1)], ' with ',
_strutils.types[typeof(v2)], '.')
func _make_missing_string(text):
return '<missing ' + text + '>'
func _create_missing_result(v1, v2, text):
var to_return = null
var v1_str = format_value(v1)
var v2_str = format_value(v2)
if(typeof(v1) == TYPE_STRING and v1 == MISSING):
v1_str = _make_missing_string(text)
to_return = GutUtils.CompareResult.new()
elif(typeof(v2) == TYPE_STRING and v2 == MISSING):
v2_str = _make_missing_string(text)
to_return = GutUtils.CompareResult.new()
if(to_return != null):
to_return.summary = str(v1_str, ' != ', v2_str)
to_return.are_equal = false
return to_return
func simple(v1, v2, missing_string=''):
var missing_result = _create_missing_result(v1, v2, missing_string)
if(missing_result != null):
return missing_result
var result = GutUtils.CompareResult.new()
var cmp_str = null
var extra = ''
var tv1 = typeof(v1)
var tv2 = typeof(v2)
# print(tv1, '::', tv2, ' ', _strutils.types[tv1], '::', _strutils.types[tv2])
if(_should_compare_int_to_float and [TYPE_INT, TYPE_FLOAT].has(tv1) and [TYPE_INT, TYPE_FLOAT].has(tv2)):
result.are_equal = v1 == v2
elif([TYPE_STRING, TYPE_STRING_NAME].has(tv1) and [TYPE_STRING, TYPE_STRING_NAME].has(tv2)):
result.are_equal = v1 == v2
elif(GutUtils.are_datatypes_same(v1, v2)):
result.are_equal = v1 == v2
if(typeof(v1) == TYPE_DICTIONARY or typeof(v1) == TYPE_ARRAY):
var sub_result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
result.summary = sub_result.get_short_summary()
if(!sub_result.are_equal):
extra = ".\n" + sub_result.get_short_summary()
else:
cmp_str = '!='
result.are_equal = false
extra = str('. ', _cannot_compare_text(v1, v2))
cmp_str = get_compare_symbol(result.are_equal)
result.summary = str(format_value(v1), ' ', cmp_str, ' ', format_value(v2), extra)
return result
func shallow(v1, v2):
var result = null
if(GutUtils.are_datatypes_same(v1, v2)):
if(typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]):
result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
else:
result = simple(v1, v2)
else:
result = simple(v1, v2)
return result
func deep(v1, v2):
var result = null
if(GutUtils.are_datatypes_same(v1, v2)):
if(typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]):
result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
else:
result = simple(v1, v2)
else:
result = simple(v1, v2)
return result
func format_value(val, max_val_length=_max_length):
return _strutils.truncate_string(_strutils.type2str(val), max_val_length)
func compare(v1, v2, diff_type=GutUtils.DIFF.SIMPLE):
var result = null
if(diff_type == GutUtils.DIFF.SIMPLE):
result = simple(v1, v2)
elif(diff_type == GutUtils.DIFF.DEEP):
result = deep(v1, v2)
return result
func get_should_compare_int_to_float():
return _should_compare_int_to_float
func set_should_compare_int_to_float(should_compare_int_float):
_should_compare_int_to_float = should_compare_int_float
func get_compare_symbol(is_equal):
if(is_equal):
return '=='
else:
return '!='

View File

@@ -0,0 +1 @@
uid://bohry7fhscy7y

View File

@@ -0,0 +1,70 @@
var _are_equal = false
var are_equal = false :
get:
return get_are_equal()
set(val):
set_are_equal(val)
var _summary = null
var summary = null :
get:
return get_summary()
set(val):
set_summary(val)
var _max_differences = 30
var max_differences = 30 :
get:
return get_max_differences()
set(val):
set_max_differences(val)
var _differences = {}
var differences :
get:
return get_differences()
set(val):
set_differences(val)
func _block_set(which, val):
push_error(str('cannot set ', which, ', value [', val, '] ignored.'))
func _to_string():
return str(get_summary()) # could be null, gotta str it.
func get_are_equal():
return _are_equal
func set_are_equal(r_eq):
_are_equal = r_eq
func get_summary():
return _summary
func set_summary(smry):
_summary = smry
func get_total_count():
pass
func get_different_count():
pass
func get_short_summary():
return summary
func get_max_differences():
return _max_differences
func set_max_differences(max_diff):
_max_differences = max_diff
func get_differences():
return _differences
func set_differences(diffs):
_block_set('differences', diffs)
func get_brackets():
return null

View File

@@ -0,0 +1 @@
uid://cow1xqmqqvn4e

View File

@@ -0,0 +1,63 @@
var _strutils = GutUtils.Strutils.new()
const INDENT = ' '
var _max_to_display = 30
const ABSOLUTE_MAX_DISPLAYED = 10000
const UNLIMITED = -1
func _single_diff(diff, depth=0):
var to_return = ""
var brackets = diff.get_brackets()
if(brackets != null and !diff.are_equal):
to_return = ''
to_return += str(brackets.open, "\n",
_strutils.indent_text(differences_to_s(diff.differences, depth), depth+1, INDENT), "\n",
brackets.close)
else:
to_return = str(diff)
return to_return
func make_it(diff):
var to_return = ''
if(diff.are_equal):
to_return = diff.summary
else:
if(_max_to_display == ABSOLUTE_MAX_DISPLAYED):
to_return = str(diff.get_value_1(), ' != ', diff.get_value_2())
else:
to_return = diff.get_short_summary()
to_return += str("\n", _strutils.indent_text(_single_diff(diff, 0), 1, ' '))
return to_return
func differences_to_s(differences, depth=0):
var to_return = ''
var keys = differences.keys()
keys.sort()
var limit = min(_max_to_display, differences.size())
for i in range(limit):
var key = keys[i]
to_return += str(key, ": ", _single_diff(differences[key], depth))
if(i != limit -1):
to_return += "\n"
if(differences.size() > _max_to_display):
to_return += str("\n\n... ", differences.size() - _max_to_display, " more.")
return to_return
func get_max_to_display():
return _max_to_display
func set_max_to_display(max_to_display):
_max_to_display = max_to_display
if(_max_to_display == UNLIMITED):
_max_to_display = ABSOLUTE_MAX_DISPLAYED

View File

@@ -0,0 +1 @@
uid://ch2km05phxacd

View File

@@ -0,0 +1,156 @@
extends 'res://addons/gut/compare_result.gd'
const INDENT = ' '
enum {
DEEP,
SIMPLE
}
var _strutils = GutUtils.Strutils.new()
var _compare = GutUtils.Comparator.new()
var _value_1 = null
var _value_2 = null
var _total_count = 0
var _diff_type = null
var _brackets = null
var _valid = true
var _desc_things = 'somethings'
# -------- comapre_result.gd "interface" ---------------------
func set_are_equal(val):
_block_set('are_equal', val)
func get_are_equal():
if(!_valid):
return null
else:
return differences.size() == 0
func set_summary(val):
_block_set('summary', val)
func get_summary():
return summarize()
func get_different_count():
return differences.size()
func get_total_count():
return _total_count
func get_short_summary():
var text = str(_strutils.truncate_string(str(_value_1), 50),
' ', _compare.get_compare_symbol(are_equal), ' ',
_strutils.truncate_string(str(_value_2), 50))
if(!are_equal):
text += str(' ', get_different_count(), ' of ', get_total_count(),
' ', _desc_things, ' do not match.')
return text
func get_brackets():
return _brackets
# -------- comapre_result.gd "interface" ---------------------
func _invalidate():
_valid = false
differences = null
func _init(v1,v2,diff_type=DEEP):
_value_1 = v1
_value_2 = v2
_diff_type = diff_type
_compare.set_should_compare_int_to_float(false)
_find_differences(_value_1, _value_2)
func _find_differences(v1, v2):
if(GutUtils.are_datatypes_same(v1, v2)):
if(typeof(v1) == TYPE_ARRAY):
_brackets = {'open':'[', 'close':']'}
_desc_things = 'indexes'
_diff_array(v1, v2)
elif(typeof(v2) == TYPE_DICTIONARY):
_brackets = {'open':'{', 'close':'}'}
_desc_things = 'keys'
_diff_dictionary(v1, v2)
else:
_invalidate()
GutUtils.get_logger().error('Only Arrays and Dictionaries are supported.')
else:
_invalidate()
GutUtils.get_logger().error('Only Arrays and Dictionaries are supported.')
func _diff_array(a1, a2):
_total_count = max(a1.size(), a2.size())
for i in range(a1.size()):
var result = null
if(i < a2.size()):
if(_diff_type == DEEP):
result = _compare.deep(a1[i], a2[i])
else:
result = _compare.simple(a1[i], a2[i])
else:
result = _compare.simple(a1[i], _compare.MISSING, 'index')
if(!result.are_equal):
differences[i] = result
if(a1.size() < a2.size()):
for i in range(a1.size(), a2.size()):
differences[i] = _compare.simple(_compare.MISSING, a2[i], 'index')
func _diff_dictionary(d1, d2):
var d1_keys = d1.keys()
var d2_keys = d2.keys()
# Process all the keys in d1
_total_count += d1_keys.size()
for key in d1_keys:
if(!d2.has(key)):
differences[key] = _compare.simple(d1[key], _compare.MISSING, 'key')
else:
d2_keys.remove_at(d2_keys.find(key))
var result = null
if(_diff_type == DEEP):
result = _compare.deep(d1[key], d2[key])
else:
result = _compare.simple(d1[key], d2[key])
if(!result.are_equal):
differences[key] = result
# Process all the keys in d2 that didn't exist in d1
_total_count += d2_keys.size()
for i in range(d2_keys.size()):
differences[d2_keys[i]] = _compare.simple(_compare.MISSING, d2[d2_keys[i]], 'key')
func summarize():
var summary = ''
if(are_equal):
summary = get_short_summary()
else:
var formatter = load('res://addons/gut/diff_formatter.gd').new()
formatter.set_max_to_display(max_differences)
summary = formatter.make_it(self)
return summary
func get_diff_type():
return _diff_type
func get_value_1():
return _value_1
func get_value_2():
return _value_2

View File

@@ -0,0 +1 @@
uid://beoxokvl1hjs8

View File

@@ -0,0 +1,22 @@
var __gutdbl_values = {
thepath = '{path}',
subpath = '{subpath}',
stubber = {stubber_id},
spy = {spy_id},
gut = {gut_id},
singleton_name = '{singleton_name}',
singleton = {singleton_id},
is_partial = {is_partial},
doubled_methods = {doubled_methods},
}
var __gutdbl = load('res://addons/gut/double_tools.gd').new(self)
# Here so other things can check for a method to know if this is a double.
func __gutdbl_check_method__():
pass
# Cleanup called by GUT after tests have finished. Important for RefCounted
# objects. Nodes are freed, and won't have this method called on them.
func __gutdbl_done():
__gutdbl = null
__gutdbl_values.clear()

View File

@@ -0,0 +1,9 @@
{func_decleration}
if(__gutdbl == null):
return
__gutdbl.spy_on('{method_name}', {param_array})
if(__gutdbl.is_stubbed_to_call_super('{method_name}', {param_array})):
{super_call}
else:
return await __gutdbl.handle_other_stubs('{method_name}', {param_array})

View File

@@ -0,0 +1,4 @@
{func_decleration}:
super({super_params})
__gutdbl.spy_on('{method_name}', {param_array})

View File

@@ -0,0 +1,13 @@
# ##############################################################################
# Gut Doubled Script
# ##############################################################################
{extends}
# ------------------------------------------------------------------------------
# GUT stuff
# ------------------------------------------------------------------------------
{double_data}
# ------------------------------------------------------------------------------
# Doubled Methods
# ------------------------------------------------------------------------------

View File

@@ -0,0 +1,16 @@
{extends}
{constants}
{signals}
{properties}
# ------------------------------------------------------------------------------
# GUT stuff
# ------------------------------------------------------------------------------
{double_data}
# ------------------------------------------------------------------------------
# Doubled Methods
# ------------------------------------------------------------------------------

View File

@@ -0,0 +1,83 @@
var thepath = ''
var subpath = ''
var singleton_name = null
var is_partial = null
var double_ref : WeakRef = null
var stubber_ref : WeakRef = null
var spy_ref : WeakRef = null
var gut_ref : WeakRef = null
var singleton_ref : WeakRef = null
var __gutdbl_values = {}
const NO_DEFAULT_VALUE = '!__gut__no__default__value__!'
func _init(double = null):
if(double != null):
var values = double.__gutdbl_values
__gutdbl_values = double.__gutdbl_values
double_ref = weakref(double)
thepath = values.thepath
subpath = values.subpath
stubber_ref = weakref_from_id(values.stubber)
spy_ref = weakref_from_id(values.spy)
gut_ref = weakref_from_id(values.gut)
singleton_ref = weakref_from_id(values.singleton)
singleton_name = values.singleton_name
is_partial = values.is_partial
if(gut_ref.get_ref() != null):
gut_ref.get_ref().get_autofree().add_free(double_ref.get_ref())
func _get_stubbed_method_to_call(method_name, called_with):
var method = stubber_ref.get_ref().get_call_this(double_ref.get_ref(), method_name, called_with)
if(method != null):
method = method.bindv(called_with)
return method
return method
func weakref_from_id(inst_id):
if(inst_id == -1):
return weakref(null)
else:
return weakref(instance_from_id(inst_id))
func is_stubbed_to_call_super(method_name, called_with):
if(stubber_ref.get_ref() != null):
return stubber_ref.get_ref().should_call_super(double_ref.get_ref(), method_name, called_with)
else:
return false
func handle_other_stubs(method_name, called_with):
if(stubber_ref.get_ref() == null):
return
var method = _get_stubbed_method_to_call(method_name, called_with)
if(method != null):
return await method.call()
else:
return stubber_ref.get_ref().get_return(double_ref.get_ref(), method_name, called_with)
func spy_on(method_name, called_with):
if(spy_ref.get_ref() != null):
spy_ref.get_ref().add_call(double_ref.get_ref(), method_name, called_with)
func default_val(method_name, p_index):
if(stubber_ref.get_ref() == null):
return null
else:
var result = stubber_ref.get_ref().get_default_value(double_ref.get_ref(), method_name, p_index)
return result
func get_singleton():
var to_return = singleton_ref.get_ref()
if(to_return == null):
push_error("Trying to get a singleton reference on a non-singleton double: ",
__gutdbl_values.singleton_name, "/", __gutdbl_values.singleton)
return to_return

View File

@@ -0,0 +1 @@
uid://tr4khoco1hef

View File

@@ -0,0 +1,396 @@
extends RefCounted
static var _base_script_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/script_template.txt')
static var _singleton_script_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/singleton_template.txt')
static var _double_data_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/double_data_template.txt')
var _script_collector = GutUtils.ScriptCollector.new()
var _singleton_parser = GutUtils.SingletonParser.new()
# used by tests for debugging purposes.
var print_source = false
var inner_class_registry = GutUtils.inner_class_registry
# ###############
# Properties
# ###############
var _stubber = GutUtils.Stubber.new()
func get_stubber():
return _stubber
func set_stubber(stubber):
_stubber = stubber
var _lgr = GutUtils.get_logger()
func get_logger():
return _lgr
func set_logger(logger):
_lgr = logger
_method_maker.set_logger(logger)
var _spy = null
func get_spy():
return _spy
func set_spy(spy):
_spy = spy
var _gut = null
func get_gut():
return _gut
func set_gut(gut):
_gut = gut
var _strategy = null
func get_strategy():
return _strategy
func set_strategy(strategy):
if(GutUtils.DOUBLE_STRATEGY.values().has(strategy)):
_strategy = strategy
else:
_lgr.error(str('doubler.gd: invalid double strategy ', strategy))
var _method_maker = GutUtils.MethodMaker.new()
func get_method_maker():
return _method_maker
var _ignored_methods = GutUtils.OneToMany.new()
func get_ignored_methods():
return _ignored_methods
# ###############
# Private
# ###############
func _init(strategy=GutUtils.DOUBLE_STRATEGY.SCRIPT_ONLY):
set_logger(GutUtils.get_logger())
_strategy = strategy
func _notification(what: int) -> void:
if(what == NOTIFICATION_PREDELETE):
if(_stubber != null):
_stubber.clear()
func _get_indented_line(indents, text):
var to_return = ''
for _i in range(indents):
to_return += "\t"
return str(to_return, text, "\n")
func _stub_to_call_super(parsed, method_name):
if(!parsed.get_method(method_name).is_eligible_for_doubling()):
return
var params = null
if(parsed.is_native):
params = GutUtils.StubParams.new(parsed._native_class, method_name, parsed.subpath)
else:
params = GutUtils.StubParams.new(parsed.script_path, method_name, parsed.subpath)
params.to_call_super()
params.is_script_default = true
_stubber.add_stub(params)
func _get_base_script_text(parsed, override_path, partial, included_methods):
var path = parsed.script_path
if(override_path != null):
path = override_path
var stubber_id = -1
if(_stubber != null):
stubber_id = _stubber.get_instance_id()
var spy_id = -1
if(_spy != null):
spy_id = _spy.get_instance_id()
var gut_id = -1
if(_gut != null):
gut_id = _gut.get_instance_id()
var extends_text = parsed.get_extends_text()
var double_data_values = {
"path":path,
"subpath":GutUtils.nvl(parsed.subpath, ''),
"stubber_id":stubber_id,
"spy_id":spy_id,
"gut_id":gut_id,
"singleton_name":'',
"singleton_id":-1,
"is_partial":partial,
"doubled_methods":included_methods,
}
var values = {
"extends":extends_text,
"double_data":_double_data_text.format(double_data_values),
}
return _base_script_text.format(values)
func _get_singleton_text(parsed, included_methods, is_partial):
var stubber_id = -1
if(_stubber != null):
stubber_id = _stubber.get_instance_id()
var spy_id = -1
if(_spy != null):
spy_id = _spy.get_instance_id()
var gut_id = -1
if(_gut != null):
gut_id = _gut.get_instance_id()
var double_data_values = {
"path":'',
"subpath":'',
"stubber_id":stubber_id,
"spy_id":spy_id,
"gut_id":gut_id,
"singleton_name":parsed.singleton_name,
"singleton_id":parsed.singleton_id,
"is_partial":is_partial,
"doubled_methods":included_methods,
}
var values = {
"extends":"extends RefCounted",
"double_data":_double_data_text.format(double_data_values),
"signals":parsed.get_all_signal_text(),
"constants":parsed.get_all_constants_text(),
"properties":parsed.get_all_properties_text()
}
var src = _singleton_script_text.format(values)
return src
func _is_method_eligible_for_doubling(parsed_script, parsed_method):
return !parsed_method.is_accessor() and \
parsed_method.is_eligible_for_doubling() and \
!_ignored_methods.has(parsed_script.resource, parsed_method.meta.name)
# Disable the native_method_override setting so that doubles do not generate
# errors or warnings when doubling with INCLUDE_NATIVE or when a method has
# been added because of param_count stub.
func _create_script_no_warnings(src):
var prev_native_override_value = null
var native_method_override = 'debug/gdscript/warnings/native_method_override'
prev_native_override_value = ProjectSettings.get_setting(native_method_override)
ProjectSettings.set_setting(native_method_override, 0)
var DblClass = GutUtils.create_script_from_source(src)
ProjectSettings.set_setting(native_method_override, prev_native_override_value)
return DblClass
func _create_double(parsed, strategy, override_path, partial):
var dbl_src = ""
var included_methods = []
for method in parsed.get_local_methods():
if(_is_method_eligible_for_doubling(parsed, method)):
included_methods.append(method.meta.name)
dbl_src += _get_func_text(method.meta)
if(strategy == GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE):
for method in parsed.get_super_methods():
if(_is_method_eligible_for_doubling(parsed, method)):
included_methods.append(method.meta.name)
_stub_to_call_super(parsed, method.meta.name)
dbl_src += _get_func_text(method.meta)
var base_script = _get_base_script_text(parsed, override_path, partial, included_methods)
dbl_src = base_script + "\n\n" + dbl_src
if(print_source):
var to_print :String = GutUtils.add_line_numbers(dbl_src)
to_print = to_print.rstrip("\n")
_lgr.log(str(to_print))
var DblClass = _create_script_no_warnings(dbl_src)
if(_stubber != null):
_stub_method_default_values(parsed)
if(print_source):
_lgr.log(str(" path | ", DblClass.resource_path, "\n"))
return DblClass
func _create_singleton_double(singleton, is_partial):
var parsed = _singleton_parser.parse(singleton)
var dbl_src = _get_singleton_text(parsed, parsed.methods_by_name.keys(), is_partial)
for key in parsed.methods_by_name:
if(!_ignored_methods.has(singleton, key)):
dbl_src += _method_maker.get_function_text(parsed.methods_by_name[key], singleton) + "\n"
if(print_source):
var to_print :String = GutUtils.add_line_numbers(dbl_src)
to_print = to_print.rstrip("\n")
_lgr.log(str(to_print))
var DblClass = GutUtils.create_script_from_source(dbl_src)
if(_stubber != null):
for key in parsed.methods_by_name:
var meta = parsed.methods_by_name[key]
if(meta != {} and !meta.flags & METHOD_FLAG_VARARG):
_stubber.stub_defaults_from_meta(singleton, meta)
return DblClass
func _stub_method_default_values(parsed):
for method in parsed.get_local_methods():
if(method.is_eligible_for_doubling() and !_ignored_methods.has(parsed.resource, method.meta.name)):
_stubber.stub_defaults_from_meta(parsed.script_path, method.meta)
func _double_scene_and_script(scene, strategy, partial):
var dbl_bundle = scene._bundled.duplicate(true)
var script_obj = GutUtils.get_scene_script_object(scene)
# I'm not sure if the script object for the root node of a packed scene is
# always the first entry in "variants" so this tries to find it.
var script_index = dbl_bundle["variants"].find(script_obj)
var script_dbl = null
if(script_obj != null):
if(partial):
script_dbl = _partial_double(script_obj, strategy, scene.get_path())
else:
script_dbl = _double(script_obj, strategy, scene.get_path())
if(script_index != -1):
dbl_bundle["variants"][script_index] = script_dbl
var doubled_scene = PackedScene.new()
doubled_scene._set_bundled_scene(dbl_bundle)
return doubled_scene
func _get_inst_id_ref_str(inst):
var ref_str = 'null'
if(inst):
ref_str = str('instance_from_id(', inst.get_instance_id(),')')
return ref_str
func _get_func_text(method_hash):
return _method_maker.get_function_text(method_hash) + "\n"
func _parse_script(obj):
var parsed = null
if(GutUtils.is_inner_class(obj)):
if(inner_class_registry.has(obj)):
parsed = _script_collector.parse(inner_class_registry.get_base_resource(obj), obj)
else:
_lgr.error('Doubling Inner Classes requires you register them first. Call register_inner_classes passing the script that contains the inner class.')
else:
parsed = _script_collector.parse(obj)
return parsed
# Override path is used with scenes.
func _double(obj, strategy, override_path=null):
var parsed = _parse_script(obj)
if(parsed != null):
return _create_double(parsed, strategy, override_path, false)
func _partial_double(obj, strategy, override_path=null):
var parsed = _parse_script(obj)
if(parsed != null):
return _create_double(parsed, strategy, override_path, true)
# -------------------------
# Public
# -------------------------
# double a script/object
func double(obj, strategy=_strategy):
return _double(obj, strategy)
func partial_double(obj, strategy=_strategy):
return _partial_double(obj, strategy)
# double a scene
func double_scene(scene, strategy=_strategy):
return _double_scene_and_script(scene, strategy, false)
func partial_double_scene(scene, strategy=_strategy):
return _double_scene_and_script(scene, strategy, true)
func double_gdnative(which):
return _double(which, GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE)
func partial_double_gdnative(which):
return _partial_double(which, GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE)
func double_inner(parent, inner, strategy=_strategy):
var parsed = _script_collector.parse(parent, inner)
return _create_double(parsed, strategy, null, false)
func partial_double_inner(parent, inner, strategy=_strategy):
var parsed = _script_collector.parse(parent, inner)
return _create_double(parsed, strategy, null, true)
func double_singleton(obj):
return _create_singleton_double(obj, false)
func partial_double_singleton(obj):
return _create_singleton_double(obj, true)
func add_ignored_method(obj, method_name):
_ignored_methods.add(obj, method_name)
# ##############################################################################
#(G)odot (U)nit (T)est class
#
# ##############################################################################
# The MIT License (MIT)
# =====================
#
# Copyright (c) 2025 Tom "Butch" Wesley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ##############################################################################

View File

@@ -0,0 +1 @@
uid://cpy013l0wqwmg

View File

@@ -0,0 +1,33 @@
@tool
var default_script_name_no_extension = 'gut_dynamic_script'
var default_script_resource_path = 'res://addons/gut/not_a_real_file/'
var default_script_extension = "gd"
var _created_script_count = 0
# Creates a loaded script from the passed in source. This loaded script is
# returned unless there is an error. When an error occcurs the error number
# is returned instead.
func create_script_from_source(source, override_path=null):
_created_script_count += 1
var r_path = str(default_script_resource_path,
default_script_name_no_extension, '_', _created_script_count, ".",
default_script_extension)
if(override_path != null):
r_path = override_path
var DynamicScript = GDScript.new()
DynamicScript.source_code = source.dedent()
# The resource_path must be unique or Godot thinks it is trying
# to load something it has already loaded and generates an error like
# ERROR: Another resource is loaded from path 'workaround for godot
# issue #65263' (possible cyclic resource inclusion).
DynamicScript.resource_path = r_path
var result = DynamicScript.reload()
if(result != OK):
DynamicScript = result
return DynamicScript

View File

@@ -0,0 +1 @@
uid://cnbjsrik0p5uf

View File

@@ -0,0 +1,212 @@
@tool
extends Node
# ##############################################################################
#
# Watches script editors and emits a signal whenever the method, inner class,
# or script changes based on cursor position and other stuff.
#
# Basically, whenever this thing's signal is emitted, then the RunAtCursor
# buttons should be updated to match the data passed to the signal.
# ##############################################################################
# In the editor, whenever a script is opened you get these new things that
# hang off of EditorInterface.get_script_editor()
# * ScriptEditorBase
# * CodeEdit
# ##############################################################################
var _last_info : Dictionary = {}
var _last_line = -1
# This is the control that holds all the individual editors.
var _current_script_editor : ScriptEditor = null
# Reference to the GDScript for the last script we were notified about.
var _current_script = null
var _current_script_is_test_script = false
var _current_editor_base : ScriptEditorBase = null
var _current_editor : CodeEdit = null
# Quick lookup of editors based on the current script.
var _editors_for_scripts : Dictionary= {}
# In order to keep the data that comes back from the emitted signal way more
# usable, we have to know what GUT looks for for an inner-test-class prefix.
# If we didn't do this, then this thing would have to return all the inner
# classes and then we'd have to determine if we were in an inner-test-class
# outside of here by traversing all the classes returned. It makes this thing
# less generic and know too much, but this is probably already too generic as
# it is.
var inner_class_prefix = "Test"
var method_prefix = "test_"
var script_prefix = "test_"
var script_suffix = ".gd"
# Based on cursor and open editors, this will be emitted. You do what you
# want with it.
signal it_changed(change_data)
func _ready():
# This will not change, and should not change, over the course of a session.
_current_script_editor = EditorInterface.get_script_editor()
_current_script_editor.editor_script_changed.connect(_on_editor_script_changed)
_current_script_editor.script_close.connect(_on_editor_script_close)
func _handle_caret_location(which):
var current_line = which.get_caret_line(0) + 1
if(_last_line != current_line):
_last_line = current_line
if(_current_script_is_test_script):
var new_info = _make_info(which, _current_script, _current_script_is_test_script)
if(_last_info != new_info):
_last_info = new_info
it_changed.emit(_last_info.duplicate())
func _get_func_name_from_line(text):
text = text.strip_edges()
var left = text.split("(")[0]
var func_name = left.split(" ")[1]
return func_name
func _get_class_name_from_line(text):
text = text.strip_edges()
var right = text.split(" ")[1]
var the_name = right.rstrip(":")
return the_name
func _make_info(editor, script, test_script_flag):
if(editor == null):
return
var info = {
script = script,
inner_class = null,
method = null,
is_test_script = test_script_flag
}
var start_line = editor.get_caret_line()
var line = start_line
var done_func = false
var done_inner = false
while(line > 0 and (!done_func or !done_inner)):
if(editor.can_fold_line(line)):
var text = editor.get_line(line)
var strip_text = text.strip_edges(true, false) # only left
if(!done_func and strip_text.begins_with("func ")):
info.method = _get_func_name_from_line(text)
done_func = true
# If the func line is left justified then there won't be any
# inner classes above it.
if(editor.get_indent_level(line) == 0):
done_inner = true
if(!done_inner and strip_text.begins_with("class")):
var inner_name = _get_class_name_from_line(text)
# See note about inner_class_prefix, this knows too much, but
# if it was to know less it would insanely more difficult
# everywhere.
if(inner_name.begins_with(inner_class_prefix)):
info.inner_class = inner_name
done_inner = true
done_func = true
line -= 1
# print('parsed lines: ', start_line - line, '(', info.inner_class, ':', info.method, ')')
return info
# -------------
# Events
# -------------
# Fired whenever the script changes. This does not fire for help files. If
# you click a help file and then back to the same file, then this will fire
# for the same script
#
# This does fire for some non-script files such as .cfg, .json and .md files,
# but the passed in value will be null.
#
# This can fire multiple times for the same script when a script is opened.
func _on_editor_script_changed(script):
if(script == null):
return
_last_line = -1
_current_script = script
_current_editor_base = _current_script_editor.get_current_editor()
if(_current_editor_base.get_base_editor() is CodeEdit):
_current_editor = _current_editor_base.get_base_editor()
if(!_current_editor.caret_changed.is_connected(_on_caret_changed)):
_current_editor.caret_changed.connect(_on_caret_changed.bind(_current_editor))
else:
_current_editor = null
_editors_for_scripts[script] = _current_editor
_current_script_is_test_script = is_test_script(_current_script)
_handle_caret_location(_current_editor)
func _on_editor_script_close(script):
var script_editor = _editors_for_scripts.get(script, null)
if(script_editor != null):
if(script_editor.caret_changed.is_connected(_on_caret_changed)):
script_editor.caret_changed.disconnect(_on_caret_changed)
_editors_for_scripts.erase(script)
func _on_caret_changed(which):
# Sometimes this is fired for editors that are not the current. I could
# make this fire by saving a file in an external editor. I was unable to
# get useful data out when it wasn't the current editor so I'm only doing
# anything when it is the current editor.
if(which == _current_editor):
_handle_caret_location(which)
func _could_be_test_script(script):
return script.resource_path.get_file().begins_with(script_prefix) and \
script.resource_path.get_file().ends_with(script_suffix)
# -------------
# Public
# -------------
var _scripts_that_have_been_warned_about = []
var _we_have_warned_enough = false
var _max_warnings = 5
func is_test_script(script):
var base = script.get_base_script()
if(base == null and script.get_script_method_list().size() == 0 and _could_be_test_script(script)):
if(OS.is_stdout_verbose() or (!_scripts_that_have_been_warned_about.has(script.resource_path) and !_we_have_warned_enough)):
_scripts_that_have_been_warned_about.append(script.resource_path)
push_warning(str('[GUT] Treating ', script.resource_path, " as test script: ",
"GUT was not able to retrieve information about this script. If this is ",
"a new script you can ignore this warning. Otherwise, this may ",
"have to do with having VSCode open. Restarting Godot sometimes helps. See ",
"https://github.com/bitwes/Gut/issues/754"))
if(!OS.is_stdout_verbose() and _scripts_that_have_been_warned_about.size() >= _max_warnings):
print("[GUT] Disabling warning.")
_we_have_warned_enough = true
# We can't know if this is a test script. It's more usable if we
# assume this is a test script.
return true
else:
while(base and base.resource_path != 'res://addons/gut/test.gd'):
base = base.get_base_script()
return base != null
func get_info():
return _last_info.duplicate()
func log_values():
print("---------------------------------------------------------------")
print("script ", _current_script)
print("script_editor ", _current_script_editor)
print("editor_base ", _current_editor_base)
print("editor ", _current_editor)

View File

@@ -0,0 +1 @@
uid://c110s7a32x4su

View File

@@ -0,0 +1,192 @@
extends Logger
class_name GutErrorTracker
# ------------------------------------------------------------------------------
# Static methods wrap around add/remove logger to make disabling the logger
# easier and to help avoid misusing add/remove in tests. If GUT needs to
# add/remove a logger then this is how it should do it.
# ------------------------------------------------------------------------------
static var registered_loggers := {}
static var register_loggers = true
static func register_logger(which):
if(register_loggers and !registered_loggers.has(which)):
OS.add_logger(which)
registered_loggers[which] = get_stack()
static func deregister_logger(which):
if(registered_loggers.has(which)):
OS.remove_logger(which)
registered_loggers.erase(which)
# ------------------------------------------------------------------------------
# GutErrorTracker
# ------------------------------------------------------------------------------
var _current_test_id = GutUtils.NO_TEST
var _mutex = Mutex.new()
var errors = GutUtils.OneToMany.new()
var treat_gut_errors_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
var treat_engine_errors_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
var treat_push_error_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
var disabled = false
# ----------------
#region Private
# ----------------
func _get_stack_data(current_test_name):
var test_entry = {}
var stackTrace = get_stack()
if(stackTrace!=null):
var index = 0
while(index < stackTrace.size() and test_entry == {}):
var line = stackTrace[index]
var function = line.get("function")
if function == current_test_name:
test_entry = stackTrace[index]
else:
index += 1
for i in range(index):
stackTrace.remove_at(0)
return {
"test_entry": test_entry,
"full_stack": stackTrace
}
func _is_error_failable(error : GutTrackedError):
var is_it = false
if(error.handled == false):
if(error.is_gut_error()):
is_it = treat_gut_errors_as == GutUtils.TREAT_AS.FAILURE
elif(error.is_push_error()):
is_it = treat_push_error_as == GutUtils.TREAT_AS.FAILURE
elif(error.is_engine_error()):
is_it = treat_engine_errors_as == GutUtils.TREAT_AS.FAILURE
return is_it
# ----------------
#endregion
#region Godot's Logger Overrides
# ----------------
# Godot's Logger virtual method for errors
func _log_error(function: String, file: String, line: int,
code: String, rationale: String, editor_notify: bool,
error_type: int, script_backtraces: Array[ScriptBacktrace]) -> void:
add_error(function, file, line,
code, rationale, editor_notify,
error_type, script_backtraces)
# Godot's Logger virtual method for any output?
# func _log_message(message: String, error: bool) -> void:
# pass
# ----------------
#endregion
#region Public
# ----------------
func start_test(test_id):
_current_test_id = test_id
func end_test():
_current_test_id = GutUtils.NO_TEST
func did_test_error(test_id=_current_test_id):
return errors.size(test_id) > 0
func get_current_test_errors():
return errors.items.get(_current_test_id, [])
# This should look through all the errors for a test and see if a failure
# should happen based off of flags.
func should_test_fail_from_errors(test_id = _current_test_id):
var to_return = false
if(errors.items.has(test_id)):
var errs = errors.items[test_id]
var index = 0
while(index < errs.size() and !to_return):
var error = errs[index]
to_return = _is_error_failable(error)
index += 1
return to_return
func get_errors_for_test(test_id=_current_test_id):
var to_return = []
if(errors.items.has(test_id)):
to_return = errors.items[test_id].duplicate()
return to_return
# Returns emtpy string or text for errors that occurred during the test that
# should cause failure based on this class' flags.
func get_fail_text_for_errors(test_id=_current_test_id) -> String:
var error_texts = []
if(errors.items.has(test_id)):
for error in errors.items[test_id]:
if(_is_error_failable(error)):
error_texts.append(str('<', error.get_error_type_name(), '>', error.code))
var to_return = ""
for i in error_texts.size():
if(to_return != ""):
to_return += "\n"
to_return += str("[", i + 1, "] ", error_texts[i])
return to_return
func add_gut_error(text) -> GutTrackedError:
if(_current_test_id != GutUtils.NO_TEST):
var data = _get_stack_data(_current_test_id)
if(data.test_entry != {}):
return add_error(_current_test_id, data.test_entry.source, data.test_entry.line,
text, '', false,
GutUtils.GUT_ERROR_TYPE, data.full_stack)
return add_error(_current_test_id, "unknown", -1,
text, '', false,
GutUtils.GUT_ERROR_TYPE, get_stack())
func add_error(function: String, file: String, line: int,
code: String, rationale: String, editor_notify: bool,
error_type: int, script_backtraces: Array) -> GutTrackedError:
if(disabled):
return
_mutex.lock()
var err := GutTrackedError.new()
err.backtrace = script_backtraces
err.code = code
err.rationale = rationale
err.error_type = error_type
err.editor_notify = editor_notify
err.file = file
err.function = function
err.line = line
errors.add(_current_test_id, err)
_mutex.unlock()
return err

View File

@@ -0,0 +1 @@
uid://35kxgqotjmlu

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://c8axnpxc0nrk4"
path="res://.godot/imported/AnonymousPro-Bold.ttf-9d8fef4d357af5b52cd60afbe608aa49.fontdata"
[deps]
source_file="res://addons/gut/fonts/AnonymousPro-Bold.ttf"
dest_files=["res://.godot/imported/AnonymousPro-Bold.ttf-9d8fef4d357af5b52cd60afbe608aa49.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://msst1l2s2s"
path="res://.godot/imported/AnonymousPro-BoldItalic.ttf-4274bf704d3d6b9cd32c4f0754d8c83d.fontdata"
[deps]
source_file="res://addons/gut/fonts/AnonymousPro-BoldItalic.ttf"
dest_files=["res://.godot/imported/AnonymousPro-BoldItalic.ttf-4274bf704d3d6b9cd32c4f0754d8c83d.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Some files were not shown because too many files have changed in this diff Show More