Compare commits

...

91 Commits

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
174 changed files with 16219 additions and 982 deletions

View File

@@ -7,7 +7,7 @@ description: >
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 the Tallow Reach", "flesh out Duncarrow", "someone the
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
@@ -86,12 +86,21 @@ 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 voice and structure of `content/lore/duncarrow.md` — it is the
reference specimen.
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.
@@ -103,10 +112,11 @@ 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 CI enforces (`.github/workflows/content-build.yml`). Run,
from repo root, with the repo venv active (`source .venv/bin/activate`) — the
`content_build` package lives under `tools/`, so `PYTHONPATH=tools` is required:
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/
@@ -114,6 +124,19 @@ PYTHONPATH=tools python -m content_build --check # must exit 0: fresh, valid,
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
@@ -125,11 +148,19 @@ touched those, just confirm they're valid JSON.)
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/lore/canon-roadmap.md`'s Authored/Pending lists if you
added a new bible file or closed out a pending thread.
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.

View File

@@ -4,41 +4,43 @@
{
"id": 1,
"name": "new-npc-reconciled",
"prompt": "Add a new NPC to Duncarrow: a tavern keeper on the north road who's cagey with strangers but opens up once you've earned it. Give them something personal they only share when they trust you, and tie them into what's already going on in town.",
"expected_output": "New npc.* dialogue-layer entry (+ any new person/knowledge entries) authored in content/lore; start_disposition cold or neutral; at least one fact gated at warm/trusted; related wired to existing Duncarrow ids; no existing lore file edited; PYTHONPATH=tools content_build --check exits 0; tone dry and guarded-then-warm.",
"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)",
"New entry's related list references at least one pre-existing Duncarrow id",
"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"
"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": "I want the town militia to actually be honest and investigate the disappearances near the shrine. Rewrite it so the militia does ride out.",
"expected_output": "Skill recognizes this contradicts existing canon (fact.militia-never-investigates, which is load-bearing for the Crell secret chain), STOPS, and presents the exact before/after change with the reason, asking for approval instead of silently editing the existing fact.",
"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 silently edit fact.militia-never-investigates or any existing lore entry",
"Surfaced the contradiction explicitly, naming the affected canon and why it matters (Crell chain)",
"Presented a concrete proposed change and asked for approval before writing"
"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 the elven road-peoples beyond Duncarrow: who they are, a faction or two, and a rumor the player might hear about why they've stopped using the pass. Make it its own thing.",
"expected_output": "A new content/lore/*.md bible file with canon entities (faction/region/etc.) and at least one knowledge entry; related wired to the existing faction.elves stub; build + --check green; canon-roadmap Pending/Authored updated.",
"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 (did not cram into duncarrow.md)",
"Wired at least one new entry's related to the existing faction.elves id",
"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/lore/canon-roadmap.md updated to reflect the new content"
"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

@@ -23,7 +23,7 @@ A bible file is Markdown. Prose between blocks is for human authors — the buil
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/duncarrow.md`.
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`).
@@ -54,7 +54,7 @@ Namespaces `town`, `place`, `region`, `faction`, `person`, `rule`.
- `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 already lives in `duncarrow.md`.
Don't duplicate or remove it; it lives in `mechanics.md`.
### Knowledge — the atoms NPCs reveal
@@ -96,7 +96,8 @@ lever. It answers "how sensitive is this text, where may it live?"
| 4 | The core secret. | Server-only. |
Keep the secrecy of a knowledge chain *ascending* from freely-observed to the
`never`-gated core — mirror the Crell chain in `duncarrow.md` (1→2→2→3→4).
`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
@@ -114,9 +115,9 @@ 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 (Mera Fenn tops out at
`crells-guard-acts-alone`, never `crell-runs-slave-trade` the player infers the
rest). Understanding earned beats understanding handed over.
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
@@ -138,52 +139,75 @@ build failure.
## Worked example
A guarded fisherman who warms up and, if trusted, names who really owns the boats.
A guarded fisherman who warms up and names the coin — and who holds one thing no
disposition ever buys.
```yaml
id: person.old-teague
id: person.specimen-teague
type: person
status: canon
secrecy: 0
related: [town.duncarrow]
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.foreign-coin-on-the-docks
id: fact.specimen-foreign-coin
type: fact
status: canon
secrecy: 2
related: [town.duncarrow, person.old-teague]
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: npc.old-teague
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.old-teague, fact.foreign-coin-on-the-docks]
related:
[person.specimen-teague, fact.specimen-foreign-coin,
secret.specimen-drowned-clerk]
body: >
Same man as person.old-teague; his interactive layer. Curt with strangers, not
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.foreign-coin-on-the-docks, gate: warm}
- {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.
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.old-teague` (secrecy 0, public) and `npc.old-teague` (the layer,
persona server-only) are two entries. The fact is secrecy 2, gated at `warm`.
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
@@ -228,7 +252,7 @@ construction fails loudly.
"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_classes": ["sellsword","assassin","priest"], "luck_modifier": 0 }
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
}
```

View File

@@ -32,9 +32,9 @@ 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 `duncarrow.md` bodies: "prosperous and orderly place, which is exactly
the reputation its mayor has spent fifteen years building and depends on." The
menace is in the plain statement, not in adjectives.
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
@@ -49,7 +49,7 @@ 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 — Mera Fenn is the model). `hostile` is a
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.
@@ -61,7 +61,8 @@ 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. Old Teague's "dryness turns to something almost fond."
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
@@ -86,6 +87,22 @@ If you write companion content, hold these exactly — they carry the register:
- 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

View File

@@ -1,19 +0,0 @@
name: content-build
on:
push:
paths: ["content/**", "tools/content_build/**", ".github/workflows/content-build.yml"]
pull_request:
paths: ["content/**", "tools/content_build/**"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r tools/requirements-dev.txt
- name: Unit tests
run: PYTHONPATH=tools python -m pytest tools/content_build/tests -q
- name: Content freshness + validity
run: PYTHONPATH=tools python -m content_build --check

6
.gitignore vendored
View File

@@ -1,3 +1,9 @@
# ─────────────────────────────────────────────
# Local scratch — never versioned
# ─────────────────────────────────────────────
# Ad-hoc F6 / smoke-test captures. Kept for the working session, not the repo.
/screenshots/
# ─────────────────────────────────────────────
# Godot 4.7 (client/)
# ─────────────────────────────────────────────

View File

@@ -559,6 +559,12 @@ contracts (§12), and Failure UX (§13).
- **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

View File

@@ -12,6 +12,29 @@ 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")
@@ -50,7 +73,7 @@ def render_digest(canon_log: dict) -> str:
descriptor = player.get("luck_descriptor")
if descriptor:
lines.append(f"Fortune: {descriptor}")
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
lines.append(f"Player: {_describe_player(player)}")
lines.append("")
lines.append("Describe the scene as it is now.")
@@ -104,7 +127,7 @@ def render_npc_digest(
lines.append(f"Active quest: {quest.get('name', '')}{quest.get('objective', '')}")
player = canon_log.get("player", {})
lines.append(f"The player is {player.get('name', '')}, a {player.get('class_id', '')}.")
lines.append(f"The player is {_describe_player(player)}.")
lines.append("")
lines.append(f'The player says to you: "{utterance}"')

View File

@@ -2,7 +2,8 @@
"schema_version": 1,
"player": {
"name": "Aldric",
"class_id": "sellsword",
"race_id": "human",
"calling_id": "sellsword",
"luck_descriptor": "Fortune spits on you"
},
"location": { "id": "greywater_docks", "name": "the Greywater docks" },

View File

@@ -68,3 +68,20 @@ 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

@@ -30,7 +30,8 @@ def test_live_npc_speak_fenn():
log = {
"player": {
"name": "Aldric",
"class_id": "sellsword",
"race_id": "human",
"calling_id": "sellsword",
"luck_descriptor": "the dice are kind",
},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},

View File

@@ -4,7 +4,7 @@ import app.npc as npc
from app.ollama_client import ModelError
LOG = {
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"},
"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": [],

View File

@@ -14,7 +14,7 @@ VALID_ORIGIN = {
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_classes": ["sellsword", "assassin", "priest"],
"allowed_callings": ["sellsword", "cutpurse", "bonesetter"],
"luck_modifier": 0,
},
}
@@ -30,9 +30,9 @@ def test_null_start_quest_is_allowed():
assert validate_origin(doc) == []
def test_unknown_class_is_rejected():
def test_unknown_calling_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
doc["build_constraints"]["allowed_classes"] = ["bard"]
doc["build_constraints"]["allowed_callings"] = ["bard"]
assert validate_origin(doc) != []

View File

@@ -22,7 +22,7 @@ def test_digest_has_the_narrative_context():
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 sellsword" in d
assert "Player: Aldric, a human sellsword" in d
assert d.rstrip().endswith("Describe the scene as it is now.")
@@ -42,10 +42,47 @@ def test_narrator_body_is_authored():
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", "class_id": "sellsword", "luck_descriptor": "the dice are kind"},
"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"],

3
clean_superpowers.sh Executable file
View File

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

View File

@@ -1,13 +1,29 @@
[gd_resource type="Theme" format=3]
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_4lwr2"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_eckwq"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="2_4lwr2"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="3_eckwq"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_cqbvn"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"]
content_margin_left = 10.0
content_margin_top = 16.0
content_margin_right = 10.0
content_margin_bottom = 14.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_q5gpk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -23,7 +39,7 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -39,7 +55,7 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -55,7 +71,19 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"]
content_margin_left = 8.0
content_margin_top = 3.0
content_margin_right = 8.0
content_margin_bottom = 3.0
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -71,9 +99,9 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ube3r"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_4kten"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -89,7 +117,7 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -105,7 +133,7 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -121,7 +149,7 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -137,7 +165,7 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -154,7 +182,7 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -170,46 +198,14 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_lw8lk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_dtr07"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
@@ -230,13 +226,45 @@ border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -252,7 +280,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -268,7 +296,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -284,7 +312,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -300,7 +328,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -316,41 +344,53 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txxuc"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
content_margin_left = 10.0
content_margin_top = 16.0
content_margin_right = 10.0
content_margin_bottom = 14.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"]
content_margin_left = 9.0
content_margin_top = 2.0
content_margin_right = 9.0
content_margin_bottom = 2.0
bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wmbj4"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_xdylb"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -360,31 +400,95 @@ border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i2sl5"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_onsig"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_gw0t7"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_88l5g"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f8qac"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_u6gcs"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -400,7 +504,109 @@ corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nufy3"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_0d7vs"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3fnfk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2txdw"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w7qkp"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ta3oh"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ohwwq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ismtf"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gs634"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -417,80 +623,136 @@ corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[resource]
default_font = ExtResource("3_eckwq")
default_font = ExtResource("2_4lwr2")
default_font_size = 18
AbilityCard/base_type = &"PanelContainer"
AbilityCard/styles/panel = SubResource("StyleBoxFlat_cqbvn")
Accent/base_type = &"Label"
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
Accent/font_sizes/font_size = 24
Accent/fonts/font = ExtResource("1_605w4")
CardBody/base_type = &"Label"
CardBody/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
CardBody/font_sizes/font_size = 14
CardBody/fonts/font = ExtResource("2_4lwr2")
CardTitle/base_type = &"Label"
CardTitle/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
CardTitle/font_sizes/font_size = 20
CardTitle/fonts/font = ExtResource("2_4lwr2")
Chip/base_type = &"Button"
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
Chip/font_sizes/font_size = 11
Chip/fonts/font = ExtResource("2_4lwr2")
Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn")
Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk")
Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc")
Chip/styles/pressed = SubResource("StyleBoxFlat_73lme")
Chip/font_sizes/font_size = 12
Chip/fonts/font = ExtResource("3_eckwq")
Chip/styles/focus = SubResource("StyleBoxEmpty_q5gpk")
Chip/styles/hover = SubResource("StyleBoxFlat_bbpmc")
Chip/styles/normal = SubResource("StyleBoxFlat_73lme")
Chip/styles/pressed = SubResource("StyleBoxFlat_js3tr")
ChosenTag/base_type = &"Label"
ChosenTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
ChosenTag/font_sizes/font_size = 11
ChosenTag/fonts/font = ExtResource("3_eckwq")
ChosenTag/styles/normal = SubResource("StyleBoxFlat_ube3r")
DarkPanel/base_type = &"PanelContainer"
DarkPanel/styles/panel = SubResource("StyleBoxFlat_js3tr")
DarkPanel/styles/panel = SubResource("StyleBoxFlat_2tooq")
DockButton/base_type = &"Button"
DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1)
DockButton/styles/focus = SubResource("StyleBoxEmpty_ube3r")
DockButton/styles/hover = SubResource("StyleBoxFlat_2tooq")
DockButton/styles/normal = SubResource("StyleBoxFlat_4kten")
DockButton/styles/pressed = SubResource("StyleBoxFlat_p6wtm")
DockButton/styles/focus = SubResource("StyleBoxEmpty_4kten")
DockButton/styles/hover = SubResource("StyleBoxFlat_p6wtm")
DockButton/styles/normal = SubResource("StyleBoxFlat_0fjdt")
DockButton/styles/pressed = SubResource("StyleBoxFlat_ns3kg")
Heading/base_type = &"Label"
Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
Heading/font_sizes/font_size = 34
Heading/fonts/font = ExtResource("3_eckwq")
Heading/fonts/font = ExtResource("2_4lwr2")
ItemTile/base_type = &"PanelContainer"
ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt")
ItemTile/styles/panel = SubResource("StyleBoxFlat_jly1r")
ItemTileEmpty/base_type = &"PanelContainer"
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg")
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_lw8lk")
Mono/base_type = &"Label"
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
Mono/font_sizes/font_size = 13
Mono/fonts/font = ExtResource("2_4lwr2")
Mono/font_sizes/font_size = 14
Mono/fonts/font = ExtResource("3_eckwq")
ParchmentButton/base_type = &"Button"
ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_jly1r")
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_lw8lk")
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nh0e7")
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_dtr07")
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nmowx")
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_nh0e7")
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_dtr07")
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nmowx")
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_oi067")
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_ukblj")
ParchmentCard/base_type = &"PanelContainer"
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067")
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_utt73")
ParchmentInset/base_type = &"PanelContainer"
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj")
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_5u8fh")
PrimaryCTA/base_type = &"Button"
PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73")
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh")
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh")
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_6y6ak")
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_p0qbh")
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_6y6ak")
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_txxuc")
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_7d6ug")
PrimaryCard/base_type = &"PanelContainer"
PrimaryCard/styles/panel = SubResource("StyleBoxFlat_najyk")
PrimaryTag/base_type = &"Label"
PrimaryTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
PrimaryTag/font_sizes/font_size = 10
PrimaryTag/fonts/font = ExtResource("3_eckwq")
PrimaryTag/styles/normal = SubResource("StyleBoxFlat_jtb1k")
RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
RichTextLabel/font_sizes/normal_font_size = 20
RichTextLabel/font_sizes/normal_font_size = 22
RichTextLabel/fonts/italics_font = ExtResource("4_i0b23")
RichTextLabel/fonts/normal_font = ExtResource("3_eckwq")
RichTextLabel/fonts/normal_font = ExtResource("2_4lwr2")
SectionLabel/base_type = &"Label"
SectionLabel/colors/font_color = Color(0.5411765, 0.46666667, 0.28235295, 1)
SectionLabel/font_sizes/font_size = 14
SectionLabel/fonts/font = ExtResource("3_eckwq")
SelectCard/base_type = &"Button"
SelectCard/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
SelectCard/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
SelectCard/styles/disabled = SubResource("StyleBoxFlat_wmbj4")
SelectCard/styles/focus = SubResource("StyleBoxEmpty_xdylb")
SelectCard/styles/hover = SubResource("StyleBoxFlat_pjnll")
SelectCard/styles/normal = SubResource("StyleBoxFlat_klelq")
SelectCard/styles/pressed = SubResource("StyleBoxFlat_i2sl5")
SkillChip/base_type = &"Button"
SkillChip/colors/font_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
SkillChip/colors/font_disabled_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
SkillChip/colors/font_hover_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
SkillChip/colors/font_pressed_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
SkillChip/font_sizes/font_size = 14
SkillChip/fonts/font = ExtResource("3_eckwq")
SkillChip/styles/disabled = SubResource("StyleBoxFlat_onsig")
SkillChip/styles/focus = SubResource("StyleBoxEmpty_gw0t7")
SkillChip/styles/hover = SubResource("StyleBoxFlat_88l5g")
SkillChip/styles/normal = SubResource("StyleBoxFlat_f8qac")
SkillChip/styles/pressed = SubResource("StyleBoxFlat_u6gcs")
SkillChipGranted/base_type = &"Button"
SkillChipGranted/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
SkillChipGranted/colors/font_disabled_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
SkillChipGranted/font_sizes/font_size = 14
SkillChipGranted/fonts/font = ExtResource("3_eckwq")
SkillChipGranted/styles/disabled = SubResource("StyleBoxFlat_nufy3")
SkillChipGranted/styles/focus = SubResource("StyleBoxEmpty_0d7vs")
SkillChipGranted/styles/hover = SubResource("StyleBoxFlat_nufy3")
SkillChipGranted/styles/normal = SubResource("StyleBoxFlat_nufy3")
SkillChipGranted/styles/pressed = SubResource("StyleBoxFlat_nufy3")
Tab/base_type = &"Button"
Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc")
Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug")
Tab/styles/normal = SubResource("StyleBoxFlat_najyk")
Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k")
Tab/styles/focus = SubResource("StyleBoxEmpty_3fnfk")
Tab/styles/hover = SubResource("StyleBoxFlat_2txdw")
Tab/styles/normal = SubResource("StyleBoxFlat_w7qkp")
Tab/styles/pressed = SubResource("StyleBoxFlat_rthnr")
TabActive/base_type = &"Button"
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4")
TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb")
TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll")
TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq")
TabActive/styles/focus = SubResource("StyleBoxEmpty_ta3oh")
TabActive/styles/hover = SubResource("StyleBoxFlat_ohwwq")
TabActive/styles/normal = SubResource("StyleBoxFlat_ismtf")
TabActive/styles/pressed = SubResource("StyleBoxFlat_gs634")
TitleKicker/base_type = &"Label"
TitleKicker/colors/font_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
TitleKicker/font_sizes/font_size = 13
TitleKicker/fonts/font = ExtResource("2_4lwr2")
TitleKicker/fonts/font = ExtResource("3_eckwq")
TitleLogo/base_type = &"Label"
TitleLogo/colors/font_color = Color(0.9098039, 0.8666667, 0.78431374, 1)
TitleLogo/font_sizes/font_size = 116
TitleLogo/fonts/font = ExtResource("3_eckwq")
TitleLogo/fonts/font = ExtResource("2_4lwr2")

View File

@@ -17,7 +17,15 @@ Cross-cutting design (anything touching both client and api) goes in the root [`
Screens are **authored editor-first** — the layout lives in the `.tscn`, not in
code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why;
this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn`
+ `scripts/ui/shell/*.gd`.
+ `scripts/ui/shell/*.gd`, and `scenes/creation/CharacterCreation.tscn` +
`scripts/ui/creation/{character_creation,creation_draft,creation_copy}.gd` (M4-b).
- **Clickable cards are Buttons with `mouse_filter = 2` on every child.** A card that
holds a name, a blurb and a trait line is a `Button` (Button-base variation, so
`normal`/`hover`/`pressed` actually skin it) with a `VBoxContainer` of Labels inside.
Every child needs `mouse_filter = 2` (IGNORE) or the Label swallows the click and the
card never toggles. `pressed` on a `toggle_mode` Button *is* the "chosen" state — no
script-side stylebox swapping.
- **The `.tscn` owns the tree.** Author every node in the editor: containers,
panels, labels, buttons, art-slot placeholders. Assign the shared theme on the

View File

@@ -11,19 +11,15 @@ config_version=5
[application]
config/name="coc-rpg"
config/version="0.01-alpha"
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
run/main_scene="res://scenes/title/TitleScreen.tscn"
config/version="0.01-alpha"
run/main_scene="res://scenes/Main.tscn"
config/features=PackedStringArray("4.7")
[display]
; Design/logical canvas stays 1920x1080 (charter §16, mockups authored here).
window/size/viewport_width=1920
window/size/viewport_height=1080
; Open a smaller 1600x900 window that scales the 1920x1080 canvas down, so it
; doesn't fill the whole screen. canvas_items + keep aspect does the scaling.
window/size/window_width_override=1600
window/size/window_height_override=900
window/size/window_width_override=1280
window/size/window_height_override=720
window/stretch/mode="canvas_items"
window/stretch/aspect="keep"

6
client/scenes/Main.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
[node name="Main" type="Node"]
script = ExtResource("1")

File diff suppressed because it is too large Load Diff

View File

@@ -154,10 +154,10 @@ layout_mode = 2
theme_type_variation = &"Mono"
text = "MP 18 / 30"
[node name="Gold" type="Label" parent="Split/World/CommandBar/Vitals"]
[node name="Purse" type="Label" parent="Split/World/CommandBar/Vitals"]
layout_mode = 2
theme_type_variation = &"Mono"
text = "◈ 1240 gp"
text = "◈ 3s 47c"
[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"]
layout_mode = 2

View File

@@ -99,17 +99,17 @@ static func from_dict(d: Dictionary) -> CanonLog:
if v != SCHEMA_VERSION:
push_error("unsupported canon_log schema_version: %s" % v)
return null
var log := CanonLog.new()
log.player = LogPlayer.from_dict(d.get("player", {}))
log.location = LogLocation.from_dict(d.get("location", {}))
var out := CanonLog.new()
out.player = LogPlayer.from_dict(d.get("player", {}))
out.location = LogLocation.from_dict(d.get("location", {}))
for m in d.get("party", []):
log.party.append(PartyMember.from_dict(m))
out.party.append(PartyMember.from_dict(m))
for e in d.get("recent_events", []):
log.push_event(e)
out.push_event(e)
for f in d.get("established_facts", []):
log.add_fact(f)
out.add_fact(f)
for q in d.get("active_quests", []):
log.active_quests.append(Quest.from_dict(q))
out.active_quests.append(Quest.from_dict(q))
for h in d.get("humiliations", []):
log.humiliations.append(Humiliation.from_dict(h))
return log
out.humiliations.append(Humiliation.from_dict(h))
return out

View File

@@ -1,33 +1,46 @@
class_name LogPlayer
extends RefCounted
## A canon-log player row. Carries ONLY name, class_id, luck_descriptor (§7):
## no numeric Luck, no stats — those live in GameState and never reach the AI.
const CLASSES := ["sellsword", "assassin", "priest"]
## A canon-log player row. Carries ONLY name, race_id, calling_id, luck_descriptor
## (§7): no numeric Luck, no attributes — those live in GameState and never reach the
## AI. race_id is here because the Narrator and NPCs must be able to describe the
## player ("a beastfolk cutpurse"); the mechanical sheet must not.
var name: String
var class_id: String
var race_id: String
var calling_id: String
var luck_descriptor: String
func _init(p_name := "", p_class_id := "", p_luck_descriptor := "") -> void:
func _init(p_name := "", p_race_id := "", p_calling_id := "", p_luck_descriptor := "") -> void:
name = p_name
luck_descriptor = p_luck_descriptor
if p_class_id != "":
set_class_id(p_class_id)
if p_race_id != "":
set_race_id(p_race_id)
if p_calling_id != "":
set_calling_id(p_calling_id)
func set_class_id(v: String) -> bool:
if v not in CLASSES:
push_error("invalid class_id: %s" % v)
func set_race_id(v: String) -> bool:
if not Races.exists(v):
push_error("invalid race_id: %s" % v)
return false
class_id = v
race_id = v
return true
func set_calling_id(v: String) -> bool:
if not Callings.exists(v):
push_error("invalid calling_id: %s" % v)
return false
calling_id = v
return true
func to_dict() -> Dictionary:
return {"name": name, "class_id": class_id, "luck_descriptor": luck_descriptor}
return {"name": name, "race_id": race_id, "calling_id": calling_id,
"luck_descriptor": luck_descriptor}
static func from_dict(d: Dictionary) -> LogPlayer:
return LogPlayer.new(d.get("name", ""), d.get("class_id", ""), d.get("luck_descriptor", ""))
return LogPlayer.new(d.get("name", ""), d.get("race_id", ""),
d.get("calling_id", ""), d.get("luck_descriptor", ""))

View File

@@ -8,6 +8,8 @@ var locations: Dictionary = {}
var npcs: Dictionary = {}
var quests: Dictionary = {}
var items: Dictionary = {}
var callings: Dictionary = {}
var races: Dictionary = {}
var canon_entities: Dictionary = {}
var topics: Dictionary = {}
@@ -31,6 +33,8 @@ func load_from(content_root: String) -> void:
npcs = _load_dir(world.path_join("npcs"))
quests = _load_dir(world.path_join("quests"))
items = _load_dir(world.path_join("items"))
callings = _load_dir(world.path_join("callings"))
races = _load_dir(world.path_join("races"))
canon_entities = _load_dir(world.path_join("canon"))
topics = _load_dir(world.path_join("topics"))
@@ -39,7 +43,8 @@ func _load_dir(dir_path: String) -> Dictionary:
var out: Dictionary = {}
var dir := DirAccess.open(dir_path)
if dir == null:
push_error("content dir not found: %s" % dir_path)
# A missing content dir means "no content of this kind authored yet",
# not an error — e.g. content/world/topics/ before any gated content exists.
return out
for file in dir.get_files():
if not file.ends_with(".json"):
@@ -60,6 +65,10 @@ func has_location(id: String) -> bool: return locations.has(id)
func has_npc(id: String) -> bool: return npcs.has(id)
func has_quest(id: String) -> bool: return quests.has(id)
func has_item(id: String) -> bool: return items.has(id)
func calling(id: String) -> Dictionary: return callings.get(id, {})
func race(id: String) -> Dictionary: return races.get(id, {})
func has_calling(id: String) -> bool: return callings.has(id)
func has_race(id: String) -> bool: return races.has(id)
func canon(id: String) -> Dictionary: return canon_entities.get(id, {})
func topic(id: String) -> Dictionary: return topics.get(id, {})
func has_canon(id: String) -> bool: return canon_entities.has(id)

View File

@@ -59,7 +59,7 @@ func _on_narrate_pressed() -> void:
func _build_scene_log() -> CanonLog:
var log := CanonLog.new()
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
log.player = LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you")
log.set_location("greywater_docks", "the Greywater docks")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))

View File

@@ -39,7 +39,7 @@ func _ready() -> void:
# Build the player through LogPlayer so luck_descriptor is non-empty — the
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
# the digest surfaces to the NPC. An empty string here 422s the request.
_canon_log.player = LogPlayer.new("Aldric", "sellsword", "the dice are kind today")
_canon_log.player = LogPlayer.new("Aldric", "human", "sellsword", "the dice are kind today")
# Deliberately no find_the_ledger quest here — leave it un-started so Fenn
# can offer_quest it during the conversation.

93
client/scripts/main.gd Normal file
View File

@@ -0,0 +1,93 @@
class_name Main
extends Node
## The single home for the game's screen sequence: Title → Creation → Shell.
## Script-first by design — a pure coordinator with no Control layout, so ADR 0001's
## editor-first rule does not apply (that rule governs layout scenes). It owns one
## ContentDB + one DmService and swaps screens via instantiate → inject → add_child,
## the injection seam change_scene_to_file cannot provide.
##
## §2: the flow carries only plain data; NewGame.construct re-rolls the character
## from the creation seed itself. ⛨ saga: _show_creation and _start_campaign are
## separate, so a fourth entry path is an added arm, not surgery on existing edges.
const HttpTransport = preload("res://scripts/net/http_transport.gd")
const TITLE := preload("res://scenes/title/TitleScreen.tscn")
const CREATION := preload("res://scenes/creation/CharacterCreation.tscn")
const SHELL := preload("res://scenes/shell/MainWindowShell.tscn")
# Injection seams — set BETWEEN instantiate() and add_child(), as the shell/creation
# do for their own deps. _ready builds the real ones when nothing was supplied.
var world: ContentDB = null
var service: DmService = null
var origin: Dictionary = {}
var _current: Node = null
var _http: HTTPRequest
func _ready() -> void:
if world == null:
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
if origin.is_empty():
origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
if service == null:
_http = HTTPRequest.new()
add_child(_http)
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
_show_title()
func _show(next: PackedScene, inject: Callable) -> void:
## The one swap primitive. inject runs after instantiate() (which does NOT run
## _ready) and before add_child (which does) — the injection window. The outgoing
## screen is queue_free'd (deferred): a signal handler must never free the node
## still executing up its own call stack.
var old := _current
var screen := next.instantiate()
inject.call(screen)
_current = screen
add_child(screen)
if old != null:
old.queue_free()
func _show_title() -> void:
_show(TITLE, func(s): s.menu_activated.connect(_on_menu))
func _show_creation() -> void:
_show(CREATION, func(s):
s.world = world
s.origin = origin
s.creation_confirmed.connect(_start_campaign))
func _show_shell(log: CanonLog, state: GameState) -> void:
_show(SHELL, func(s):
s.service = service
s.injected_log = log
s.injected_state = state)
func _on_menu(id: StringName) -> void:
match id:
&"new_game":
_show_creation()
&"quit":
get_tree().quit()
_:
pass # continue / load land in later milestones (M9); inert for now
func _start_campaign(creation: Dictionary) -> void:
## Takes a plain Dictionary — does NOT assume it came from the creation screen, so
## a saga can synthesize one and call this directly (skipping creation).
var result := NewGame.construct(origin, world, creation)
if not result.get("ok", false):
# Unreachable via the UI (the CTA gates on validate), but §2 forbids trusting
# the caller and §13 forbids surfacing an error. Log and stay put.
push_error("Main: construct rejected a creation: %s" % str(result.get("errors", [])))
return
_show_shell(result["log"], result["state"])

View File

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

View File

