Compare commits
89 Commits
58dd7be7cf
...
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 |
@@ -7,7 +7,7 @@ description: >
|
||||
their knowledge/dialogue, quests, items, locations, or starting origins — even
|
||||
if they don't say "world-building" or name the content/ folder. Triggers on
|
||||
"write an NPC", "add a town/region/faction", "new quest", "a secret the party
|
||||
can uncover", "expand the Tallow Reach", "flesh out Duncarrow", "someone the
|
||||
can uncover", "expand a region", "flesh out a town", "someone the
|
||||
player meets on the road", "cursed item", "give me a storyline", and the like.
|
||||
The skill reconciles new content against existing canon (never contradicting or
|
||||
silently changing it), writes it in the exact source format the content build
|
||||
@@ -86,12 +86,21 @@ body, a disposition, who knows what — **STOP.** Do not edit. Present the exact
|
||||
before/after change and the reason, and get explicit approval first. New files
|
||||
and new entries need no approval; edits to existing canon always do.
|
||||
|
||||
New content must also slot against the world's overarching cosmology — the good
|
||||
god (the Warden), his angels (the Sent), and the seven demon kings who work
|
||||
against him through influence, possession, and blood sacrifice. Read
|
||||
`references/cosmology.md` before authoring; it is the backdrop nothing may
|
||||
contradict, and it carries the authoring rules that keep the war in tone
|
||||
(distant, worked through people, never epic, blessing/curse read through Luck).
|
||||
|
||||
### 3. Draft in the correct format
|
||||
|
||||
- **Bible entries:** see `references/schema.md` for the full field-by-field
|
||||
contract, the id/namespace/type rules, the secrecy scale, and the knowledge-gate
|
||||
model. Match the voice and structure of `content/lore/duncarrow.md` — it is the
|
||||
reference specimen.
|
||||
model. Match the **structure** of `content/lore/specimen.md` (the synthetic
|
||||
disposition-gate specimen — a guarded-then-warm NPC with one reveal earned at
|
||||
`warm` and a `never`-gated core no disposition unlocks) and the **voice** of the
|
||||
canon bodies in `content/lore/cosmology.md`.
|
||||
- **Hand-authored JSON:** mirror the shape of the existing file in that folder
|
||||
(`references/schema.md` documents each). Keep ids stable and lowercase-snake.
|
||||
|
||||
@@ -103,10 +112,11 @@ default — it's what the five-rung gate system exists to deliver.
|
||||
|
||||
### 4. Build and verify — not done until green
|
||||
|
||||
The build regenerates `content/world/` + `content/server/` from the bible and the
|
||||
`--check` gate is what CI enforces (`.github/workflows/content-build.yml`). Run,
|
||||
from repo root, with the repo venv active (`source .venv/bin/activate`) — the
|
||||
`content_build` package lives under `tools/`, so `PYTHONPATH=tools` is required:
|
||||
The build regenerates `content/world/` + `content/server/` from the bible, and the
|
||||
`--check` gate is what proves it stayed valid. **There is no CI — nothing runs
|
||||
these for you.** You are the gate; run them, from repo root, with the repo venv
|
||||
active (`source .venv/bin/activate`). The `content_build` package lives under
|
||||
`tools/`, so `PYTHONPATH=tools` is required:
|
||||
|
||||
```
|
||||
PYTHONPATH=tools python -m content_build # regenerate world/ + server/ from lore/
|
||||
@@ -114,6 +124,19 @@ PYTHONPATH=tools python -m content_build --check # must exit 0: fresh, valid,
|
||||
PYTHONPATH=tools python -m pytest tools/content_build/tests -q # schema + secrecy + cli coverage
|
||||
```
|
||||
|
||||
**If the build changed anything under `content/world/`, also run the client
|
||||
suite** — `bash client/run_tests.sh` (must be fully green):
|
||||
|
||||
```
|
||||
bash client/run_tests.sh # Godot/GUT; content/world/ is a CLIENT INPUT, not just build output
|
||||
```
|
||||
|
||||
That last one is not optional and not paranoia. `client/scripts/content/content_db.gd`
|
||||
loads `content/world/`, and `client/tests/unit/test_content_db.gd` asserts on it — so
|
||||
**deleting or renaming generated content is a client change.** A content purge once
|
||||
left the client erroring on a vanished directory and turned a secrecy assertion into a
|
||||
test that passed while checking nothing. Nothing else will catch that for you.
|
||||
|
||||
(If `python` isn't found, the venv isn't active — use `.venv/bin/python`.)
|
||||
If any step fails, read the `BUILD FAILED` / `STALE:` message — it names the
|
||||
source file and line — fix the bible, rebuild, recheck. Never hand-edit the
|
||||
@@ -125,11 +148,19 @@ touched those, just confirm they're valid JSON.)
|
||||
|
||||
Summarize: new ids added (by layer), any existing id you reused in `related`,
|
||||
anything you flagged for approval, and confirmation the build + check + tests are
|
||||
green. Update `content/lore/canon-roadmap.md`'s Authored/Pending lists if you
|
||||
added a new bible file or closed out a pending thread.
|
||||
green. Update [`content/roadmap.md`](../../../content/roadmap.md) if you added a new
|
||||
bible file or closed out a thread — its Spine list, or the Bill of Materials line
|
||||
you just satisfied.
|
||||
|
||||
**Respect its rule: never author anything that is not in the Bill of Materials.**
|
||||
An idea that arrives during authoring goes to that doc's Backlog, not into the
|
||||
world.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/cosmology.md` — the overarching theme (the Warden and the Seven)
|
||||
every piece of content reconciles against, plus the rules that keep it in tone.
|
||||
Read before authoring any lore, NPC, quest, or item.
|
||||
- `references/schema.md` — the complete bible + JSON schema, all validation rules,
|
||||
the secrecy scale, the knowledge-gate model, worked examples. Read before
|
||||
authoring bible entries.
|
||||
|
||||
@@ -4,41 +4,43 @@
|
||||
{
|
||||
"id": 1,
|
||||
"name": "new-npc-reconciled",
|
||||
"prompt": "Add a new NPC to Duncarrow: a tavern keeper on the north road who's cagey with strangers but opens up once you've earned it. Give them something personal they only share when they trust you, and tie them into what's already going on in town.",
|
||||
"expected_output": "New npc.* dialogue-layer entry (+ any new person/knowledge entries) authored in content/lore; start_disposition cold or neutral; at least one fact gated at warm/trusted; related wired to existing Duncarrow ids; no existing lore file edited; PYTHONPATH=tools content_build --check exits 0; tone dry and guarded-then-warm.",
|
||||
"prompt": "Add a new NPC on the road: a caravan guard who's cagey with strangers but opens up once you've earned it. Give them something personal they only share when they trust you. Put it in a new lore file.",
|
||||
"expected_output": "New npc.* dialogue-layer entry (+ its person/place/knowledge entries) authored in a NEW content/lore file; start_disposition cold or neutral; at least one fact gated at warm/trusted; every related resolves; no existing lore file edited; PYTHONPATH=tools content_build --check exits 0; tone dry and guarded-then-warm; nothing contradicts the cosmology backdrop.",
|
||||
"assertions": [
|
||||
"Created at least one new npc.* entry in content/lore with start_disposition of cold or neutral",
|
||||
"The NPC's knows list gates a personal fact at warm or trusted (earned reveal)",
|
||||
"New entry's related list references at least one pre-existing Duncarrow id",
|
||||
"Every related id resolves to a real entry",
|
||||
"No pre-existing content/lore/*.md file was modified (or any edit was surfaced for approval, not silent)",
|
||||
"python -m content_build --check exits 0 after the change",
|
||||
"Persona/body prose is dry and in-tone; no narrator winking or 'hilarious' register"
|
||||
"Persona/body prose is dry and in-tone; no narrator winking or 'hilarious' register",
|
||||
"Nothing authored contradicts the cosmology canon (the Warden / the Seven)"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "contradiction-approval-gate",
|
||||
"prompt": "I want the town militia to actually be honest and investigate the disappearances near the shrine. Rewrite it so the militia does ride out.",
|
||||
"expected_output": "Skill recognizes this contradicts existing canon (fact.militia-never-investigates, which is load-bearing for the Crell secret chain), STOPS, and presents the exact before/after change with the reason, asking for approval instead of silently editing the existing fact.",
|
||||
"prompt": "Write an NPC priest who tells the player the Warden's true name and proves the god is real and good.",
|
||||
"expected_output": "Skill recognizes this contradicts locked cosmology (the Warden is nameless BY DESIGN; his existence and goodness are 'distant & contested', never confirmed by the narrator), STOPS instead of authoring it, and either explains the conflict or proposes an in-tone alternative (an NPC who CLAIMS a name or believes, with the game confirming nothing) and asks before writing.",
|
||||
"assertions": [
|
||||
"Did NOT silently edit fact.militia-never-investigates or any existing lore entry",
|
||||
"Surfaced the contradiction explicitly, naming the affected canon and why it matters (Crell chain)",
|
||||
"Presented a concrete proposed change and asked for approval before writing"
|
||||
"Did NOT author content that states the Warden's true name or confirms the god is real/good",
|
||||
"Surfaced the contradiction explicitly, naming the cosmology rule (nameless by design; distant & contested)",
|
||||
"Proposed an in-tone alternative and/or asked for approval before writing"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "new-storyline-file",
|
||||
"prompt": "Start a new storyline about the elven road-peoples beyond Duncarrow: who they are, a faction or two, and a rumor the player might hear about why they've stopped using the pass. Make it its own thing.",
|
||||
"expected_output": "A new content/lore/*.md bible file with canon entities (faction/region/etc.) and at least one knowledge entry; related wired to the existing faction.elves stub; build + --check green; canon-roadmap Pending/Authored updated.",
|
||||
"prompt": "Start a new storyline about a roadside cult bleeding travelers to feed one of the Seven: who they are, and a rumor the player might hear. Make it its own file.",
|
||||
"expected_output": "A new content/lore/*.md file with canon entities and at least one knowledge entry; related wired to a real cosmology id (faction.the-seven or a specific lord); knowledge with correct ascending secrecy; build + --check green; content/roadmap.md updated. The demon tie is handled in-tone: a gated secret (hidden engine), not stated in the open.",
|
||||
"assertions": [
|
||||
"Authored a new content/lore/*.md file (did not cram into duncarrow.md)",
|
||||
"Wired at least one new entry's related to the existing faction.elves id",
|
||||
"Authored a new content/lore/*.md file (its own file)",
|
||||
"Wired at least one new entry's related to a real cosmology id (faction.the-seven or a lord)",
|
||||
"Includes at least one knowledge entry (rumor/fact/secret) with correct ascending secrecy",
|
||||
"python -m content_build --check exits 0 after the change",
|
||||
"content/lore/canon-roadmap.md updated to reflect the new content"
|
||||
"content/roadmap.md updated to reflect the new content",
|
||||
"The demonic connection is a gated secret, not open surface text"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -23,7 +23,7 @@ A bible file is Markdown. Prose between blocks is for human authors — the buil
|
||||
tool ignores it. Content lives in fenced blocks opened by a line that is exactly
|
||||
` ```yaml ` and closed by ` ``` `. Each block is one YAML **mapping** = one entry.
|
||||
|
||||
Organize a file with `##` headings and `>` notes like `content/lore/duncarrow.md`.
|
||||
Organize a file with `##` headings and `>` notes like `content/lore/specimen.md`.
|
||||
Every `*.md` in `content/lore/` is parsed; a new region/storyline can be its own
|
||||
file (e.g. `content/lore/the-tallow-reach.md`).
|
||||
|
||||
@@ -54,7 +54,7 @@ Namespaces `town`, `place`, `region`, `faction`, `person`, `rule`.
|
||||
- `body` is public description.
|
||||
- The special entry `rule.disposition-ladder` carries a `rungs:` list and is
|
||||
**required exactly once** across all bibles — it defines the gate vocabulary.
|
||||
Don't duplicate or remove it; it already lives in `duncarrow.md`.
|
||||
Don't duplicate or remove it; it lives in `mechanics.md`.
|
||||
|
||||
### Knowledge — the atoms NPCs reveal
|
||||
|
||||
@@ -96,7 +96,8 @@ lever. It answers "how sensitive is this text, where may it live?"
|
||||
| 4 | The core secret. | Server-only. |
|
||||
|
||||
Keep the secrecy of a knowledge chain *ascending* from freely-observed to the
|
||||
`never`-gated core — mirror the Crell chain in `duncarrow.md` (1→2→2→3→4).
|
||||
`never`-gated core — a chain climbs by secrecy, e.g. 1→2→2→3→4 from an
|
||||
observable rumor to the core secret.
|
||||
|
||||
## The knowledge-gate model
|
||||
|
||||
@@ -114,9 +115,9 @@ mayor) holds the secret without leaking it.
|
||||
|
||||
Design a chain so climbing an NPC's disposition unlocks successively deeper facts.
|
||||
The reachable ceiling is a design choice: an NPC can *witness* enough to imply a
|
||||
secret without holding the secret entry itself (Mera Fenn tops out at
|
||||
`crells-guard-acts-alone`, never `crell-runs-slave-trade` — the player infers the
|
||||
rest). Understanding earned beats understanding handed over.
|
||||
secret without holding the secret entry itself: its top rung names what it
|
||||
witnessed, not the whole truth, and the player infers the rest. Understanding
|
||||
earned beats understanding handed over.
|
||||
|
||||
## Client vs server routing
|
||||
|
||||
@@ -138,52 +139,75 @@ build failure.
|
||||
|
||||
## Worked example
|
||||
|
||||
A guarded fisherman who warms up and, if trusted, names who really owns the boats.
|
||||
A guarded fisherman who warms up and names the coin — and who holds one thing no
|
||||
disposition ever buys.
|
||||
|
||||
```yaml
|
||||
id: person.old-teague
|
||||
id: person.specimen-teague
|
||||
type: person
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: [town.duncarrow]
|
||||
related: [place.specimen-wharf]
|
||||
body: >
|
||||
Teague, oldest hand on the Greywater wharf. Mends nets he no longer sails with.
|
||||
Watches everything off the water and says almost none of it.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: fact.foreign-coin-on-the-docks
|
||||
id: fact.specimen-foreign-coin
|
||||
type: fact
|
||||
status: canon
|
||||
secrecy: 2
|
||||
related: [town.duncarrow, person.old-teague]
|
||||
related: [place.specimen-wharf, person.specimen-teague]
|
||||
body: >
|
||||
The dock hands are paid in pass-country coin, not town mint. Someone outside the
|
||||
charter is buying the wharf's silence, a little at a time.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: npc.old-teague
|
||||
id: secret.specimen-drowned-clerk
|
||||
type: secret
|
||||
status: canon
|
||||
secrecy: 4
|
||||
related: [place.specimen-wharf, person.specimen-teague, fact.specimen-foreign-coin]
|
||||
body: >
|
||||
The charter clerk who came asking after the coin went into the river in the
|
||||
spring. Teague held his legs. He has not been out on the water since and tells
|
||||
anyone who asks that it is his knees.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: npc.specimen-teague
|
||||
type: person
|
||||
status: canon
|
||||
start_disposition: cold
|
||||
related: [person.old-teague, fact.foreign-coin-on-the-docks]
|
||||
related:
|
||||
[person.specimen-teague, fact.specimen-foreign-coin,
|
||||
secret.specimen-drowned-clerk]
|
||||
body: >
|
||||
Same man as person.old-teague; his interactive layer. Curt with strangers, not
|
||||
Same man as person.specimen-teague; his interactive layer. Curt with strangers, not
|
||||
from malice but from a lifetime of watching talkers end up face-down in the
|
||||
river. Warms slowly, and when he does the dryness turns to something almost
|
||||
fond.
|
||||
|
||||
knows:
|
||||
- {fact: fact.foreign-coin-on-the-docks, gate: warm}
|
||||
- {fact: fact.specimen-foreign-coin, gate: warm}
|
||||
- {fact: secret.specimen-drowned-clerk, gate: never}
|
||||
|
||||
disposition_notes: >
|
||||
Starts cold. Buying his catch, not his story, is what moves him. At warm he
|
||||
names the coin; he never speculates aloud about who mints it.
|
||||
names the coin; he never speculates aloud about who mints it. The clerk is the
|
||||
one thing no rung buys — saying it aloud would hang him, and he knows it. He
|
||||
reasons around it, steers off it, and takes it to the ground.
|
||||
```
|
||||
|
||||
Note: `person.old-teague` (secrecy 0, public) and `npc.old-teague` (the layer,
|
||||
persona server-only) are two entries. The fact is secrecy 2, gated at `warm`.
|
||||
Note: `person.specimen-teague` (secrecy 0, public) and `npc.specimen-teague` (the layer,
|
||||
persona server-only) are two entries. The chain ascends 2 → 4: the coin is gated at
|
||||
`warm` and the player earns it; the clerk is gated `never` and no disposition unlocks
|
||||
it. **That is what `never` is for.** Teague holds it — the Improviser may reason with
|
||||
it, and the player may infer it from what he *will* say — but he cannot be talked into
|
||||
saying it, because saying it would hang him. A `never` gate needs a reason like that:
|
||||
if any rung *should* buy the secret, gate it at `trusted` instead.
|
||||
|
||||
## Hand-authored JSON
|
||||
|
||||
@@ -228,7 +252,7 @@ construction fails loudly.
|
||||
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
|
||||
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": { "allowed_classes": ["sellsword","assassin","priest"], "luck_modifier": 0 }
|
||||
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ Dry. Economical. Observational. States what is, lets the reader feel the weight.
|
||||
It never editorializes, never reassures, never jokes. A good body reads like
|
||||
someone who has seen a lot and is not impressed, telling you the truth plainly.
|
||||
|
||||
Look at the `duncarrow.md` bodies: "prosperous and orderly place, which is exactly
|
||||
the reputation its mayor has spent fifteen years building and depends on." The
|
||||
menace is in the plain statement, not in adjectives.
|
||||
Look at the cosmology bodies: "Most of the Margreave believes the Seven are real.
|
||||
Most of the Margreave is right." The weight is in the plain statement, not in
|
||||
adjectives.
|
||||
|
||||
## NPCs: hard by default, warm when earned
|
||||
|
||||
@@ -49,7 +49,7 @@ own troubles and no reason to spend them on you. Mechanically:
|
||||
|
||||
- `start_disposition` is usually `cold` or `neutral`, rarely `warm`.
|
||||
- A frightened or burned NPC withholds **more** than a stranger would expect, not
|
||||
less (fear reads as prickliness — Mera Fenn is the model). `hostile` is a
|
||||
less (fear reads as prickliness). `hostile` is a
|
||||
reachable floor: push them, threaten, side against them, and they close entirely.
|
||||
- What they reveal is gated. Low rungs get you surface; the personal and the
|
||||
dangerous sit higher up.
|
||||
@@ -61,7 +61,8 @@ When the player climbs the ladder, two things change together:
|
||||
1. **They reveal more** — deeper `knows` gates unlock.
|
||||
2. **Their manner softens** — write the persona and `disposition_notes` so that at
|
||||
`warm`/`trusted` the dryness turns to something human: fondness, relief at being
|
||||
heard, blunt loyalty. Old Teague's "dryness turns to something almost fond."
|
||||
heard, blunt loyalty — the specimen NPC's "dryness turns to something almost
|
||||
fond."
|
||||
|
||||
The warmth is never sycophantic and never a personality transplant — a hard person
|
||||
stays recognizably themselves, just no longer guarded against *you*. That's more
|
||||
@@ -86,6 +87,22 @@ If you write companion content, hold these exactly — they carry the register:
|
||||
- The pair dislike each other mildly and permanently, and neither will leave the
|
||||
other. Cadwyn generates, Brannoc annotates. Don't add a third to the loop.
|
||||
|
||||
## The cosmic backdrop (the Warden and the Seven)
|
||||
|
||||
The world sits inside a hidden war — a distant good god and seven demon kings
|
||||
(see `references/cosmology.md`). It colors tone but must never break the world's
|
||||
indifference:
|
||||
|
||||
- The cosmology is the **hidden engine**, not the surface text. Day-to-day the
|
||||
world is still taxes, mud, and bad men; the war is what's *underneath* them.
|
||||
- Demons work **through people** — influence, rarely possession. Write the
|
||||
wreckage and the person going wrong, not a monster in the square. The narrator
|
||||
never confirms the god, never preaches, never goes epic.
|
||||
- Which side has hold of someone reads through **Luck's flavor, never a number**
|
||||
(a blessing is a run of fortune; a curse is the trap door opening under you).
|
||||
- The good/evil is real but **argued-over** — most folk dispute whether any of it
|
||||
is true. Certainty is the enemy of this tone as much as of §7's Luck.
|
||||
|
||||
## Luck's fingerprint (charter §7)
|
||||
|
||||
If content touches Luck: it is **visible in prose, never in numbers.** Never state
|
||||
|
||||
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
|
||||
6
.gitignore
vendored
6
.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/)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"disposition_notes": "Control case for the OTHER half of the test: does disposition-gating feel good on a fact that costs nothing to reveal and spoils nothing? If earning the daughter story feels warm and human, gating works. If it feels like grinding a vendor, the mechanic has a problem the plot-secret would only hide.",
|
||||
"id": "npc.harn-blackwood",
|
||||
"persona": "Harn Blackwood, the town smith. Gruff, fair, talks freely about iron, the road, and the price of salt. Carries one small private grief he will not hand a stranger.\n"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"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 \u2014 which is itself a valuable POC result.",
|
||||
"id": "npc.mayor-oswin-crell",
|
||||
"persona": "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 \u2014 no disposition moves him past it, because there is nothing past it he will give.\n"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"disposition_notes": "Starts at `cold`, not `neutral` \u2014 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.",
|
||||
"id": "npc.mera-fenn",
|
||||
"persona": "Mera Fenn runs the north gate's night watch \u2014 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 \u2014 she withholds MORE than a stranger would, not less, until you've earned otherwise.\n"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"body": "Mayor Crell runs an elf-slaving operation. When a small enough elven party camps near the White Antlers \u2014 few enough that no one will come looking \u2014 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.\nPOC 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.",
|
||||
"id": "secret.crell-runs-slave-trade",
|
||||
"secrecy": 4
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"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.",
|
||||
"id": "fact.crells-guard-acts-alone",
|
||||
"secrecy": 3
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user