Compare commits
93 Commits
a3506f7f02
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fb173008 | |||
| f5e6c59f2a | |||
| 7f155b8de2 | |||
| d553213563 | |||
| 5a9f39ea48 | |||
| 583d32456a | |||
| 6b10b6ea6c | |||
| 8d963e8030 | |||
| e169885d90 | |||
| f763aa0c31 | |||
| 10f89baef9 | |||
| aaff2c8554 | |||
| a106be673f | |||
| 1ae62fe0db | |||
| 8f3aa75fa9 | |||
| 63cbc8c16e | |||
| 7471d2e37f | |||
| 6291f21a00 | |||
| 34bf064904 | |||
| 31759ef848 | |||
| 4494856a44 | |||
| c5f127e0f1 | |||
| 6589d7c4ed | |||
| 2227d703f6 | |||
| fe8e24cf39 | |||
| 0bbf94289a | |||
| 1621a38b29 | |||
| 866158cc0d | |||
| 1c4d19492d | |||
| 537b0e1d18 | |||
| 057b83679b | |||
| c6b8a651aa | |||
| e4db022c4b | |||
| 17ef142689 | |||
| 7a74a8de94 | |||
| 333d901931 | |||
| b4b72e472e | |||
| b663703da7 | |||
| a9357fb787 | |||
| 62b8113ffa | |||
| bd49e118d5 | |||
| eba706b906 | |||
| 5149e817f6 | |||
| a668cf98eb | |||
| bb5d176262 | |||
| a7e153884f | |||
| 78e18107b2 | |||
| 89d4c65a7a | |||
| 9de8ee40b3 | |||
| 198ea27d1a | |||
| bb8097929a | |||
| c3e02c7f7d | |||
| e8a61bf76f | |||
| acc5a7b2a2 | |||
| 96c270601a | |||
| a495543290 | |||
| 8e1238c316 | |||
| 6c496d3ed8 | |||
| a9d9eedb7e | |||
| c106dbbd37 | |||
| 8a71671df7 | |||
| 2a372bff2f | |||
| a90cc16463 | |||
| 37ec1bb1be | |||
| 9e8fc55a44 | |||
| ef335a93c3 | |||
| 021c2480d8 | |||
| c171299cb4 | |||
| 0e8aa5efbd | |||
| ccff2fdbf2 | |||
| 997fef1803 | |||
| 2b54dad1fa | |||
| dc4b14d3e5 | |||
| fae39d9c79 | |||
| 894bde959e | |||
| 602edbf257 | |||
| ac8da4e6da | |||
| c1edcfe015 | |||
| 2bf4ae43d1 | |||
| 9aa06d59d7 | |||
| df5ed2b525 | |||
| 1dfa9462ba | |||
| 5646bdd42a | |||
| 6c542e3669 | |||
| c6d16430de | |||
| 0cf1b27773 | |||
| ce9db1d9c8 | |||
| d15a8fdcab | |||
| 8f137aba20 | |||
| 58dd7be7cf | |||
| 4780ab4802 | |||
| 86a4ebc105 | |||
| b15bd2bb1e |
168
.claude/skills/world-building/SKILL.md
Normal file
168
.claude/skills/world-building/SKILL.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: world-building
|
||||
description: >
|
||||
Author gritty, dark, disposition-gated world content for Code of Conquest (the
|
||||
Margreave). Use this WHENEVER the user wants to write, expand, or add lore,
|
||||
storylines, plot threads, secrets, factions, regions, towns, dungeons, NPCs and
|
||||
their knowledge/dialogue, quests, items, locations, or starting origins — even
|
||||
if they don't say "world-building" or name the content/ folder. Triggers on
|
||||
"write an NPC", "add a town/region/faction", "new quest", "a secret the party
|
||||
can uncover", "expand a region", "flesh out a town", "someone the
|
||||
player meets on the road", "cursed item", "give me a storyline", and the like.
|
||||
The skill reconciles new content against existing canon (never contradicting or
|
||||
silently changing it), writes it in the exact source format the content build
|
||||
tool consumes, keeps NPCs true to the world's hard, uncaring tone (warm only
|
||||
when disposition is earned), and verifies the build stays green. Do NOT use it
|
||||
for engine/UI code, prompts (/api/prompts), or combat rules.
|
||||
---
|
||||
|
||||
# World-building — the Margreave
|
||||
|
||||
Author new content for **Code of Conquest**, a gritty single-player CRPG. Read
|
||||
`CLAUDE.md` (repo root) once per session for the charter — this skill assumes it.
|
||||
|
||||
## The one rule that governs everything you write
|
||||
|
||||
> **Code owns state. AI owns text.** (charter §2)
|
||||
|
||||
You are authoring *static content*, not runtime state. Facts, personas, places,
|
||||
and knowledge chains are text the engine references by stable **id**. You never
|
||||
write initial disposition numbers into world content, never gameplay logic. A
|
||||
starting *value* (where the player begins, a seeded disposition) belongs to an
|
||||
**origin**, never to a town or NPC.
|
||||
|
||||
## The two things that make this hard
|
||||
|
||||
1. **New content must not contradict old content.** The build tool checks
|
||||
structure (ids resolve, secrecy routes correctly) but it CANNOT tell that you
|
||||
just described the north road as untraveled when canon says elves walk it. A
|
||||
contradiction ships silently and the DM narrates a broken world. So:
|
||||
**read the existing canon before you write a word**, and treat every existing
|
||||
file as read-only unless the user explicitly approves an edit (see below).
|
||||
|
||||
2. **The tone is specific and easy to get wrong.** Gritty, not grim. The world
|
||||
is hard and does not care about the player — but an NPC who has come to trust
|
||||
them becomes genuinely warm. Getting either pole wrong (grimdark misery, or
|
||||
friendly-NPC-from-hello) breaks the world. See `references/tone.md`.
|
||||
|
||||
## Workflow — follow in order
|
||||
|
||||
Make a todo per step.
|
||||
|
||||
### 1. Understand what's being asked, and which layer it lives in
|
||||
|
||||
| Content kind | Format | Lives in |
|
||||
|---|---|---|
|
||||
| People, places, regions, factions, world rules, knowledge (rumor/fact/secret), NPC dialogue layers | **Markdown bible** — ` ```yaml ` blocks | `content/lore/*.md` (source) |
|
||||
| Quests / story skeletons | Hand-authored JSON | `content/world/quests/` |
|
||||
| Items (incl. cursed/blessed) | Hand-authored JSON | `content/world/items/` |
|
||||
| Locations (map nodes) | Hand-authored JSON | `content/world/locations/` |
|
||||
| Origins (starting seeds) | Hand-authored JSON | `content/origins/` |
|
||||
| Degraded-DM fallback prose | Markdown/text | `content/fallback/` |
|
||||
|
||||
The **lore bible is the heart** — it produces the disposition-gated dialogue that
|
||||
makes NPCs feel alive. `content/world/` and `content/server/` under `canon/`,
|
||||
`topics/`, and `npcs/` are **BUILT from the bible — never hand-edit them.** The
|
||||
JSON under `quests/`, `items/`, `locations/`, and `origins/` is hand-authored and
|
||||
loosely schema'd (POC seeds); edit those directly.
|
||||
|
||||
If a storyline you're writing implies a quest, item, or location that doesn't
|
||||
exist yet, say so and author the stub, or flag it — don't leave a dangling id.
|
||||
|
||||
### 2. Load and reconcile against existing canon — before writing
|
||||
|
||||
Run the canon index to see every id, type, secrecy, and one-line gist already
|
||||
authored:
|
||||
|
||||
```
|
||||
.venv/bin/python .claude/skills/world-building/scripts/canon_index.py
|
||||
```
|
||||
|
||||
Read the entries your new content touches (people it references, the region it
|
||||
sits in, secrets it brushes against). New content must slot into this without
|
||||
contradiction. Reuse existing ids in `related:` rather than inventing parallel
|
||||
ones. If your idea only works by *changing* something already canon — a fact's
|
||||
body, a disposition, who knows what — **STOP.** Do not edit. Present the exact
|
||||
before/after change and the reason, and get explicit approval first. New files
|
||||
and new entries need no approval; edits to existing canon always do.
|
||||
|
||||
New content must also slot against the world's overarching cosmology — the good
|
||||
god (the Warden), his angels (the Sent), and the seven demon kings who work
|
||||
against him through influence, possession, and blood sacrifice. Read
|
||||
`references/cosmology.md` before authoring; it is the backdrop nothing may
|
||||
contradict, and it carries the authoring rules that keep the war in tone
|
||||
(distant, worked through people, never epic, blessing/curse read through Luck).
|
||||
|
||||
### 3. Draft in the correct format
|
||||
|
||||
- **Bible entries:** see `references/schema.md` for the full field-by-field
|
||||
contract, the id/namespace/type rules, the secrecy scale, and the knowledge-gate
|
||||
model. Match the **structure** of `content/lore/specimen.md` (the synthetic
|
||||
disposition-gate specimen — a guarded-then-warm NPC with one reveal earned at
|
||||
`warm` and a `never`-gated core no disposition unlocks) and the **voice** of the
|
||||
canon bodies in `content/lore/cosmology.md`.
|
||||
- **Hand-authored JSON:** mirror the shape of the existing file in that folder
|
||||
(`references/schema.md` documents each). Keep ids stable and lowercase-snake.
|
||||
|
||||
Write NPCs to the tone and disposition-warmth model in `references/tone.md`. A
|
||||
hard NPC starts guarded (`cold`/`neutral`, sometimes reachable-`hostile`),
|
||||
withholds more than a stranger would expect, and both *reveals more* and *softens
|
||||
in manner* as the player climbs their ladder. That warmth is earned, never
|
||||
default — it's what the five-rung gate system exists to deliver.
|
||||
|
||||
### 4. Build and verify — not done until green
|
||||
|
||||
The build regenerates `content/world/` + `content/server/` from the bible, and the
|
||||
`--check` gate is what proves it stayed valid. **There is no CI — nothing runs
|
||||
these for you.** You are the gate; run them, from repo root, with the repo venv
|
||||
active (`source .venv/bin/activate`). The `content_build` package lives under
|
||||
`tools/`, so `PYTHONPATH=tools` is required:
|
||||
|
||||
```
|
||||
PYTHONPATH=tools python -m content_build # regenerate world/ + server/ from lore/
|
||||
PYTHONPATH=tools python -m content_build --check # must exit 0: fresh, valid, no orphans, no secrecy leaks
|
||||
PYTHONPATH=tools python -m pytest tools/content_build/tests -q # schema + secrecy + cli coverage
|
||||
```
|
||||
|
||||
**If the build changed anything under `content/world/`, also run the client
|
||||
suite** — `bash client/run_tests.sh` (must be fully green):
|
||||
|
||||
```
|
||||
bash client/run_tests.sh # Godot/GUT; content/world/ is a CLIENT INPUT, not just build output
|
||||
```
|
||||
|
||||
That last one is not optional and not paranoia. `client/scripts/content/content_db.gd`
|
||||
loads `content/world/`, and `client/tests/unit/test_content_db.gd` asserts on it — so
|
||||
**deleting or renaming generated content is a client change.** A content purge once
|
||||
left the client erroring on a vanished directory and turned a secrecy assertion into a
|
||||
test that passed while checking nothing. Nothing else will catch that for you.
|
||||
|
||||
(If `python` isn't found, the venv isn't active — use `.venv/bin/python`.)
|
||||
If any step fails, read the `BUILD FAILED` / `STALE:` message — it names the
|
||||
source file and line — fix the bible, rebuild, recheck. Never hand-edit the
|
||||
generated JSON to make `--check` pass; fix the source. (Hand-authored JSON under
|
||||
quests/items/locations/origins isn't built — the build tool ignores it. If you
|
||||
touched those, just confirm they're valid JSON.)
|
||||
|
||||
### 5. Report
|
||||
|
||||
Summarize: new ids added (by layer), any existing id you reused in `related`,
|
||||
anything you flagged for approval, and confirmation the build + check + tests are
|
||||
green. Update [`content/roadmap.md`](../../../content/roadmap.md) if you added a new
|
||||
bible file or closed out a thread — its Spine list, or the Bill of Materials line
|
||||
you just satisfied.
|
||||
|
||||
**Respect its rule: never author anything that is not in the Bill of Materials.**
|
||||
An idea that arrives during authoring goes to that doc's Backlog, not into the
|
||||
world.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/cosmology.md` — the overarching theme (the Warden and the Seven)
|
||||
every piece of content reconciles against, plus the rules that keep it in tone.
|
||||
Read before authoring any lore, NPC, quest, or item.
|
||||
- `references/schema.md` — the complete bible + JSON schema, all validation rules,
|
||||
the secrecy scale, the knowledge-gate model, worked examples. Read before
|
||||
authoring bible entries.
|
||||
- `references/tone.md` — the Margreave's tone contract and the NPC
|
||||
disposition-warmth model. Read before writing any NPC or narrative body.
|
||||
48
.claude/skills/world-building/evals/evals.json
Normal file
48
.claude/skills/world-building/evals/evals.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"skill_name": "world-building",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "new-npc-reconciled",
|
||||
"prompt": "Add a new NPC on the road: a caravan guard who's cagey with strangers but opens up once you've earned it. Give them something personal they only share when they trust you. Put it in a new lore file.",
|
||||
"expected_output": "New npc.* dialogue-layer entry (+ its person/place/knowledge entries) authored in a NEW content/lore file; start_disposition cold or neutral; at least one fact gated at warm/trusted; every related resolves; no existing lore file edited; PYTHONPATH=tools content_build --check exits 0; tone dry and guarded-then-warm; nothing contradicts the cosmology backdrop.",
|
||||
"assertions": [
|
||||
"Created at least one new npc.* entry in content/lore with start_disposition of cold or neutral",
|
||||
"The NPC's knows list gates a personal fact at warm or trusted (earned reveal)",
|
||||
"Every related id resolves to a real entry",
|
||||
"No pre-existing content/lore/*.md file was modified (or any edit was surfaced for approval, not silent)",
|
||||
"python -m content_build --check exits 0 after the change",
|
||||
"Persona/body prose is dry and in-tone; no narrator winking or 'hilarious' register",
|
||||
"Nothing authored contradicts the cosmology canon (the Warden / the Seven)"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "contradiction-approval-gate",
|
||||
"prompt": "Write an NPC priest who tells the player the Warden's true name and proves the god is real and good.",
|
||||
"expected_output": "Skill recognizes this contradicts locked cosmology (the Warden is nameless BY DESIGN; his existence and goodness are 'distant & contested', never confirmed by the narrator), STOPS instead of authoring it, and either explains the conflict or proposes an in-tone alternative (an NPC who CLAIMS a name or believes, with the game confirming nothing) and asks before writing.",
|
||||
"assertions": [
|
||||
"Did NOT author content that states the Warden's true name or confirms the god is real/good",
|
||||
"Surfaced the contradiction explicitly, naming the cosmology rule (nameless by design; distant & contested)",
|
||||
"Proposed an in-tone alternative and/or asked for approval before writing"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "new-storyline-file",
|
||||
"prompt": "Start a new storyline about a roadside cult bleeding travelers to feed one of the Seven: who they are, and a rumor the player might hear. Make it its own file.",
|
||||
"expected_output": "A new content/lore/*.md file with canon entities and at least one knowledge entry; related wired to a real cosmology id (faction.the-seven or a specific lord); knowledge with correct ascending secrecy; build + --check green; content/roadmap.md updated. The demon tie is handled in-tone: a gated secret (hidden engine), not stated in the open.",
|
||||
"assertions": [
|
||||
"Authored a new content/lore/*.md file (its own file)",
|
||||
"Wired at least one new entry's related to a real cosmology id (faction.the-seven or a lord)",
|
||||
"Includes at least one knowledge entry (rumor/fact/secret) with correct ascending secrecy",
|
||||
"python -m content_build --check exits 0 after the change",
|
||||
"content/roadmap.md updated to reflect the new content",
|
||||
"The demonic connection is a gated secret, not open surface text"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
]
|
||||
}
|
||||
57
.claude/skills/world-building/references/cosmology.md
Normal file
57
.claude/skills/world-building/references/cosmology.md
Normal 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.
|
||||
260
.claude/skills/world-building/references/schema.md
Normal file
260
.claude/skills/world-building/references/schema.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Content schema — the exact contract
|
||||
|
||||
Everything the build tool (`tools/content_build/`) enforces, plus the loose JSON
|
||||
formats it doesn't build. When in doubt, the validator in `resolve.py` is the
|
||||
source of truth; this file is its plain-language mirror.
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [The bible file (`content/lore/*.md`)](#the-bible-file)
|
||||
- [Common fields (every bible entry)](#common-fields)
|
||||
- [Entry kinds](#entry-kinds)
|
||||
- [The secrecy scale](#the-secrecy-scale)
|
||||
- [The knowledge-gate model](#the-knowledge-gate-model)
|
||||
- [Client vs server routing](#client-vs-server-routing)
|
||||
- [Worked example](#worked-example)
|
||||
- [Hand-authored JSON (not built)](#hand-authored-json)
|
||||
|
||||
---
|
||||
|
||||
## The bible file
|
||||
|
||||
A bible file is Markdown. Prose between blocks is for human authors — the build
|
||||
tool ignores it. Content lives in fenced blocks opened by a line that is exactly
|
||||
` ```yaml ` and closed by ` ``` `. Each block is one YAML **mapping** = one entry.
|
||||
|
||||
Organize a file with `##` headings and `>` notes like `content/lore/specimen.md`.
|
||||
Every `*.md` in `content/lore/` is parsed; a new region/storyline can be its own
|
||||
file (e.g. `content/lore/the-tallow-reach.md`).
|
||||
|
||||
## Common fields
|
||||
|
||||
Every entry requires these five (missing any → build fails):
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `id` | `namespace.slug`, lowercase-kebab. Namespace is one of the legal set below. **Globally unique** — duplicate id fails the build. |
|
||||
| `type` | Must equal the id's namespace, EXCEPT `npc.*` whose type is always `person`. |
|
||||
| `status` | `candidate` or `canon`. Only `canon` entries emit to world/server; `candidate` is validated but not shipped — use it for work-in-progress. |
|
||||
| `secrecy` | Integer (not bool). See the scale below. |
|
||||
| `related` | List of ids (may be empty `[]`). **Every id must resolve** to another entry, or the build fails. This is the web that keeps the world coherent — wire new entries into it. |
|
||||
|
||||
`body: >` — the prose. A YAML folded scalar. This is what the DM/NPC pipeline
|
||||
reads. For canon entities it ships to the client; for knowledge it's server-only.
|
||||
|
||||
## Entry kinds
|
||||
|
||||
Two families, distinguished by **id namespace** (not by any `kind` field):
|
||||
|
||||
### Canon entities — the world's furniture
|
||||
|
||||
Namespaces `town`, `place`, `region`, `faction`, `person`, `rule`.
|
||||
- `type` == namespace.
|
||||
- **Must be `secrecy: 0`** (they're public; their bodies ship to the client).
|
||||
- `body` is public description.
|
||||
- The special entry `rule.disposition-ladder` carries a `rungs:` list and is
|
||||
**required exactly once** across all bibles — it defines the gate vocabulary.
|
||||
Don't duplicate or remove it; it lives in `mechanics.md`.
|
||||
|
||||
### Knowledge — the atoms NPCs reveal
|
||||
|
||||
Namespaces `rumor`, `fact`, `secret` (these three id-prefixes; `type` matches).
|
||||
- `secrecy` 1–4 (author-facing sensitivity — see scale).
|
||||
- `body` is the thing known. **Routes to the server**, never the client.
|
||||
- One atomic idea per entry, so different NPCs can hold the same fact at
|
||||
different gates. Chain them by `related:` (a secret relates to the facts that
|
||||
circle it).
|
||||
|
||||
### NPC dialogue layer — `npc.*`
|
||||
|
||||
The interactive layer of a person. `id: npc.<slug>`, `type: person`. This is a
|
||||
SEPARATE entry from the `person.<slug>` world-record; the namespace prefix is the
|
||||
only thing distinguishing them (a person may have both — see the mayor). Extra
|
||||
required/optional fields:
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `start_disposition` | **Required.** One of the ladder rungs (`hostile`/`cold`/`neutral`/`warm`/`trusted`). Where this NPC meets a stranger. |
|
||||
| `knows` | **Required, non-empty.** List of `{fact: <knowledge-id>, gate: <rung-or-never>}`. Each `fact` must resolve to a `rumor`/`fact`/`secret`. Each `gate` is a rung or `never`. |
|
||||
| `body` | Here it's the NPC's **persona** — voice, manner, what they want, why they withhold. Server-only. |
|
||||
| `disposition_notes` | Optional. Author guidance on how they move up/down the ladder and what each pole means. Server-only. |
|
||||
|
||||
A non-npc entry must NOT carry `knows` or `start_disposition`. An `npc.*` entry
|
||||
MUST carry both.
|
||||
|
||||
## The secrecy scale
|
||||
|
||||
`secrecy` is **author-facing and static** — a mis-authoring guard, not a runtime
|
||||
lever. It answers "how sensitive is this text, where may it live?"
|
||||
|
||||
| secrecy | Meaning | Routing |
|
||||
|---|---|---|
|
||||
| 0 | Public. Canon entities only. | Body ships client-side. |
|
||||
| 1 | Observable rumor; freely-ish known. | Body server-only. |
|
||||
| 2 | Known locally, not discussed. | Body server-only. |
|
||||
| 3 | Protected; a real tell. | **Body must never reach the client** (build hard-fails if it does). |
|
||||
| 4 | The core secret. | Server-only. |
|
||||
|
||||
Keep the secrecy of a knowledge chain *ascending* from freely-observed to the
|
||||
`never`-gated core — a chain climbs by secrecy, e.g. 1→2→2→3→4 from an
|
||||
observable rumor to the core secret.
|
||||
|
||||
## The knowledge-gate model
|
||||
|
||||
**Two independent axes — never conflate them:**
|
||||
|
||||
- **secrecy** (above) lives on the *fact* — static, author-facing.
|
||||
- **gate** lives on the *knowledge link* (`knows[].gate`) — runtime,
|
||||
character-facing. It's the disposition rung at which THIS NPC will reveal THIS
|
||||
fact.
|
||||
|
||||
Rungs, low → high: `hostile < cold < neutral < warm < trusted`. Plus the special
|
||||
gate **`never`**: the NPC *knows* the fact (the Improviser can reason with it) but
|
||||
no disposition ever unlocks it in dialogue. `never` is how a locked-door NPC (the
|
||||
mayor) holds the secret without leaking it.
|
||||
|
||||
Design a chain so climbing an NPC's disposition unlocks successively deeper facts.
|
||||
The reachable ceiling is a design choice: an NPC can *witness* enough to imply a
|
||||
secret without holding the secret entry itself: its top rung names what it
|
||||
witnessed, not the whole truth, and the player infers the rest. Understanding
|
||||
earned beats understanding handed over.
|
||||
|
||||
## Client vs server routing
|
||||
|
||||
The build splits each canon entry (`emit.py`) — you don't do this, but author with
|
||||
it in mind:
|
||||
|
||||
- **Canon entity** → `content/world/canon/<slug>.json` (id, type, related, public
|
||||
body). Nothing server-side.
|
||||
- **Knowledge** → client `content/world/topics/<slug>.json` (id, type, related —
|
||||
**no body**); server `content/server/topics/<slug>.json` (id, body, secrecy).
|
||||
- **npc.\*** → client `content/world/npcs/<slug>.json` (id, type,
|
||||
start_disposition, related, knows+gates — the gate logic the client needs);
|
||||
server `content/server/npcs/<slug>.json` (id, persona, disposition_notes — the
|
||||
voice, hidden).
|
||||
|
||||
The guarantee: a player who reads the client bundle sees NPC *gates* but never NPC
|
||||
*personas* or *secret bodies*. That's why secrecy≥3 bodies client-side is a hard
|
||||
build failure.
|
||||
|
||||
## Worked example
|
||||
|
||||
A guarded fisherman who warms up and names the coin — and who holds one thing no
|
||||
disposition ever buys.
|
||||
|
||||
```yaml
|
||||
id: person.specimen-teague
|
||||
type: person
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: [place.specimen-wharf]
|
||||
body: >
|
||||
Teague, oldest hand on the Greywater wharf. Mends nets he no longer sails with.
|
||||
Watches everything off the water and says almost none of it.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: fact.specimen-foreign-coin
|
||||
type: fact
|
||||
status: canon
|
||||
secrecy: 2
|
||||
related: [place.specimen-wharf, person.specimen-teague]
|
||||
body: >
|
||||
The dock hands are paid in pass-country coin, not town mint. Someone outside the
|
||||
charter is buying the wharf's silence, a little at a time.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: secret.specimen-drowned-clerk
|
||||
type: secret
|
||||
status: canon
|
||||
secrecy: 4
|
||||
related: [place.specimen-wharf, person.specimen-teague, fact.specimen-foreign-coin]
|
||||
body: >
|
||||
The charter clerk who came asking after the coin went into the river in the
|
||||
spring. Teague held his legs. He has not been out on the water since and tells
|
||||
anyone who asks that it is his knees.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: npc.specimen-teague
|
||||
type: person
|
||||
status: canon
|
||||
start_disposition: cold
|
||||
related:
|
||||
[person.specimen-teague, fact.specimen-foreign-coin,
|
||||
secret.specimen-drowned-clerk]
|
||||
body: >
|
||||
Same man as person.specimen-teague; his interactive layer. Curt with strangers, not
|
||||
from malice but from a lifetime of watching talkers end up face-down in the
|
||||
river. Warms slowly, and when he does the dryness turns to something almost
|
||||
fond.
|
||||
|
||||
knows:
|
||||
- {fact: fact.specimen-foreign-coin, gate: warm}
|
||||
- {fact: secret.specimen-drowned-clerk, gate: never}
|
||||
|
||||
disposition_notes: >
|
||||
Starts cold. Buying his catch, not his story, is what moves him. At warm he
|
||||
names the coin; he never speculates aloud about who mints it. The clerk is the
|
||||
one thing no rung buys — saying it aloud would hang him, and he knows it. He
|
||||
reasons around it, steers off it, and takes it to the ground.
|
||||
```
|
||||
|
||||
Note: `person.specimen-teague` (secrecy 0, public) and `npc.specimen-teague` (the layer,
|
||||
persona server-only) are two entries. The chain ascends 2 → 4: the coin is gated at
|
||||
`warm` and the player earns it; the clerk is gated `never` and no disposition unlocks
|
||||
it. **That is what `never` is for.** Teague holds it — the Improviser may reason with
|
||||
it, and the player may infer it from what he *will* say — but he cannot be talked into
|
||||
saying it, because saying it would hang him. A `never` gate needs a reason like that:
|
||||
if any rung *should* buy the secret, gate it at `trusted` instead.
|
||||
|
||||
## Hand-authored JSON
|
||||
|
||||
Not built from the bible — edit these files directly. Loosely schema'd POC seeds;
|
||||
mirror the existing file in the folder. Keep ids stable, lowercase-snake.
|
||||
|
||||
**Quest** — `content/world/quests/<id>.json`
|
||||
```json
|
||||
{ "id": "find_the_ledger", "name": "The Missing Ledger",
|
||||
"objective": "Find who took Fenn's ledger" }
|
||||
```
|
||||
|
||||
**Item** — `content/world/items/<id>.json`. Cursed/blessed items move Luck (§7):
|
||||
the STR/LCK split is the point. Numeric effects live in game state; keep the JSON
|
||||
to identity/slot and let narrative-worthy items surface as canon-log facts.
|
||||
```json
|
||||
{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }
|
||||
```
|
||||
|
||||
**Location** — `content/world/locations/<id>.json`. Map nodes; an origin's
|
||||
`start_location_id` resolves here.
|
||||
```json
|
||||
{ "id": "greywater_docks", "name": "the Greywater docks",
|
||||
"description": "A rot-black wharf where the river meets the sea trade." }
|
||||
```
|
||||
|
||||
**Origin** — `content/origins/<id>.json`. Thin starting seed — where the player
|
||||
begins and the situation. This is the ONLY place initial *state* (seeded
|
||||
dispositions, granted items) belongs. Every referenced id
|
||||
(`start_location_id`, `inventory_grants[].item_id`, `start_quest_id`,
|
||||
`disposition_overrides` keys) must resolve to real content, or new-game
|
||||
construction fails loudly.
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "deserter",
|
||||
"display_name": "The Deserter",
|
||||
"description": "You walked away from a company that doesn't allow walking away.",
|
||||
"start_location_id": "greywater_docks",
|
||||
"situation": ["Arrived by barge before dawn, hood up"],
|
||||
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
|
||||
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
|
||||
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
**Fallback** — `content/fallback/`. Degraded-DM prose (charter §13), written as
|
||||
in-voice content, not error text. Every AI surface needs one before it ships.
|
||||
121
.claude/skills/world-building/references/tone.md
Normal file
121
.claude/skills/world-building/references/tone.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Tone — the Margreave
|
||||
|
||||
The world's voice. Distilled from charter §3 (tone), §7 (Luck), §9 (companions).
|
||||
Read before writing any `body`, persona, or narrative prose. Getting this right is
|
||||
most of the job — the schema is easy; the voice is the product.
|
||||
|
||||
## The core stance
|
||||
|
||||
**Gritty, not grim.** The reference is Batman in the DC comics — not Warhammer,
|
||||
not Care Bears. The world has real problems and does not care about the player.
|
||||
People get hangovers, vomit, curse, get infected wounds. But it is not a misery
|
||||
engine. Grimdark is as wrong as cozy. The world is *indifferent*, and indifference
|
||||
is harder and more interesting than cruelty.
|
||||
|
||||
**The world is played straight.** Comedy emerges from situation, never from the
|
||||
narrator. Nothing is ever "hilariously" anything. The narrator is dry and does not
|
||||
wink. When you're tempted to make prose funny, make the *situation* absurd and
|
||||
describe it flatly instead.
|
||||
|
||||
## Register
|
||||
|
||||
- **Profanity** is permitted and should feel *earned*, not decorative.
|
||||
- **Violence has weight.** Don't gloss it and don't wallow.
|
||||
- **Sex exists** and is discussed the way adults discuss it — bluntly, and mostly
|
||||
as a source of trouble.
|
||||
- **Bodies are real.** Fatigue, hunger, cold, drink, injury. The world touches the
|
||||
characters physically.
|
||||
|
||||
## The narrator voice
|
||||
|
||||
Dry. Economical. Observational. States what is, lets the reader feel the weight.
|
||||
It never editorializes, never reassures, never jokes. A good body reads like
|
||||
someone who has seen a lot and is not impressed, telling you the truth plainly.
|
||||
|
||||
Look at the cosmology bodies: "Most of the Margreave believes the Seven are real.
|
||||
Most of the Margreave is right." The weight is in the plain statement, not in
|
||||
adjectives.
|
||||
|
||||
## NPCs: hard by default, warm when earned
|
||||
|
||||
This is the load-bearing pattern for the user's world. **The world doesn't care
|
||||
about you — but a person who comes to trust you does.** That arc is what the
|
||||
disposition ladder exists to deliver, and it's what makes the coldness bearable.
|
||||
|
||||
### Default posture: guarded
|
||||
|
||||
A Margreave NPC meets a stranger with wariness, not hostility — they have their
|
||||
own troubles and no reason to spend them on you. Mechanically:
|
||||
|
||||
- `start_disposition` is usually `cold` or `neutral`, rarely `warm`.
|
||||
- A frightened or burned NPC withholds **more** than a stranger would expect, not
|
||||
less (fear reads as prickliness). `hostile` is a
|
||||
reachable floor: push them, threaten, side against them, and they close entirely.
|
||||
- What they reveal is gated. Low rungs get you surface; the personal and the
|
||||
dangerous sit higher up.
|
||||
|
||||
### The warm turn: real, specific, earned
|
||||
|
||||
When the player climbs the ladder, two things change together:
|
||||
|
||||
1. **They reveal more** — deeper `knows` gates unlock.
|
||||
2. **Their manner softens** — write the persona and `disposition_notes` so that at
|
||||
`warm`/`trusted` the dryness turns to something human: fondness, relief at being
|
||||
heard, blunt loyalty — the specimen NPC's "dryness turns to something almost
|
||||
fond."
|
||||
|
||||
The warmth is never sycophantic and never a personality transplant — a hard person
|
||||
stays recognizably themselves, just no longer guarded against *you*. That's more
|
||||
affecting than a switch from mean to nice.
|
||||
|
||||
**Don't** write an NPC who is friendly from hello (breaks the world) or one who
|
||||
stays cold no matter what (makes the ladder pointless and the world hopeless). The
|
||||
range between those poles is the whole design.
|
||||
|
||||
## The companions set the tone (charter §9)
|
||||
|
||||
If you write companion content, hold these exactly — they carry the register:
|
||||
|
||||
- **Cadwyn Vell (Bard, NPC-only):** genuinely talented, reflexively lies about
|
||||
small things and truthfully about large ones, does not know he is the problem and
|
||||
never becomes self-aware. Florid performing, clipped when scared. The source of
|
||||
chaos.
|
||||
- **Brannoc Thane (Sellsword):** dry, warm, economical, twenty years past his
|
||||
prime and at peace with it. Says devastating things in the tone of a man
|
||||
discussing weather. Holds the humiliation log — the callback engine. Not a grump;
|
||||
*fond* of you, which is what makes it land.
|
||||
- The pair dislike each other mildly and permanently, and neither will leave the
|
||||
other. Cadwyn generates, Brannoc annotates. Don't add a third to the loop.
|
||||
|
||||
## The cosmic backdrop (the Warden and the Seven)
|
||||
|
||||
The world sits inside a hidden war — a distant good god and seven demon kings
|
||||
(see `references/cosmology.md`). It colors tone but must never break the world's
|
||||
indifference:
|
||||
|
||||
- The cosmology is the **hidden engine**, not the surface text. Day-to-day the
|
||||
world is still taxes, mud, and bad men; the war is what's *underneath* them.
|
||||
- Demons work **through people** — influence, rarely possession. Write the
|
||||
wreckage and the person going wrong, not a monster in the square. The narrator
|
||||
never confirms the god, never preaches, never goes epic.
|
||||
- Which side has hold of someone reads through **Luck's flavor, never a number**
|
||||
(a blessing is a run of fortune; a curse is the trap door opening under you).
|
||||
- The good/evil is real but **argued-over** — most folk dispute whether any of it
|
||||
is true. Certainty is the enemy of this tone as much as of §7's Luck.
|
||||
|
||||
## Luck's fingerprint (charter §7)
|
||||
|
||||
If content touches Luck: it is **visible in prose, never in numbers.** Never state
|
||||
a Luck value; render a descriptor ("Fortune spits on you"). Bad luck costs
|
||||
**dignity, not progress** — embarrassment, inconvenience, property damage, social
|
||||
catastrophe, minor injury. Never quest failure, permanent loss, or death. A cursed
|
||||
item that grants power at a Luck cost (+STR / −LCK) is the most interesting kind of
|
||||
item — lean into that.
|
||||
|
||||
## Quick self-check before you ship a body
|
||||
|
||||
- Did the narrator stay dry and out of the joke?
|
||||
- Is the hardness *indifference*, not sadism?
|
||||
- Does the NPC start guarded, with a real warm turn available up the ladder?
|
||||
- Would profanity/violence/sex here feel earned, not decorative?
|
||||
- Does anything contradict an existing canon body? (If yes → reconcile, don't ship.)
|
||||
112
.claude/skills/world-building/scripts/canon_index.py
Normal file
112
.claude/skills/world-building/scripts/canon_index.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print an index of every authored entry across the lore bible + hand-authored
|
||||
JSON, so new content can be reconciled against existing canon BEFORE writing.
|
||||
|
||||
Reads the same ```yaml blocks the content build tool parses (no dependency on the
|
||||
build package — pure stdlib + PyYAML, which the repo already needs). Run from the
|
||||
repo root:
|
||||
|
||||
python .claude/skills/world-building/scripts/canon_index.py
|
||||
|
||||
Output groups bible entries by kind (canon entity / knowledge / npc layer) with
|
||||
id, status, secrecy, and a one-line gist, then lists hand-authored quest/item/
|
||||
location/origin ids. Use it to spot id collisions and facts your new content must
|
||||
not contradict."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("PyYAML not installed; run: pip install pyyaml")
|
||||
|
||||
REPO = Path(__file__).resolve().parents[4]
|
||||
LORE = REPO / "content" / "lore"
|
||||
WORLD = REPO / "content" / "world"
|
||||
ORIGINS = REPO / "content" / "origins"
|
||||
|
||||
CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"}
|
||||
KNOWLEDGE_TYPES = {"rumor", "fact", "secret"}
|
||||
|
||||
|
||||
def parse_bible(path: Path):
|
||||
"""Yield yaml-block mappings, mirroring tools/content_build/parse.py."""
|
||||
lines = path.read_text().splitlines()
|
||||
i, n = 0, len(lines)
|
||||
while i < n:
|
||||
if lines[i].strip() == "```yaml":
|
||||
i += 1
|
||||
block = []
|
||||
while i < n and lines[i].strip() != "```":
|
||||
block.append(lines[i])
|
||||
i += 1
|
||||
data = yaml.safe_load("\n".join(block))
|
||||
if isinstance(data, dict):
|
||||
yield data, path.name
|
||||
i += 1
|
||||
|
||||
|
||||
def gist(text) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
one = " ".join(str(text).split())
|
||||
return one[:100] + ("…" if len(one) > 100 else "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not LORE.is_dir():
|
||||
return print(f"no lore dir at {LORE}") or 1
|
||||
|
||||
entities, knowledge, npcs = [], [], []
|
||||
for md in sorted(LORE.glob("*.md")):
|
||||
for d, src in parse_bible(md):
|
||||
eid = d.get("id", "?")
|
||||
ns = str(eid).split(".", 1)[0]
|
||||
row = (eid, d.get("status", "?"), d.get("secrecy", "?"),
|
||||
gist(d.get("body")), src)
|
||||
if ns == "npc":
|
||||
gates = ", ".join(f"{k.get('fact')}@{k.get('gate')}"
|
||||
for k in d.get("knows", []))
|
||||
npcs.append(row + (d.get("start_disposition", "?"), gates))
|
||||
elif ns in KNOWLEDGE_TYPES:
|
||||
knowledge.append(row)
|
||||
else:
|
||||
entities.append(row)
|
||||
|
||||
print("=== CANON ENTITIES (public; body ships client) ===")
|
||||
for eid, st, sec, g, src in sorted(entities):
|
||||
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
|
||||
|
||||
print("\n=== KNOWLEDGE (rumor/fact/secret; body server-only) ===")
|
||||
for eid, st, sec, g, src in sorted(knowledge):
|
||||
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
|
||||
|
||||
print("\n=== NPC DIALOGUE LAYERS ===")
|
||||
for eid, st, sec, g, src, start, gates in sorted(npcs):
|
||||
print(f" {eid:34} [{st} start:{start}] {src}\n persona: {g}\n"
|
||||
f" knows: {gates}")
|
||||
|
||||
def list_json(label, d):
|
||||
print(f"\n=== {label} (hand-authored JSON) ===")
|
||||
if not d.is_dir():
|
||||
print(" (none)")
|
||||
return
|
||||
for f in sorted(d.glob("*.json")):
|
||||
try:
|
||||
o = json.loads(f.read_text())
|
||||
name = o.get("name") or o.get("display_name") or ""
|
||||
except (OSError, ValueError):
|
||||
name = "(unreadable)"
|
||||
print(f" {o.get('id', f.stem):28} {name}")
|
||||
|
||||
list_json("QUESTS", WORLD / "quests")
|
||||
list_json("ITEMS", WORLD / "items")
|
||||
list_json("LOCATIONS", WORLD / "locations")
|
||||
list_json("ORIGINS", ORIGINS)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
19
.github/workflows/content-build.yml
vendored
19
.github/workflows/content-build.yml
vendored
@@ -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
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -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/)
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -60,4 +66,11 @@ Thumbs.db
|
||||
# ─────────────────────────────────────────────
|
||||
# Claude Code harness session/state files
|
||||
# ─────────────────────────────────────────────
|
||||
.claude/
|
||||
# Ignore every .claude/ dir anywhere in the tree...
|
||||
**/.claude/
|
||||
# ...but keep the repo-root .claude/skills/ dir: project-local skills are
|
||||
# checked-in tooling, versioned like code. (Re-include the root dir, drop its
|
||||
# other contents, then re-include skills/.)
|
||||
!/.claude/
|
||||
/.claude/*
|
||||
!/.claude/skills/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"')
|
||||
|
||||
3
api/tests/fixtures/canon_log_valid.json
vendored
3
api/tests/fixtures/canon_log_valid.json
vendored
@@ -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" },
|
||||
|
||||
@@ -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) != []
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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) != []
|
||||
|
||||
|
||||
|
||||
@@ -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
3
clean_superpowers.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/env bash
|
||||
rm .superpowers/sdd/*.diff
|
||||
rm .superpowers/sdd/*.md
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
client/scenes/Main.tscn
Normal 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")
|
||||
1249
client/scenes/creation/CharacterCreation.tscn
Normal file
1249
client/scenes/creation/CharacterCreation.tscn
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", ""))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
93
client/scripts/main.gd
Normal 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"])
|
||||
1
client/scripts/main.gd.uid
Normal file
1
client/scripts/main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://du7rwup648n60
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
_:
|
||||
|
||||
@@ -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
|
||||
|
||||
15
client/scripts/rules/attributes.gd
Normal file
15
client/scripts/rules/attributes.gd
Normal 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)
|
||||
1
client/scripts/rules/attributes.gd.uid
Normal file
1
client/scripts/rules/attributes.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dnlwajfs3vysm
|
||||
93
client/scripts/rules/callings.gd
Normal file
93
client/scripts/rules/callings.gd
Normal 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", ""))
|
||||
1
client/scripts/rules/callings.gd.uid
Normal file
1
client/scripts/rules/callings.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cewlixhavh1h4
|
||||
60
client/scripts/rules/races.gd
Normal file
60
client/scripts/rules/races.gd
Normal 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))
|
||||
1
client/scripts/rules/races.gd.uid
Normal file
1
client/scripts/rules/races.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmkoxq4eabfcq
|
||||
28
client/scripts/rules/skills.gd
Normal file
28
client/scripts/rules/skills.gd
Normal 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, ""))
|
||||
1
client/scripts/rules/skills.gd.uid
Normal file
1
client/scripts/rules/skills.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d2pvtvrrhc27b
|
||||
94
client/scripts/state/character_sheet.gd
Normal file
94
client/scripts/state/character_sheet.gd
Normal 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")
|
||||
1
client/scripts/state/character_sheet.gd.uid
Normal file
1
client/scripts/state/character_sheet.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://mofimtmsxuui
|
||||
46
client/scripts/state/currency.gd
Normal file
46
client/scripts/state/currency.gd
Normal 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)
|
||||
1
client/scripts/state/currency.gd.uid
Normal file
1
client/scripts/state/currency.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cwnflbwacp5jf
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
1
client/scripts/ui/considering_indicator.gd.uid
Normal file
1
client/scripts/ui/considering_indicator.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://be6fibq5rd367
|
||||
1
client/scripts/ui/considering_phrases.gd.uid
Normal file
1
client/scripts/ui/considering_phrases.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://0yb5865chxnp
|
||||
304
client/scripts/ui/creation/character_creation.gd
Normal file
304
client/scripts/ui/creation/character_creation.gd
Normal 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
|
||||
1
client/scripts/ui/creation/character_creation.gd.uid
Normal file
1
client/scripts/ui/creation/character_creation.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://3cxsq82gjlfk
|
||||
149
client/scripts/ui/creation/creation_copy.gd
Normal file
149
client/scripts/ui/creation/creation_copy.gd
Normal 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))
|
||||
1
client/scripts/ui/creation/creation_copy.gd.uid
Normal file
1
client/scripts/ui/creation/creation_copy.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://rm6bkgstenf1
|
||||
222
client/scripts/ui/creation/creation_draft.gd
Normal file
222
client/scripts/ui/creation/creation_draft.gd
Normal 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()
|
||||
1
client/scripts/ui/creation/creation_draft.gd.uid
Normal file
1
client/scripts/ui/creation/creation_draft.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dkxuw5c4r6tld
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
761
client/tests/unit/test_character_creation_screen.gd
Normal file
761
client/tests/unit/test_character_creation_screen.gd
Normal 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")
|
||||
1
client/tests/unit/test_character_creation_screen.gd.uid
Normal file
1
client/tests/unit/test_character_creation_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dmliumgn42tv8
|
||||
91
client/tests/unit/test_character_sheet.gd
Normal file
91
client/tests/unit/test_character_sheet.gd
Normal 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")
|
||||
1
client/tests/unit/test_character_sheet.gd.uid
Normal file
1
client/tests/unit/test_character_sheet.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dgi5hu3y2fg2u
|
||||
1
client/tests/unit/test_considering_indicator.gd.uid
Normal file
1
client/tests/unit/test_considering_indicator.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dgtady6c4xvo3
|
||||
1
client/tests/unit/test_considering_phrases.gd.uid
Normal file
1
client/tests/unit/test_considering_phrases.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://unc4x2epgi6c
|
||||
@@ -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()])
|
||||
|
||||
214
client/tests/unit/test_creation_copy.gd
Normal file
214
client/tests/unit/test_creation_copy.gd
Normal 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")
|
||||
1
client/tests/unit/test_creation_copy.gd.uid
Normal file
1
client/tests/unit/test_creation_copy.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bvb87ime573x4
|
||||
241
client/tests/unit/test_creation_draft.gd
Normal file
241
client/tests/unit/test_creation_draft.gd
Normal 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"))
|
||||
1
client/tests/unit/test_creation_draft.gd.uid
Normal file
1
client/tests/unit/test_creation_draft.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dqllxamt2tcgu
|
||||
42
client/tests/unit/test_currency.gd
Normal file
42
client/tests/unit/test_currency.gd
Normal 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)
|
||||
1
client/tests/unit/test_currency.gd.uid
Normal file
1
client/tests/unit/test_currency.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dwpdm680q83ws
|
||||
25
client/tests/unit/test_dev_harness_seed_logs.gd
Normal file
25
client/tests/unit/test_dev_harness_seed_logs.gd
Normal 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")
|
||||
1
client/tests/unit/test_dev_harness_seed_logs.gd.uid
Normal file
1
client/tests/unit/test_dev_harness_seed_logs.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmaooitlx0ox4
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
71
client/tests/unit/test_main.gd
Normal file
71
client/tests/unit/test_main.gd
Normal 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")
|
||||
1
client/tests/unit/test_main.gd.uid
Normal file
1
client/tests/unit/test_main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://17abdh3almry
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
97
client/tests/unit/test_rules_tables.gd
Normal file
97
client/tests/unit/test_rules_tables.gd
Normal 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)
|
||||
1
client/tests/unit/test_rules_tables.gd.uid
Normal file
1
client/tests/unit/test_rules_tables.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bjld6ctq8tqnk
|
||||
53
client/tests/unit/test_schema_parity.gd
Normal file
53
client/tests/unit/test_schema_parity.gd
Normal 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)
|
||||
1
client/tests/unit/test_schema_parity.gd.uid
Normal file
1
client/tests/unit/test_schema_parity.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dond21vwh46rt
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
131
content/lore/cosmology.md
Normal 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.
|
||||
```
|
||||
@@ -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
21
content/lore/mechanics.md
Normal 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
80
content/lore/specimen.md
Normal 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
124
content/lore/the-seven.md
Normal 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.
|
||||
```
|
||||
@@ -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
185
content/roadmap.md
Normal 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 2–7)** — 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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user