@@ -1,73 +1,223 @@
class_name NewGame
extends RefCounted
## New-game construction (spec "New-game construction"). Reads three immutable
## inputs (origin, world, creation) and writes two products (CanonLog, GameState).
## Nothing flows back up — charter §2 on the client. Validation is front-loaded:
## a broken origin or illegal class fails here, loudly, not three scenes later.
## New-game construction. Reads three immutable inputs (origin, world, creation) and
## writes two products (CanonLog, GameState). Nothing flows back up — charter §2.
##
## 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 always yields the same character (§10 — "the difference between a bug you can
## reproduce and a bug you cannot"). The whole character is reproducible from four
## primitives: seed, race_id, calling_id, spend.
##
## `creation` stays a plain Dictionary: a saga (later) synthesizes one and skips the
## UI entirely, so the creation SCENE must never become the only thing that can make it.
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary:
var errors: Array = []
const SPEND_POOL := 3
# 1. content integrity — every referenced id must resolve in world content.
for ref in world.unresolved_refs(origin):
errors.append("unresolved ref: %s" % ref)
# 2. build constraints — chosen class must be allowed by the origin.
var bc: Dictionary = origin.get("build_constraints", {})
var allowed: Array = bc.get("allowed_classes", [])
var class_id: String = creation.get("class_id", "")
if class_id not in allowed:
errors.append("class not allowed by origin: %s" % class_id)
if creation.get("name", "").strip_edges() == "":
errors.append("player name is required")
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionary:
var errors := _validate(origin, world, creation)
if not errors.is_empty():
return {"ok": false, "errors": errors, "log": null, "state": null}
# 3. roll Luck (numeric -> state only) and 4. the stat block (data only).
var rng := RandomNumberGenerator.new()
rng.seed = int(creation.get("seed", 0))
var state := GameState.new()
var sheet := CharacterSheet.new()
sheet.race_id = str(creation.get("race_id", ""))
sheet.calling_id = str(creation.get("calling_id", ""))
# The RNG ORDER is part of the contract: five attributes in a fixed order, then
# Luck. Change the order and every existing seed produces a different character.
# roll_attributes() is the ONE implementation — the creation screen calls the same
# function to DISPLAY the roll, so what the player saw is what he gets (§2/§10).
var attrs: Dictionary = _roll_five(rng)
var bc: Dictionary = origin.get("build_constraints", {})
state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
state.luck = state.luck_base
state.stats = {
"str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng),
"fth": _roll_stat(rng), "mag": _roll_stat(rng),
}
# 5. assemble the canon log.
var log := CanonLog.new()
log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor())
var spend: Dictionary = creation.get("spend", {})
for stat in spend:
attrs[stat] = int(attrs[stat]) + int(spend[stat])
sheet.attributes = attrs
# Proficiencies: what the race grants + what the player picked + the human's bonus.
# All land in one list, so is_proficient() has exactly one thing to read.
var skills: Array = []
skills.append_array(Races.granted_skills(sheet.race_id))
skills.append_array(creation.get("skills", []))
var bonus := str(creation.get("bonus_skill", ""))
if bonus != "":
skills.append(bonus)
sheet.bonus_skill = bonus
sheet.skills = skills
sheet.hp = sheet.max_hp() # the character starts whole
sheet.mp = sheet.max_mp()
state.sheet = sheet
var canon := CanonLog.new()
canon.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id,
sheet.calling_id, state.luck_descriptor())
var loc: Dictionary = world.location(origin["start_location_id"])
log.set_location(loc.get("id", ""), loc.get("name", ""))
canon.set_location(loc.get("id", ""), loc.get("name", ""))
var overrides: Dictionary = origin.get("disposition_overrides", {})
var companion_ids := {}
for c in world.companions():
companion_ids[c["id"]] = true
log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
# non-companion overrides go to game state, never the log (spec §5 / charter §6).
canon.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
for npc_id in overrides:
if not companion_ids.has(npc_id):
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
for line in origin.get("situation", []):
log.push_event(line)
canon.push_event(line)
for fact in origin.get("opening_facts", []):
log.add_fact(fact)
canon.add_fact(fact)
var quest_id: Variant = origin.get("start_quest_id", null)
if quest_id != null:
var qd: Dictionary = world.quest(quest_id)
log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", qd.get("objective", "")))
canon.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active",
qd.get("objective", "")))
# inventory grants -> game state (NOT the log). Charter §2.
for grant in origin.get("inventory_grants", []):
state.add_item(grant.get("item_id", ""), int(grant.get("qty", 0)))
# inventory grants -> game state (NOT the log). §2. grant() routes currency to the
# purse; everything else to the inventory dict.
for g in origin.get("inventory_grants", []):
state.grant(g.get("item_id", ""), int(g.get("qty", 0)))
return {"ok": true, "errors": [], "log": log, "state": state}
return {"ok": true, "errors": [], "log": canon, "state": state}
static func _roll_stat(rng: RandomNumberGenerator) -> int:
# 3d6 placeholder; the stat block is data-only in this plan. TUNABLE.
return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
static func roll_attributes(character_seed: int) -> Dictionary:
## PUBLIC and PURE. The creation screen calls this to SHOW the roll; construct
## calls it to BUILD it. One implementation, so the two can never disagree.
## NO DEFAULT PARAMETERS (traps.md #2) — a defaulted arg turns a wrong-arity
## call into a silent miscompile that binds the wrong value into the wrong slot.
var rng := RandomNumberGenerator.new()
rng.seed = character_seed
return _roll_five(rng)
static func _roll_five(rng: RandomNumberGenerator) -> Dictionary:
var attrs: Dictionary = {}
for stat in Attributes.IDS:
attrs[stat] = _roll_attribute(rng)
return attrs
static func validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
## PUBLIC. The creation screen gates its CTA on this, so the player cannot press
## his way into a rejection. construct() still calls it and still does not trust
## its caller — the screen is a caller like any other. NO DEFAULT PARAMETERS.
return _validate(origin, world, creation)
static func _roll_attribute(rng: RandomNumberGenerator) -> int:
# 3d6 with a HARD FLOOR of 8. The floor clamps the die, not the player: straight
# 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character who is bad
# at the one thing he is FOR is not grit — it is the "fine" §7 forbids, paid for
# twenty hours.
var roll := rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
return maxi(8, roll)
static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
var errors: Array = []
for ref in world.unresolved_refs(origin):
errors.append("unresolved ref: %s" % ref)
if str(creation.get("name", "")).strip_edges() == "":
errors.append("player name is required")
if not creation.has("seed") or typeof(creation["seed"]) != TYPE_INT:
errors.append("seed is required and must be an integer")
var race_id: String = str(creation.get("race_id", ""))
if not Races.exists(race_id):
errors.append("unknown race: %s" % race_id)
var calling_id: String = str(creation.get("calling_id", ""))
if not Callings.exists(calling_id):
errors.append("unknown calling: %s" % calling_id)
else:
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
if calling_id not in allowed:
errors.append("calling not allowed by origin: %s" % calling_id)
# Guard container SHAPES before any statically-typed call below touches them.
# A JSON round-trip can hand back {"spend": null} or {"skills": "stealth"} —
# without this check, assigning that into a typed Dictionary/Array parameter
# throws a GDScript runtime error INSIDE construct() instead of landing in the
# front-loaded {"ok": false, "errors": [...]} the spec promises.
if typeof(creation.get("spend", {})) != TYPE_DICTIONARY:
errors.append("spend must be an object")
else:
errors.append_array(_validate_spend(creation.get("spend", {})))
if typeof(creation.get("skills", [])) != TYPE_ARRAY:
errors.append("skills must be an array")
elif Races.exists(race_id) and Callings.exists(calling_id):
errors.append_array(_validate_skills(creation, race_id, calling_id))
return errors
static func _validate_spend(spend: Dictionary) -> Array:
var errors: Array = []
var total := 0
for stat in spend:
# LCK is not merely hidden at creation — it is unspendable (§7).
if not Attributes.exists(str(stat)):
errors.append("cannot spend on '%s' — not an attribute" % stat)
continue
if typeof(spend[stat]) != TYPE_INT:
errors.append("spend value for '%s' must be an integer" % stat)
continue
var v := int(spend[stat])
if v < 0:
errors.append("spend is additive only: %s is negative" % stat)
continue
total += v
if total > SPEND_POOL:
errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total])
return errors
static func _validate_skills(creation: Dictionary, race_id: String, calling_id: String) -> Array:
var errors: Array = []
var picks: Array = creation.get("skills", [])
var granted: Array = Races.granted_skills(race_id)
var pool: Array = Callings.skill_pool(calling_id)
var want := Callings.skill_count(calling_id)
if picks.size() != want:
errors.append("%s picks %d skills, got %d" % [calling_id, want, picks.size()])
var seen := {}
for s in picks:
if s in seen:
errors.append("duplicate skill pick: %s" % s)
seen[s] = true
if s not in pool:
errors.append("%s is not in the %s pool" % [s, calling_id])
if s in granted:
errors.append("%s is already granted by %s — the pick buys nothing" % [s, race_id])
var bonus := str(creation.get("bonus_skill", ""))
if Races.wants_bonus_skill(race_id):
if bonus == "":
errors.append("%s must choose a bonus skill" % race_id)
elif not Skills.exists(bonus):
errors.append("unknown bonus skill: %s" % bonus)
elif bonus in picks or bonus in granted:
errors.append("bonus skill %s is already proficient" % bonus)
elif bonus != "":
errors.append("%s does not get a bonus skill" % race_id)
return errors

View File

@@ -11,9 +11,24 @@ const HOSTILE_FLOOR := -100
static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void:
# ONE RULE: a move applies AT MOST ONCE PER REPLY. The tag extractor returns
# every occurrence and the validator is a membership test, so without this a
# model repeating a tag N times picks an amount in unary — the AI choosing a
# number, which is the §2 breach the bounded vocabulary exists to prevent.
# It is worth the same for real items: three give_item(amulet) tags are one
# amulet, not three.
var applied := {} # "name(id)" -> true
var dispo_total := 0 # the reply's NET disposition swing, capped at MAX_DELTA
for move in moves:
var name: String = move.get("name", "")
var args: Array = move.get("args", [])
# MoveValidator.ID_MOVES is the one list of moves that name a thing — those
# are the ones a (name, id) key can dedupe. Reused, not restated.
if name in MoveValidator.ID_MOVES and not args.is_empty():
var key := "%s(%s)" % [name, str(args[0])]
if applied.has(key):
continue
applied[key] = true
match name:
"offer_quest":
var q: String = str(args[0])
@@ -23,14 +38,23 @@ static func apply(moves: Array, game_state, canon_log, content_db, npc_id: Strin
game_state.mark_revealed(str(args[0]))
"give_item":
var i: String = str(args[0])
game_state.add_item(i, 1)
game_state.mark_gift_given(i)
game_state.grant(i, 1)
if not Currency.is_currency(i):
game_state.mark_gift_given(i) # the one-shot gate is for real items only
"accept_item":
game_state.remove_item(str(args[0]), 1)
game_state.take(str(args[0]), 1)
"adjust_disposition":
# MAX_DELTA caps the reply's NET swing, not each tag — clamping per
# tag let three +15s move standing by 45, which is exactly the
# "wholesale swing" the constant is here to forbid. Applied inside
# the loop (not batched at the end) so emission order still holds
# and a later become_hostile floors what came before it.
var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA)
var capped: int = clampi(dispo_total + delta, -MAX_DELTA, MAX_DELTA)
var allowed: int = capped - dispo_total
dispo_total = capped
var cur: int = int(game_state.npc_dispositions.get(npc_id, 0))
game_state.set_npc_disposition(npc_id, cur + delta)
game_state.set_npc_disposition(npc_id, cur + allowed)
"become_hostile":
game_state.set_npc_disposition(npc_id, HOSTILE_FLOOR)
_:

View File

@@ -21,12 +21,20 @@ static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Arra
moves.append("reveal(%s)" % t)
for i in caps.get("giveable_items", []):
if not game_state.is_gift_given(i):
# Currency is exempt from the one-shot gift gate — an NPC can pay you twice.
if Currency.is_currency(i) or not game_state.is_gift_given(i):
moves.append("give_item(%s)" % i)
for item_id in game_state.inventory.keys():
if int(game_state.inventory[item_id]) > 0:
moves.append("accept_item(%s)" % item_id)
# Money is not in the inventory dict, so it needs its own offer. Only the
# denominations the purse can actually pay: an NPC cannot ask a party holding
# 47c for a gold coin.
for denom in Currency.VALUES:
if game_state.purse_copper >= Currency.value(denom):
moves.append("accept_item(%s)" % denom)
moves.append_array(UNIVERSAL)
return moves

View File

@@ -0,0 +1,15 @@
class_name Attributes
extends RefCounted
## The five attribute rows and the one modifier formula. LCK is deliberately NOT
## here — it is never an attribute row, never a save, never a skill (§7). It lives
## in GameState.luck.
const IDS := ["str", "dex", "con", "fth", "mag"]
static func exists(id: String) -> bool:
return id in IDS
static func modifier(score: int) -> int:
return floori((score - 10) / 2.0)

View File

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

View File

@@ -0,0 +1,93 @@
class_name Callings
extends RefCounted
## The 7 callings (races-and-classes spec §4). A calling is what a VILLAGE calls you,
## not what a rulebook calls you — Cutpurse, not Assassin; Bonesetter, not Priest.
##
## The skill POOLS are this spec's addition: the races/classes spec said only "drawn
## from the class's stat-appropriate slice" and never enumerated them. Each pool is the
## calling's primary-stat skills plus a small adjacent slice, always larger than the
## pick count so the choice is real.
##
## L1 talents are ID STUBS. M5 implements them. armor is a proficiency CATEGORY; real
## armor is an item (M7).
const IDS := ["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"]
const TABLE := {
"sellsword": {
"primary": "str", "hit_die": 10, "saves": ["str", "con"],
"skill_pool": ["athletics", "intimidation", "endurance", "perception"], "skill_count": 2,
"casting_stat": "", "armor": "heavy", "talent": "second_wind",
},
"reaver": {
"primary": "str", "hit_die": 12, "saves": ["str", "con"],
"skill_pool": ["athletics", "intimidation", "endurance", "acrobatics"], "skill_count": 2,
"casting_stat": "", "armor": "medium", "talent": "blood_fury",
},
"cutpurse": {
"primary": "dex", "hit_die": 8, "saves": ["dex", "mag"],
"skill_pool": ["stealth", "sleight_of_hand", "acrobatics", "perception", "athletics", "intimidation"],
"skill_count": 4,
"casting_stat": "", "armor": "light", "talent": "backstab",
},
"trapper": {
"primary": "dex", "hit_die": 10, "saves": ["dex", "con"],
"skill_pool": ["stealth", "acrobatics", "perception", "endurance", "athletics"], "skill_count": 3,
"casting_stat": "", "armor": "light", "talent": "set_snare",
},
"hedge_mage": {
"primary": "mag", "hit_die": 6, "saves": ["mag", "fth"],
"skill_pool": ["sorcery", "perception", "sleight_of_hand", "faith_lore"], "skill_count": 2,
"casting_stat": "mag", "armor": "none", "talent": "hexbolt",
},
"bonesetter": {
"primary": "fth", "hit_die": 8, "saves": ["fth", "con"],
"skill_pool": ["faith_lore", "perception", "endurance", "intimidation"], "skill_count": 2,
"casting_stat": "fth", "armor": "medium", "talent": "mend",
},
"bloodsworn": {
"primary": "fth", "hit_die": 8, "saves": ["fth", "mag"],
"skill_pool": ["faith_lore", "sorcery", "intimidation", "perception"], "skill_count": 2,
"casting_stat": "fth", "armor": "light", "talent": "pact_mark",
},
}
static func exists(id: String) -> bool:
return TABLE.has(id)
static func primary(id: String) -> String:
return str(TABLE.get(id, {}).get("primary", ""))
static func hit_die(id: String) -> int:
return int(TABLE.get(id, {}).get("hit_die", 0))
static func saves(id: String) -> Array:
return TABLE.get(id, {}).get("saves", [])
static func skill_pool(id: String) -> Array:
return TABLE.get(id, {}).get("skill_pool", [])
static func skill_count(id: String) -> int:
return int(TABLE.get(id, {}).get("skill_count", 0))
static func casting_stat(id: String) -> String:
return str(TABLE.get(id, {}).get("casting_stat", ""))
static func is_caster(id: String) -> bool:
return casting_stat(id) != ""
static func armor(id: String) -> String:
return str(TABLE.get(id, {}).get("armor", ""))
static func talent(id: String) -> String:
return str(TABLE.get(id, {}).get("talent", ""))

View File

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

View File

@@ -0,0 +1,60 @@
class_name Races
extends RefCounted
## The 4 races (races-and-classes spec §3). FEATURES ONLY — no race touches an
## attribute score, which is what keeps the roll + spend pure.
##
## nightsight / claws / keen_scent are FLAGS, not numbers. Combat (M5) and the DM's
## exploration checks consume them; creation only stores them.
##
## The dwarf's +2 is SITUATIONAL (vs poison and the affliction track), so it is NOT
## a flat save bonus — baking a conditional into a flat number is how a sheet starts
## lying. It is exposed separately as poison_save_bonus().
const IDS := ["human", "elf", "dwarf", "beastfolk"]
const TABLE := {
"human": {
"save_bonus": 1, "granted_skills": [], "bonus_skill_choice": true,
"poison_save_bonus": 0,
"flags": {"nightsight": false, "claws": false, "keen_scent": false},
},
"elf": {
"save_bonus": 0, "granted_skills": ["perception"], "bonus_skill_choice": false,
"poison_save_bonus": 0,
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
},
"dwarf": {
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
"poison_save_bonus": 2,
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
},
"beastfolk": {
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
"poison_save_bonus": 0,
"flags": {"nightsight": false, "claws": true, "keen_scent": true},
},
}
static func exists(id: String) -> bool:
return TABLE.has(id)
static func save_bonus(id: String) -> int:
return int(TABLE.get(id, {}).get("save_bonus", 0))
static func granted_skills(id: String) -> Array:
return TABLE.get(id, {}).get("granted_skills", [])
static func wants_bonus_skill(id: String) -> bool:
return bool(TABLE.get(id, {}).get("bonus_skill_choice", false))
static func poison_save_bonus(id: String) -> int:
return int(TABLE.get(id, {}).get("poison_save_bonus", 0))
static func flag(id: String, name: String) -> bool:
return bool(TABLE.get(id, {}).get("flags", {}).get(name, false))

View File

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

View File

@@ -0,0 +1,28 @@
class_name Skills
extends RefCounted
## The 9 skills and the attribute each is governed by (races-and-classes spec §2).
## Perception sits under FTH, not a standalone WIS — §8's FTH/MAG replace WIS/INT,
## so a Bonesetter is also the party's best lookout. LCK owns no skill (§7).
const BY_ATTRIBUTE := {
"athletics": "str",
"intimidation": "str",
"stealth": "dex",
"sleight_of_hand": "dex",
"acrobatics": "dex",
"endurance": "con",
"perception": "fth",
"faith_lore": "fth",
"sorcery": "mag",
}
const IDS := ["athletics", "intimidation", "stealth", "sleight_of_hand", "acrobatics",
"endurance", "perception", "faith_lore", "sorcery"]
static func exists(skill: String) -> bool:
return BY_ATTRIBUTE.has(skill)
static func attribute(skill: String) -> String:
return str(BY_ATTRIBUTE.get(skill, ""))

View File

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

View File

@@ -0,0 +1,94 @@
class_name CharacterSheet
extends RefCounted
## The character, as state (§2). STORES only what was rolled or chosen, plus the two
## numbers that genuinely mutate in play (current hp/mp). Everything else is DERIVED —
## so a derived number can never drift from the inputs that produced it, and M5's level
## curve becomes a function change rather than a stored-field migration.
##
## LCK IS NOT HERE, and there is no accessor for it. It lives in GameState.luck: no
## save, no skill, no row (§7). That holds by construction, not by discipline.
const PROF_BONUS_L1 := 2
# --- stored: rolled, chosen, or genuinely mutable -------------------------------
var attributes: Dictionary = {} # {str,dex,con,fth,mag} — final, post-spend
var race_id: String = ""
var calling_id: String = ""
var level: int = 1
var skills: Array = [] # chosen + race-granted + the human's bonus pick
var bonus_skill: String = "" # human only; also present in `skills`
var hp: int = 0 # CURRENT hp — combat writes this
var mp: int = 0 # CURRENT mp
# --- derived: functions, never stored -------------------------------------------
func modifier(stat: String) -> int:
return Attributes.modifier(int(attributes.get(stat, 10)))
func prof_bonus() -> int:
return PROF_BONUS_L1 # the level curve is M5's; creation is L1
func max_hp() -> int:
return Callings.hit_die(calling_id) + modifier("con")
func max_mp() -> int:
# TUNABLE — M5 owns the real MP curve (races-and-classes spec §6 defers it).
# The SHAPE is what M4-b needs to draw a bar: martials have no pool at all; a
# caster's scales off the casting stat, with a floor so a bad roll still casts.
if not Callings.is_caster(calling_id):
return 0
return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id)))
func ac() -> int:
return 10 + modifier("dex") # armor is an item -> M7
func initiative() -> int:
return modifier("dex")
func is_proficient(skill: String) -> bool:
return skill in skills
func save(stat: String) -> int:
var v := modifier(stat)
if stat in Callings.saves(calling_id):
v += prof_bonus()
v += Races.save_bonus(race_id) # human's +1 to all five
return v
func poison_save_bonus() -> int:
# Situational (vs poison + the affliction track) — deliberately NOT folded into
# save("con"), because a conditional inside a flat number is a sheet that lies.
return Races.poison_save_bonus(race_id)
func skill_bonus(skill: String) -> int:
var v := modifier(Skills.attribute(skill))
if is_proficient(skill):
v += prof_bonus()
return v
func spell_dc() -> int:
if not Callings.is_caster(calling_id):
return 0
return 8 + prof_bonus() + modifier(Callings.casting_stat(calling_id))
func has_nightsight() -> bool:
return Races.flag(race_id, "nightsight")
func has_claws() -> bool:
return Races.flag(race_id, "claws")
func has_keen_scent() -> bool:
return Races.flag(race_id, "keen_scent")

View File

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

View File

@@ -0,0 +1,46 @@
class_name Currency
extends RefCounted
## Money. Everything is stored in COPPER — one integer, on GameState.purse_copper.
## The denominations exist so a bounded §6 move can name a unit (give_item(gold)
## is +10,000c); they are NOT stored buckets, so change-making never arises.
##
## The ratios are rules and live here in code. PRICES are content and live in
## /content (roadmap: "the economy is two systems"). Formatting happens only at
## the display edge — this is the one place that knows what "12g 40s 8c" means.
const COPPER := 1
const SILVER := 100
const GOLD := 10000
const VALUES := {"copper": COPPER, "silver": SILVER, "gold": GOLD}
static func is_currency(item_id: String) -> bool:
return VALUES.has(item_id)
static func value(item_id: String) -> int:
return int(VALUES.get(item_id, 0))
static func format(copper: int) -> String:
# Empty denominations are suppressed and at least one unit always shows:
# "0c", "47c", "1g 8c", "12g 40s". "0g 0s 8c" reads as a spreadsheet, not a
# purse — and being broke should read as poverty (§3).
if copper <= 0:
return "0c"
var parts: Array[String] = []
var left := copper
@warning_ignore("integer_division") # denomination math — the remainder is carried, not lost
var gold := left / GOLD
if gold > 0:
parts.append("%dg" % gold)
left -= gold * GOLD
@warning_ignore("integer_division")
var silver := left / SILVER
if silver > 0:
parts.append("%ds" % silver)
left -= silver * SILVER
if left > 0:
parts.append("%dc" % left)
return " ".join(parts)

View File

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

View File

@@ -1,14 +1,16 @@
class_name GameState
extends RefCounted
## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck,
## stats, world-NPC dispositions, and inventory — none of which enter the canon
## log (charter §7/§2). The log reads only luck_descriptor() from here.
## the character sheet, world-NPC dispositions, and inventory — none of which
## enter the canon log (charter §7/§2). The log reads only luck_descriptor()
## from here.
var luck: int = 0
var luck_base: int = 0
var stats: Dictionary = {} # {str, dex, con, fth, mag}
var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame.
var npc_dispositions: Dictionary = {} # world-npc id -> int (100..100)
var inventory: Dictionary = {} # item_id -> qty
var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper)
var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec).
var revealed_topics: Dictionary = {} # topic_id -> true
var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item)
@@ -41,6 +43,27 @@ func remove_item(item_id: String, qty: int) -> void:
inventory.erase(item_id)
func add_copper(amount: int) -> void:
purse_copper = maxi(0, purse_copper + amount)
func grant(item_id: String, qty: int) -> void:
# The single routing point (with take): currency lands in the purse, every
# other item in the inventory dict. Money is never an inventory row, so it
# can never surface in an inventory grid — exclusion by construction.
if Currency.is_currency(item_id):
add_copper(Currency.value(item_id) * qty)
else:
add_item(item_id, qty)
func take(item_id: String, qty: int) -> void:
if Currency.is_currency(item_id):
add_copper(-Currency.value(item_id) * qty)
else:
remove_item(item_id, qty)
func mark_revealed(topic_id: String) -> void:
revealed_topics[topic_id] = true

View File

@@ -41,11 +41,33 @@ static func build_theme() -> Theme:
_dock_button(theme)
_parchment_button(theme)
_chip(theme)
_section_label(theme)
_select_card(theme)
_skill_chip(theme)
_skill_chip_granted(theme)
_tags_and_primary(theme)
_tiles(theme)
_cards_and_panels(theme)
return theme
static func _pill(bg: Color, radius: int, margin_x: int, margin_y: int) -> StyleBoxFlat:
# A BADGE, not a card. _flat() bakes in the 12/6 content margin every panel and
# button on the sheet wants — on a 9px "PRIMARY" flag that padding is most of the
# control, which is what made the badge swallow the ability card's rolled value.
# The mock's flags are padding:2px 9px (PRIMARY) and 3px 8px (CHOSEN).
var s := StyleBoxFlat.new()
s.bg_color = bg
s.border_color = bg
s.set_border_width_all(0)
s.set_corner_radius_all(radius)
s.content_margin_left = margin_x
s.content_margin_right = margin_x
s.content_margin_top = margin_y
s.content_margin_bottom = margin_y
return s
static func _flat(bg: Color, border: Color, width := 1, radius := 4) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = bg
@@ -77,8 +99,22 @@ static func _fonts(theme: Theme) -> void:
# Mono chrome role.
theme.set_type_variation(ThemeKeys.MONO, "Label")
theme.set_font(&"font", ThemeKeys.MONO, load(MONO))
theme.set_font_size(&"font_size", ThemeKeys.MONO, 13)
theme.set_font_size(&"font_size", ThemeKeys.MONO, 14)
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
# Card type — the name + blurb inside a select-card. These sit ON PARCHMENT, so
# they take INK, not CREAM. Godot's built-in default Label colour is WHITE, and a
# Label left bare inherits it: the race and calling names shipped invisible on the
# parchment sheet for exactly that reason. There is no such thing as an unstyled
# Label on this sheet — every one of them names a role. (mock: 19px/17px #3a2f1c
# title, 13px italic-serif #6a5a3a blurb.)
theme.set_type_variation(ThemeKeys.CARD_TITLE, "Label")
theme.set_font(&"font", ThemeKeys.CARD_TITLE, serif)
theme.set_font_size(&"font_size", ThemeKeys.CARD_TITLE, 20)
theme.set_color(&"font_color", ThemeKeys.CARD_TITLE, Palette.INK_HEADING)
theme.set_type_variation(ThemeKeys.CARD_BODY, "Label")
theme.set_font(&"font", ThemeKeys.CARD_BODY, serif)
theme.set_font_size(&"font_size", ThemeKeys.CARD_BODY, 14)
theme.set_color(&"font_color", ThemeKeys.CARD_BODY, Palette.INK_MUTED)
static func _title_type(theme: Theme) -> void:
@@ -103,7 +139,7 @@ static func _rich_text(theme: Theme) -> void:
# (the narration book, later quest briefs/dialogue) reads dark serif ink from here.
theme.set_font(&"normal_font", "RichTextLabel", load(SERIF))
theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC))
theme.set_font_size(&"normal_font_size", "RichTextLabel", 20)
theme.set_font_size(&"normal_font_size", "RichTextLabel", 22)
theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY)
@@ -175,10 +211,112 @@ static func _chip(theme: Theme) -> void:
theme.set_stylebox(&"pressed", k, _flat(Color(Palette.MUTED_MONO, 0.32), Palette.MUTED_MONO, 1, 3))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 11)
theme.set_font_size(&"font_size", k, 12)
theme.set_color(&"font_color", k, Palette.MUTED_MONO)
static func _section_label(theme: Theme) -> void:
# The mono section headers on the parchment sheet (RACE / CALLING / NAME …).
# Mono's own colour is for dark surfaces; on parchment it needs the muted ink.
theme.set_type_variation(ThemeKeys.SECTION_LABEL, "Label")
theme.set_font(&"font", ThemeKeys.SECTION_LABEL, load(MONO))
theme.set_font_size(&"font_size", ThemeKeys.SECTION_LABEL, 14)
theme.set_color(&"font_color", ThemeKeys.SECTION_LABEL, Palette.INK_LABEL_MUTED)
static func _select_card(theme: Theme) -> void:
# Race + calling cards. Button-base (a PanelContainer variation's lone `panel`
# stylebox does not skin a Button — ADR 0001). `pressed` IS the CHOSEN state:
# the card is a toggle in a ButtonGroup, so `pressed` persists after the click.
var k := ThemeKeys.SELECT_CARD
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 12))
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 12))
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_CARD, Palette.BLOOD, 3, 12))
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 12))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_color(&"font_color", k, Palette.INK_HEADING)
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
static func _skill_chip(theme: Theme) -> void:
# A proficiency chip is a TOGGLE with three states the player must be able to
# read at a glance: unpicked (parchment), picked (crimson), and granted-by-race
# (dim + inert — the elf already has perception, so the chip cannot be pressed).
var k := ThemeKeys.SKILL_CHIP
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 14))
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 14))
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 14))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 14)
theme.set_color(&"font_color", k, Palette.INK_LABEL)
theme.set_color(&"font_pressed_color", k, Palette.CREAM_BRIGHT)
theme.set_color(&"font_hover_color", k, Palette.INK_LABEL)
theme.set_color(&"font_disabled_color", k, Palette.PARCHMENT_BORDER_3)
static func _skill_chip_granted(theme: Theme) -> void:
# The FOURTH skill-chip state, split out of SkillChip's dim `disabled` look. A
# race-GRANTED skill (the elf already has perception) must read "you already have
# this" — OWNED — not "unavailable". So: a solid gold fill with cream text (the
# binding prefixes a ✓), where the pool-is-spent chips keep the plain dim disabled.
# It is inert like a disabled chip, so every interactive state paints the owned box.
var k := ThemeKeys.SKILL_CHIP_GRANTED
theme.set_type_variation(k, "Button")
var owned := _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 14)
theme.set_stylebox(&"normal", k, owned)
theme.set_stylebox(&"hover", k, owned)
theme.set_stylebox(&"pressed", k, owned)
theme.set_stylebox(&"disabled", k, owned)
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_font(&"font", k, load(MONO))
theme.set_font_size(&"font_size", k, 14)
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
theme.set_color(&"font_disabled_color", k, Palette.CREAM_BRIGHT)
static func _tags_and_primary(theme: Theme) -> void:
# The two little flags that ride on a card's top edge, and the gold ring the
# calling's primary attribute wears. Label takes a `normal` stylebox in Godot 4.
var chosen := ThemeKeys.CHOSEN_TAG
theme.set_type_variation(chosen, "Label")
theme.set_stylebox(&"normal", chosen, _pill(Palette.BLOOD, 5, 8, 3))
theme.set_font(&"font", chosen, load(MONO))
theme.set_font_size(&"font_size", chosen, 11)
theme.set_color(&"font_color", chosen, Palette.CREAM_BRIGHT)
var primary_tag := ThemeKeys.PRIMARY_TAG
theme.set_type_variation(primary_tag, "Label")
theme.set_stylebox(&"normal", primary_tag, _pill(Palette.INK_GOLD, 5, 9, 2))
theme.set_font(&"font", primary_tag, load(MONO))
theme.set_font_size(&"font_size", primary_tag, 10)
theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT)
# The ability card in both states. It is NOT a plain ParchmentCard: it wears the PRIMARY
# flag on its top edge, and _flat()'s 6px top margin puts the "STR" key label directly
# under the badge — they collided. The mock's card is padding:16px 10px 14px, and that
# 16px IS the headroom the badge needs. ParchmentCard cannot simply be retuned: the
# shell shares it, and the shell has no badge.
theme.set_stylebox(&"panel", ThemeKeys.ABILITY_CARD,
_ability_card(Palette.PARCHMENT_BORDER, 1))
theme.set_type_variation(ThemeKeys.ABILITY_CARD, "PanelContainer")
theme.set_type_variation(ThemeKeys.PRIMARY_CARD, "PanelContainer")
theme.set_stylebox(&"panel", ThemeKeys.PRIMARY_CARD,
_ability_card(Palette.INK_GOLD, 2))
static func _ability_card(border: Color, width: int) -> StyleBoxFlat:
var s := _flat(Palette.PARCHMENT_CARD, border, width, 12)
s.content_margin_top = 16
s.content_margin_bottom = 14
s.content_margin_left = 10
s.content_margin_right = 10
return s
static func _tiles(theme: Theme) -> void:
var filled := _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 2, 6)
theme.set_type_variation(ThemeKeys.ITEM_TILE, "PanelContainer")

View File

@@ -43,6 +43,7 @@ const INK_BODY := Color("4a3f2c")
const INK_MUTED := Color("6a5a3a")
const INK_LABEL := Color("5a4326")
const INK_GOLD := Color("8f6a2a")
const INK_LABEL_MUTED := Color("8a7748") # section labels on parchment (creation sheet)
# --- Text on dark ---
const CREAM := Color("e8ddc8")

View File

@@ -16,6 +16,17 @@ const ITEM_TILE_EMPTY := &"ItemTileEmpty"
const PARCHMENT_CARD := &"ParchmentCard"
const PARCHMENT_INSET := &"ParchmentInset"
const DARK_PANEL := &"DarkPanel"
const SELECT_CARD := &"SelectCard" # race/calling card: a Button whose `pressed` is CHOSEN
const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / can't-pick-now (dim)
const SKILL_CHIP_GRANTED := &"SkillChipGranted" # a race-GRANTED skill: owned (gold + ✓), inert
const CHOSEN_TAG := &"ChosenTag" # the little crimson "CHOSEN" flag
const PRIMARY_TAG := &"PrimaryTag" # the little gold "PRIMARY" flag
const PRIMARY_CARD := &"PrimaryCard" # the ability card ringed as the calling's primary
const ABILITY_CARD := &"AbilityCard" # ...and its UNringed state. Not ParchmentCard: the
# ability card carries a badge on its top edge and
# needs the headroom for it (mock padding-top 16 vs
# the generic card's 6), and ParchmentCard is shared
# with the shell.
## Font roles (Label type-variations carrying a font, not a stylebox). Kept
## separate from ALL below, which is stylebox variations + their base type only.
@@ -24,6 +35,40 @@ const ACCENT := &"Accent"
const MONO := &"Mono"
const TITLE_LOGO := &"TitleLogo"
const TITLE_KICKER := &"TitleKicker"
const SECTION_LABEL := &"SectionLabel"
const CARD_TITLE := &"CardTitle" # a select-card's name, dark ink ON PARCHMENT
const CARD_BODY := &"CardBody" # a select-card's blurb, muted ink ON PARCHMENT
## Every FONT role + the base Control type it decorates. Deliberately NOT folded
## into ALL — ALL's "stylebox variations only" contract is relied on elsewhere
## (test_every_variation_resolves, and the styleboxes the screens set). Kept as a
## second enumerable set so a guard that means "every variation the builder
## touches" can iterate BOTH and never silently cover only half of them: the
## drift guard used to iterate ALL alone, so a font, a font size or a font colour
## could change in the builder and the committed .tres stay stale, suite green.
const FONT_ROLES := {
HEADING: "Label",
ACCENT: "Label",
MONO: "Label",
TITLE_LOGO: "Label",
TITLE_KICKER: "Label",
SECTION_LABEL: "Label",
CARD_TITLE: "Label",
CARD_BODY: "Label",
}
## Base Control types the builder styles DIRECTLY — no set_type_variation is
## ever called for these, so a fresh Theme reports get_type_variation_base("")
## for them. Kept as its own set (not folded into ALL or FONT_ROLES) because a
## base type is not a variation: nothing sets `theme_type_variation` to
## "RichTextLabel" anywhere, the node's own class name is the lookup key.
## Today: RichTextLabel, the builder's prose voice (mock README Typography:
## serif body/italic emphasis) — the creation screen's origin panel and
## calling detail panel are RichTextLabels rendering this styling directly,
## i.e. every line of DM prose the screen shows.
const BASE_TYPES := {
&"RichTextLabel": true,
}
## Every variation name + the base Control type it decorates. The builder and the
## test both iterate this so they can never drift apart.
@@ -39,4 +84,11 @@ const ALL := {
PARCHMENT_CARD: "PanelContainer",
PARCHMENT_INSET: "PanelContainer",
DARK_PANEL: "PanelContainer",
SELECT_CARD: "Button",
SKILL_CHIP: "Button",
SKILL_CHIP_GRANTED: "Button",
CHOSEN_TAG: "Label",
PRIMARY_TAG: "Label",
PRIMARY_CARD: "PanelContainer",
ABILITY_CARD: "PanelContainer",
}

View File

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

View File

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

View File

@@ -0,0 +1,304 @@
class_name CharacterCreation
extends Control
## The creation screen (mock: Character Creation). Layout is authored in
## CharacterCreation.tscn — open it in the editor to arrange it (ADR 0001). This
## script does on-load work only: @onready refs, signal wiring, and binding the
## draft into AUTHORED nodes. It creates no nodes and it owns no rules.
##
## Every rule lives in CreationDraft; every display string derived from a rules
## table lives in CreationCopy; the prose lives in ContentDB. This file is glue.
##
## §2: the screen never hands an attribute value to anything. It emits a seed and
## four choices, and NewGame.construct re-rolls from that seed itself.
## §7: LCK appears nowhere here. Five ability cards. Never six.
signal creation_confirmed(creation: Dictionary)
## Shown under the CTA when the draft is legal. When it is NOT legal, the first
## validation error takes this slot instead — the player is told what is wrong, in
## the same place, in the same dry voice. §13: even the nag is content.
const CTA_HINT := "no second chances out there"
## Injection seams — set BETWEEN instantiate() and add_child(), as the shell does
## for DmService. _ready builds the real ones when nothing was supplied.
var world: ContentDB = null
var origin: Dictionary = {}
var draft: CreationDraft
var _race_cards: Array = []
var _calling_cards: Array = []
var _pool_chips: Array = []
var _bonus_chips: Array = []
var _ability_cards: Array = []
@onready var _plate_name: Label = $Split/Bay/Col/Nameplate/Name
@onready var _plate_sub: Label = $Split/Bay/Col/Nameplate/Sub
@onready var _origin_text: RichTextLabel = $Split/Bay/Col/OriginCard/Box/Text
@onready var _race_row: HBoxContainer = $Split/Sheet/Body/RaceSection/Cards
@onready var _calling_row: HBoxContainer = $Split/Sheet/Body/CallingSection/Cards
@onready var _detail_blurb: RichTextLabel = $Split/Sheet/Body/CallingSection/Detail/Box/Blurb
@onready var _detail_mech: Label = $Split/Sheet/Body/CallingSection/Detail/Box/Mechanics
@onready var _pool_row: HBoxContainer = $Split/Sheet/Body/SkillSection/PoolRow
@onready var _pool_count: Label = $Split/Sheet/Body/SkillSection/PoolRow/Count
@onready var _bonus_row: HFlowContainer = $Split/Sheet/Body/SkillSection/BonusRow
@onready var _ability_row: HBoxContainer = $Split/Sheet/Body/AbilitySection/Cards
@onready var _points: Label = $Split/Sheet/Body/AbilitySection/Head/Points
@onready var _reroll: Button = $Split/Sheet/Body/AbilitySection/Head/Reroll
@onready var _name_edit: LineEdit = $Split/Sheet/Body/Footer/NameBox/NameEdit
@onready var _enter: Button = $Split/Sheet/Body/Footer/CtaBox/Enter
@onready var _error: Label = $Split/Sheet/Body/Footer/CtaBox/Error
func _ready() -> void:
# The .tscn root has a fixed size and NO full-rect anchors — a root Control run
# via F6 is not sized by full-rect anchors and collapses to (0,0) (the M3-a bug).
_fit_to_viewport()
get_viewport().size_changed.connect(_fit_to_viewport)
if world == null:
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
if origin.is_empty():
origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
_collect_authored_nodes()
_wire()
draft = CreationDraft.fresh()
draft.set_race(Races.IDS[0])
var allowed := _allowed_callings()
if not allowed.is_empty():
draft.set_calling(allowed[0])
# A degenerate origin (zero allowed callings, or no build_constraints at all)
# leaves calling_id unset rather than crashing — §13, never an error thrown
# at the player. The CTA simply stays disabled; there is no calling to invent.
_bind()
func _fit_to_viewport() -> void:
size = get_viewport_rect().size
func _allowed_callings() -> Array:
## The ORIGIN gates the callings. The screen does not hardcode seven — a later
## origin may allow fewer, and construct would reject a calling it disallows.
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
var out: Array = []
for id in Callings.IDS: # Callings.IDS fixes the ORDER; the origin fixes the SET
if id in allowed:
out.append(id)
return out
func _collect_authored_nodes() -> void:
# Fixed authored nodes, gathered — never created (ADR 0001). Spares hide.
for i in range(4):
_race_cards.append(_race_row.get_node("Race%d" % i))
for i in range(7):
_calling_cards.append(_calling_row.get_node("Calling%d" % i))
for i in range(6):
_pool_chips.append(_pool_row.get_node("Pool%d" % i))
for i in range(9):
_bonus_chips.append(_bonus_row.get_node("Bonus%d" % i))
for i in range(5):
_ability_cards.append(_ability_row.get_node("Ab%d" % i))
func _wire() -> void:
for i in range(_race_cards.size()):
_race_cards[i].pressed.connect(_on_race_pressed.bind(i))
for i in range(_calling_cards.size()):
_calling_cards[i].pressed.connect(_on_calling_pressed.bind(i))
for i in range(_pool_chips.size()):
_pool_chips[i].pressed.connect(_on_pool_pressed.bind(i))
for i in range(_bonus_chips.size()):
_bonus_chips[i].pressed.connect(_on_bonus_pressed.bind(i))
for i in range(_ability_cards.size()):
var card: PanelContainer = _ability_cards[i]
card.get_node("Box/Buttons/Minus").pressed.connect(_on_minus_pressed.bind(i))
card.get_node("Box/Buttons/Plus").pressed.connect(_on_plus_pressed.bind(i))
_reroll.pressed.connect(_on_reroll_pressed)
_name_edit.text_changed.connect(_on_name_changed)
_enter.pressed.connect(_on_enter_pressed)
# ---------------------------------------------------------------- input -> draft
func _on_race_pressed(i: int) -> void:
draft.set_race(Races.IDS[i])
_bind()
func _on_calling_pressed(i: int) -> void:
var allowed := _allowed_callings()
if i >= allowed.size():
return
draft.set_calling(allowed[i])
_bind()
func _on_pool_pressed(i: int) -> void:
var pool := draft.skill_pool()
if i < pool.size():
draft.toggle_skill(pool[i])
_bind()
func _on_bonus_pressed(i: int) -> void:
draft.set_bonus_skill(Skills.IDS[i])
_bind()
func _on_plus_pressed(i: int) -> void:
draft.increment(Attributes.IDS[i])
_bind()
func _on_minus_pressed(i: int) -> void:
draft.decrement(Attributes.IDS[i])
_bind()
func _on_reroll_pressed() -> void:
draft.reroll()
_bind()
func _on_name_changed(text: String) -> void:
draft.name = text
_bind()
func _on_enter_pressed() -> void:
if not draft.is_complete(origin, world):
return
creation_confirmed.emit(draft.to_creation())
# ---------------------------------------------------------------- draft -> nodes
func _bind() -> void:
_bind_races()
_bind_callings()
_bind_skills()
_bind_abilities()
_bind_bay()
_bind_cta()
func _bind_races() -> void:
for i in range(_race_cards.size()):
var id: String = Races.IDS[i]
var card: Button = _race_cards[i]
card.get_node("Box/Name").text = str(world.race(id).get("name", id.capitalize()))
card.get_node("Box/Blurb").text = str(world.race(id).get("blurb", ""))
card.get_node("Box/Trait").text = CreationCopy.race_trait(id)
card.button_pressed = (id == draft.race_id)
card.get_node("Tag").visible = card.button_pressed
func _bind_callings() -> void:
var allowed := _allowed_callings()
for i in range(_calling_cards.size()):
var card: Button = _calling_cards[i]
if i >= allowed.size():
card.visible = false # a spare authored slot: hide it (ADR 0001)
continue
var id: String = allowed[i]
card.visible = true
card.get_node("Box/Name").text = str(world.calling(id).get("name", id.capitalize()))
card.get_node("Box/Chip").text = Callings.primary(id).to_upper()
card.button_pressed = (id == draft.calling_id)
card.get_node("Tag").visible = card.button_pressed
_detail_blurb.text = "[i]%s[/i]" % str(world.calling(draft.calling_id).get("blurb", ""))
_detail_mech.text = CreationCopy.calling_detail(draft.calling_id)
func _bind_skills() -> void:
var pool := draft.skill_pool()
_pool_count.text = "%d of %d chosen" % [draft.skills.size(), draft.picks_needed()]
for i in range(_pool_chips.size()):
var chip: Button = _pool_chips[i]
if i >= pool.size():
chip.visible = false
continue
var skill: String = pool[i]
var granted := draft.is_granted(skill)
chip.visible = true
# A granted skill reads as OWNED (gold + ✓ via SkillChipGranted), not merely
# disabled — otherwise it looks identical to a "picks are spent" chip and reads
# as "unavailable" rather than "you already have this".
chip.theme_type_variation = ThemeKeys.SKILL_CHIP_GRANTED if granted else ThemeKeys.SKILL_CHIP
chip.text = ("" + CreationCopy.skill_label(skill)) if granted else CreationCopy.skill_label(skill)
chip.button_pressed = draft.is_picked(skill)
# Inert when the race already granted it, or when the picks are spent.
chip.disabled = granted or (not draft.is_picked(skill) and not draft.can_pick(skill))
_bonus_row.visible = draft.wants_bonus_skill()
for i in range(_bonus_chips.size()):
var skill: String = Skills.IDS[i]
var chip: Button = _bonus_chips[i]
chip.text = CreationCopy.skill_label(skill)
chip.button_pressed = (draft.bonus_skill == skill)
chip.disabled = not draft.can_take_bonus(skill) and draft.bonus_skill != skill
func _bind_abilities() -> void:
var rolled := draft.rolled()
var final := draft.final()
var primary := Callings.primary(draft.calling_id)
_points.text = "points left: %d" % draft.points_left()
for i in range(_ability_cards.size()):
var stat: String = Attributes.IDS[i]
var card: PanelContainer = _ability_cards[i]
var spent := int(draft.spend.get(stat, 0))
card.get_node("Box/Key").text = stat.to_upper()
card.get_node("Box/Value").text = str(int(final[stat]))
card.get_node("Box/Rolled").text = "rolled %d%s" % [
int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""]
card.get_node("Box/Buttons/Plus").disabled = not draft.can_increment(stat)
card.get_node("Box/Buttons/Minus").disabled = not draft.can_decrement(stat)
# Overlay/Tag, not Tag: the ability card is a PanelContainer, and a Container
# force-fits every child it has — which stretched the badge across the card and
# parked it on top of the rolled value. The Control overlay is not a Container,
# so the badge's anchors survive and it rides the card's top edge (ADR 0001).
card.get_node("Overlay/Tag").visible = (stat == primary)
card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.ABILITY_CARD
func _bind_bay() -> void:
var shown_name := draft.name.strip_edges()
_plate_name.text = shown_name if shown_name != "" else "Nameless"
_plate_sub.text = "%s · %s" % [
str(world.race(draft.race_id).get("name", "")),
str(world.calling(draft.calling_id).get("name", "")),
]
_origin_text.text = "[i]%s[/i]" % _origin_prose()
func _origin_prose() -> String:
## The DM sizes you up — authored, not an AI call. The DM's real entrance is the
## shell's opening narration, one screen later, at the first moment where speaking
## means something (§13: authored text is CONTENT, not error handling).
var race: Dictionary = world.race(draft.race_id)
var calling: Dictionary = world.calling(draft.calling_id)
# A missing fragment degrades to the blurb rather than crashing. The content
# parity test stops that from ever shipping; this stops it from ever exploding.
var race_frag := str(race.get("fragment", race.get("blurb", "")))
var calling_frag := str(calling.get("fragment", calling.get("blurb", "")))
return "%s Now you carry a %s's work — %s" % [
race_frag, str(calling.get("name", "")).to_lower(), calling_frag]
func _bind_cta() -> void:
## The errors array is the VALIDATOR's, written for code. What lands on the
## label is CreationCopy's, written for the player — "choose two more
## proficiencies", not "hedge_mage picks 2 skills, got 0" (§13). The mapping is
## in the presentation layer on purpose: NewGame.validate's strings are a
## contract, and this screen is not the only thing that reads them.
var errors := draft.errors(origin, world)
_enter.disabled = not errors.is_empty()
_error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT

View File

@@ -0,0 +1 @@
uid://3cxsq82gjlfk

View File

@@ -0,0 +1,149 @@
class_name CreationCopy
extends RefCounted
## Display strings for the creation screen, DERIVED from the rules tables.
##
## Prose lives in content (blurbs, fragments). NUMBERS LIVE IN CODE. The card reads
## both and repeats neither. A hit die written into a JSON — or typed into a string
## literal here — is a second source of truth, and it will eventually disagree with
## the table. Every number below is read from Callings/Races/Skills at call time, so
## retuning a calling retunes its card.
##
## §7: nothing here may mention Luck, in any form. §2: presentation only.
const ARMOR_WORD := {
"none": "no armour",
"light": "light armour",
"medium": "medium armour",
"heavy": "heavy armour",
}
## The one line the player reads when the draft will not hold. §13: a fallback is
## CONTENT, not error handling — so even an error nobody anticipated speaks in the
## DM's voice rather than showing him a validator's grammar.
const UNSPOKEN := "something in this does not hold — look again"
const NUMBER_WORD := {
1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
6: "six", 7: "seven", 8: "eight", 9: "nine",
}
static func calling_detail(id: String) -> String:
## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab"
if not Callings.exists(id):
return ""
var saves: Array = []
for s in Callings.saves(id):
saves.append(s.to_upper())
return "d%d · %s · %s · saves %s · picks %d skills · talent %s" % [
Callings.hit_die(id),
armor_word(id),
resource_word(id),
" + ".join(saves),
Callings.skill_count(id),
talent_word(id),
]
static func talent_word(id: String) -> String:
## second_wind -> "second wind". Four of the seven talent IDS carry an
## underscore, and the card printed the raw id: the player read "talent
## second_wind" off the calling card. Ids are snake_case; humans are not — the
## same reason skill_label() exists. Read from the table at call time, so
## retuning a talent retunes the card.
return Callings.talent(id).replace("_", " ")
static func armor_word(id: String) -> String:
## The card says "no armour", not "none". Public so the test can assert against
## the DERIVED word rather than the raw table value — "none" is not a substring
## of "no armour", and asserting on the raw value only ever passed by accident.
return ARMOR_WORD.get(Callings.armor(id), Callings.armor(id))
static func resource_word(id: String) -> String:
## Martials live on cooldowns; casters on a pool off their casting stat.
if not Callings.is_caster(id):
return "cooldowns"
return "MP (%s)" % Callings.casting_stat(id).to_upper()
static func race_trait(id: String) -> String:
## e.g. "+1 to every save · +1 skill of choice" / "Poison resist · Nightsight"
if not Races.exists(id):
return ""
var parts: Array = []
if Races.save_bonus(id) > 0:
parts.append("+%d to every save" % Races.save_bonus(id))
for s in Races.granted_skills(id):
parts.append(skill_label(s).capitalize())
if Races.poison_save_bonus(id) > 0:
parts.append("Poison resist")
if Races.flag(id, "nightsight"):
parts.append("Nightsight")
if Races.flag(id, "claws"):
parts.append("Claws")
if Races.flag(id, "keen_scent"):
parts.append("Keen scent")
if Races.wants_bonus_skill(id):
parts.append("+1 skill of choice")
return " · ".join(parts)
static func skill_label(skill: String) -> String:
## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not.
return skill.replace("_", " ")
# ------------------------------------------------------------------- §13: the nag
static func error_line(error: String, draft: CreationDraft) -> String:
## The validator's diagnostics are written for whoever is reading the errors
## array — "hedge_mage picks 2 skills, got 0", "unresolved ref: item:x". They are
## a CONTRACT (other code reads those strings), so they are not rewritten; they
## are TRANSLATED, here, in the presentation layer, where translating them
## belongs. What the player reads on the second screen of the game is authored
## copy: dry, second person, flat. Never an id, never a compiler's grammar (§13).
##
## Anything unmapped degrades to UNSPOKEN — never the raw string, never blank.
if error.begins_with("player name is required"):
return "name yourself"
if error.begins_with("unknown race"):
return "decide what blood you carry"
if error.begins_with("unknown calling"):
return "decide what you do for coin"
if error.begins_with("calling not allowed by origin"):
return "that trade is closed to you — you left it behind"
if error.contains(" picks ") and error.contains(" skills, got "):
return _proficiency_line(draft)
if error.begins_with("duplicate skill pick"):
return "you have taken the same proficiency twice"
if error.contains(" is not in the ") and error.ends_with(" pool"):
return "your calling never taught you that"
if error.contains(" is already granted by "):
return "you have that one already — it buys you nothing"
if error.contains(" must choose a bonus skill"):
return "one more proficiency, any of them — take it"
if error.begins_with("unknown bonus skill"):
return "no one here would call that a skill"
if error.begins_with("bonus skill ") and error.ends_with(" is already proficient"):
return "you have that one already — take another"
if error.contains(" does not get a bonus skill"):
return "your blood grants no proficiency of its own"
return UNSPOKEN
static func _proficiency_line(draft: CreationDraft) -> String:
## "choose two more proficiencies". The NUMBER is derived — Callings.skill_count
## is the rules table's business and the copy asks it, every time, rather than
## carrying a second copy of it that would one day disagree.
var left: int = draft.picks_left()
if left <= 0:
return "you have taken more proficiencies than your calling can carry"
if left == 1:
return "choose one more proficiency"
return "choose %s more proficiencies" % number_word(left)
static func number_word(n: int) -> String:
return str(NUMBER_WORD.get(n, n))

View File

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

View File

@@ -0,0 +1,222 @@
class_name CreationDraft
extends RefCounted
## The creation screen's whole brain. Pure, node-free, headlessly testable.
##
## Stores exactly the seven things a creation Dictionary is made of, and nothing
## derived. It CARRIES A SEED, NOT A STAT BLOCK — the screen shows the roll by
## calling NewGame.roll_attributes(seed), the same function construct calls, so
## the numbers on screen are the numbers the player gets and the screen still
## never hands a number to anything (§2).
##
## It owns no rules of its own: errors() delegates to NewGame.validate. A second
## copy of the rules is a second thing to keep in sync, and it would lose.
##
## §7: LCK is not here. Not a field, not an accessor, not a key. It is rolled
## inside construct off the same seed, immediately after MAG, and never surfaces.
var name: String = ""
var race_id: String = ""
var calling_id: String = ""
var spend: Dictionary = {} # {stat: int} — additive only
var skills: Array = [] # the player's picks (NOT the race's grants)
var bonus_skill: String = "" # human only
## NOT named `seed` — `seed()` is a global GDScript function and a member that
## shadows it reads as a bug (and warns). The creation Dictionary's KEY is still
## "seed"; only the GDScript identifier differs.
var character_seed: int = 0
static func fresh() -> CreationDraft:
## The ONE place non-determinism enters the entire pipeline — and what it
## produces is a seed, which is thereafter the character's anchor forever.
var d := CreationDraft.new()
d.character_seed = new_seed()
return d
static func new_seed() -> int:
var rng := RandomNumberGenerator.new()
rng.randomize()
return int(rng.randi())
# ---------------------------------------------------------------- the numbers
func rolled() -> Dictionary:
return NewGame.roll_attributes(character_seed)
func final() -> Dictionary:
var out := rolled()
for stat in spend:
out[stat] = int(out[stat]) + int(spend[stat])
return out
func spent_total() -> int:
var total := 0
for stat in spend:
total += int(spend[stat])
return total
func points_left() -> int:
## NewGame.SPEND_POOL is the ONE authority on the pool's size. A local copy of the
## number would be a second thing to keep in sync, and this class's whole contract
## is that it keeps none.
return NewGame.SPEND_POOL - spent_total()
func can_increment(stat: String) -> bool:
return Attributes.exists(stat) and points_left() > 0
func increment(stat: String) -> void:
if not can_increment(stat):
return
spend[stat] = int(spend.get(stat, 0)) + 1
func can_decrement(stat: String) -> bool:
## Removes only what YOU spent. "Additive only" is a rule about where the number
## can END UP — never below the roll — not about whether a mis-click is
## recoverable. Those are different things.
return int(spend.get(stat, 0)) > 0
func decrement(stat: String) -> void:
if not can_decrement(stat):
return
var left := int(spend[stat]) - 1
if left <= 0:
spend.erase(stat)
else:
spend[stat] = left
func reroll() -> void:
## A re-roll IS a new seed. Nothing else. The spend clears because points spent
## against a roll that no longer exists are meaningless; race, calling, skills
## and name survive because none of them came from the die.
##
## Note what the player is NOT told: the seed drives the five visible attributes
## AND the hidden Luck roll. Chase a 16 and you re-roll your Luck, blind, every
## time. That is §7 working exactly as designed. Say nothing.
character_seed = new_seed()
spend = {}
# ---------------------------------------------------------------- the choices
func set_race(id: String) -> void:
race_id = id
# A pick the new race GRANTS now buys nothing, and construct rejects it by name.
# Drop it here rather than let the player press a button into a 422.
var granted: Array = Races.granted_skills(id)
var kept: Array = []
for s in skills:
if s not in granted:
kept.append(s)
skills = kept
if not Races.wants_bonus_skill(id):
bonus_skill = ""
func set_calling(id: String) -> void:
# The pool changed underneath the picks. Keeping them leaves the player holding
# an invalid draft he did not cause and cannot see.
if id != calling_id:
skills = []
calling_id = id
func skill_pool() -> Array:
return Callings.skill_pool(calling_id)
func picks_needed() -> int:
return Callings.skill_count(calling_id)
func picks_left() -> int:
return picks_needed() - skills.size()
func is_granted(skill: String) -> bool:
return skill in Races.granted_skills(race_id)
func is_picked(skill: String) -> bool:
return skill in skills
func can_pick(skill: String) -> bool:
if is_granted(skill):
return false
# The mirror of can_take_bonus's `not is_picked(skill)`. Without it the guard is
# one-directional: take athletics as the human's bonus, then pick it AGAIN from
# the calling's pool, and the draft is invalid ("bonus skill athletics is already
# proficient") — the player holding an invalid draft he did not cause and cannot
# see, which is the exact thing set_calling's comment forbids. The chip goes inert
# instead; the bonus row is right there and the player can hand it back.
if skill == bonus_skill:
return false
if skill not in skill_pool():
return false
return picks_left() > 0
func toggle_skill(skill: String) -> void:
if is_picked(skill):
skills.erase(skill)
return
if can_pick(skill):
skills.append(skill)
func wants_bonus_skill() -> bool:
return Races.wants_bonus_skill(race_id)
func can_take_bonus(skill: String) -> bool:
if not wants_bonus_skill():
return false
if not Skills.exists(skill):
return false
return not is_granted(skill) and not is_picked(skill)
func set_bonus_skill(skill: String) -> void:
if bonus_skill == skill:
bonus_skill = ""
return
if can_take_bonus(skill):
bonus_skill = skill
# ---------------------------------------------------------------- the contract
func to_creation() -> Dictionary:
## The plain Dictionary NewGame.construct takes. A saga (later) synthesizes one
## of these and skips this screen entirely — so it stays a plain Dictionary, and
## this scene must never become the only thing that can produce it.
return {
"name": name.strip_edges(),
"race_id": race_id,
"calling_id": calling_id,
"seed": character_seed, # the DICT key stays "seed" — the contract is unchanged
"spend": spend.duplicate(),
"skills": skills.duplicate(),
"bonus_skill": bonus_skill,
}
func errors(origin: Dictionary, world: ContentDB) -> Array:
## Delegates. The screen owns no second copy of the rules — it asks construct's
## validator what is wrong, and shows the answer.
return NewGame.validate(origin, world, to_creation())
func is_complete(origin: Dictionary, world: ContentDB) -> bool:
return errors(origin, world).is_empty()

View File

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

View File

@@ -19,6 +19,8 @@ extends Control
const HttpTransport = preload("res://scripts/net/http_transport.gd")
var service: DmService = null # injection seam; real HttpTransport built if null
var injected_log: CanonLog = null # injection seam; _build_seed_log() if null (F6/standalone)
var injected_state: GameState = null # injection seam; ShellState.seed() if null
var _state: ShellState
var _log: CanonLog
@@ -29,7 +31,7 @@ var _http: HTTPRequest
@onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens
@onready var _hp: Label = $Split/World/CommandBar/Vitals/HP
@onready var _mp: Label = $Split/World/CommandBar/Vitals/MP
@onready var _gold: Label = $Split/World/CommandBar/Vitals/Gold
@onready var _purse: Label = $Split/World/CommandBar/Vitals/Purse
@onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables
@@ -40,8 +42,10 @@ func _ready() -> void:
_fit_to_viewport()
get_viewport().size_changed.connect(_fit_to_viewport)
_state = ShellState.seed()
_log = _build_seed_log()
# The injected character wins; the seed calls are the F6/standalone fallback —
# same shape as `service == null` building a real transport below.
_state = ShellState.for_character(injected_state, injected_log) if injected_state != null else ShellState.seed()
_log = injected_log if injected_log != null else _build_seed_log()
_bind_state()
if service == null:
@@ -93,8 +97,8 @@ func _side_color(e: TurnEntry) -> Color:
func _bind_vitals() -> void:
_hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]]
_mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]]
_gold.text = "%d gp" % _state.vitals["gold"]
_gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
_purse.text = "%s" % Currency.format(_state.vitals["purse_copper"])
_purse.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
func _bind_consumables() -> void:
@@ -121,7 +125,7 @@ func _build_seed_log() -> CanonLog:
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
# number is rendered anywhere (§7); the descriptor is prose-only.
var log := CanonLog.new()
log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")
log.player = LogPlayer.new("Vexcca", "human", "sellsword", "Fortune spits on you")
log.set_location("lower_ward", "the Lower Ward")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))

View File

@@ -4,7 +4,7 @@ extends RefCounted
## 2a; later systems (combat vitals, inventory consumables) become the writers.
## No Luck/LCK value lives here — it is never surfaced (§7).
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0}
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "purse_copper": 0}
var turn_order: Array[TurnEntry] = []
var consumables: Array = []
var round_label: String = ""
@@ -19,7 +19,7 @@ func toggle_dock() -> bool:
static func seed() -> ShellState:
var s := ShellState.new()
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240}
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "purse_copper": 347}
s.turn_order = [
TurnEntry.new("EL", 17, &"you", true, false),
TurnEntry.new("DW", 12, &"ally"),
@@ -34,3 +34,19 @@ static func seed() -> ShellState:
s.location_label = "THE LOWER WARD"
s.dock_open = true
return s
static func for_character(state: GameState, log: CanonLog) -> ShellState:
## The real HUD state for a constructed character. Vitals + location come from
## the character (§2 — code owns them); turn_order/consumables/round_label are
## still seed() placeholders because combat (M5) and inventory (M7) own those.
var s := ShellState.seed() # start from the placeholders, then overwrite what is real
s.vitals = {
"hp": state.sheet.hp,
"hp_max": state.sheet.max_hp(),
"mp": state.sheet.mp,
"mp_max": state.sheet.max_mp(),
"purse_copper": state.purse_copper,
}
s.location_label = log.location.name
return s

View File

@@ -9,8 +9,6 @@ extends Control
signal menu_activated(id: StringName)
const SHELL := "res://scenes/shell/MainWindowShell.tscn"
var _rows: Array = []
var _sel: int = 0
@@ -44,7 +42,6 @@ func _ready() -> void:
_wire_row_mouse()
_footer_left.text = Version.new().footer()
menu_activated.connect(_on_menu_activated)
_update_selection()
@@ -128,16 +125,6 @@ func _activate(i: int) -> void:
menu_activated.emit(_rows[i].get_meta("action"))
func _on_menu_activated(id: StringName) -> void:
match id:
&"new_game":
get_tree().change_scene_to_file(SHELL)
&"quit":
get_tree().quit()
_:
pass # inert placeholder — the screen lands in a later milestone
func _update_selection() -> void:
for i in range(_rows.size()):
var row: Control = _rows[i]

View File

@@ -0,0 +1,761 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/creation/CharacterCreation.tscn"
const ContentDB = preload("res://scripts/content/content_db.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
func _world() -> ContentDB:
var w := ContentDB.new()
w.load_from(ContentDB.default_content_root())
return w
func _screen() -> CharacterCreation:
# Inject origin + world BEFORE _ready (add_child) — instantiate() does not run
# _ready(), add_child does. Same seam the shell uses for DmService.
var node = load(SCENE).instantiate()
node.world = _world()
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
add_child_autofree(node)
return node
func _screen_with_origin(origin: Dictionary) -> CharacterCreation:
# Same seam as _screen(), but with an origin the CALLER controls — used to
# prove the calling-gate actually gates, which the real "deserter" origin
# (all 7 callings allowed) cannot do: 7 shown == 7 allowed is trivially true
# whether or not the hide branch in _bind_callings exists at all.
var node = load(SCENE).instantiate()
node.world = _world()
node.origin = origin
add_child_autofree(node)
return node
func _complete_a_valid_draft(s: CharacterCreation) -> void:
## Drives the draft to a legal state by pressing the same AUTHORED NODES a
## player's clicks fire — not by calling the _on_*_pressed handlers directly.
## Calling the handler proves the handler works; it proves nothing about
## _wire() actually connecting the node's `pressed` signal to it. Pressing the
## node is the only path that exercises both at once.
s._name_edit.text = "Aldric"
s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text in a test.
s._race_cards[Races.IDS.find("human")].pressed.emit()
s._calling_cards[s._allowed_callings().find("sellsword")].pressed.emit()
var pool: Array = s.draft.skill_pool()
for i in range(pool.size()):
s._pool_chips[i].pressed.emit()
for i in range(Skills.IDS.size()):
if s.draft.can_take_bonus(Skills.IDS[i]):
s._bonus_chips[i].pressed.emit()
break
func test_screen_applies_theme_and_fits_viewport():
var packed = load(SCENE)
assert_true(packed is PackedScene)
var s := _screen()
assert_true(s is Control)
assert_true(s.theme is Theme, "the screen pulls the shared theme")
# The .tscn root is authored at exactly 1920x1080 (project.godot's viewport
# size too) — assert_gt(s.size.x, 0.0) passes on the AUTHORED offsets alone,
# even with _fit_to_viewport() and its size_changed connection deleted
# outright. Prove the fit is actually live: change the window's reported
# resolution to something that is NOT 1920x1080 and assert the root followed.
#
# get_viewport().size / get_window().size do not move get_viewport_rect() —
# this project runs with window/stretch/mode="canvas_items", so a Control's
# visible rect tracks content_scale_size, not the raw window pixel size.
# content_scale_size is the property that actually reaches Viewport.size_changed
# here, headless included.
var win := s.get_window()
var original_scale_size: Vector2i = win.content_scale_size
win.content_scale_size = Vector2i(1600, 900)
assert_eq(s.size, Vector2(1600, 900),
"the root must follow a resize through _fit_to_viewport()'s size_changed hook (the M3-a collapse guard)")
win.content_scale_size = original_scale_size
assert_eq(s.size, Vector2(1920, 1080), "restoring the viewport must restore the root, or later tests see a stale size")
func test_a_draft_exists_with_a_seed_on_load():
var s := _screen()
assert_not_null(s.draft)
assert_ne(s.draft.character_seed, 0)
func test_five_ability_cards_never_six():
# §7 — LCK has no card, no row, no tooltip. The player must never be able to
# CALCULATE that he is cursed.
#
# _ability_cards is populated by _collect_authored_nodes() looping
# `range(5)` — that loop is 5 by construction and would stay 5 even if the
# .tscn grew a 6th card. Assert against the SCENE's actual children instead.
var s := _screen()
assert_eq(s._ability_cards.size(), 5)
assert_eq(s._ability_row.get_child_count(), 5,
"five authored ability cards — the scene, not the script's range()")
assert_null(s._ability_row.get_node_or_null("Ab5"),
"there is no sixth ability card, and there never is (§7)")
for card in s._ability_cards:
assert_false(card.name.to_lower().contains("lck"))
func test_no_node_on_the_screen_says_luck():
# Sweep every Control, not just Label — RichTextLabel (_origin_text,
# _detail_blurb, the only two nodes rendering authored ContentDB prose)
# extends Control, not Label, and a `find_children(..., "Label", ...)` filter
# never looks at it. Buttons (`text`) and the LineEdit (`placeholder_text`)
# are swept too.
#
# The two RichTextLabels render race.fragment / calling.fragment /
# calling.blurb, which DIFFER per race and per calling. Checking only the
# default binding (human + sellsword) means a "luck" in, say, the Reaver's
# blurb or the Beastfolk's fragment would reach the screen and never be seen.
# Rebind across every race x every allowed calling (the deserter origin used
# by _screen() allows all 7, so this is a full 4 x 7 = 28-combination sweep).
var s := _screen()
var checked_a_rich_text_label := false
var combos_checked := 0
# The word "luck" is not the only thing that leaks Luck. Luck.BANDS holds the
# DESCRIPTORS — "the dice are kind today" — and a descriptor on this screen is
# strictly worse than the number: the player would re-roll until his Luck read
# well, which is Luck made CALCULABLE, the precise §7 failure. It is structurally
# unreachable today (the screen holds no GameState and the draft has no LCK), and
# it costs three lines to keep it that way.
var forbidden: Array = ["luck", "lck"]
for band in Luck.BANDS:
forbidden.append(str(band["text"]).to_lower())
for race_i in range(Races.IDS.size()):
s._race_cards[race_i].pressed.emit()
var allowed: Array = s._allowed_callings()
for calling_i in range(allowed.size()):
s._calling_cards[calling_i].pressed.emit()
combos_checked += 1
var combo := "%s + %s" % [Races.IDS[race_i], allowed[calling_i]]
for node in s.find_children("*", "Control", true, false):
if node is RichTextLabel:
checked_a_rich_text_label = true
for prop in ["text", "placeholder_text", "tooltip_text"]:
var value: Variant = node.get(prop)
if typeof(value) != TYPE_STRING:
continue
var lowered: String = value.to_lower()
for word in forbidden:
assert_false(lowered.contains(word),
"%s.%s leaks Luck (§7) at %s: '%s'" % [node.name, prop, combo, word])
# `checked_a_rich_text_label` alone only proves the sweep REACHED a
# RichTextLabel, not that it had anything in it — an empty label would
# pass the Luck check for a reason that has nothing to do with §7.
#
# But _bind_bay() and _bind_callings() both wrap their 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 fragment is
# blank. So `"[i][/i]".strip_edges().is_empty()` is FALSE — the wrapper
# alone would pass this guard even if ContentDB served nothing at all.
# Assert the labels render the WORLD's own strings, not just non-blankness.
var race_id: String = Races.IDS[race_i]
var calling_id: String = allowed[calling_i]
var race_frag: String = str(s.world.race(race_id).get("fragment", s.world.race(race_id).get("blurb", "")))
var calling_frag: String = str(s.world.calling(calling_id).get("fragment", s.world.calling(calling_id).get("blurb", "")))
var calling_blurb: String = str(s.world.calling(calling_id).get("blurb", ""))
assert_false(race_frag.strip_edges().is_empty(),
"race %s has no fragment (or blurb) to render — the content itself must not be blank" % race_id)
assert_false(calling_frag.strip_edges().is_empty(),
"calling %s has no fragment (or blurb) to render — the content itself must not be blank" % calling_id)
assert_false(calling_blurb.strip_edges().is_empty(),
"calling %s has no blurb to render — the content itself must not be blank" % calling_id)
assert_true(s._origin_text.text.contains(race_frag),
"the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [race_id, combo])
assert_true(s._origin_text.text.contains(calling_frag),
"the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [calling_id, combo])
assert_true(s._detail_blurb.text.contains(calling_blurb),
"the detail panel must render %s's own blurb, not just non-empty BBCode, at %s" % [calling_id, combo])
assert_true(checked_a_rich_text_label,
"the sweep must actually reach the RichTextLabels or it is checking nothing")
assert_eq(combos_checked, Races.IDS.size() * Callings.IDS.size(),
"every race must be checked against every allowed calling — 4 x 7, not just the default binding")
func test_only_the_origin_s_allowed_callings_are_shown():
# The deserter origin allows all 7 callings, so against it "shown == allowed"
# is a trivial 7 == 7 that would stay green even with the hide branch in
# _bind_callings deleted outright. Use a RESTRICTIVE origin, deliberately out
# of Callings.IDS order, so the gate is doing real work.
var allowed := ["bonesetter", "sellsword", "cutpurse"]
var s := _screen_with_origin({"build_constraints": {"allowed_callings": allowed}})
var expected_order: Array = []
for id in Callings.IDS: # Callings.IDS fixes the ORDER
if id in allowed: # the origin fixes the SET
expected_order.append(id)
assert_eq(expected_order.size(), 3)
var shown := 0
for i in range(s._calling_cards.size()):
var card: Button = s._calling_cards[i]
if i < expected_order.size():
assert_true(card.visible, "calling %s must be shown" % expected_order[i])
var expected_name: String = str(
s.world.calling(expected_order[i]).get("name", expected_order[i].capitalize()))
assert_eq(card.get_node("Box/Name").text, expected_name,
"visible cards must appear in Callings.IDS order, not the origin's list order")
shown += 1
else:
assert_false(card.visible,
"a calling the origin disallows stays hidden, not deleted")
assert_eq(shown, 3)
func test_the_deserter_origin_shows_all_seven_callings():
var s := _screen()
var shown := 0
for card in s._calling_cards:
if card.visible:
shown += 1
assert_eq(shown, 7)
func test_a_degenerate_origin_disables_entry_without_throwing():
# §13 — a degrade, never a crash. An origin with no build_constraints at all
# (or an empty allowed_callings list) must leave the draft with no calling to
# invent, every calling card hidden, and the CTA disabled — quietly.
#
# NOT {} — CharacterCreation._ready() treats an EMPTY origin Dictionary as
# "none supplied" and substitutes the deserter origin (`origin.is_empty()`),
# which allows all 7 callings and is not degenerate at all. A non-empty
# Dictionary that simply carries no build_constraints is the real degenerate
# case §13's comment in the source describes.
var s := _screen_with_origin({"id": "test-degenerate-origin"})
assert_eq(s.draft.calling_id, "",
"no allowed callings means no calling to default into")
for card in s._calling_cards:
assert_false(card.visible, "every calling card hides when none are allowed")
assert_true(s._enter.disabled, "a calling-less draft can never be legal")
func test_authored_node_counts_match_the_rules_tables():
# _collect_authored_nodes() hardcodes 4/7/6/9/5. A table growing (a 5th race,
# an 8th calling, a 10th skill) must go red HERE, before a binder silently
# under-renders or indexes a table out of range at runtime.
var s := _screen()
assert_eq(s._race_cards.size(), Races.IDS.size())
assert_eq(s._calling_cards.size(), Callings.IDS.size())
assert_eq(s._bonus_chips.size(), Skills.IDS.size())
assert_eq(s._ability_cards.size(), Attributes.IDS.size())
# The pool row's 6 is the WIDEST calling's skill pool, not a table length —
# assert it covers every calling, so a 7-wide pool added later goes red
# instead of silently dropping a chip.
for id in Callings.IDS:
assert_true(s._pool_chips.size() >= Callings.skill_pool(id).size(),
"the pool row must fit %s's skill pool" % id)
# The above ties the SCRIPT's arrays to the tables. It says nothing about the
# SCENE — an orphan authored card (a stray "Race4" nobody wired or hid) would
# render, unbound and visible, with every assertion above still green. Tie the
# rows' actual child counts to the same tables.
assert_eq(s._race_row.get_child_count(), Races.IDS.size(),
"an orphan authored race card would sit here, unbound and visible")
assert_eq(s._calling_row.get_child_count(), Callings.IDS.size(),
"an orphan authored calling card would sit here, unbound and visible")
# BonusRow's first child is its own section Label ("HUMAN — ONE MORE, ANY
# SKILL") — not a chip — so its total is one MORE than Skills.IDS.size().
assert_eq(s._bonus_row.get_child_count(), Skills.IDS.size() + 1,
"an orphan authored bonus chip would sit here, unbound and visible")
# PoolRow was the one row the sweep above forgot — a stray authored "Pool6" would
# render unbound and visible with every assertion so far still green, which is
# exactly the bug this test exists to catch. Its count is not a table LENGTH: it
# is the widest calling's pool (plus its own "Count" label, which is not a chip),
# so derive that from the rules table rather than writing 6 down again.
var widest := 0
for id in Callings.IDS:
widest = maxi(widest, Callings.skill_pool(id).size())
assert_eq(widest, s._pool_chips.size(), "the pool row must hold exactly the widest calling's pool")
assert_eq(s._pool_row.get_child_count(), widest + 1,
"an orphan authored pool chip would sit here, unbound and visible (PoolRow also holds its Count label)")
assert_null(s._pool_row.get_node_or_null("Pool%d" % widest),
"there is no chip past the widest calling's pool — a stray Pool%d renders unbound" % widest)
assert_eq(s._pool_count.get_parent(), s._pool_row, "the +1 above is the Count label, and nothing else")
func test_cta_is_disabled_until_the_draft_is_legal():
var s := _screen()
assert_true(s._enter.disabled, "an empty draft cannot enter the world")
assert_ne(s._error.text, CharacterCreation.CTA_HINT, "the screen says WHY, dryly")
assert_ne(s._error.text.strip_edges(), "")
_complete_a_valid_draft(s)
assert_false(s._enter.disabled)
assert_eq(s._error.text, CharacterCreation.CTA_HINT,
"a legal draft gets the mock's line back, not a blank")
func test_the_nag_under_the_cta_is_authored_copy_and_never_a_validator_string():
# §13. The label used to read `str(errors[0])` — so the second screen of the game
# showed the player "sellsword picks 2 skills, got 0". Drive the screen through
# every invalid state it can actually reach, by pressing the nodes, and pin what
# the label says. Anchored to the authored phrases themselves: "contains no
# underscore" alone would pass on a blank label, or on the wrong copy.
var s := _screen() # human + sellsword, nameless, nothing picked
var seen: Array = []
assert_eq(s._error.text, "name yourself", "the first thing missing is a name")
seen.append(s._error.text)
s._name_edit.text = "Aldric"
s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text
assert_eq(s._error.text, "choose two more proficiencies",
"the sellsword picks 2 and has none — and the two is DERIVED from Callings.skill_count")
seen.append(s._error.text)
s._pool_chips[0].pressed.emit()
assert_eq(s.draft.skills.size(), 1, "the pick landed, or the line below is about nothing")
assert_eq(s._error.text, "choose one more proficiency", "one is singular")
seen.append(s._error.text)
s._pool_chips[1].pressed.emit()
assert_eq(s.draft.skills.size(), 2)
assert_eq(s._error.text, "one more proficiency, any of them — take it",
"the human's bonus skill, in the Margreave's voice, not 'human must choose a bonus skill'")
seen.append(s._error.text)
for i in range(Skills.IDS.size()):
if s.draft.can_take_bonus(Skills.IDS[i]):
s._bonus_chips[i].pressed.emit()
break
assert_eq(s._error.text, CharacterCreation.CTA_HINT, "a legal draft gets the mock's line back")
# Nothing above may be an engineering string, and none of them may repeat.
assert_eq(seen.size(), 4)
for line in seen:
assert_false(line.contains("_"), "a snake_case id reached the player: %s" % line)
assert_false(line.contains("got "), "the validator's grammar reached the player: %s" % line)
var unique := {}
for line in seen:
unique[line] = true
assert_eq(unique.size(), 4, "each invalid state says a DIFFERENT thing — the nag is telling him what is missing")
func test_a_skill_taken_as_the_bonus_goes_inert_in_the_calling_s_pool():
# The one-directional guard: can_take_bonus() refused an already-PICKED skill, but
# can_pick() ignored the bonus. Human + Sellsword, take athletics as the bonus,
# then press athletics in the pool — the chip was live and the pick LANDED, leaving
# a draft NewGame.validate rejects ("bonus skill athletics is already proficient").
var s := _screen() # human + sellsword
var pool: Array = s.draft.skill_pool()
var athletics_i: int = pool.find("athletics")
var bonus_i: int = Skills.IDS.find("athletics")
assert_ne(athletics_i, -1, "athletics must be in the sellsword's pool or this proves nothing")
assert_false(s._pool_chips[athletics_i].disabled, "the chip starts live, or the assertion below is trivially true")
s._bonus_chips[bonus_i].pressed.emit()
assert_eq(s.draft.bonus_skill, "athletics")
assert_true(s._pool_chips[athletics_i].disabled,
"a skill the bonus already bought must render INERT in the pool")
# Button.pressed fires even on a disabled Button — the rule lives in the draft, not
# in the styling, so press it and prove the pick does not land.
s._pool_chips[athletics_i].pressed.emit()
assert_false(s.draft.is_picked("athletics"), "the pick must not land")
assert_false("bonus skill athletics is already proficient" in s.draft.errors(s.origin, s.world),
"the player must not be able to press his way into an invalid draft")
# And it is recoverable: hand the bonus back and the chip is live again.
s._bonus_chips[bonus_i].pressed.emit()
assert_eq(s.draft.bonus_skill, "")
assert_false(s._pool_chips[athletics_i].disabled, "handing the bonus back makes the pool chip live")
s._pool_chips[athletics_i].pressed.emit()
assert_true(s.draft.is_picked("athletics"))
func test_pressing_enter_emits_exactly_the_draft_s_creation_dict():
var s := _screen()
_complete_a_valid_draft(s)
watch_signals(s)
s._enter.pressed.emit()
assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()])
func test_the_screen_never_constructs_the_character():
# The flow is M4-c's job, and a saga later synthesizes the same Dictionary and
# skips this scene entirely. The screen PRODUCES the dict; it does not spend it.
var s := _screen()
assert_false(s.has_method("construct"))
func test_the_displayed_scores_are_the_draft_s_scores():
var s := _screen()
_complete_a_valid_draft(s)
# A fresh, unspent draft has final() == rolled() for EVERY stat, so comparing
# the Value label against final() alone cannot tell a `final`/`rolled` swap in
# the ability binder apart from the real thing. Spend a point — by pressing
# the actual Plus BUTTON, not _on_plus_pressed, so a broken _wire() would also
# be caught here — so final() and rolled() diverge and the assertion below has
# to actually choose one.
var spent_stat: String = Attributes.IDS[0]
s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit()
assert_eq(int(s.draft.spend.get(spent_stat, 0)), 1, "the spend actually landed")
var rolled: Dictionary = s.draft.rolled()
var final: Dictionary = s.draft.final()
assert_eq(int(final[spent_stat]), int(rolled[spent_stat]) + 1,
"spending a point must move final away from rolled, or nothing below distinguishes them")
for i in range(Attributes.IDS.size()):
var stat: String = Attributes.IDS[i]
var card = s._ability_cards[i]
var value_label: Label = card.get_node("Box/Value")
var rolled_label: Label = card.get_node("Box/Rolled")
assert_eq(value_label.text, str(int(final[stat])),
"%s's Value must show final(), not rolled()" % stat)
var spent: int = int(s.draft.spend.get(stat, 0))
var expected_rolled_text: String = "rolled %d%s" % [
int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""]
assert_eq(rolled_label.text, expected_rolled_text,
"%s's Rolled sub-label must show the roll and the spend, nothing else covers it" % stat)
func test_reroll_changes_what_is_on_screen():
var s := _screen()
# Spend a point BEFORE the re-roll, by pressing the actual Plus button — a
# fresh draft's pool already reads "points left: 3" with nothing spent, so
# asserting that value after reroll alone proves nothing about whether
# reroll() actually clears the spend.
s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit()
assert_eq(s._points.text, "points left: 2", "the spend landed before the reroll")
var before := s.draft.character_seed
s._reroll.pressed.emit()
assert_ne(s.draft.character_seed, before)
assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool")
func test_pressing_a_race_card_sets_the_draft_s_race():
# Nothing in the suite ever emits a card's `pressed` signal — every existing
# test called _on_race_pressed directly. A mis-wired card (bound to the wrong
# index, or not connected at all — see _wire()) would leave every other test
# green. Press the elf card itself.
var s := _screen()
var elf_i: int = Races.IDS.find("elf")
s._race_cards[elf_i].pressed.emit()
assert_eq(s.draft.race_id, "elf",
"the CARD's pressed signal must reach the draft through _wire()")
assert_true(s._race_cards[elf_i].get_node("Tag").visible, "the chosen card shows its tag")
func test_pressing_a_calling_card_sets_the_draft_s_calling():
var s := _screen()
var idx: int = s._allowed_callings().find("cutpurse")
s._calling_cards[idx].pressed.emit()
assert_eq(s.draft.calling_id, "cutpurse",
"the CARD's pressed signal must reach the draft through _wire()")
func test_pressing_a_pool_chip_toggles_the_skill():
var s := _screen()
var pool: Array = s.draft.skill_pool()
var skill: String = pool[0]
s._pool_chips[0].pressed.emit()
assert_true(s.draft.is_picked(skill),
"the CHIP's pressed signal must reach toggle_skill through _wire()")
s._pool_chips[0].pressed.emit()
assert_false(s.draft.is_picked(skill), "pressing it again toggles it back off")
func test_pressing_a_bonus_chip_sets_the_bonus_skill():
var s := _screen() # human is the default race, and wants a bonus skill
var idx := -1
for i in range(Skills.IDS.size()):
if s.draft.can_take_bonus(Skills.IDS[i]):
idx = i
break
assert_ne(idx, -1, "the default draft (human) must have a takeable bonus skill")
s._bonus_chips[idx].pressed.emit()
assert_eq(s.draft.bonus_skill, Skills.IDS[idx],
"the CHIP's pressed signal must reach set_bonus_skill through _wire()")
func test_pressing_the_minus_button_returns_a_spent_point():
# Nothing else in the suite ever presses Minus — every prior test only ever
# spent points, never gave one back.
var s := _screen()
var stat: String = Attributes.IDS[0]
var card = s._ability_cards[0]
card.get_node("Box/Buttons/Plus").pressed.emit()
assert_eq(int(s.draft.spend.get(stat, 0)), 1)
card.get_node("Box/Buttons/Minus").pressed.emit()
assert_eq(int(s.draft.spend.get(stat, 0)), 0,
"the MINUS button's pressed signal must reach the draft through _wire()")
assert_eq(s._points.text, "points left: 3")
func test_minus_is_disabled_at_the_rolled_floor_plus_at_the_spent_pool():
var s := _screen()
var card = s._ability_cards[0]
var plus: Button = card.get_node("Box/Buttons/Plus")
var minus: Button = card.get_node("Box/Buttons/Minus")
assert_true(minus.disabled, "nothing has been spent yet — minus must be inert at the rolled floor")
plus.pressed.emit()
assert_false(minus.disabled, "a spent point makes minus live again")
# Drain the whole pool (SPEND_POOL == 3) into this one stat. points_left() is
# POOL-wide, not per-card, so every Plus on the screen must go inert together.
# A BOUNDED loop, deliberately — `while points_left() > 0` would hang forever
# if _wire() were ever broken (the press would never reach the draft), turning
# a guard meant to catch that exact bug into a stuck test run instead of a red one.
var pool_size: int = s.draft.points_left()
for i in range(pool_size + 1):
plus.pressed.emit()
assert_eq(s.draft.points_left(), 0)
assert_true(plus.disabled, "the pool is spent — this card's Plus must go inert")
var other_plus: Button = s._ability_cards[1].get_node("Box/Buttons/Plus")
assert_true(other_plus.disabled, "points_left() is pool-wide — every card's Plus disables together")
func test_a_race_granted_skill_chip_is_inert_and_so_is_a_chip_once_picks_are_spent():
# Two real binder behaviours in one chip's line
# (chip.disabled = draft.is_granted(skill) or (not is_picked(skill) and not can_pick(skill)))
# that nothing in the suite observed. Elf grants "perception"; Cutpurse's pool
# has 6 skills (one of them "perception") and needs 4 picks — the only
# race/calling pairing where both halves of that OR are exercised at once.
var s := _screen()
s._race_cards[Races.IDS.find("elf")].pressed.emit()
var cutpurse_i: int = s._allowed_callings().find("cutpurse")
s._calling_cards[cutpurse_i].pressed.emit()
var pool: Array = s.draft.skill_pool()
var perception_i: int = pool.find("perception")
assert_ne(perception_i, -1, "cutpurse's pool must include perception for this test to mean anything")
assert_true(s._pool_chips[perception_i].disabled,
"a race-granted skill must be inert — the player already has it for free")
# Spend every pick on the OTHER skills in the pool, leaving exactly one behind.
var picked := 0
var left_behind_i := -1
for i in range(pool.size()):
if i == perception_i:
continue
if picked < s.draft.picks_needed():
s._pool_chips[i].pressed.emit()
picked += 1
else:
left_behind_i = i
assert_eq(picked, s.draft.picks_needed(), "the pool must actually be spent for this test to mean anything")
assert_ne(left_behind_i, -1, "cutpurse's pool must be wider than its pick count for this test to mean anything")
assert_true(s._pool_chips[left_behind_i].disabled,
"once the picks are spent, the remaining UNPICKED chip must go inert too")
assert_false(s._pool_chips[perception_i].button_pressed,
"the granted chip was never toggled — it was inert from the start")
func test_bonus_row_shows_only_for_the_race_that_wants_one():
# _bonus_row.visible = draft.wants_bonus_skill() — nothing in the suite checks
# this. Human wants a bonus skill; the other three races do not.
var s := _screen() # default race is human
assert_true(s._bonus_row.visible, "human wants a bonus skill choice")
for race_id in ["elf", "dwarf", "beastfolk"]:
s._race_cards[Races.IDS.find(race_id)].pressed.emit()
assert_false(s._bonus_row.visible, "%s does not want a bonus skill choice" % race_id)
s._race_cards[Races.IDS.find("human")].pressed.emit()
assert_true(s._bonus_row.visible, "switching back to a bonus-wanting race must show the row again")
func test_ability_card_surfaces_the_calling_s_primary_stat():
# The RACE card's Tag is checked (test_pressing_a_race_card_sets_the_draft_s_race);
# the ability card's equivalent — Tag.visible and theme_type_variation marking
# which of the five stats is the calling's primary — is not.
var s := _screen()
var idx: int = s._allowed_callings().find("cutpurse")
s._calling_cards[idx].pressed.emit()
var primary: String = Callings.primary("cutpurse")
assert_eq(primary, "dex", "cutpurse's primary must be dex for this test to mean anything")
for i in range(Attributes.IDS.size()):
var stat: String = Attributes.IDS[i]
var card: PanelContainer = s._ability_cards[i]
var is_primary := (stat == primary)
assert_eq(card.get_node("Overlay/Tag").visible, is_primary,
"%s's Tag must show only when it is the calling's primary stat" % stat)
var expected_variation: StringName = ThemeKeys.PRIMARY_CARD if is_primary else ThemeKeys.ABILITY_CARD
assert_eq(card.theme_type_variation, expected_variation,
"%s's card style must reflect whether it is the primary stat" % stat)
func test_pressing_enter_on_an_illegal_draft_emits_nothing():
# _on_enter_pressed's early-out (`if not draft.is_complete(): return`) is the
# one branch in the file nothing else in the suite enters — every other Enter
# test first drives the draft to a legal state. Button.pressed still fires even
# on a disabled Button, so the early-out must be proven explicitly.
var s := _screen()
assert_true(s._enter.disabled, "an empty draft must start illegal, or this test proves nothing")
watch_signals(s)
s._enter.pressed.emit()
assert_signal_not_emitted(s, "creation_confirmed",
"an illegal draft must never reach creation_confirmed, even when Enter fires anyway")
# ------------------------------------------- the screen a human actually has to read
## Luminance gap a Label must clear against the surface it is drawn on. Every styled
## Label on the sheet measures >= 0.419 (the dimmest is SectionLabel's ink); an UNSTYLED
## Label — Godot's built-in Label colour is WHITE — measures 0.112 on parchment. The
## threshold sits in that gap, nearer the failure so it cannot be squeaked past.
const MIN_CONTRAST := 0.30
func _labels_under(n: Node, out: Array = []) -> Array:
for c in n.get_children():
if c is Label:
out.append(c)
_labels_under(c, out)
return out
func _laid_out() -> void:
## Geometry assertions are meaningless until the container tree has actually sorted.
## On the frame add_child() runs, a race card reports 102px wide and its autowrapped
## blurb reports a 2508px minimum height (one unwrapped line) — numbers that would
## make a size guard fail for the wrong reason, or pass for one.
await get_tree().process_frame
await get_tree().process_frame
func test_no_label_on_the_parchment_sheet_is_unreadable():
# The race and calling names, and the race blurbs, SHIPPED INVISIBLE: 15 Labels were
# left with no type-variation, so they inherited Godot's default Label colour — white
# — and rendered white-on-parchment. The trait line beneath them was the only readable
# string on the card. Nothing in the suite could see it, because every assertion was
# about text CONTENT, and the content was correct. It was the colour that was wrong.
#
# This is TOTAL: no Label is skipped. A Label that paints its own background (the
# CHOSEN / PRIMARY pills) is judged against THAT background rather than the sheet, so
# it is checked, not exempted — a whitelist here would be the same hole traps.md #16
# describes, where the guard's own `continue` covered the gap it was hunting.
var s := _screen()
var sheet: Control = s.get_node("Split/Sheet")
var parchment := Palette.SHEET_TOP.get_luminance()
var checked := 0
for lab in _labels_under(sheet):
var col: Color = lab.get_theme_color(&"font_color")
var bg := parchment
var surface := "the parchment sheet"
var sb: StyleBox = lab.get_theme_stylebox(&"normal")
if sb is StyleBoxFlat and (sb as StyleBoxFlat).bg_color.a > 0.0:
bg = (sb as StyleBoxFlat).bg_color.get_luminance()
surface = "its own pill"
checked += 1
assert_gt(absf(col.get_luminance() - bg), MIN_CONTRAST,
"'%s' (variation '%s') is unreadable on %s: text luminance %.3f vs surface %.3f" % [
lab.name, str(lab.theme_type_variation), surface, col.get_luminance(), bg])
# 15 Labels carried the bug. If this sweep ever walks fewer than that it has stopped
# reaching the cards, and a green result would mean nothing.
assert_gt(checked, 15, "the sweep must actually reach the sheet's labels, not an empty subtree")
func test_the_primary_badge_rides_the_card_edge_instead_of_covering_the_roll():
# The ability card is a PanelContainer. A Container FORCE-FITS its children, so the
# badge's authored anchors were dead letters: it stretched to the card's full content
# width (200 of 224px) and centred itself vertically, straight across the rolled value
# — measured, not guessed. Nesting it under a plain Control (NOT a Container) restores
# absolute positioning. These three assertions are the three ways that regressed.
var s := _screen()
_complete_a_valid_draft(s)
await _laid_out()
var primary: String = Callings.primary("sellsword")
var i: int = Attributes.IDS.find(primary)
assert_gt(i, -1, "the sellsword's primary must be a real attribute, or this test proves nothing")
var card: PanelContainer = s._ability_cards[i]
var tag: Label = card.get_node("Overlay/Tag")
var value: Label = card.get_node("Box/Value")
assert_true(tag.visible, "the primary card must show its badge, or the geometry below is untested")
var tag_r := tag.get_global_rect()
var card_r := card.get_global_rect()
var value_r := value.get_global_rect()
assert_lt(tag_r.position.y, card_r.position.y,
"the badge must RIDE the card's top edge, not sit inside the card")
assert_lt(tag_r.size.x, card_r.size.x * 0.6,
"the badge is a flag, not a banner — it stretched to the card's full width (%.0f of %.0f)" % [
tag_r.size.x, card_r.size.x])
assert_false(tag_r.intersects(value_r),
"the badge is covering the rolled value: badge %s vs value %s" % [tag_r, value_r])
func test_no_card_overflows_its_own_border():
# "+1 skill of choice" and "Claws · Keen scent" rendered BELOW the race card's bottom
# border: the card's height is a fixed custom_minimum_size, and its Box is anchored
# inside it rather than being a container child — so content CANNOT push the card
# taller, it just spills out. Every card must be authored tall enough for its own text.
var s := _screen()
await _laid_out()
var cards: Array = []
cards.append_array(s._race_cards)
cards.append_array(s._calling_cards)
assert_gt(cards.size(), 4, "no cards reached the sweep")
# Measure against the CARD, never against the Box's own size. Godot clamps a Control's
# size UP to its combined minimum, so box.size.y is forced equal to the very number it
# would be compared against — `needed <= box.size.y` is a tautology that stays green
# with the card at its original, overflowing 108px. It was written that way first and
# it passed against the bug it names (traps.md: a green suite is evidence only if the
# test can fail). The card's height is the thing the text actually spills out of.
for card in cards:
var box: VBoxContainer = card.get_node("Box")
var needed := box.get_combined_minimum_size().y
# The Box is anchored inside the card; offset_bottom is negative, so this is the
# card's height less its top and bottom insets.
var available: float = card.size.y - box.offset_top + box.offset_bottom
assert_lte(needed, available,
"'%s' overflows its card: content needs %.0fpx, the card leaves %.0fpx" % [
box.get_node("Name").text, needed, available])
func test_the_hint_label_advertises_the_floor_the_roll_actually_enforces():
# The label used to say "rolled 3d6" and stop there, but the roll is maxi(8, 3d6)
# (new_game.gd) — so a player who rolled low read a label that looked like it was
# lying to him. The label now names the floor, and this pins it TO THE CODE.
#
# The floor is OBSERVED, never hardcoded: roll a deterministic spread of seeds and
# take the minimum the player can actually be dealt. Retune maxi(8, ..) to 7 and the
# observed floor becomes 7, the label still claims 8, and this goes red. Delete the
# clamp and the floor falls to 3 — red. Reword the label past the claim — red.
#
# It costs the phrase "never below N": reword that and update this string, do not
# delete the guard. The number must keep coming from the roll.
var observed_floor := 99
for seed_value in range(1, 201): # deterministic — no RNG in the test itself
for stat in Attributes.IDS:
observed_floor = mini(observed_floor, int(NewGame.roll_attributes(seed_value)[stat]))
# ~16% of 3d6 lands at or under 7, so across 1000 rolls the minimum is the CLAMP,
# not the luck of the draw. Assert the clamp is actually binding rather than trusting
# that: an unclamped 3d6 reaches 3, and a floor at or below 3 would make the pin vacuous.
assert_true(observed_floor > 3,
"the roll's minimum is %d — the clamp is not binding, so the label's floor claim is a lie" % observed_floor)
var hint: Label = _screen().get_node("Split/Sheet/Body/AbilitySection/Head/Hint")
assert_string_contains(hint.text, "never below %d" % observed_floor)
assert_string_contains(hint.text, "3d6")

View File

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

View File

@@ -0,0 +1,91 @@
extends "res://addons/gut/test.gd"
const CharacterSheet = preload("res://scripts/state/character_sheet.gd")
func _sheet(race := "human", calling := "sellsword", attrs := {}) -> CharacterSheet:
var s = CharacterSheet.new()
s.race_id = race
s.calling_id = calling
s.attributes = {"str": 14, "dex": 12, "con": 14, "fth": 10, "mag": 8}
for k in attrs:
s.attributes[k] = attrs[k]
return s
func test_max_hp_is_hit_die_plus_con_mod():
var s = _sheet("human", "reaver") # d12, CON 14 -> +2
assert_eq(s.max_hp(), 14)
var frail = _sheet("human", "hedge_mage", {"con": 8}) # d6, CON 8 -> -1
assert_eq(frail.max_hp(), 5)
func test_ac_is_ten_plus_dex():
assert_eq(_sheet().ac(), 11) # DEX 12 -> +1. Armor is an item (M7).
func test_initiative_is_dex():
assert_eq(_sheet().initiative(), 1)
func test_save_picks_up_calling_proficiency():
var s = _sheet("elf", "sellsword") # saves STR, CON; prof +2
assert_eq(s.save("str"), 4) # STR 14 -> +2, +2 prof
assert_eq(s.save("dex"), 1) # DEX 12 -> +1, not proficient
func test_human_plus_one_lands_on_all_five_saves():
var human = _sheet("human", "sellsword")
var elf = _sheet("elf", "sellsword")
for stat in ["str", "dex", "con", "fth", "mag"]:
assert_eq(human.save(stat), elf.save(stat) + 1, "human +1 on %s" % stat)
func test_dwarf_poison_bonus_is_not_in_the_con_save():
var dwarf = _sheet("dwarf", "sellsword")
var elf = _sheet("elf", "sellsword")
assert_eq(dwarf.save("con"), elf.save("con"), "the +2 is situational, not a flat CON save")
assert_eq(dwarf.poison_save_bonus(), 2)
assert_eq(elf.poison_save_bonus(), 0)
func test_skill_bonus_uses_the_governing_attribute():
var s = _sheet() # STR 14 -> +2
assert_eq(s.skill_bonus("athletics"), 2, "not proficient: attribute only")
s.skills = ["athletics"]
assert_eq(s.skill_bonus("athletics"), 4, "proficient: +2 prof")
func test_bonus_skill_confers_proficiency_via_the_skills_list():
var s = _sheet()
s.skills = ["athletics", "endurance"] # Task 5 appends the human's pick here
s.bonus_skill = "endurance"
assert_true(s.is_proficient("endurance"))
assert_false(s.is_proficient("stealth"))
func test_martials_have_no_mp_and_no_spell_dc():
var s = _sheet("human", "sellsword")
assert_eq(s.max_mp(), 0)
assert_eq(s.spell_dc(), 0)
func test_casters_have_mp_and_a_spell_dc():
var s = _sheet("human", "bonesetter", {"fth": 16}) # FTH 16 -> +3
assert_true(s.max_mp() > 0, "a caster has a pool")
assert_eq(s.spell_dc(), 13) # 8 + 2 prof + 3
func test_race_flags_are_exposed():
assert_true(_sheet("elf").has_nightsight())
assert_false(_sheet("human").has_nightsight())
assert_true(_sheet("beastfolk").has_claws())
assert_true(_sheet("beastfolk").has_keen_scent())
assert_false(_sheet("dwarf").has_claws())
func test_luck_is_nowhere_on_the_sheet():
# §7: LCK has no save, no skill, no row, no accessor. It lives in GameState.
var s = _sheet()
assert_false("lck" in s.attributes, "LCK is never an attribute row")
assert_false(s.has_method("luck"), "the sheet must not expose Luck")

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,24 @@ func test_loads_world_ids():
assert_true(db.has_location("greywater_docks"))
assert_true(db.has_quest("find_the_ledger"))
assert_true(db.has_item("worn_shortsword"))
assert_true(db.has_item("coin"))
assert_true(db.has_item("copper"))
assert_true(db.has_item("silver"))
assert_true(db.has_item("gold"))
assert_false(db.has_item("coin"), "the undifferentiated coin is gone")
func test_currency_items_agree_with_the_code():
# The denomination ids exist twice — in code (Currency.VALUES, because the
# ratios are rules) and in content (slot: "currency"). Guard the two against
# drifting apart, since nothing at runtime reconciles them.
var content_ids: Array = []
for id in db.items:
if db.items[id].get("slot", "") == "currency":
content_ids.append(id)
content_ids.sort()
var code_ids: Array = Currency.VALUES.keys()
code_ids.sort()
assert_eq(content_ids, code_ids)
func test_companions_are_brannoc_and_cadwyn():
@@ -52,17 +69,112 @@ func test_null_quest_ref_resolves():
assert_eq(db.unresolved_refs(o), [])
func test_loads_canon_and_topics():
assert_true(db.has_canon("town.duncarrow"))
assert_true(db.has_canon("place.the-white-antlers"))
assert_true(db.has_topic("rumor.travelers-go-missing"))
func test_loads_canon():
assert_true(db.has_canon("faction.the-seven"))
assert_true(db.has_canon("person.the-warden"))
assert_true(db.has_canon("rule.disposition-ladder"))
func test_topic_skeleton_has_no_body():
var t: Dictionary = db.topic("secret.crell-runs-slave-trade")
assert_false(t.has("body")) # bodies are server-only
func test_no_loaded_topic_has_a_body():
# Bodies are server-only (secrecy≥1 knowledge routes to content/server/topics,
# never the client). Structural, so it stays a real guard even when the
# topic set is empty (as it is now — the last authored topics were purged)
# and automatically starts testing something the moment a topic exists.
# One assertion, always executed (not per-loop-item), so an empty topic
# set still exercises the check instead of asserting zero times.
var leaked: Array = []
for id in db.topics:
if db.topics[id].has("body"):
leaked.append(id)
assert_eq(leaked, [], "topic(s) leaked a body to the client")
func test_legacy_npcs_still_load():
assert_true(db.has_npc("fenn"))
assert_true(db.has_npc("npc.mera-fenn"))
func test_loads_calling_and_race_content():
assert_true(db.has_calling("reaver"))
assert_true(db.has_calling("bloodsworn"))
assert_true(db.has_race("beastfolk"))
assert_eq(db.calling("reaver")["name"], "Reaver")
assert_eq(db.race("beastfolk")["name"], "Beastfolk")
func test_calling_content_matches_the_code_table():
# The roster exists in code (Callings.IDS — mechanics are rules) and in content
# (the blurbs). Nothing at runtime reconciles them, so guard against drift.
var content_ids: Array = db.callings.keys()
content_ids.sort()
var code_ids: Array = Callings.IDS.duplicate()
code_ids.sort()
assert_eq(content_ids, code_ids)
func test_race_content_matches_the_code_table():
var content_ids: Array = db.races.keys()
content_ids.sort()
var code_ids: Array = Races.IDS.duplicate()
code_ids.sort()
assert_eq(content_ids, code_ids)
func test_blurb_content_carries_no_mechanics():
# Mechanics are code. A hit_die in a content file is a second source of truth.
for id in db.callings:
for banned in ["hit_die", "saves", "skill_pool", "skill_count", "armor", "talent"]:
assert_false(db.callings[id].has(banned),
"%s.json leaks the mechanic '%s' — that lives in Callings" % [id, banned])
for id in db.races:
for banned in ["save_bonus", "poison_save_bonus", "granted_skills", "flags",
"nightsight", "claws", "keen_scent"]:
assert_false(db.races[id].has(banned),
"%s.json leaks the mechanic '%s' — that lives in Races" % [id, banned])
func test_every_race_and_calling_carries_a_fragment():
# The DM origin panel (M4-b) composes race.fragment + calling.fragment. A
# missing one degrades to the blurb at runtime rather than crashing — but it
# must never SHIP missing, so the suite is where it fails.
#
# The composition is "{race.fragment} Now you carry a {Calling}'s work —
# {calling.fragment}", so three shape rules make all 28 pairings legal
# sentences: a race fragment opens a sentence (uppercase) and closes it (.);
# a calling fragment continues one after the em dash (lowercase) and closes
# it (.). Guard them here — a future author adding a calling gets a signal,
# not a subtly ungrammatical DM.
for id in Races.IDS:
assert_true(db.race(id).has("fragment"), "race %s has no fragment" % id)
var race_fragment := str(db.race(id).get("fragment", "")).strip_edges()
assert_ne(race_fragment, "", "race %s has an empty fragment" % id)
assert_eq(race_fragment.substr(0, 1), race_fragment.substr(0, 1).to_upper(),
"race %s: fragment opens the composition, so it must start uppercase" % id)
assert_true(race_fragment.ends_with("."),
"race %s: fragment must end with '.' — a sentence precedes 'Now you carry…'" % id)
for id in Callings.IDS:
assert_true(db.calling(id).has("fragment"), "calling %s has no fragment" % id)
var calling_fragment := str(db.calling(id).get("fragment", "")).strip_edges()
assert_ne(calling_fragment, "", "calling %s has an empty fragment" % id)
assert_eq(calling_fragment.substr(0, 1), calling_fragment.substr(0, 1).to_lower(),
"calling %s: fragment follows an em dash, so it must start lowercase" % id)
assert_true(calling_fragment.ends_with("."),
"calling %s: fragment must end with '.' — it closes the composition" % id)
func test_no_calling_is_named_with_a_vowel():
# The fourth shape rule of that same composition, and the one that breaks SILENTLY.
# CharacterCreation._origin_prose() hardcodes the article:
# "%s Now you carry a %s's work — %s"
# All seven callings are consonant-initial, so all 28 compositions read correctly
# today and nothing anywhere would notice an eighth. Add "an Auger" and every
# Auger in the game gets "Now you carry a auger's work" on the DM panel — no
# error, no test, just a DM who cannot speak. Fail HERE, where the author is.
var vowels := ["a", "e", "i", "o", "u"]
for id in Callings.IDS:
var calling_name := str(db.calling(id).get("name", "")).strip_edges()
assert_ne(calling_name, "", "calling %s has no name" % id)
assert_false(calling_name.substr(0, 1).to_lower() in vowels,
("calling %s is named '%s'. The DM origin panel composes "
+ "\"Now you carry a %s's work\" with the article HARDCODED as \"a\" "
+ "(CharacterCreation._origin_prose) — a vowel-initial calling needs \"an\". "
+ "Change the template first, then this guard.") % [id, calling_name, calling_name.to_lower()])

View File

@@ -0,0 +1,214 @@
extends "res://addons/gut/test.gd"
const ContentDB = preload("res://scripts/content/content_db.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
var world
func before_each():
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _draft(race := "human", calling := "sellsword") -> CreationDraft:
var d := CreationDraft.new()
d.character_seed = 8675309
d.set_race(race)
d.set_calling(calling)
d.name = "Aldric"
return d
func test_calling_detail_reads_the_table_rather_than_repeating_it():
# The guard that fails if anyone types "d8" into a string literal. If someone
# retunes the Cutpurse's hit die, this test keeps passing and the CARD FOLLOWS —
# which is the whole point. A hardcoded line would go red here.
#
# The talent is asserted through the DERIVED phrase ("talent second wind"), not
# the raw id ("second_wind") the card used to print — but it is still read from
# Callings.talent() at assert time, so hardcoding a talent into the format string
# still goes red (traps.md #7: anchor to the full derived phrase).
for id in Callings.IDS:
var line := CreationCopy.calling_detail(id)
assert_string_contains(line, "d%d" % Callings.hit_die(id))
assert_string_contains(line, CreationCopy.armor_word(id))
assert_string_contains(line, "talent %s" % CreationCopy.talent_word(id))
assert_string_contains(line, "picks %d skills" % Callings.skill_count(id))
for s in Callings.saves(id):
assert_string_contains(line, s.to_upper())
func test_the_talent_is_humanised_and_no_card_line_shows_a_raw_id():
# The player read "talent second_wind" off the Sellsword's card. Four of the seven
# talents carry an underscore — the card printed the ID.
assert_eq(CreationCopy.talent_word("sellsword"), "second wind")
assert_eq(CreationCopy.talent_word("cutpurse"), "backstab", "an underscore-free talent is untouched")
var underscored := 0
for id in Callings.IDS:
if Callings.talent(id).contains("_"):
underscored += 1
assert_false(CreationCopy.calling_detail(id).contains("_"),
"%s's card line shows a raw snake_case id: %s" % [id, CreationCopy.calling_detail(id)])
assert_eq(underscored, 4,
"four talents carry an underscore — if that ever hits zero this guard stops guarding")
func test_casters_show_a_pool_and_martials_show_cooldowns():
assert_string_contains(CreationCopy.calling_detail("hedge_mage"), "MP (MAG)")
assert_string_contains(CreationCopy.calling_detail("bonesetter"), "MP (FTH)")
assert_string_contains(CreationCopy.calling_detail("sellsword"), "cooldowns")
assert_false(CreationCopy.calling_detail("sellsword").contains("MP"))
func test_race_trait_lines_are_derived_from_the_race_table():
assert_string_contains(CreationCopy.race_trait("human"), "every save")
assert_string_contains(CreationCopy.race_trait("human"), "skill of choice")
assert_string_contains(CreationCopy.race_trait("elf"), "Perception")
assert_string_contains(CreationCopy.race_trait("elf"), "Nightsight")
assert_string_contains(CreationCopy.race_trait("dwarf"), "Poison")
assert_string_contains(CreationCopy.race_trait("dwarf"), "Nightsight")
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Claws")
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Keen scent")
func test_every_race_has_a_non_empty_trait_line():
for id in Races.IDS:
assert_ne(CreationCopy.race_trait(id).strip_edges(), "", "%s has no trait line" % id)
func test_skill_labels_are_human_readable():
assert_eq(CreationCopy.skill_label("sleight_of_hand"), "sleight of hand")
assert_eq(CreationCopy.skill_label("faith_lore"), "faith lore")
assert_eq(CreationCopy.skill_label("stealth"), "stealth")
func test_nothing_in_the_copy_mentions_luck():
# §7 — the player must never be able to CALCULATE that he is cursed.
for id in Callings.IDS:
assert_false(CreationCopy.calling_detail(id).to_lower().contains("luck"))
assert_false(CreationCopy.calling_detail(id).to_lower().contains("lck"))
for id in Races.IDS:
assert_false(CreationCopy.race_trait(id).to_lower().contains("luck"))
assert_false(CreationCopy.race_trait(id).to_lower().contains("lck"))
# ------------------------------------------------------------------- §13: the nag
func test_the_nag_is_authored_copy_not_the_validator_s_grammar():
# §13. The exact strings a human must be able to judge, pinned. Anchored to the
# authored phrase itself — asserting only "no underscore" would pass on any
# string that happens to have none, including a blank one.
var human := _draft("human", "sellsword")
human.name = ""
assert_eq(CreationCopy.error_line("player name is required", human), "name yourself")
# "sellsword picks 2 skills, got 0" -> the count is DERIVED from the rules table.
assert_eq(Callings.skill_count("sellsword"), 2, "the sellsword picks 2, or the line below means nothing")
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 0", human),
"choose two more proficiencies")
human.toggle_skill("athletics")
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 1", human),
"choose one more proficiency", "one is singular, and the number is never hardcoded")
# The same error against a calling that picks FOUR must say four — the proof the
# number comes from Callings.skill_count and not from a literal "two".
var thief := _draft("dwarf", "cutpurse")
assert_eq(Callings.skill_count("cutpurse"), 4, "the cutpurse picks 4, or the line below means nothing")
assert_eq(CreationCopy.error_line("cutpurse picks 4 skills, got 0", thief),
"choose four more proficiencies")
assert_eq(CreationCopy.error_line("human must choose a bonus skill", human),
"one more proficiency, any of them — take it")
func test_an_unmapped_error_still_speaks_in_voice():
# §13: "write fallbacks as content, not as error handling." An error nobody
# anticipated (broken content; a spend the screen cannot produce) must never put
# the raw string on the label, and must never blank it either.
var d := _draft()
for raw in ["unresolved ref: item:ghost_blade", "seed is required and must be an integer",
"spend exceeds the pool of 3 (got 5)", "cannot spend on 'lck' — not an attribute"]:
var line := CreationCopy.error_line(raw, d)
assert_eq(line, CreationCopy.UNSPOKEN, "an unmapped error degrades to the authored fallback: %s" % raw)
assert_ne(line, raw)
assert_ne(CreationCopy.UNSPOKEN.strip_edges(), "", "the fallback is CONTENT — it is never blank")
assert_false(CreationCopy.UNSPOKEN.contains("_"))
func _is_deliberately_unmapped(raw: String) -> bool:
# The shapes error_line has NO authored line for, on purpose, because the
# screen structurally cannot produce them (CreationDraft always emits an
# int seed and a spend built only from increment/decrement, which can
# never exceed the pool or name a non-attribute) — a malformed seed or
# spend, or a broken content reference, is not a player mistake to voice
# in character; test_an_unmapped_error_still_speaks_in_voice already pins
# the fallback for exactly this set. Named by the validator's own prefixes
# so this stays independent of error_line's internals.
return (raw.begins_with("unresolved ref:")
or raw.begins_with("seed is required")
or raw.begins_with("spend")
or raw.begins_with("cannot spend on")
or raw == "skills must be an array")
func test_no_error_the_validator_can_raise_reaches_the_player_raw():
# Sweep EVERY error NewGame.validate actually produces for a draft the screen can
# be driven into (plus the hostile shapes only a saga could hand it), and assert
# the player never sees the id, the underscore, or the validator's grammar — AND,
# for every shape error_line actually has an authored line for, that the line
# fired rather than silently falling through to the generic UNSPOKEN fallback.
#
# CreationCopy.UNSPOKEN ("something in this does not hold — look again") is
# itself non-blank, isn't the raw string, has no underscore, and has no "got " —
# so the four checks below cannot tell "correctly mapped to its authored line"
# from "silently fell through." Without the UNSPOKEN check, renaming a validator
# string in new_game.gd so an error_line branch stops matching would leave every
# error line reading the generic fallback, suite green.
var d := _draft()
var hostiles: Array = [
{"name": "", "skills": [], "bonus_skill": ""}, # nameless, unpicked
{"skills": ["athletics"]}, # too few
{"skills": ["athletics", "athletics"]}, # duplicate
{"skills": ["athletics", "sorcery"]}, # not in the pool
{"race_id": "elf", "skills": ["athletics", "perception"], "bonus_skill": ""}, # granted already
{"bonus_skill": ""}, # human, no bonus
{"bonus_skill": "athletics", "skills": ["athletics", "endurance"]}, # bonus == a pick
{"bonus_skill": "swimming"}, # no such skill
{"race_id": "dwarf", "bonus_skill": "stealth"}, # dwarf gets none
{"race_id": "orc"}, {"calling_id": "paladin"}, # off the tables
{"seed": "8675309"}, {"spend": {"lck": 3}}, {"spend": {"str": 9}},
]
var base := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309,
"spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
var seen := 0
var mapped_seen := 0
for h in hostiles:
var creation := base.duplicate(true)
for k in h:
creation[k] = h[k]
var errors: Array = NewGame.validate(_deserter(), world, creation)
assert_gt(errors.size(), 0, "hostile creation %s must actually be invalid, or it guards nothing" % str(h))
for e in errors:
seen += 1
var raw := str(e)
var line: String = CreationCopy.error_line(raw, d)
assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % raw)
assert_ne(line, raw, "'%s' reached the player raw" % raw)
assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [raw, line])
assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [raw, line])
if not _is_deliberately_unmapped(raw):
mapped_seen += 1
assert_ne(line, CreationCopy.UNSPOKEN,
"'%s' has an authored error_line branch but silently fell through to the generic fallback" % raw)
assert_gt(seen, 12, "the sweep must actually reach a spread of errors, not one or two")
assert_gt(mapped_seen, 8, "the sweep must actually exercise error_line's mapped branches, not just the deliberately-unmapped ones")

View File

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

View File

@@ -0,0 +1,241 @@
extends "res://addons/gut/test.gd"
const ContentDB = preload("res://scripts/content/content_db.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
var world
func before_each():
world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _draft() -> CreationDraft:
# A fixed seed, so every assertion below is reproducible.
var d := CreationDraft.new()
d.character_seed = 8675309
d.set_race("human")
d.set_calling("sellsword")
d.name = "Aldric"
return d
func test_a_fresh_draft_has_a_seed_and_no_choices():
var d := CreationDraft.fresh()
assert_ne(d.character_seed, 0, "randomize() gave us the one bit of non-determinism there is")
assert_eq(d.spend, {})
assert_eq(d.skills, [])
assert_eq(d.bonus_skill, "")
func test_rolled_is_exactly_what_new_game_will_roll():
var d := _draft()
assert_eq(d.rolled(), NewGame.roll_attributes(d.character_seed),
"the screen and construct must reach the same five numbers")
func test_final_is_rolled_plus_spend():
var d := _draft()
d.increment("str")
d.increment("str")
assert_eq(int(d.final()["str"]), int(d.rolled()["str"]) + 2)
assert_eq(int(d.final()["dex"]), int(d.rolled()["dex"]), "an unspent stat is untouched")
func test_the_pool_is_three_and_the_fourth_point_does_not_exist():
var d := _draft()
assert_eq(d.points_left(), 3)
d.increment("str"); d.increment("dex"); d.increment("con")
assert_eq(d.points_left(), 0)
assert_false(d.can_increment("fth"))
d.increment("fth")
assert_eq(int(d.final()["fth"]), int(d.rolled()["fth"]), "the fourth point bought nothing")
assert_eq(d.spent_total(), 3)
func test_minus_removes_only_what_you_spent_and_floors_at_the_roll():
var d := _draft()
d.increment("str")
assert_true(d.can_decrement("str"))
d.decrement("str")
assert_eq(d.points_left(), 3)
assert_false(d.can_decrement("str"), "you cannot go below the roll — additive only")
d.decrement("str")
assert_eq(int(d.final()["str"]), int(d.rolled()["str"]))
func test_reroll_changes_the_numbers_and_clears_the_spend_and_nothing_else():
var d := _draft()
d.set_calling("cutpurse")
d.toggle_skill("stealth")
d.increment("dex")
var before := d.character_seed
d.reroll()
assert_ne(d.character_seed, before, "a re-roll IS a new seed — nothing else")
assert_eq(d.spend, {}, "points spent against a roll that no longer exists are meaningless")
assert_eq(d.calling_id, "cutpurse", "the calling survives")
assert_eq(d.skills, ["stealth"], "the picks survive")
assert_eq(d.name, "Aldric", "the name survives")
func test_changing_calling_clears_the_picks():
var d := _draft()
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
d.set_calling("hedge_mage")
assert_eq(d.skills, [], "the pool changed underneath them — stale picks are an invalid draft the player did not cause")
func test_changing_race_drops_a_pick_the_new_race_now_grants():
# The elf/cutpurse case. An Elf is GRANTED perception; a Cutpurse who had
# picked it now holds a pick that "buys nothing" and construct rejects by name.
var d := _draft()
d.set_calling("cutpurse")
d.toggle_skill("perception")
assert_eq(d.skills, ["perception"])
d.set_race("elf")
assert_eq(d.skills, [], "the elf already has perception — the pick is dropped, not left to 422")
func test_changing_race_off_human_clears_the_bonus_skill():
var d := _draft()
d.set_bonus_skill("stealth")
assert_eq(d.bonus_skill, "stealth")
d.set_race("dwarf")
assert_eq(d.bonus_skill, "", "only a human gets a bonus skill")
func test_a_granted_skill_cannot_be_picked():
var d := _draft()
d.set_race("elf")
d.set_calling("cutpurse")
assert_true(d.is_granted("perception"))
assert_false(d.can_pick("perception"))
d.toggle_skill("perception")
assert_eq(d.skills, [], "the chip is inert, not merely styled to look inert")
func test_picks_stop_at_the_calling_s_count():
var d := _draft()
d.set_calling("sellsword") # picks 2
d.toggle_skill("athletics")
d.toggle_skill("endurance")
assert_eq(d.picks_left(), 0)
d.toggle_skill("perception")
assert_eq(d.skills, ["athletics", "endurance"], "the third pick does not exist")
func test_toggling_an_already_picked_skill_removes_it():
var d := _draft()
d.toggle_skill("athletics")
d.toggle_skill("athletics")
assert_eq(d.skills, [])
func test_the_bonus_skill_cannot_also_be_picked_from_the_pool():
# The guard was one-directional: can_take_bonus() refused a skill already PICKED,
# but can_pick() did not consider the bonus. Human + Sellsword, take athletics as
# the bonus, then press athletics in the Sellsword's pool — the pick landed and
# the draft went invalid ("bonus skill athletics is already proficient"), which is
# precisely the invalid draft "he did not cause and cannot see" that set_calling's
# comment forbids.
var d := _draft() # human + sellsword; athletics is in the pool
d.set_bonus_skill("athletics")
assert_eq(d.bonus_skill, "athletics")
assert_true("athletics" in d.skill_pool(), "athletics must be in the sellsword's pool or this proves nothing")
assert_false(d.can_pick("athletics"), "the chip must be INERT, not merely styled to look inert")
d.toggle_skill("athletics")
assert_eq(d.skills, [], "the pick must not land while athletics is the bonus skill")
# ...and the draft the press would have produced is exactly the one construct rejects.
d.skills = ["athletics", "endurance"] # what the bug let the player build
assert_true("bonus skill athletics is already proficient" in d.errors(_deserter(), world),
"this IS the invalid draft the guard exists to prevent")
func test_the_other_direction_still_holds_a_picked_skill_cannot_become_the_bonus():
# The half of the guard that already worked. Kept beside its mirror so the two
# can never drift apart again.
var d := _draft()
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
assert_false(d.can_take_bonus("athletics"))
d.set_bonus_skill("athletics")
assert_eq(d.bonus_skill, "", "a picked skill must not also be taken as the bonus")
func test_handing_back_the_bonus_skill_makes_the_pool_chip_live_again():
# The inert chip is recoverable — the bonus row is right there. Without this the
# fix above would be a dead end the player could walk into and not walk out of.
var d := _draft()
d.set_bonus_skill("athletics")
assert_false(d.can_pick("athletics"))
d.set_bonus_skill("athletics") # pressing the chosen bonus chip clears it
assert_eq(d.bonus_skill, "")
assert_true(d.can_pick("athletics"), "handing the bonus back must make the pool chip live")
d.toggle_skill("athletics")
assert_eq(d.skills, ["athletics"])
func test_to_creation_carries_exactly_the_seven_contract_keys():
# §2, stated as a test rather than as a comment. The screen emits a seed and four
# choices; it never hands construct a NUMBER. test_pressing_enter_emits_exactly_
# the_draft_s_creation_dict compares the emitted dict to to_creation() — both
# sides move together, so adding an "attributes" key there breaks nothing. This
# pins the contract itself: seven keys, no more, no fewer.
var d := _draft()
var keys: Array = d.to_creation().keys()
keys.sort()
assert_eq(keys, ["bonus_skill", "calling_id", "name", "race_id", "seed", "skills", "spend"],
"the creation contract is exactly seven keys — an eighth (an 'attributes' block, say) is the §2 breach")
func test_to_creation_round_trips_through_construct():
var d := _draft()
d.toggle_skill("athletics")
d.toggle_skill("endurance")
d.set_bonus_skill("perception")
d.increment("str")
var res := NewGame.construct(_deserter(), world, d.to_creation())
assert_true(res["ok"], str(res["errors"]))
assert_eq(res["state"].sheet.attributes, d.final(),
"TRAP 1: what the screen SHOWED is what the player GOT — asserted across the pipeline")
func test_errors_are_construct_s_errors_not_a_second_copy_of_the_rules():
var d := _draft() # no skills picked yet
assert_gt(d.errors(_deserter(), world).size(), 0)
assert_false(d.is_complete(_deserter(), world))
d.toggle_skill("athletics")
d.toggle_skill("endurance")
d.set_bonus_skill("perception")
assert_eq(d.errors(_deserter(), world), [], "a legal draft has nothing wrong with it")
assert_true(d.is_complete(_deserter(), world))
func test_an_unnamed_draft_is_not_complete():
var d := _draft()
d.toggle_skill("athletics")
d.toggle_skill("endurance")
d.set_bonus_skill("perception")
d.name = " "
assert_false(d.is_complete(_deserter(), world), "a nameless character cannot enter the world")
func test_the_draft_never_exposes_luck():
# §7 — no accessor, no field, no leak. The screen cannot show what it cannot reach.
var d := _draft()
assert_false("lck" in d.to_creation().get("spend", {}))
assert_false(d.to_creation().has("luck"))
assert_false(d.final().has("lck"))
assert_false(d.rolled().has("lck"))

View File

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

View File

@@ -0,0 +1,42 @@
extends "res://addons/gut/test.gd"
func test_format_zero_is_copper():
assert_eq(Currency.format(0), "0c")
func test_format_sub_silver():
assert_eq(Currency.format(8), "8c")
assert_eq(Currency.format(47), "47c")
func test_format_exact_denominations():
assert_eq(Currency.format(100), "1s")
assert_eq(Currency.format(10000), "1g")
func test_format_full_ladder():
assert_eq(Currency.format(143), "1s 43c")
assert_eq(Currency.format(347), "3s 47c")
assert_eq(Currency.format(124008), "12g 40s 8c")
func test_format_skips_empty_middle_and_tail():
assert_eq(Currency.format(10008), "1g 8c", "an empty silver place is skipped, not zero-padded")
assert_eq(Currency.format(124000), "12g 40s", "an empty copper place is skipped")
func test_value_of_each_denomination():
assert_eq(Currency.value("copper"), 1)
assert_eq(Currency.value("silver"), 100)
assert_eq(Currency.value("gold"), 10000)
func test_non_currency_id_is_not_money():
assert_false(Currency.is_currency("worn_shortsword"))
assert_eq(Currency.value("worn_shortsword"), 0)
func test_currency_ids_are_currency():
for id in ["copper", "silver", "gold"]:
assert_true(Currency.is_currency(id), "%s is money" % id)

View File

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

View File

@@ -0,0 +1,25 @@
extends "res://addons/gut/test.gd"
## Guards the two dev-only proving-scene seed logs (narrate_harness,
## npc_harness) against the same LogPlayer arg-slot regression the shell test
## guards (see test_main_window_shell.gd). Both scenes build their seed log's
## LogPlayer in _ready() with no network call until a button is pressed, so
## instantiate + add_child_autofree is cheap and touches no network.
const NARRATE_SCENE := "res://scenes/narrate_harness.tscn"
const NPC_SCENE := "res://scenes/npc_harness.tscn"
func test_narrate_harness_seed_log_has_real_race_and_calling():
var node = load(NARRATE_SCENE).instantiate()
add_child_autofree(node)
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
func test_npc_harness_seed_log_has_real_race_and_calling():
var node = load(NPC_SCENE).instantiate()
add_child_autofree(node)
assert_true(Races.exists(node._canon_log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._canon_log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._canon_log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")

View File

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

View File

@@ -14,18 +14,36 @@ func test_ids_regex():
assert_false(Ids.is_valid("has-hyphen"))
func test_player_dict_has_only_three_keys():
var p = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
var d = p.to_dict()
assert_eq(d.keys().size(), 3)
assert_false("luck" in d)
assert_eq(d["class_id"], "sellsword")
func test_log_player_carries_race_and_calling_and_no_luck_number():
var p := LogPlayer.new("Aldric", "beastfolk", "cutpurse", "Fortune spits on you")
var d := p.to_dict()
assert_eq(d["race_id"], "beastfolk")
assert_eq(d["calling_id"], "cutpurse")
assert_false("luck" in d, "numeric Luck never reaches the log (§7)")
assert_false("class_id" in d)
assert_eq(d.keys().size(), 4)
func test_player_rejects_unknown_class():
var p = LogPlayer.new("Aldric", "sellsword", "x")
assert_false(p.set_class_id("bard"))
assert_eq(p.class_id, "sellsword")
func test_log_player_rejects_a_dead_calling():
var p := LogPlayer.new()
assert_false(p.set_calling_id("priest"), "priest is not a calling")
assert_true(p.set_calling_id("bonesetter"))
assert_false(p.set_race_id("orc"))
assert_true(p.set_race_id("dwarf"))
func test_log_player_ctor_rejects_calling_passed_as_race():
# The exact shape of the bug: calling three broken call sites used to pass —
# LogPlayer.new(name, class_id, luck_descriptor) against the NEW ctor
# (name, race_id, calling_id, luck_descriptor). "sellsword" lands in the
# race_id slot (rejected — not a race), the descriptor lands in the
# calling_id slot (rejected — not a calling), and luck_descriptor is left
# at its default. Every field ends up "", which 422s against the schema.
var p := LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
var d := p.to_dict()
assert_eq(d["race_id"], "", "a calling id is not a legal race_id")
assert_eq(d["calling_id"], "", "a luck descriptor is not a legal calling_id")
assert_eq(d["luck_descriptor"], "", "old 3-arg call shape must not produce a usable player row")
func test_party_member_clamps_disposition():

View File

@@ -3,11 +3,17 @@ extends "res://addons/gut/test.gd"
const GameState = preload("res://scripts/state/game_state.gd")
func test_state_holds_a_sheet_not_a_stat_bag():
var gs = GameState.new()
assert_null(gs.sheet, "the sheet is built by NewGame.construct")
assert_false("stats" in gs, "the flat stats Dictionary is gone")
func test_add_item_accumulates():
var s = GameState.new()
s.add_item("coin", 3)
s.add_item("coin", 2)
assert_eq(s.inventory["coin"], 5)
s.add_item("worn_shortsword", 3)
s.add_item("worn_shortsword", 2)
assert_eq(s.inventory["worn_shortsword"], 5)
func test_npc_disposition_clamps():
@@ -32,11 +38,11 @@ func test_luck_descriptor_delegates():
func test_remove_item_decrements_and_floors_at_zero():
var gs = GameState.new()
gs.add_item("coin", 3)
gs.remove_item("coin", 2)
assert_eq(gs.inventory.get("coin", 0), 1)
gs.remove_item("coin", 5)
assert_eq(gs.inventory.get("coin", 0), 0)
gs.add_item("worn_shortsword", 3)
gs.remove_item("worn_shortsword", 2)
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
gs.remove_item("worn_shortsword", 5)
assert_eq(gs.inventory.get("worn_shortsword", 0), 0)
func test_revealed_topics_tracked():
@@ -51,3 +57,42 @@ func test_gifts_given_tracked():
assert_false(gs.is_gift_given("amulet"))
gs.mark_gift_given("amulet")
assert_true(gs.is_gift_given("amulet"))
func test_purse_starts_empty():
assert_eq(GameState.new().purse_copper, 0)
func test_grant_currency_lands_in_the_purse_not_the_inventory():
var gs = GameState.new()
gs.grant("silver", 2)
assert_eq(gs.purse_copper, 200)
assert_eq(gs.inventory.size(), 0, "money is never an inventory row")
func test_grant_non_currency_lands_in_the_inventory():
var gs = GameState.new()
gs.grant("worn_shortsword", 1)
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
assert_eq(gs.purse_copper, 0)
func test_take_currency_spends_from_the_purse():
var gs = GameState.new()
gs.grant("gold", 1)
gs.take("copper", 5)
assert_eq(gs.purse_copper, 9995)
func test_take_currency_clamps_at_zero():
var gs = GameState.new()
gs.grant("copper", 47)
gs.take("gold", 1)
assert_eq(gs.purse_copper, 0, "the purse never goes negative")
func test_take_non_currency_removes_from_the_inventory():
var gs = GameState.new()
gs.grant("worn_shortsword", 2)
gs.take("worn_shortsword", 1)
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)

View File

@@ -0,0 +1,71 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/Main.tscn"
const ContentDB = preload("res://scripts/content/content_db.gd")
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
func _flow() -> Node:
# Inject fakes BEFORE _ready (add_child): a real world + a network-free service,
# so the shell's initial narrate never touches HTTP.
var node = load(SCENE).instantiate()
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
node.world = world
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
node.service = DmService.new(transport, FallbackLibrary.new())
add_child_autofree(node)
return node
func _creation() -> Dictionary:
return {
"name": "Dagnet", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
func test_boots_into_the_title():
var flow := _flow()
assert_true(flow._current is TitleScreen, "Main opens on the title")
func test_new_game_shows_creation():
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
assert_true(flow._current is CharacterCreation, "new game routes to creation")
func test_confirming_creation_reaches_the_shell_as_the_created_character():
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
flow._current.emit_signal("creation_confirmed", _creation())
assert_true(flow._current is MainWindowShell, "creation routes to the shell")
assert_eq(flow._current._log.player.name, "Dagnet",
"the CREATED character flowed through — not a seed, not a swap alone")
func _assert_stays_on_creation(bad: Dictionary, why: String) -> void:
var flow := _flow()
flow._current.emit_signal("menu_activated", &"new_game")
# Drive the REAL signal with an invalid dict. The CTA gates this out via the UI,
# but §2 forbids Main trusting the caller and §13 forbids surfacing an error.
flow._current.emit_signal("creation_confirmed", bad)
assert_false(flow._current is MainWindowShell, "a failed construct never reaches the shell (§13): %s" % why)
assert_true(flow._current is CharacterCreation, "the player stays on the valid creation screen: %s" % why)
func test_construct_failure_empty_name_stays_on_creation():
var bad := _creation()
bad["name"] = "" # empty name fails NewGame.validate
_assert_stays_on_creation(bad, "empty name")
func test_construct_failure_bad_calling_stays_on_creation():
var bad := _creation()
bad["calling_id"] = "bard" # NPC-only / not a real calling — fails NewGame.validate
_assert_stays_on_creation(bad, "unknown calling")

View File

@@ -0,0 +1 @@
uid://17abdh3almry

View File

@@ -3,6 +3,8 @@ extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/MainWindowShell.tscn"
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
func _shell() -> MainWindowShell:
@@ -35,3 +37,48 @@ func test_shell_builds_a_code_owned_seed_log():
var node := _shell()
assert_not_null(node._log, "a canon log exists to post (§11)")
assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned")
func test_shell_seed_player_has_a_real_race_and_calling():
# Regression: the shell's _build_seed_log() once called LogPlayer.new() with
# the OLD 3-arg (name, class_id, luck_descriptor) shape against the NEW
# 4-arg ctor. Every param defaults, so it compiled silently and both
# race_id/calling_id came out "" — which 422s against the canon-log schema.
# Assert against the log the shell actually builds, not a fresh LogPlayer.
var node := _shell()
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
func _built_character():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var creation := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
return NewGame.construct(origin, world, creation)
func _shell_with_character(res) -> MainWindowShell:
var node = load(SCENE).instantiate()
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
node.service = DmService.new(transport, FallbackLibrary.new())
node.injected_log = res["log"]
node.injected_state = res["state"]
add_child_autofree(node)
return node
func test_shell_shows_the_injected_character_not_the_seed():
var res = _built_character()
assert_true(res["ok"], str(res["errors"]))
var node := _shell_with_character(res)
assert_same(node._log, res["log"], "the injected canon log drives narration")
var expected := "HP %d / %d" % [res["state"].sheet.hp, res["state"].sheet.max_hp()]
assert_eq(node._hp.text, expected, "HP label derives from the real sheet")
assert_ne(node._hp.text, "HP 42 / 60", "NOT the seed's Vexcca vitals")

View File

@@ -44,12 +44,167 @@ func test_become_hostile_sets_floor():
func test_accept_item_removes_from_inventory():
var gs = GameState.new()
gs.add_item("coin", 2)
MoveApplier.apply([_m("accept_item", ["coin"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("coin", 0), 1)
gs.add_item("worn_shortsword", 2)
MoveApplier.apply([_m("accept_item", ["worn_shortsword"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
func test_give_item_currency_pays_the_purse():
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 10000)
assert_eq(gs.inventory.size(), 0, "money is never an inventory row")
func test_give_item_currency_is_not_a_one_shot_gift():
# gifts_given is a GLOBAL one-shot keyed by item id. If money were marked, an
# NPC could hand over coin exactly once, ever, for the whole campaign.
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 20000)
assert_false(gs.is_gift_given("gold"), "currency is exempt from the one-shot gate")
func test_give_item_real_item_still_marks_the_one_shot_gate():
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["amulet"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("amulet", 0), 1)
assert_true(gs.is_gift_given("amulet"))
func test_accept_item_currency_spends_the_purse():
var gs = GameState.new()
gs.grant("gold", 1)
MoveApplier.apply([_m("accept_item", ["silver"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 9900)
func test_refuse_and_end_are_noops():
var gs = GameState.new()
MoveApplier.apply([_m("refuse"), _m("end_conversation")], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.size(), 0)
# --- Finding 1: a repeated currency move tag in ONE reply must not let the AI
# pick an arbitrary amount by unary repetition (§2 breach). A currency move
# (give_item/accept_item on a denomination) applies AT MOST ONCE per apply()
# call, keyed on move name + denomination.
func test_repeated_accept_item_silver_applies_once():
var gs = GameState.new()
gs.purse_copper = 10000
MoveApplier.apply([_m("accept_item", ["silver"]), _m("accept_item", ["silver"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 9900, "second accept_item(silver) tag must be a no-op")
func test_repeated_give_item_gold_applies_once():
var gs = GameState.new()
var moves: Array = []
for i in range(10):
moves.append(_m("give_item", ["gold"]))
MoveApplier.apply(moves, gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 10000, "nine of the ten give_item(gold) tags must be no-ops")
func test_repeated_accept_item_copper_applies_once():
var gs = GameState.new()
gs.purse_copper = 47
var moves: Array = []
for i in range(47):
moves.append(_m("accept_item", ["copper"]))
MoveApplier.apply(moves, gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 46, "46 of the 47 accept_item(copper) tags must be no-ops")
func test_different_currency_moves_both_still_apply():
# Proves dedup is keyed per (move name + denomination), not per reply as a whole.
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["gold"]), _m("accept_item", ["silver"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 9900)
func test_dedup_key_is_denomination_not_just_move_name():
# Two DIFFERENT denominations under the same move name must both land. A key
# of "give_item" alone would swallow the silver.
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["gold"]), _m("give_item", ["silver"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 10100)
func test_dedup_key_is_move_name_not_just_denomination():
# The same denomination under two DIFFERENT move names must both land. A key
# of "gold" alone would swallow the accept, leaving 10000 instead of 0.
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["gold"]), _m("accept_item", ["gold"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 0)
func test_repeated_give_item_applies_once_for_real_items_too():
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["amulet"]), _m("give_item", ["amulet"]), _m("give_item", ["amulet"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("amulet", 0), 1, "three tags, one amulet")
func test_repeated_accept_item_applies_once_for_real_items_too():
var gs = GameState.new()
gs.add_item("worn_shortsword", 5)
MoveApplier.apply([_m("accept_item", ["worn_shortsword"]), _m("accept_item", ["worn_shortsword"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("worn_shortsword", 0), 4, "two tags, one sword taken")
func test_different_real_items_in_one_reply_both_apply():
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["amulet"]), _m("give_item", ["ring"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("amulet", 0), 1)
assert_eq(gs.inventory.get("ring", 0), 1)
func test_repeated_disposition_tags_cannot_exceed_the_per_reply_cap():
# MAX_DELTA means "one line cannot swing standing wholesale" (§7 spirit). It was
# clamped PER TAG, so three +15 tags moved standing by 45. The cap is per REPLY.
var gs = GameState.new()
MoveApplier.apply([_m("adjust_disposition", ["+15"]), _m("adjust_disposition", ["+15"]),
_m("adjust_disposition", ["+15"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.MAX_DELTA, "+45 is capped to +15")
func test_disposition_cap_applies_to_the_net_swing():
var gs = GameState.new()
MoveApplier.apply([_m("adjust_disposition", ["+10"]), _m("adjust_disposition", ["+10"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.get("fenn", 0), 15, "+20 is capped to +15")
func test_disposition_tags_of_opposite_sign_net_out():
# The cap is on the reply's NET swing, not on the tag count — so a walk-back
# inside one reply still lands where the arithmetic says.
var gs = GameState.new()
MoveApplier.apply([_m("adjust_disposition", ["+10"]), _m("adjust_disposition", ["-4"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.get("fenn", 0), 6)
func test_become_hostile_still_wins_after_a_disposition_tag():
# Moves apply in emission order — the hostile floor must not be undone by
# batching the disposition arithmetic.
var gs = GameState.new()
MoveApplier.apply([_m("adjust_disposition", ["+15"]), _m("become_hostile")],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.HOSTILE_FLOOR)
func test_disposition_cap_is_per_reply_not_per_conversation():
# Two separate replies each get a fresh MAX_DELTA budget — standing still moves
# over a conversation, it just cannot lurch in a single line.
var gs = GameState.new()
MoveApplier.apply([_m("adjust_disposition", ["+15"])], gs, CanonLog.new(), _content(), "fenn")
MoveApplier.apply([_m("adjust_disposition", ["+15"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.get("fenn", 0), 30)

View File

@@ -15,51 +15,233 @@ func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _rng() -> RandomNumberGenerator:
var r := RandomNumberGenerator.new()
r.seed = 7
return r
func _creation(overrides := {}) -> Dictionary:
var c := {
"name": "Aldric",
"race_id": "human",
"calling_id": "sellsword",
"seed": 8675309,
"spend": {},
"skills": ["athletics", "endurance"],
"bonus_skill": "perception",
}
for k in overrides:
c[k] = overrides[k]
return c
func _build(creation: Dictionary) -> Dictionary:
return NewGame.construct(_deserter(), world, creation, _rng())
return NewGame.construct(_deserter(), world, creation)
func test_construct_succeeds():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
func test_same_seed_yields_an_identical_character():
# The determinism guarantee (§10). Two independent constructs, one seed.
var a := _build(_creation())
var b := _build(_creation())
assert_true(a["ok"] and b["ok"])
assert_eq(a["state"].sheet.attributes, b["state"].sheet.attributes)
assert_eq(a["state"].luck, b["state"].luck, "even the hidden Luck is reproducible")
func test_golden_vector_pins_the_rng_stream():
# The determinism test above (`test_same_seed_yields_an_identical_character`)
# compares two `construct` runs against EACH OTHER. Both would drift together
# if the roll order changed, so it proves nothing about the ORDER itself.
# Swap two entries in `Attributes.IDS` and that test still passes while every
# already-persisted seed silently becomes a different character.
#
# This test pins one seed to its EXACT rolled values — all five attributes and
# the hidden Luck, drawn from the same stream immediately after — so reordering
# `Attributes.IDS` or moving the Luck roll fails LOUDLY, here, instead of
# silently rewriting every saved character.
var res := _build(_creation({"seed": 8675309}))
assert_true(res["ok"], str(res["errors"]))
var attrs: Dictionary = res["state"].sheet.attributes
assert_eq(int(attrs["str"]), 8)
assert_eq(int(attrs["dex"]), 13)
assert_eq(int(attrs["con"]), 8)
assert_eq(int(attrs["fth"]), 14)
assert_eq(int(attrs["mag"]), 8)
assert_eq(int(res["state"].luck), 6)
func test_numeric_luck_absent_from_log():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
func test_a_missing_seed_is_rejected():
# A seed silently defaulting to 0 builds a perfectly valid, deterministic
# character — which is exactly the problem: a saga that forgets the key
# gets no signal at all. Require it explicitly.
var creation := _creation()
creation.erase("seed")
var res := NewGame.construct(_deserter(), world, creation)
assert_false(res["ok"])
assert_true("seed" in str(res["errors"]).to_lower())
func test_a_non_integer_seed_is_rejected():
var res := _build(_creation({"seed": "8675309"}))
assert_false(res["ok"])
assert_true("seed" in str(res["errors"]).to_lower())
func test_a_different_seed_yields_a_different_character():
var a := _build(_creation())
var b := _build(_creation({"seed": 1234567}))
assert_ne(a["state"].sheet.attributes, b["state"].sheet.attributes)
func test_every_attribute_respects_the_floor_of_eight():
# 3d6 can roll 3. The floor is a design guarantee, not a dice accident.
for s in range(40):
var res := _build(_creation({"seed": s}))
assert_true(res["ok"], str(res["errors"]))
for stat in Attributes.IDS:
assert_true(int(res["state"].sheet.attributes[stat]) >= 8,
"seed %d rolled %s below the floor" % [s, stat])
func test_spend_is_additive_on_top_of_the_roll():
var base := _build(_creation())
var spent := _build(_creation({"spend": {"str": 2, "con": 1}}))
assert_eq(int(spent["state"].sheet.attributes["str"]),
int(base["state"].sheet.attributes["str"]) + 2)
assert_eq(int(spent["state"].sheet.attributes["con"]),
int(base["state"].sheet.attributes["con"]) + 1)
assert_eq(int(spent["state"].sheet.attributes["dex"]),
int(base["state"].sheet.attributes["dex"]), "unspent stats are untouched")
func test_spend_over_the_pool_is_rejected():
var res := _build(_creation({"spend": {"str": 2, "con": 2}}))
assert_false(res["ok"])
assert_true("spend" in str(res["errors"]).to_lower())
func test_spend_cannot_be_negative():
var res := _build(_creation({"spend": {"str": -2, "con": 1}}))
assert_false(res["ok"], "additive only — there is no [-]")
func test_a_negative_spend_does_not_mask_an_over_pool_spend():
# {"str": -1, "dex": 4} sums to 3 — at the pool limit — so without a
# `continue`/floor after the negative-value error, the over-pool check
# never independently fires; only the negative-value error does.
var res := _build(_creation({"spend": {"str": -1, "dex": 4}}))
assert_false(res["ok"])
var joined := str(res["errors"]).to_lower()
assert_true("negative" in joined, "the negative-value error should fire")
assert_true("exceeds the pool" in joined, "the over-pool error should ALSO fire")
func test_spend_rejects_non_integer_values():
var floats := _build(_creation({"spend": {"str": 2.9}}))
assert_false(floats["ok"], "a float must be rejected, not truncated to +2")
var bools := _build(_creation({"spend": {"str": true}}))
assert_false(bools["ok"], "a bool must be rejected, not coerced to +1")
var strings := _build(_creation({"spend": {"str": "abc"}}))
assert_false(strings["ok"], "a string must be rejected, not coerced to +0")
func test_luck_can_never_be_spent():
# §7: LCK is untouchable at creation. Not hidden — impossible.
var res := _build(_creation({"spend": {"lck": 3}}))
assert_false(res["ok"])
func test_skills_must_come_from_the_callings_pool():
var res := _build(_creation({"skills": ["athletics", "sorcery"]}))
assert_false(res["ok"], "sorcery is not in the sellsword's pool")
func test_skills_must_be_the_right_count():
var res := _build(_creation({"skills": ["athletics"]}))
assert_false(res["ok"], "a sellsword picks 2")
func test_skills_reject_a_duplicate():
var res := _build(_creation({"skills": ["athletics", "athletics"]}))
assert_false(res["ok"])
func test_an_elf_cannot_spend_a_pick_on_perception():
# He already has it. A pick that buys nothing is a trap, not a choice.
var res := _build(_creation({
"race_id": "elf", "bonus_skill": "",
"skills": ["athletics", "perception"]}))
assert_false(res["ok"])
func test_the_elf_is_granted_perception_anyway():
var res := _build(_creation({
"race_id": "elf", "bonus_skill": "", "skills": ["athletics", "endurance"]}))
assert_true(res["ok"], str(res["errors"]))
assert_true(res["state"].sheet.is_proficient("perception"))
func test_a_human_must_choose_a_bonus_skill():
var res := _build(_creation({"bonus_skill": ""}))
assert_false(res["ok"])
func test_a_non_human_must_not_have_one():
var res := _build(_creation({"race_id": "dwarf", "bonus_skill": "stealth"}))
assert_false(res["ok"])
func test_the_humans_bonus_skill_confers_proficiency():
var res := _build(_creation()) # human, bonus_skill perception
assert_true(res["ok"], str(res["errors"]))
assert_true(res["state"].sheet.is_proficient("perception"))
func test_the_character_starts_whole():
var res := _build(_creation())
var sheet = res["state"].sheet
assert_eq(sheet.hp, sheet.max_hp())
assert_eq(sheet.mp, sheet.max_mp())
func test_a_calling_the_origin_forbids_is_rejected():
var o := _deserter()
o["build_constraints"] = o["build_constraints"].duplicate(true)
o["build_constraints"]["allowed_callings"] = ["bonesetter"]
var res := NewGame.construct(o, world, _creation())
assert_false(res["ok"])
func test_no_luck_number_and_no_attributes_reach_the_log():
# §7 + §11: the log carries only what the AI narrates from.
var res := _build(_creation())
var pd: Dictionary = res["log"].player.to_dict()
assert_eq(pd.keys().size(), 4)
assert_false("luck" in pd)
assert_eq(pd.keys().size(), 3)
assert_false("attributes" in pd)
assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX)
func test_inventory_and_dispo_land_in_state_not_log():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
assert_eq(res["state"].inventory.get("coin", 0), 3)
var res := _build(_creation())
assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1)
assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin")
assert_false("copper" in res["state"].inventory, "money is never an inventory row")
assert_false("inventory" in res["log"].to_dict())
func test_companion_dispositions_from_overrides():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15)
func test_situation_and_facts_seeded():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].recent_events.size(), 2)
assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts)
assert_eq(res["log"].humiliations.size(), 0)
func test_active_quest_resolved_from_world():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].active_quests.size(), 1)
assert_eq(res["log"].active_quests[0].id, "find_the_ledger")
assert_eq(res["log"].active_quests[0].status, "active")
@@ -68,45 +250,136 @@ func test_active_quest_resolved_from_world():
func test_null_start_quest_yields_no_quest():
var o := _deserter()
o["start_quest_id"] = null
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_eq(res["log"].active_quests.size(), 0)
func test_class_not_allowed_is_rejected():
var res := _build({"name": "X", "class_id": "berserker"})
assert_false(res["ok"])
assert_true(res["errors"].size() > 0)
assert_null(res["log"])
func test_unresolved_origin_is_rejected():
var o := _deserter()
o["start_location_id"] = "nowhere"
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_false(res["ok"])
assert_true("unresolved ref: location:nowhere" in res["errors"])
func test_empty_name_is_rejected():
var missing_name := _build({"class_id": "sellsword"})
var missing_name := _build(_creation({"name": ""}))
assert_false(missing_name["ok"])
assert_true(missing_name["errors"].size() > 0)
assert_null(missing_name["log"])
var whitespace_name := _build({"name": " ", "class_id": "sellsword"})
var whitespace_name := _build(_creation({"name": " "}))
assert_false(whitespace_name["ok"])
assert_true(whitespace_name["errors"].size() > 0)
assert_null(whitespace_name["log"])
func test_a_null_spend_is_rejected_not_crashed():
# A JSON round-trip can hand back {"spend": null}. Without a container-type
# guard, assigning null into a statically-typed Dictionary parameter throws
# a GDScript runtime error INSIDE construct() instead of a front-loaded error.
var res := _build(_creation({"spend": null}))
assert_false(res["ok"])
assert_true("spend" in str(res["errors"]).to_lower())
func test_non_array_skills_is_rejected_not_crashed():
var res := _build(_creation({"skills": "athletics"}))
assert_false(res["ok"])
assert_true("skills" in str(res["errors"]).to_lower())
func test_non_companion_override_goes_to_state_not_log():
world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"}
var o := _deserter()
o["disposition_overrides"] = {"brannoc_thane": 40, "oda_fenn": -25}
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_true(res["ok"], str(res["errors"]))
assert_eq(res["state"].npc_dispositions["oda_fenn"], -25)
assert_null(res["log"].party_member("oda_fenn"))
for m in res["log"].to_dict()["party"]:
assert_ne(m["id"], "oda_fenn")
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
func test_roll_attributes_is_pure_and_seeded():
# Same seed, same five. The screen shows these; construct builds from them.
var a := NewGame.roll_attributes(8675309)
var b := NewGame.roll_attributes(8675309)
assert_eq(a, b)
assert_eq(a.keys(), Attributes.IDS, "the five rows, in the contract's order")
assert_false(a.has("lck"), "LCK is not an attribute and never leaves construct (§7)")
func test_roll_attributes_holds_the_floor_of_eight():
# Sweep enough seeds that a straight 3d6 would certainly have rolled below 8
# somewhere. (GUT has no assert_gte — assert_true on the comparison.)
var lowest := 999
for s in range(400):
for stat in Attributes.IDS:
lowest = mini(lowest, int(NewGame.roll_attributes(s)[stat]))
assert_true(lowest >= 8, "the floor broke: something rolled %d" % lowest)
assert_eq(lowest, 8, "the floor should actually BITE across 2000 rolls, not sit unused")
func test_construct_builds_exactly_what_roll_attributes_showed():
# TRAP 1 (traps.md): this is the load-bearing claim of the creation SCREEN —
# "the numbers you saw are the numbers you got". Asserting that two fresh
# rolls agree would pass even if construct kept its own second implementation
# of the die. This asserts across the PIPELINE: what roll_attributes returns,
# plus the spend, IS what construct puts on the sheet.
var spend := {"str": 2, "con": 1}
var res := _build(_creation({"seed": 4242, "spend": spend}))
assert_true(res["ok"], str(res["errors"]))
var shown := NewGame.roll_attributes(4242)
var expected := {}
for stat in Attributes.IDS:
expected[stat] = int(shown[stat]) + int(spend.get(stat, 0))
assert_eq(res["state"].sheet.attributes, expected)
func test_construct_ignores_an_attribute_block_handed_to_it():
# §2 — "code owns state" — stated as a TEST, not as a comment. Every other test
# here feeds construct a well-behaved creation dict, so nothing in the suite ever
# proves construct REFUSES a number: a construct that trusted creation["attributes"]
# would pass all of them. Hand it a hostile one — an 18 in every stat — and assert
# the sheet is still, exactly, roll_attributes(seed) + spend.
var spend := {"str": 2, "con": 1}
var hostile := _creation({
"seed": 4242,
"spend": spend,
"attributes": {"str": 18, "dex": 18, "con": 18, "fth": 18, "mag": 18},
})
var res := _build(hostile)
assert_true(res["ok"], str(res["errors"]))
var shown := NewGame.roll_attributes(4242)
var expected := {}
for stat in Attributes.IDS:
expected[stat] = int(shown[stat]) + int(spend.get(stat, 0))
assert_eq(res["state"].sheet.attributes, expected,
"construct re-rolls from the SEED — a stat block handed to it is not state, it is noise (§2)")
# And the hostile block is not merely ignored on the sheet — it never becomes state
# anywhere else either. `res["log"].to_dict().has("attributes")` (the old check here)
# was already false before this test's action ran: LogPlayer.to_dict() has no such
# TOP-LEVEL key on any code path, hostile block or not, so it could never fail — delete
# construct's whole §2 guard and this stayed green. Assert the player row's exact key
# SET instead (sorted — insertion order in LogPlayer.to_dict() isn't the contract):
# it feeds the AI (§2/§7), so a stray "attributes", "str", or anything else reaching it
# is a real leak, and this is what actually fails if one does.
var player_keys: Array = res["log"].to_dict()["player"].keys()
player_keys.sort()
assert_eq(player_keys, ["calling_id", "luck_descriptor", "name", "race_id"],
"the canon-log player row carries a key beyond name/race_id/calling_id/luck_descriptor — numeric state reached the AI-facing log (§2/§7)")
func test_validate_is_public_and_agrees_with_construct():
var bad := _creation({"calling_id": "paladin"})
var errors := NewGame.validate(_deserter(), world, bad)
assert_gt(errors.size(), 0)
var res := _build(bad)
assert_false(res["ok"])
assert_eq(res["errors"], errors, "the screen must be told exactly what construct would reject")

View File

@@ -39,6 +39,54 @@ func test_revealed_topic_drops_out():
func test_accept_item_offered_per_held_item():
var gs = GameState.new()
gs.add_item("coin", 2)
gs.add_item("worn_shortsword", 2)
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
assert_true("accept_item(coin)" in moves)
assert_true("accept_item(worn_shortsword)" in moves)
func test_a_broke_party_is_asked_for_nothing():
var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
for denom in ["copper", "silver", "gold"]:
assert_false("accept_item(%s)" % denom in moves, "an empty purse cannot pay a %s" % denom)
func test_accept_item_denomination_gated_on_affordability():
var gs = GameState.new()
gs.grant("copper", 347)
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
assert_true("accept_item(copper)" in moves)
assert_true("accept_item(silver)" in moves)
assert_false("accept_item(gold)" in moves, "347c cannot pay a gold coin")
func test_a_gold_coin_is_asked_for_once_affordable():
var gs = GameState.new()
gs.grant("gold", 1)
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
assert_true("accept_item(gold)" in moves)
func test_currency_never_arrives_via_the_inventory_loop():
# §7 of the currency spec: currency never enters the inventory dict, so the
# only accept_item(<denom>) in available_moves comes from the affordability
# loop — never a double-offer via the inventory.keys() loop.
var gs = GameState.new()
gs.grant("gold", 1)
assert_true(gs.inventory.is_empty(), "currency must never be written into inventory")
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
var gold_offers := 0
for m in moves:
if m == "accept_item(gold)":
gold_offers += 1
assert_eq(gold_offers, 1, "accept_item(gold) must be offered exactly once")
func test_currency_gift_is_offered_repeatedly():
var npc := _fenn()
npc["capabilities"]["giveable_items"] = ["copper", "amulet"]
var gs = GameState.new()
gs.mark_gift_given("copper")
gs.mark_gift_given("amulet")
var moves = NpcContent.available_moves(npc, gs, CanonLog.new())
assert_true("give_item(copper)" in moves, "money is not a one-shot gift")
assert_false("give_item(amulet)" in moves, "a real item still is")

View File

@@ -8,10 +8,17 @@ const CanonLog = preload("res://scripts/canon_log/canon_log.gd")
func _built_log():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var rng := RandomNumberGenerator.new()
rng.seed = 3
var origin: Dictionary = ContentDB.load_json(ContentDB.origin_path("deserter"))
var res := NewGame.construct(origin, world, {"name": "Aldric", "class_id": "priest"}, rng)
var creation := {
"name": "Aldric",
"race_id": "human",
"calling_id": "bonesetter",
"seed": 3,
"spend": {},
"skills": ["faith_lore", "perception"],
"bonus_skill": "endurance",
}
var res := NewGame.construct(origin, world, creation)
assert_true(res["ok"], str(res["errors"]))
return res["log"]
@@ -28,9 +35,10 @@ func test_constructed_log_satisfies_contract_invariants():
var d: Dictionary = _built_log().to_dict()
assert_eq(d["schema_version"], 1)
assert_true(d["recent_events"].size() <= 5)
assert_eq(d["player"].keys().size(), 3) # exactly name/class_id/luck_descriptor
assert_eq(d["player"].keys().size(), 4) # exactly name/race_id/calling_id/luck_descriptor
assert_false("luck" in d["player"]) # §7: no numeric luck
assert_true(d["player"]["class_id"] in ["sellsword", "assassin", "priest"])
assert_true(d["player"]["calling_id"] in Callings.IDS)
assert_true(d["player"]["race_id"] in Races.IDS)
for m in d["party"]:
assert_true(m["disposition"] >= -100 and m["disposition"] <= 100)
for q in d["active_quests"]:

View File

@@ -0,0 +1,97 @@
extends "res://addons/gut/test.gd"
func test_modifier_at_the_boundaries():
assert_eq(Attributes.modifier(7), -2)
assert_eq(Attributes.modifier(8), -1)
assert_eq(Attributes.modifier(9), -1)
assert_eq(Attributes.modifier(10), 0)
assert_eq(Attributes.modifier(11), 0)
assert_eq(Attributes.modifier(12), 1)
assert_eq(Attributes.modifier(18), 4)
func test_the_five_attributes_exclude_luck():
assert_eq(Attributes.IDS, ["str", "dex", "con", "fth", "mag"])
assert_false("lck" in Attributes.IDS, "LCK is never an attribute row (§7)")
func test_nine_skills_each_governed_by_an_attribute():
assert_eq(Skills.IDS.size(), 9)
for s in Skills.IDS:
assert_true(Skills.attribute(s) in Attributes.IDS, "%s has no governing attribute" % s)
func test_perception_is_faith_and_sorcery_is_magic():
assert_eq(Skills.attribute("perception"), "fth")
assert_eq(Skills.attribute("sorcery"), "mag")
assert_false(Skills.exists("lockpicking"))
func test_four_races():
assert_eq(Races.IDS, ["human", "elf", "dwarf", "beastfolk"])
func test_human_gets_a_save_bonus_and_a_skill_choice():
assert_eq(Races.save_bonus("human"), 1)
assert_true(Races.wants_bonus_skill("human"))
assert_eq(Races.granted_skills("human"), [])
func test_elf_is_granted_perception_and_nightsight():
assert_eq(Races.granted_skills("elf"), ["perception"])
assert_true(Races.flag("elf", "nightsight"))
assert_false(Races.wants_bonus_skill("elf"))
func test_dwarf_poison_bonus_is_situational_not_a_save_bonus():
assert_eq(Races.poison_save_bonus("dwarf"), 2)
assert_eq(Races.save_bonus("dwarf"), 0, "the +2 is vs poison only — it is NOT a flat save bonus")
func test_beastfolk_has_claws_and_scent():
assert_true(Races.flag("beastfolk", "claws"))
assert_true(Races.flag("beastfolk", "keen_scent"))
func test_seven_callings():
assert_eq(Callings.IDS,
["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"])
func test_hit_dice():
assert_eq(Callings.hit_die("reaver"), 12)
assert_eq(Callings.hit_die("sellsword"), 10)
assert_eq(Callings.hit_die("hedge_mage"), 6)
func test_casters_have_a_casting_stat_and_martials_do_not():
for id in ["hedge_mage", "bonesetter", "bloodsworn"]:
assert_true(Callings.is_caster(id), "%s is a caster" % id)
assert_true(Callings.casting_stat(id) in Attributes.IDS)
for id in ["sellsword", "reaver", "cutpurse", "trapper"]:
assert_false(Callings.is_caster(id), "%s lives on cooldowns" % id)
assert_eq(Callings.casting_stat(id), "")
func test_cutpurse_picks_four_skills_trapper_three_rest_two():
assert_eq(Callings.skill_count("cutpurse"), 4)
assert_eq(Callings.skill_count("trapper"), 3)
for id in ["sellsword", "reaver", "hedge_mage", "bonesetter", "bloodsworn"]:
assert_eq(Callings.skill_count(id), 2, "%s picks 2" % id)
func test_every_skill_pool_is_real_skills_and_big_enough_to_choose_from():
for id in Callings.IDS:
var pool: Array = Callings.skill_pool(id)
for s in pool:
assert_true(Skills.exists(s), "%s pool has a bogus skill: %s" % [id, s])
assert_true(pool.size() > Callings.skill_count(id),
"%s must have more pool than picks or the choice is fake" % id)
func test_every_calling_saves_are_real_attributes():
for id in Callings.IDS:
assert_eq(Callings.saves(id).size(), 2)
for s in Callings.saves(id):
assert_true(s in Attributes.IDS)

View File

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

View File

@@ -0,0 +1,53 @@
extends "res://addons/gut/test.gd"
## The canon log crosses the HTTP boundary, so its roster exists twice: in code
## (Callings.IDS / Races.IDS) and in the JSON Schema the API validates against.
## Nothing at runtime reconciles them. This does.
const ContentDB = preload("res://scripts/content/content_db.gd")
func _player_schema() -> Dictionary:
var path := ProjectSettings.globalize_path("res://") \
.path_join("../docs/schemas/canon-log.schema.json").simplify_path()
var doc: Variant = ContentDB.load_json(path)
assert_eq(typeof(doc), TYPE_DICTIONARY, "canon-log schema did not parse")
return doc["properties"]["player"]
func test_schema_calling_enum_matches_the_code():
var enum_ids: Array = _player_schema()["properties"]["calling_id"]["enum"]
enum_ids.sort()
var code_ids: Array = Callings.IDS.duplicate()
code_ids.sort()
assert_eq(enum_ids, code_ids)
func test_schema_race_enum_matches_the_code():
var enum_ids: Array = _player_schema()["properties"]["race_id"]["enum"]
enum_ids.sort()
var code_ids: Array = Races.IDS.duplicate()
code_ids.sort()
assert_eq(enum_ids, code_ids)
func test_schema_requires_race_and_calling():
var required: Array = _player_schema()["required"]
assert_true("race_id" in required)
assert_true("calling_id" in required)
assert_false("class_id" in required, "class_id is the rulebook's word — it is gone")
func test_origin_schema_allowed_callings_matches_the_code():
# origin.schema.json's build_constraints.allowed_callings duplicates the
# same seven calling ids inline (a JSON Schema "enum" cannot $ref another
# file's enum), so nothing at runtime reconciles it with Callings.IDS
# either. This guards it the same way _player_schema() guards canon-log.
var path := ProjectSettings.globalize_path("res://") \
.path_join("../docs/schemas/origin.schema.json").simplify_path()
var doc: Variant = ContentDB.load_json(path)
assert_eq(typeof(doc), TYPE_DICTIONARY, "origin schema did not parse")
var enum_ids: Array = doc["properties"]["build_constraints"]["properties"]["allowed_callings"]["items"]["enum"]
enum_ids.sort()
var code_ids: Array = Callings.IDS.duplicate()
code_ids.sort()
assert_eq(enum_ids, code_ids)

View File

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

View File

@@ -1,5 +1,46 @@
extends "res://addons/gut/test.gd"
const NewGame = preload("res://scripts/newgame/new_game.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
func _built():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
var creation := {
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
"seed": 8675309, "spend": {},
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
}
return NewGame.construct(origin, world, creation)
func test_for_character_derives_vitals_and_location_from_the_real_character():
var res = _built()
assert_true(res["ok"], str(res["errors"]))
var state = res["state"]
var log = res["log"]
var s := ShellState.for_character(state, log)
assert_eq(s.vitals["hp"], state.sheet.hp, "hp from the sheet")
assert_eq(s.vitals["hp_max"], state.sheet.max_hp(), "hp_max from the sheet")
assert_eq(s.vitals["mp"], state.sheet.mp, "mp from the sheet")
assert_eq(s.vitals["mp_max"], state.sheet.max_mp(), "mp_max from the sheet")
assert_eq(s.vitals["purse_copper"], state.purse_copper, "purse from origin grants (47c)")
assert_eq(s.location_label, log.location.name, "location from the canon log")
assert_ne(s.vitals["hp_max"], 60, "NOT the seed's hardcoded 60 — this is a real L1 sheet")
func test_for_character_keeps_the_seed_placeholders_for_combat_and_inventory():
# turn_order/consumables/round_label have no real source yet (M5/M7 own them);
# for_character must reuse seed()'s placeholders, not invent its own.
var res = _built()
var s := ShellState.for_character(res["state"], res["log"])
var seed := ShellState.seed()
assert_eq(s.turn_order.size(), seed.turn_order.size(), "turn rail is still the placeholder")
assert_eq(s.consumables, seed.consumables, "consumables are still the placeholder")
assert_eq(s.round_label, seed.round_label, "round label is still the placeholder")
func test_turn_entry_holds_fields():
var e := TurnEntry.new("DW", 12, &"ally")
@@ -16,7 +57,13 @@ func test_seed_vitals():
assert_eq(s.vitals["hp_max"], 60)
assert_eq(s.vitals["mp"], 18)
assert_eq(s.vitals["mp_max"], 30)
assert_eq(s.vitals["gold"], 1240)
assert_eq(s.vitals["purse_copper"], 347)
assert_false("gold" in s.vitals, "the flat gold placeholder is gone")
func test_seed_purse_renders_as_denominations():
var s := ShellState.seed()
assert_eq(Currency.format(s.vitals["purse_copper"]), "3s 47c")
func test_seed_turn_order():

View File

@@ -29,22 +29,166 @@ func test_default_font_is_set():
assert_true(t.default_font is FontFile)
func _guarded_variations() -> Array:
# EVERY variation OR BASE TYPE the builder touches — the stylebox variations
# (ThemeKeys.ALL), the six font roles (ThemeKeys.FONT_ROLES), AND the base
# types the builder styles directly with no variation on top
# (ThemeKeys.BASE_TYPES — today just RichTextLabel). ALL and FONT_ROLES
# deliberately exclude each other's half by their own docstrings; missing
# BASE_TYPES here left RichTextLabel's two fonts, its font size and its
# font colour — the ONLY things rendering DM prose on the creation screen —
# unguarded against a stale .tres. traps.md #12, the third miss: iterating
# ALL alone, then ALL+FONT_ROLES, both still left a gap "covers everything"
# did not actually cover.
var out: Array = ThemeKeys.ALL.keys()
out.append_array(ThemeKeys.FONT_ROLES.keys())
out.append_array(ThemeKeys.BASE_TYPES.keys())
return out
func test_the_drift_guard_covers_every_variation_the_builder_touches():
# The guard below is only as good as the set it walks. Pin that set to the
# builder's own output: every variation the fresh Theme carries a stylebox, a
# font, a font size or a font colour for MUST be in _guarded_variations(), or
# it can drift unwatched. A new variation added to the builder and forgotten in
# ThemeKeys goes red here rather than sitting unguarded forever.
var fresh: Theme = Builder.build_theme()
var guarded := _guarded_variations()
for variation in fresh.get_type_list():
var touched := (not fresh.get_stylebox_list(variation).is_empty()
or not fresh.get_font_list(variation).is_empty()
or not fresh.get_font_size_list(variation).is_empty()
or not fresh.get_color_list(variation).is_empty())
if not touched:
continue
if fresh.get_type_variation_base(variation) == &"":
# "" means one of two things, and they must be told apart, not both
# waved through: (a) a KNOWN base type the builder styles directly
# with no set_type_variation (RichTextLabel today — registered in
# ThemeKeys.BASE_TYPES), or (b) a type nobody ever registered at
# all — styled with set_stylebox/set_font/set_color but never
# given a set_type_variation, which reports the same "" and used
# to slip through this `continue` forever. The old guard could not
# tell these apart because it treated "" as "definitely (a)" and
# skipped it unconditionally — the exact whitelist that let
# RichTextLabel through in the first place (traps.md #12, third
# miss). Assert (a); anything else IS (b) and must be named.
assert_true(variation in ThemeKeys.BASE_TYPES,
("the builder styles %s directly (no set_type_variation) and it is not in " +
"ThemeKeys.BASE_TYPES — add it there, or if it should be a real variation, " +
"give it a set_type_variation(...) base so ALL/FONT_ROLES can guard it") % variation)
continue
assert_true(variation in guarded,
"the builder styles the variation %s but no drift guard walks it — add it to ThemeKeys.ALL or ThemeKeys.FONT_ROLES" % variation)
func test_committed_tres_matches_builder():
# Catches "palette/builder changed but nobody regenerated game_theme.tres."
# Preloading the builder script and calling its static build_theme() does
# NOT run _init() (that only fires on .new()), so this is side-effect-free.
#
# Drives its checks off ThemeKeys.ALL *and* ThemeKeys.FONT_ROLES so every
# variation is guarded, not just whichever three someone remembered to hardcode
# here — and not just the stylebox half. For each variation: every stylebox
# state, every font, every font size and every font colour the builder actually
# set is compared. Nothing the builder never touches is asserted on.
var fresh: Theme = Builder.build_theme()
var committed: Theme = load(THEME_PATH)
var checks := [
[ThemeKeys.PRIMARY_CTA, &"normal"],
[ThemeKeys.PARCHMENT_CARD, &"panel"],
[ThemeKeys.DARK_PANEL, &"panel"],
]
for pair in checks:
var variation: StringName = pair[0]
var state: StringName = pair[1]
var fresh_box: StyleBoxFlat = fresh.get_stylebox(state, variation)
var committed_box: StyleBoxFlat = committed.get_stylebox(state, variation)
assert_eq(fresh_box.bg_color, committed_box.bg_color,
"stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
# Theme-LEVEL defaults (build_game_theme.gd:69-70) live on the Theme object
# itself, not under any type or variation, so no per-variation loop below
# ever reaches them — they need their own comparison or a changed
# default_font_size (18 -> anything) ships silently. Font by resource_path,
# not the Resource: load() caches, so two references to the same .ttf are
# `==` even when the builder swapped fonts, unless the .tres happens to
# still point at the same file.
assert_eq(committed.default_font.resource_path, fresh.default_font.resource_path,
"stale game_theme.tres — theme.default_font drifted from the builder; re-run build_game_theme.gd")
assert_eq(committed.default_font_size, fresh.default_font_size,
"stale game_theme.tres — theme.default_font_size drifted from the builder; re-run build_game_theme.gd")
for variation in _guarded_variations():
for state in fresh.get_stylebox_list(variation):
assert_true(committed.has_stylebox(state, variation),
"stale game_theme.tres — %s/%s stylebox is missing entirely; re-run build_game_theme.gd" % [variation, state])
var fresh_box: StyleBox = fresh.get_stylebox(state, variation)
var committed_box: StyleBox = committed.get_stylebox(state, variation)
assert_eq(committed_box.get_class(), fresh_box.get_class(),
"stale game_theme.tres — %s/%s stylebox type drifted from the builder; re-run build_game_theme.gd" % [variation, state])
if fresh_box is StyleBoxFlat:
var f: StyleBoxFlat = fresh_box
var c: StyleBoxFlat = committed_box
assert_eq(c.bg_color, f.bg_color,
"stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_color, f.border_color,
"stale game_theme.tres — %s/%s border_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_width_left, f.border_width_left,
"stale game_theme.tres — %s/%s border_width_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_width_top, f.border_width_top,
"stale game_theme.tres — %s/%s border_width_top drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_width_right, f.border_width_right,
"stale game_theme.tres — %s/%s border_width_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.border_width_bottom, f.border_width_bottom,
"stale game_theme.tres — %s/%s border_width_bottom drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.corner_radius_top_left, f.corner_radius_top_left,
"stale game_theme.tres — %s/%s corner_radius_top_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.corner_radius_top_right, f.corner_radius_top_right,
"stale game_theme.tres — %s/%s corner_radius_top_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.corner_radius_bottom_right, f.corner_radius_bottom_right,
"stale game_theme.tres — %s/%s corner_radius_bottom_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
assert_eq(c.corner_radius_bottom_left, f.corner_radius_bottom_left,
"stale game_theme.tres — %s/%s corner_radius_bottom_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
# The FONT half — the whole reason this guard could not fail against a font
# role. Compare the font's resource_path, not the Resource: `load()` caches,
# so two references to the same .ttf are the same instance and `==` would be
# true even when the builder swapped SERIF for MONO... only if the .tres
# happened to point at the same file anyway. The path is the thing that drifts.
for font_name in fresh.get_font_list(variation):
assert_true(committed.has_font(font_name, variation),
"stale game_theme.tres — %s/%s font is missing entirely; re-run build_game_theme.gd" % [variation, font_name])
var fresh_font: Font = fresh.get_font(font_name, variation)
var committed_font: Font = committed.get_font(font_name, variation)
assert_eq(committed_font.resource_path, fresh_font.resource_path,
"stale game_theme.tres — %s/%s font drifted from the builder; re-run build_game_theme.gd" % [variation, font_name])
for size_name in fresh.get_font_size_list(variation):
assert_true(committed.has_font_size(size_name, variation),
"stale game_theme.tres — %s/%s font size is missing entirely; re-run build_game_theme.gd" % [variation, size_name])
assert_eq(committed.get_font_size(size_name, variation), fresh.get_font_size(size_name, variation),
"stale game_theme.tres — %s/%s font size drifted from the builder; re-run build_game_theme.gd" % [variation, size_name])
for color_name in fresh.get_color_list(variation):
assert_true(committed.has_color(color_name, variation),
"stale game_theme.tres — %s/%s colour is missing entirely; re-run build_game_theme.gd" % [variation, color_name])
var fresh_color: Color = fresh.get_color(color_name, variation)
var committed_color: Color = committed.get_color(color_name, variation)
assert_eq(committed_color, fresh_color,
"stale game_theme.tres — %s/%s drifted from the builder; re-run build_game_theme.gd" % [variation, color_name])
func test_creation_variations_exist_with_the_states_the_screen_needs():
var t: Theme = load(THEME_PATH)
# A race/calling card is a Button that must show a CHOSEN state.
assert_true(t.has_stylebox(&"normal", ThemeKeys.SELECT_CARD))
assert_true(t.has_stylebox(&"hover", ThemeKeys.SELECT_CARD))
assert_true(t.has_stylebox(&"pressed", ThemeKeys.SELECT_CARD), "pressed IS the chosen ring")
assert_true(t.has_stylebox(&"disabled", ThemeKeys.SELECT_CARD), "an origin may disallow a calling")
# A skill chip is a toggle with three states: unpicked, picked, and granted-so-inert.
assert_true(t.has_stylebox(&"normal", ThemeKeys.SKILL_CHIP))
assert_true(t.has_stylebox(&"pressed", ThemeKeys.SKILL_CHIP))
assert_true(t.has_stylebox(&"disabled", ThemeKeys.SKILL_CHIP))
# The tags and the primary ring.
assert_true(t.has_stylebox(&"normal", ThemeKeys.CHOSEN_TAG))
assert_true(t.has_stylebox(&"normal", ThemeKeys.PRIMARY_TAG))
assert_true(t.has_stylebox(&"panel", ThemeKeys.PRIMARY_CARD))
func test_section_label_is_a_font_role_on_parchment():
var t: Theme = load(THEME_PATH)
assert_true(t.has_font(&"font", ThemeKeys.SECTION_LABEL))
assert_eq(t.get_color(&"font_color", ThemeKeys.SECTION_LABEL), Palette.INK_LABEL_MUTED)

View File

@@ -38,8 +38,7 @@ func test_up_from_first_wraps_to_last():
func test_new_game_and_quit_emit_their_action():
var t := _title()
# Isolate the signal seam so the test does not actually change scene / quit.
t.menu_activated.disconnect(t._on_menu_activated)
# Title is emit-only now: no internal handler to isolate. Just watch the emit.
watch_signals(t)
t._activate(_index_of(t, &"new_game"))
assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"])

View File

@@ -4,15 +4,21 @@ Cross-cutting authored writing, split by role in the data model (see the canon
log spec in [`/docs`](../docs)):
```
/world Static, ID-referenced game content. Identical every playthrough.
roadmap.md The content track — Spine · Bill of Materials · Backlog. START HERE.
/lore Authored Markdown bibles. THE SOURCE OF TRUTH; world/ + server/ are built from it.
/world Static, ID-referenced game content. Identical every playthrough. BUILT.
/locations The map — towns, dungeons, points of interest (by id)
/npcs Per-NPC persona + knowledge lists (charter §6)
/quests Story skeletons and quest definitions (charter §17)
/items Item definitions — gear, consumables, cursed items (§7)
/server Spoiler bodies + personas. API-only; excluded from the client export. BUILT.
/origins Thin starting-state seeds. One per starting point. POC authors one.
/fallback Authored degraded-DM text for every AI surface (charter §13)
```
**[`roadmap.md`](roadmap.md) governs what gets authored.** Its rule: *never author
anything that is not in the Bill of Materials.* Ideas that arrive go to its Backlog.
## The three layers
- **`world/`** is static content the origin and the canon log reference by stable

View File

@@ -1,18 +1,11 @@
# Canon Roadmap
# Moved
The world's answer to `docs/roadmap.md`: what is authored vs pending, and the
order the Margreave is being written. Source of truth is `content/lore/*.md`;
`content/world` + `content/server` are BUILT from it (`python -m content_build`).
The canon roadmap is now the **content roadmap**: [`content/roadmap.md`](../roadmap.md).
## Authored (status: canon)
It grew past canon — it holds the Spine, a Bill of Materials (items, prices,
quests, the map), and a Backlog. An items-and-prices table does not belong under
`lore/`. See the
[content-track design](../../docs/superpowers/specs/2026-07-12-content-track-design.md).
- **duncarrow.md** — Duncarrow (the Tallow Reach market town), the White Antlers
shrine, the Crell secret chain, and NPCs Mera Fenn (witness), Mayor Crell
(control), Harn Blackwood (texture). The bounded-dialogue / disposition-gate
specimen.
## Pending (not yet authored)
- The rest of the Tallow Reach region; the elven road-peoples beyond the stub
`faction.elves`; the seven worldbuilding topics tracked in the
canon-architecture direction.
This pointer exists because older specs and plans reference the old path. It holds
no canon and emits nothing.

131
content/lore/cosmology.md Normal file
View File

@@ -0,0 +1,131 @@
# COSMOLOGY — the Warden and the Seven — status: canon
> The overarching theme of the Margreave: a distant good god, his angels, and
> the seven demon kings who work against him. Public, believed world-furniture —
> all `secrecy: 0`, bodies ship to the client. WHO is possessed, WHICH road is
> bled, are authored later as gated `rumor`/`fact`/`secret` per-town; this file
> never names a victim.
>
> Tone: distant & contested. The narrator never confirms the Warden, never goes
> epic. The war is the hidden engine under the grime.
---
## THE WARDEN — the good god (distant)
```yaml
id: person.the-warden
type: person
status: canon
secrecy: 0
related: [faction.the-sent, faction.the-seven, rule.the-pact]
body: >
The god most of the Margreave professes, though few claim to have felt him.
He is called only the Warden; no living mouth speaks his name, and the priests
who might have known it are a long time dead. What the faithful hold is simple:
he made the races, he loves them, and he wards the world against the Seven.
What he asks in return is worship, and what he gives for it is blessing —
rarely, and never where you can point to it and be sure. He does not come when
called. Whether that is because he is good and restrained, or absent, or dead,
is the oldest argument there is, and the Margreave has never settled it.
```
## THE SENT — his angels (indirect, rare)
```yaml
id: faction.the-sent
type: faction
status: canon
secrecy: 0
related: [person.the-warden, rule.the-pact]
body: >
The Warden's messengers — angels, the Sent, his hand where he will not put his
own. They do not fight the Seven and they do not save anyone. They ask. A Sent
one comes to a mortal, names a thing that must be done in the Warden's name,
and leaves the doing to them; blessing follows the deed, or does not follow the
refusal. Most people go their whole lives without meeting one, and a fair
number of those who claim to have met one were lying or drunk or worse. That
they are rare is either the mark of a careful god or a thin one.
```
## THE SEVEN — the demonic host
```yaml
id: faction.the-seven
type: faction
status: canon
secrecy: 0
related: [person.the-warden, rule.blood-price, rule.possession,
person.kareth-the-red-king, person.vael-the-quiet-knife,
person.morren-the-grey-mother, person.ghaul-the-yoke,
person.ishri-the-open-mouth, person.nuun-the-hollow-choir,
person.draeth-the-crowned-worm]
body: >
Seven kings and the armies that answer to them — the Warden's enemy, and the
world's. They do not want to rule the races so much as end them, dead or yoked,
it makes little difference to the Seven which. Each king is its own hunger and
keeps its own kind of war, and they cooperate the way wolves cooperate, which
is to say until the meat runs low. What they run on is blood: spilled,
sacrificed, offered. A mortal who learns how to feed them can be given a share
of what that blood buys. Most of the Margreave believes the Seven are real.
Most of the Margreave is right.
```
## THE PACT — worship for blessing (the Warden's economy)
```yaml
id: rule.the-pact
type: rule
status: canon
secrecy: 0
related: [person.the-warden, faction.the-sent]
body: >
The bargain at the heart of the Warden's faith: worship for blessing. Prayer,
offering, a shrine kept clean, a deed done in his name — these buy his favor,
and his favor turns fortune a little kinder. A bonesetter, the closest thing
the Margreave has to a priest, is a mortal who has given enough of themselves
to the Warden to channel a thread of that blessing into the living — a wound
closed, a fever broken, a death postponed. The pact is never a receipt. You
give, and blessing may come, on the Warden's terms and in his time, which is to
say seldom and never on demand.
```
## THE BLOOD-PRICE — blood for power (the Seven's economy)
```yaml
id: rule.blood-price
type: rule
status: canon
secrecy: 0
related: [faction.the-seven, rule.possession]
body: >
What the Seven trade in. Their power is not given, it is bought, and the only
coin is blood — the more, and the more wrongly spilled, the better the rate. A
killing floor, a sacrifice, a murder done in a lord's name feeds the king it is
offered to. A mortal willing to strike that bargain becomes what the Margreave
calls bloodsworn: sworn to one of the Seven, marked by it, and able to spend a
thread of its power the way the faithful spend the Warden's — a curse laid, a
hurt dealt, a thing that should not bend made to bend. The blessing side of it,
such as it is, is that a king does not waste ill luck on its own sworn; the
price for that mercy is simply the blood, and the blood is never paid once.
```
## POSSESSION — how the Seven reach mortals
```yaml
id: rule.possession
type: rule
status: canon
secrecy: 0
related: [faction.the-seven, rule.blood-price]
body: >
How the Seven reach across into the world. Mostly it is not the horror the
stories promise — it is influence, a hand on the scale of a mortal's wanting:
the grudge nursed a little hotter, the easy cruelty made easier, the appetite
that will not be talked down. That is the common work, and most of it is never
noticed as anything but a person going bad. Outright possession — a king
wearing a body like a coat — is rarer and costs the Seven dearly, and it is
Nuun, the Hollow Choir, that does most of it; the others prefer a willing hand
to a stolen one. A possessed mortal is not the enemy. The enemy is the thing
inside them, and it will spend the body it wears without a thought.
```

View File

@@ -1,298 +0,0 @@
# DUNCARROW — status: canon
> The bounded-dialogue / disposition-gate specimen, promoted from
> `candidate-town.md`. Every fact_id resolves, every gate is a legal
> disposition value, every knowledge link points at a real fact, and nothing
> here contradicts existing canon.
>
> Disposition ladder referenced by all gates:
> `hostile / cold / neutral / warm / trusted`
>
> Two guard axes, kept separate on purpose:
> - **secrecy** lives on the fact (author-facing, static, mis-authoring guard)
> - **gate** lives on the knowledge link (character-facing, runtime disposition)
> `never` is a real gate: the NPC knows the fact but no disposition unlocks it.
---
## WORLD RULE (canon dependency)
```yaml
id: rule.disposition-ladder
type: rule
status: canon
secrecy: 0
related: []
rungs: [hostile, cold, neutral, warm, trusted]
body: >
NPC trust toward the player is one of five ordered states:
hostile < cold < neutral < warm < trusted. Where a given player sits with a
given NPC is runtime state owned by code. The ladder itself is canon. Every
knowledge-link gate references one of these five names, plus the special
gate `never` (fact is known but never revealed through dialogue at any rung).
```
```yaml
id: region.the-tallow-reach
type: region
status: canon
secrecy: 0
related: []
body: >
The Tallow Reach — the lowland march of grain towns and wool roads that
Duncarrow sits at the edge of, feeding the pass trade.
```
```yaml
id: faction.elves
type: faction
status: canon
secrecy: 0
related: []
body: >
The elven travellers of the road — no single polity, but the people whose
passage through the White Antlers, and its recent absence, the town notices.
```
---
## TOWN — public layer (tier 0, freely given)
```yaml
id: town.duncarrow
type: town
status: canon
secrecy: 0
related: [region.the-tallow-reach, place.the-white-antlers, person.mayor-oswin-crell]
body: >
Duncarrow is a walled market town at the edge of the Tallow Reach, grown fat
on the road trade between the lowland grain towns and the pass. Timber and
wool go out; salt, iron, and coin come in. Ten militia hold the walls under
the mayor's charter. It is, by every visible measure, a prosperous and orderly
place, which is exactly the reputation its mayor has spent fifteen years
building and depends on.
```
```yaml
id: place.the-white-antlers
type: place
status: canon
secrecy: 0
related: [town.duncarrow, faction.elves]
body: >
An old elven shrine a half-hour's walk north of Duncarrow, in the birch stand
off the pass road. Weathered antler-carvings, a dry basin, nothing worth
stealing. Elves on the road have historically stopped here. Locals know it as
a curiosity and a good spot to water horses.
```
```yaml
id: person.mayor-oswin-crell
type: person
status: canon
secrecy: 0
related: [town.duncarrow]
body: >
Mayor of Duncarrow for fifteen years. Warm, unhurried, remembers names and
the names of your dead. Runs a clean council and a clean set of books. Widely
liked. The visible locked door of this town: pleasant to every player at every
disposition, and revealing of nothing.
```
---
## THE SECRET CHAIN (the ladder the town exists to test)
Ordered from freely-observed to `never`. Each is its own atomic fact so different
NPCs can hold the same fact at different gates.
```yaml
id: rumor.elves-avoid-the-shrine
type: rumor
status: canon
secrecy: 1
related: [place.the-white-antlers, faction.elves]
body: >
Elves don't stop at the White Antlers anymore. Have not, for a couple of
years. Anyone observant notices; most townsfolk assume the elves simply moved
on or the road changed.
```
```yaml
id: rumor.travelers-go-missing
type: rumor
status: canon
secrecy: 2
related: [town.duncarrow, place.the-white-antlers]
body: >
Small parties on the north road — always small, always few enough not to be
missed — sometimes don't arrive where they were going. Locals know it as bad
luck on a bad stretch of road. They do not like discussing it.
```
```yaml
id: fact.militia-never-investigates
type: fact
status: canon
secrecy: 2
related: [town.duncarrow, rumor.travelers-go-missing]
body: >
When travelers go missing near the shrine, the town militia does not ride out.
There is always a reason — jurisdiction, weather, the road being the pass's
problem not the town's. The pattern is the tell: this is protected, not
neglected.
```
```yaml
id: fact.crells-guard-acts-alone
type: fact
status: canon
secrecy: 3
related: [person.mayor-oswin-crell, fact.militia-never-investigates]
body: >
The mayor keeps three personal guardsmen who answer only to him, separate from
the town militia of ten. They come and go on the north road at odd hours on
the mayor's word alone. Someone close enough to the militia notices they
operate outside its command entirely.
```
```yaml
id: secret.crell-runs-slave-trade
type: secret
status: canon
secrecy: 4
related: [person.mayor-oswin-crell, place.the-white-antlers, faction.elves]
body: >
Mayor Crell runs an elf-slaving operation. When a small enough elven party
camps near the White Antlers — few enough that no one will come looking — his
three personal guard take them on the road and Crell sells them into slavery
in towns beyond the pass. The town's prosperity and order are partly the
proceeds and partly the cover.
POC CONSTRAINT: this fact is learnable but not actionable in this build. No NPC
proposes a move to stop it. The witness's top rung delivers understanding, not
a quest. Faction/positional combat that acting on this would require is out of
POC scope.
```
---
## NPC — THE WITNESS (deep — the variable)
This is the NPC the POC is really testing. The reachable top rung. Whether
climbing her ladder feels like relationship or like a lockpick minigame IS the
question.
```yaml
id: npc.mera-fenn
type: person
status: canon
secrecy: 0
start_disposition: cold
related: [town.duncarrow, fact.crells-guard-acts-alone]
body: >
Mera Fenn runs the north gate's night watch — militia, low rank, overlooked,
which is how she sees things she shouldn't. Dry, tired, careful. She saw the
mayor's guard bring a covered cart through her gate before dawn and was told,
pleasantly, to log it as grain. She has told no one. She is afraid, and her
fear reads as prickliness to strangers — she withholds MORE than a stranger
would, not less, until you've earned otherwise.
knows:
- {fact: rumor.elves-avoid-the-shrine, gate: neutral}
- {fact: rumor.travelers-go-missing, gate: warm}
- {fact: fact.militia-never-investigates, gate: warm}
- {fact: fact.crells-guard-acts-alone, gate: trusted}
# the reachable ceiling
# She does NOT hold secret.crell-runs-slave-trade. She saw the guard, the cart,
# the pattern — she never saw a sale. Her top rung is naming what she witnessed,
# not stating the whole truth. The player infers the secret; the game never
# hands it over as a line. This is deliberate: understanding earned, not told.
disposition_notes: >
Starts at `cold`, not `neutral` — she's guarded by default. Hostile is
reachable (push her, threaten, side visibly with the mayor) and at hostile she
reveals nothing, not even the tier-1 rumor a stranger would get. This is the
fear-driven withholding the 5-rung ladder exists for.
```
---
## NPC — THE MAYOR (medium — the control)
Holds the secret at `never`. His job is to be the locked door the witness
explains. Do not judge him by the aliveness bar; he is the control, not the
variable.
```yaml
id: npc.mayor-oswin-crell
type: person
status: canon
secrecy: 0
start_disposition: neutral
related: [person.mayor-oswin-crell, secret.crell-runs-slave-trade]
body: >
Same person as person.mayor-oswin-crell; this is his interactive NPC layer.
Warm to every player at every rung. Rises to `warm` easily and stalls there —
no disposition moves him past it, because there is nothing past it he will give.
knows:
- {fact: rumor.elves-avoid-the-shrine, gate: cold}
# dismisses it lightly
- {fact: secret.crell-runs-slave-trade, gate: never}
# knows; unlocked by no rung
- {fact: fact.crells-guard-acts-alone, gate: never}
# knows; explains it away if pressed
disposition_notes: >
The `never` gates are the point: he KNOWS these facts, the Improviser can
reason with them, and the eight-move vocabulary demonstrably cannot leak them
at any disposition. If a player at `trusted` can get anything out of him about
the guard or the trade, the bounded-dialogue guarantee has failed and the POC
has found a real bug — which is itself a valuable POC result.
```
---
## NPC — ONE PUBLIC-LAYER TOWNSPERSON (shallow — texture + a clean guarded-personal test)
Proves the "eloped daughter tier": low-secrecy fact that's still personally
guarded, unrelated to the main secret, so you can test disposition-gating on
something with no plot weight.
```yaml
id: npc.harn-blackwood
type: person
status: canon
secrecy: 0
start_disposition: neutral
related: [town.duncarrow]
body: >
Harn Blackwood, the town smith. Gruff, fair, talks freely about iron, the
road, and the price of salt. Carries one small private grief he will not hand
a stranger.
knows:
- {fact: rumor.travelers-go-missing, gate: warm}
# locals know, say little
- {fact: fact.harn-daughter-left, gate: warm}
# personal, no plot weight
disposition_notes: >
Control case for the OTHER half of the test: does disposition-gating feel good
on a fact that costs nothing to reveal and spoils nothing? If earning the
daughter story feels warm and human, gating works. If it feels like grinding a
vendor, the mechanic has a problem the plot-secret would only hide.
```
```yaml
id: fact.harn-daughter-left
type: fact
status: canon
secrecy: 1
related: [npc.harn-blackwood]
body: >
Harn's daughter left Duncarrow two winters ago with a wool trader's caravan
and does not write. It shames and grieves him. Spoils nothing; guarded purely
because it is his.
```

21
content/lore/mechanics.md Normal file
View File

@@ -0,0 +1,21 @@
# MECHANICS — canonical world rules (mechanical, not lore)
> Home for mechanical `rule.*` entries the dialogue/engine layer depends on.
> `rule.disposition-ladder` lives here (moved from the retired `duncarrow.md`
> scaffolding); it is the required-exactly-once gate vocabulary every NPC
> knowledge-gate references.
```yaml
id: rule.disposition-ladder
type: rule
status: canon
secrecy: 0
related: []
rungs: [hostile, cold, neutral, warm, trusted]
body: >
NPC trust toward the player is one of five ordered states:
hostile < cold < neutral < warm < trusted. Where a given player sits with a
given NPC is runtime state owned by code. The ladder itself is canon. Every
knowledge-link gate references one of these five names, plus the special
gate `never` (fact is known but never revealed through dialogue at any rung).
```

80
content/lore/specimen.md Normal file
View File

@@ -0,0 +1,80 @@
# SPECIMEN — format example, NOT game canon
> A synthetic disposition-gate specimen: the shape a guarded-then-warm NPC takes
> in the bible, with an ascending knowledge chain — one reveal the player earns at
> `warm`, and a `never`-gated core the NPC knows and no disposition ever unlocks.
> Authored `status: candidate`, so the build VALIDATES it (`--check` resolves its
> gates against the ladder) but emits NOTHING to `content/world` or
> `content/server`. This is a teaching example for the world-building skill, not
> part of the Margreave. Do not wire real content to these ids.
```yaml
id: place.specimen-wharf
type: place
status: candidate
secrecy: 0
related: []
body: >
A generic river wharf, here only to anchor the specimen NPC. Not a real place
in the Margreave.
```
```yaml
id: person.specimen-teague
type: person
status: candidate
secrecy: 0
related: [place.specimen-wharf]
body: >
Teague, oldest hand on the 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: candidate
secrecy: 2
related: [place.specimen-wharf, person.specimen-teague]
body: >
The dock hands are paid in coin from outside the charter. Someone is buying the
wharf's silence, a little at a time.
```
```yaml
id: secret.specimen-drowned-clerk
type: secret
status: candidate
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: candidate
secrecy: 0
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.
```

124
content/lore/the-seven.md Normal file
View File

@@ -0,0 +1,124 @@
# THE SEVEN — the demon kings — status: canon
> Seven kings, seven armies, seven kinds of blood. Each is a `person.*`,
> `secrecy: 0`, relating up to `faction.the-seven` (Task 1 / cosmology.md).
> Tight sketches — a king, the blood it craves, its army, how it corrupts a
> mortal. Gritty, not cartoonish: each is a way mortals actually bleed. No
> questlines here; those hang off these ids later.
---
```yaml
id: person.kareth-the-red-king
type: person
status: canon
secrecy: 0
related: [faction.the-seven]
body: >
Kareth, the Red King, first and loudest of the Seven. The blood he wants is the
battlefield's — slaughter, the more of it and the less reason the better. His
army is the horde, and it grows because he is generous with the promise of
victory and the men who take it rarely live to complain that it was a lie. He
does not whisper. He works through the rage already in a mortal, the feud that
only needs a reason, the certainty that the sword is the short way through.
Where Kareth has been, there is a field that will not grow for a generation and
no one left who remembers starting it.
```
```yaml
id: person.vael-the-quiet-knife
type: person
status: canon
secrecy: 0
related: [faction.the-seven]
body: >
Vael, the Quiet Knife, the intimate one. The blood Vael craves is the kind
spilled indoors — the trusted killed by the trusted, the poison in the cup you
were poured, the debt settled between people who once shared bread. Vael keeps
no horde; its army is a scatter of ones, each certain they acted alone. It
works through the grudge held too long and the moment the knife looks like the
only door left in the room. Kareth takes the field. Vael takes the household,
and no one hears it happen.
```
```yaml
id: person.morren-the-grey-mother
type: person
status: canon
secrecy: 0
related: [faction.the-seven]
body: >
Morren, the Grey Mother, who comes with the fever. The blood she wants is thin
and slow — the dying, the plague-struck, the ones given up on. Her army is the
sick, and she is patient in a way the others are not, because rot does her
recruiting. She works through despair, and cruelest of all through mercy: the
pillow held over a suffering face, the kindness that is also a killing. Morren
does not lie about what she offers. She offers an end, and for a great many of
the Margreave's dying, that is temptation enough.
```
```yaml
id: person.ghaul-the-yoke
type: person
status: canon
secrecy: 0
related: [faction.the-seven]
body: >
Ghaul, the Yoke, the patient profit of the Seven. The blood Ghaul takes is
worked out slow — chattel bought and sold and used until there is nothing left
to use. Its army is the coffle and the counting-house, and it does not need to
whisper to a man who has already found that a person is cheaper than an ox. It
works through the ledger: the wage shaved, the debt made unpayable, the
reasoning that a body one owns is a body one may spend. Ghaul is the least
dramatic of the Seven and by a plain count of the ruined the most successful.
```
```yaml
id: person.ishri-the-open-mouth
type: person
status: canon
secrecy: 0
related: [faction.the-seven]
body: >
Ishri, the Open Mouth, the hunger that is never a meal. The blood Ishri wants
is the blood spent chasing more — drunk to death, wanted to ruin, taken because
taking felt like living. Its army is the appetite of anyone who has stopped
being able to stop. It does not corrupt so much as encourage; it stands at a
mortal's shoulder and agrees that one more will not hurt, and it is right until
it is not. Ishri leaves the neatest corpses and the emptiest houses, everything
sold to feed a want that was never going to be full.
```
```yaml
id: person.nuun-the-hollow-choir
type: person
status: canon
secrecy: 0
related: [faction.the-seven, rule.possession]
body: >
Nuun, the Hollow Choir, the many-voiced. The blood Nuun gathers is a mortal's
own — the self-slain, the ones talked into the river or off the roof by a voice
they took for their own thought. Its army is the emptied, and it is Nuun, of
all the Seven, that most often does the old horror outright and wears a mortal
like a coat, because a hollowed body is the only kind it truly wants. It works
through the whisper, the crack in a mind widened patiently until there is room
for something else to move in. A person going quietly, terribly wrong, and
swearing nothing is the matter, is Nuun's ordinary work.
```
```yaml
id: person.draeth-the-crowned-worm
type: person
status: canon
secrecy: 0
related: [faction.the-seven]
body: >
Draeth, the Crowned Worm, the one that likes a throne. The blood Draeth feeds
on is a ruler's to spend — a people bled by the one meant to keep them, for
power that always seems worth the cost to the one paying it with other lives.
Its army is whoever a corrupted crown can command, which is the cruelest of the
Seven's arithmetic. It works through ambition, the small justified step and the
next, until a lord is sacrificing the realm to hold the realm and calling it
duty. Draeth rarely has to lie. It only has to wait by the chair and let the
sitting do the rest.
```

View File

@@ -15,11 +15,12 @@
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
"inventory_grants": [
{ "item_id": "worn_shortsword", "qty": 1 },
{ "item_id": "coin", "qty": 3 }
{ "item_id": "copper", "qty": 47 }
],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_classes": ["sellsword", "assassin", "priest"],
"allowed_callings": ["sellsword", "reaver", "cutpurse", "trapper",
"hedge_mage", "bonesetter", "bloodsworn"],
"luck_modifier": 0
}
}

185
content/roadmap.md Normal file
View File

@@ -0,0 +1,185 @@
# Content Roadmap
The world's answer to [`docs/roadmap.md`](../docs/roadmap.md). That doc sequences
**systems**; this one sequences **content** — lore, the economy, items, quests, the
map — and makes it **countable rather than open-ended**.
Source of truth is `content/lore/*.md`; `content/world` + `content/server` are
BUILT from it (`python3 -m content_build`). Realizes the
[content-track design](../docs/superpowers/specs/2026-07-12-content-track-design.md).
Three sections. **Only one of them is ever the work.**
1. **[Spine](#1-the-spine)** — the slice's world. Authored once; gates everything downstream.
2. **[Bill of Materials](#2-bill-of-materials)** — a countable checklist, per milestone. Numbers, not vibes.
3. **[Backlog](#3-backlog)** — everything the slice does not need. Named so it is not lost; parked so it is not looming.
> ## The rule
>
> **Never author anything that is not in the Bill of Materials.**
>
> An idea that arrives goes to the Backlog, and is then no longer carried. The
> Backlog is not a debt. It is where ideas go to be safe.
---
## 1. The Spine
**Greywater Docks and one dungeon** — the smallest thing that is a *game*. Every
system (creation, combat, dialogue, inventory, shop, quest log, map) is exercised
exactly once, against real content. The cosmology touching ground for the first
time.
- **The town — Greywater Docks.** A rot-black wharf where the river meets the sea
trade; everyone owes someone. **~6 NPCs:** Fenn (promoted from M2 test fixture to
real NPC), the harbourmaster, a fence, an innkeeper, a dock rough, one more.
- **The chain — "The Missing Ledger,"** extended from the existing hook. Town → road
→ dungeon → back. **Four objectives.**
- **The turn.** The debt Fenn cannot cover is a **blood** debt. The harbourmaster is
named in the ledger because he is the one *collecting*. The dungeon is the harvest
site.
- **The patron — Ghaul the Yoke.** Already, by name, the king of debt and bondage; a
ledger of what men owe is his liturgy. This makes the quest chain and the cosmology
**the same object** rather than two things bolted together, and gives
`rule.blood-price` its first concrete instance.
- **The dungeon** — the harvest site. One enemy family (the yoked) + a boss.
**Adopting Greywater, not replacing it.** Fenn is the NPC the aliveness question was
actually answered with (M2, live-proven). The slice's job is to make Greywater
*deep*, not *new*.
Breadth afterwards is **repetition of a proven shape**, not invention.
### Already authored (status: canon)
- **`lore/cosmology.md`** — the overarching theme: the Warden (distant good god), the
Sent (his angels), the Seven (demonic host), and the two economies + possession
(`rule.the-pact`, `rule.blood-price`, `rule.possession`). The backdrop every future
piece of content reconciles against.
- **`lore/the-seven.md`** — the seven demon kings, one `person.*` each: Kareth (Red
King), Vael (Quiet Knife), Morren (Grey Mother), Ghaul (the Yoke), Ishri (Open
Mouth), Nuun (Hollow Choir), Draeth (Crowned Worm).
- **FTH-axis callings** (folded into the cosmology, from the races/classes spec): the
Bonesetter channels the Warden, the **Bloodsworn** pacts one of the Seven.
- **`lore/mechanics.md`** — hosts `rule.disposition-ladder` (the required-once gate
vocabulary).
- **`lore/specimen.md`** — a synthetic `status: candidate` disposition-gate specimen
(validated by `--check`, emitted nowhere); the world-building skill's structure
example. **Not game canon.**
### Still to author for the slice
Greywater itself — the town, its ~6 NPCs, the ledger chain's four objectives, and
Ghaul's harvest site — as `content/lore/greywater.md`. The hand-written JSON under
`content/world/{locations,quests,npcs,items}` gets **authored upward** into that
bible, and `python3 -m content_build` emits `world/` from it.
Per-town knowledge that grounds the cosmology (possessed NPCs, blood-harvest sites,
shrine fronts) is authored as gated `rumor`/`fact`/`secret`.
---
## 2. Bill of Materials
Countable. This is the anti-overwhelm device.
The **Fixed/Variable** column is the [seven-campaign
saga](../docs/superpowers/specs/2026-07-12-character-reuse-seven-arc-design.md)'s only
ask of this table. **Fixed substrate** is identical in every campaign of every saga —
authored once, reused verbatim. **Variable** is per-campaign: the part a second town
would have to re-author, and the part v2 generation would produce. The column costs
nothing and tells you exactly what town #2 does *not* pay for.
| Milestone | Content it consumes | Count | Fixed / Variable |
|---|---|---|---|
| **M4** creation | Calling blurbs; the Bloodsworn's patron choice | ~2 | **Fixed** |
| **M4** creation | Origins (they reference world ids) | ~2 | **Variable** |
| **M5** combat | Abilities for the 3 POC classes | ~10 | **Fixed** |
| **M5** combat | 1 enemy family (4 units) + boss — **tier 1** | ~5 | **Variable** (per tier) |
| **M6** dialogue | 6 NPCs — persona + `knowledge[]` + gated topics (§6) | ~6 | **Variable** |
| **M7** inventory | 6 weapons, 4 armour, 3 consumables, **1 cursed item** (+STR / LCK — §7 says day-one) | ~14 | **Fixed** |
| **M8** world | The **price table in copper** | ~1 | **Fixed** |
| **M8** world | 3 map locations + the road; shop stock; the quest chain | ~7 | **Variable** |
| **M9** flavour | Authored fallback per AI surface (§13); shrine + Luck-drift events; humiliation seeds | ~10 | **Fixed** |
**≈57 discrete authored pieces for a complete, playable game.** An estimate, and
likely optimistic — but an estimate with an end, which is the entire point.
Roughly **37 fixed / 20 variable**: a second town costs the ~20, not the ~57.
Explicitly **not** in the slice: rings, amulets, trinkets. A second town. The other
six kings and their cults. All parked in the Backlog.
### The economy is two systems (§18)
- **The loop is code** (§2: state) — purse, buy/sell, the ~40% fence rate, gritty
disabled-CTA reasons. Sequenced as M8's *Shop economy*.
- **The numbers are content** — what a shortsword costs, a night at the inn, an ale,
the quest's purse. The **price table**, above.
**Currency model:**
```
1 gold = 100 silver = 10,000 copper
1 silver = 100 copper
```
**Gold is scary.** A peasant's whole life is copper, a mercenary's fee is silver, and
a gold coin is a plot point. The party counting coppers for an inn bed is §3 grit,
and Brannoc has an opinion about it.
**The value type landed ✅** ahead of M7, as its own item
([spec](../docs/superpowers/specs/2026-07-12-currency-copper-design.md), merged to
`dev`): a `Currency` value type storing **everything in copper** and formatting only
at the display edge; `coin.json` replaced by `copper`/`silver`/`gold`; the purse as a
single `GameState.purse_copper` int; the shell's command bar reading `◈ 3s 47c`.
Money is **not** an inventory item. `give_item`/`accept_item` carry no quantity and
`gifts_given` is a global one-shot, so a lone `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.
**What is left is the numbers.** The **price table** (above) is content and belongs to
M8's BOM. Nothing is priced yet.
*§2: state · done · goal: money that means something.*
---
## 3. Backlog
Not a debt. Where ideas go to be safe.
- **Items:** rings, amulets, trinkets.
- **Places:** a second town. Regions of the Margreave beyond the slice.
- **The saga (tiers 27)** — from the [character-reuse
spec](../docs/superpowers/specs/2026-07-12-character-reuse-seven-arc-design.md): the
other six kings as campaign patrons; tiered enemy families (proximity to a king *is*
the power scale); the three story-shapes (cult cell → cult that owns a city → the
king himself).
- **AI-generated worlds** (v2, charter §17). The saga spec guarantees the `Saga`
object is what will feed it. Nothing else about it is decided.
*(Left the Backlog 2026-07-12: the Barbarian ⚔ calling name — **Reaver**. All seven
callings are named; see the [races/classes spec](../docs/superpowers/specs/2026-07-11-races-and-classes-design.md) §4.)*
---
## Notes for whoever authors next
- Each BOM line is authored through the **`/world-building` skill** — it reconciles
against existing canon, writes the build tool's source format, and verifies the
build stays green.
- **The build tool needs two new namespaces: `item` and `quest`.** Its legal
namespaces today (`tools/content_build/model.py`) are `town`, `place`, `region`,
`faction`, `person`, `rule`, `rumor`, `fact`, `secret`, `npc` — so **locations are
already modeled** (`town`/`place`/`region`) and only items and quests fall outside
it. Adding those two (model, emit, resolve) is a prerequisite of the M7 and M8 BOM
lines. **Add `tier: 1..7` to the entry envelope at the same moment** — the saga
spec's only schema ask, free while the model is already open.
- **Moving or deleting generated content is a client change.** `content/world/**` has
a client consumer: `client/scripts/content/content_db.gd` (via
`client/scripts/harness/npc_harness.gd`) and its GUT tests in
`client/tests/unit/test_content_db.gd`. That is the Duncarrow lesson — check
`client/` before any content move.

View File

@@ -1,5 +0,0 @@
{
"disposition_notes": "Control case for the OTHER half of the test: does disposition-gating feel good on a fact that costs nothing to reveal and spoils nothing? If earning the daughter story feels warm and human, gating works. If it feels like grinding a vendor, the mechanic has a problem the plot-secret would only hide.",
"id": "npc.harn-blackwood",
"persona": "Harn Blackwood, the town smith. Gruff, fair, talks freely about iron, the road, and the price of salt. Carries one small private grief he will not hand a stranger.\n"
}

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