Compare commits
220 Commits
1f39ec709e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fb173008 | |||
| f5e6c59f2a | |||
| 7f155b8de2 | |||
| d553213563 | |||
| 5a9f39ea48 | |||
| 583d32456a | |||
| 6b10b6ea6c | |||
| 8d963e8030 | |||
| e169885d90 | |||
| f763aa0c31 | |||
| 10f89baef9 | |||
| aaff2c8554 | |||
| a106be673f | |||
| 1ae62fe0db | |||
| 8f3aa75fa9 | |||
| 63cbc8c16e | |||
| 7471d2e37f | |||
| 6291f21a00 | |||
| 34bf064904 | |||
| 31759ef848 | |||
| 4494856a44 | |||
| c5f127e0f1 | |||
| 6589d7c4ed | |||
| 2227d703f6 | |||
| fe8e24cf39 | |||
| 0bbf94289a | |||
| 1621a38b29 | |||
| 866158cc0d | |||
| 1c4d19492d | |||
| 537b0e1d18 | |||
| 057b83679b | |||
| c6b8a651aa | |||
| e4db022c4b | |||
| 17ef142689 | |||
| 7a74a8de94 | |||
| 333d901931 | |||
| b4b72e472e | |||
| b663703da7 | |||
| a9357fb787 | |||
| 62b8113ffa | |||
| bd49e118d5 | |||
| eba706b906 | |||
| 5149e817f6 | |||
| a668cf98eb | |||
| bb5d176262 | |||
| a7e153884f | |||
| 78e18107b2 | |||
| 89d4c65a7a | |||
| 9de8ee40b3 | |||
| 198ea27d1a | |||
| bb8097929a | |||
| c3e02c7f7d | |||
| e8a61bf76f | |||
| acc5a7b2a2 | |||
| 96c270601a | |||
| a495543290 | |||
| 8e1238c316 | |||
| 6c496d3ed8 | |||
| a9d9eedb7e | |||
| c106dbbd37 | |||
| 8a71671df7 | |||
| 2a372bff2f | |||
| a90cc16463 | |||
| 37ec1bb1be | |||
| 9e8fc55a44 | |||
| ef335a93c3 | |||
| 021c2480d8 | |||
| c171299cb4 | |||
| 0e8aa5efbd | |||
| ccff2fdbf2 | |||
| 997fef1803 | |||
| 2b54dad1fa | |||
| dc4b14d3e5 | |||
| fae39d9c79 | |||
| 894bde959e | |||
| 602edbf257 | |||
| ac8da4e6da | |||
| c1edcfe015 | |||
| 2bf4ae43d1 | |||
| 9aa06d59d7 | |||
| df5ed2b525 | |||
| 1dfa9462ba | |||
| 5646bdd42a | |||
| 6c542e3669 | |||
| c6d16430de | |||
| 0cf1b27773 | |||
| ce9db1d9c8 | |||
| d15a8fdcab | |||
| 8f137aba20 | |||
| 58dd7be7cf | |||
| 4780ab4802 | |||
| 86a4ebc105 | |||
| b15bd2bb1e | |||
| a3506f7f02 | |||
| bbaa59fad4 | |||
| c09b6e89a0 | |||
| f84e55ab4b | |||
| c2d67be76a | |||
| a160d3e967 | |||
| 7ce5e4584c | |||
| 11c4d6dcd7 | |||
| 973af41a6e | |||
| 0542516312 | |||
| e3c35614e7 | |||
| 9141cd1e9f | |||
| f21d7be4d9 | |||
| bd293c1701 | |||
| 40c3a7fc4b | |||
| 16527e9d60 | |||
| db5ee247e7 | |||
| aeb9ee4a9d | |||
| 1304f0aee7 | |||
| 1364850d39 | |||
| 3ee2c7c3a8 | |||
| 2eead38b54 | |||
| f07139256b | |||
| a77bf03ee4 | |||
| c6d513c966 | |||
| 263254346d | |||
| 56278a4831 | |||
| 8e4c0bf957 | |||
| 0249cbcf09 | |||
| d9caf1dff6 | |||
| c933d12504 | |||
| 58880554c2 | |||
| fb541d05cb | |||
| 6ef71aaa31 | |||
| 4698732285 | |||
| 578b3e86c9 | |||
| 5f1ebdf840 | |||
| bca04e3a2e | |||
| 35c4586792 | |||
| 9a02c9b038 | |||
| c4aa74da5a | |||
| 400445f312 | |||
| cc365c8b73 | |||
| 09922e5fd3 | |||
| 322aae4a33 | |||
| 7bbb66827b | |||
| 4a32e7df8a | |||
| 0c12e13ac1 | |||
| 6530625f4d | |||
| 0082765ee7 | |||
| 7954c579d3 | |||
| 73fcf2d096 | |||
| 9091707535 | |||
| 3981047ec6 | |||
| 888b2b32f5 | |||
| 233b1088cf | |||
| 2d7ed469bf | |||
| 29474e5751 | |||
| 49bc6b22c0 | |||
| b639cab0c2 | |||
| 57acfb75d9 | |||
| 099714d4f6 | |||
| 6d7eeaab27 | |||
| c5644d381e | |||
| 82b2de19bb | |||
| 6bd5e4f84a | |||
| 377adb10b2 | |||
| a171d40781 | |||
| 2c32bba320 | |||
| 96432bee87 | |||
| 1ed863d3fe | |||
| 1a6e8e8616 | |||
| 81db962a9d | |||
| d788225754 | |||
| af1a3aa726 | |||
| 2b1c11b1e6 | |||
| 86d1352b19 | |||
| ed2311b62c | |||
| 87f337bffd | |||
| c347da6cec | |||
| 832cdc65ca | |||
| 5181c3e74a | |||
| c0dcc8df82 | |||
| a2100f802f | |||
| 4c0b4e41c3 | |||
| 1b67b3984b | |||
| 3b71fcdcc0 | |||
| 1d1ae37281 | |||
| 9d2f93bbf2 | |||
| a2c654c047 | |||
| 0c3d64d068 | |||
| 004cc21724 | |||
| 76c04b45d2 | |||
| 6cd5003930 | |||
| 69729f7673 | |||
| 55a2ae6f19 | |||
| 097ac618f4 | |||
| 7db1eb8f26 | |||
| b949cd2164 | |||
| 5df67dcd8a | |||
| 004bd53a39 | |||
| f18d306059 | |||
| 23ccc799aa | |||
| 89afd85bb2 | |||
| c208d3f1c4 | |||
| 513225df3f | |||
| 32fa372124 | |||
| 18a18820fe | |||
| b4d22f5c5f | |||
| 3a85d11439 | |||
| 4ae9cdbe63 | |||
| 1189d2e24d | |||
| ddc00d42da | |||
| f7c51b79da | |||
| 470ccb2935 | |||
| 45d5f0360b | |||
| 5a35802075 | |||
| 2027e7a9b9 | |||
| 2558e2a2a0 | |||
| 15995c57ee | |||
| be133c4f3a | |||
| 4d8a19353e | |||
| 42b3720721 | |||
| ea2cfafd34 | |||
| ec01ce5c9b | |||
| 959abc79eb | |||
| b15e81e242 |
168
.claude/skills/world-building/SKILL.md
Normal file
168
.claude/skills/world-building/SKILL.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: world-building
|
||||
description: >
|
||||
Author gritty, dark, disposition-gated world content for Code of Conquest (the
|
||||
Margreave). Use this WHENEVER the user wants to write, expand, or add lore,
|
||||
storylines, plot threads, secrets, factions, regions, towns, dungeons, NPCs and
|
||||
their knowledge/dialogue, quests, items, locations, or starting origins — even
|
||||
if they don't say "world-building" or name the content/ folder. Triggers on
|
||||
"write an NPC", "add a town/region/faction", "new quest", "a secret the party
|
||||
can uncover", "expand a region", "flesh out a town", "someone the
|
||||
player meets on the road", "cursed item", "give me a storyline", and the like.
|
||||
The skill reconciles new content against existing canon (never contradicting or
|
||||
silently changing it), writes it in the exact source format the content build
|
||||
tool consumes, keeps NPCs true to the world's hard, uncaring tone (warm only
|
||||
when disposition is earned), and verifies the build stays green. Do NOT use it
|
||||
for engine/UI code, prompts (/api/prompts), or combat rules.
|
||||
---
|
||||
|
||||
# World-building — the Margreave
|
||||
|
||||
Author new content for **Code of Conquest**, a gritty single-player CRPG. Read
|
||||
`CLAUDE.md` (repo root) once per session for the charter — this skill assumes it.
|
||||
|
||||
## The one rule that governs everything you write
|
||||
|
||||
> **Code owns state. AI owns text.** (charter §2)
|
||||
|
||||
You are authoring *static content*, not runtime state. Facts, personas, places,
|
||||
and knowledge chains are text the engine references by stable **id**. You never
|
||||
write initial disposition numbers into world content, never gameplay logic. A
|
||||
starting *value* (where the player begins, a seeded disposition) belongs to an
|
||||
**origin**, never to a town or NPC.
|
||||
|
||||
## The two things that make this hard
|
||||
|
||||
1. **New content must not contradict old content.** The build tool checks
|
||||
structure (ids resolve, secrecy routes correctly) but it CANNOT tell that you
|
||||
just described the north road as untraveled when canon says elves walk it. A
|
||||
contradiction ships silently and the DM narrates a broken world. So:
|
||||
**read the existing canon before you write a word**, and treat every existing
|
||||
file as read-only unless the user explicitly approves an edit (see below).
|
||||
|
||||
2. **The tone is specific and easy to get wrong.** Gritty, not grim. The world
|
||||
is hard and does not care about the player — but an NPC who has come to trust
|
||||
them becomes genuinely warm. Getting either pole wrong (grimdark misery, or
|
||||
friendly-NPC-from-hello) breaks the world. See `references/tone.md`.
|
||||
|
||||
## Workflow — follow in order
|
||||
|
||||
Make a todo per step.
|
||||
|
||||
### 1. Understand what's being asked, and which layer it lives in
|
||||
|
||||
| Content kind | Format | Lives in |
|
||||
|---|---|---|
|
||||
| People, places, regions, factions, world rules, knowledge (rumor/fact/secret), NPC dialogue layers | **Markdown bible** — ` ```yaml ` blocks | `content/lore/*.md` (source) |
|
||||
| Quests / story skeletons | Hand-authored JSON | `content/world/quests/` |
|
||||
| Items (incl. cursed/blessed) | Hand-authored JSON | `content/world/items/` |
|
||||
| Locations (map nodes) | Hand-authored JSON | `content/world/locations/` |
|
||||
| Origins (starting seeds) | Hand-authored JSON | `content/origins/` |
|
||||
| Degraded-DM fallback prose | Markdown/text | `content/fallback/` |
|
||||
|
||||
The **lore bible is the heart** — it produces the disposition-gated dialogue that
|
||||
makes NPCs feel alive. `content/world/` and `content/server/` under `canon/`,
|
||||
`topics/`, and `npcs/` are **BUILT from the bible — never hand-edit them.** The
|
||||
JSON under `quests/`, `items/`, `locations/`, and `origins/` is hand-authored and
|
||||
loosely schema'd (POC seeds); edit those directly.
|
||||
|
||||
If a storyline you're writing implies a quest, item, or location that doesn't
|
||||
exist yet, say so and author the stub, or flag it — don't leave a dangling id.
|
||||
|
||||
### 2. Load and reconcile against existing canon — before writing
|
||||
|
||||
Run the canon index to see every id, type, secrecy, and one-line gist already
|
||||
authored:
|
||||
|
||||
```
|
||||
.venv/bin/python .claude/skills/world-building/scripts/canon_index.py
|
||||
```
|
||||
|
||||
Read the entries your new content touches (people it references, the region it
|
||||
sits in, secrets it brushes against). New content must slot into this without
|
||||
contradiction. Reuse existing ids in `related:` rather than inventing parallel
|
||||
ones. If your idea only works by *changing* something already canon — a fact's
|
||||
body, a disposition, who knows what — **STOP.** Do not edit. Present the exact
|
||||
before/after change and the reason, and get explicit approval first. New files
|
||||
and new entries need no approval; edits to existing canon always do.
|
||||
|
||||
New content must also slot against the world's overarching cosmology — the good
|
||||
god (the Warden), his angels (the Sent), and the seven demon kings who work
|
||||
against him through influence, possession, and blood sacrifice. Read
|
||||
`references/cosmology.md` before authoring; it is the backdrop nothing may
|
||||
contradict, and it carries the authoring rules that keep the war in tone
|
||||
(distant, worked through people, never epic, blessing/curse read through Luck).
|
||||
|
||||
### 3. Draft in the correct format
|
||||
|
||||
- **Bible entries:** see `references/schema.md` for the full field-by-field
|
||||
contract, the id/namespace/type rules, the secrecy scale, and the knowledge-gate
|
||||
model. Match the **structure** of `content/lore/specimen.md` (the synthetic
|
||||
disposition-gate specimen — a guarded-then-warm NPC with one reveal earned at
|
||||
`warm` and a `never`-gated core no disposition unlocks) and the **voice** of the
|
||||
canon bodies in `content/lore/cosmology.md`.
|
||||
- **Hand-authored JSON:** mirror the shape of the existing file in that folder
|
||||
(`references/schema.md` documents each). Keep ids stable and lowercase-snake.
|
||||
|
||||
Write NPCs to the tone and disposition-warmth model in `references/tone.md`. A
|
||||
hard NPC starts guarded (`cold`/`neutral`, sometimes reachable-`hostile`),
|
||||
withholds more than a stranger would expect, and both *reveals more* and *softens
|
||||
in manner* as the player climbs their ladder. That warmth is earned, never
|
||||
default — it's what the five-rung gate system exists to deliver.
|
||||
|
||||
### 4. Build and verify — not done until green
|
||||
|
||||
The build regenerates `content/world/` + `content/server/` from the bible, and the
|
||||
`--check` gate is what proves it stayed valid. **There is no CI — nothing runs
|
||||
these for you.** You are the gate; run them, from repo root, with the repo venv
|
||||
active (`source .venv/bin/activate`). The `content_build` package lives under
|
||||
`tools/`, so `PYTHONPATH=tools` is required:
|
||||
|
||||
```
|
||||
PYTHONPATH=tools python -m content_build # regenerate world/ + server/ from lore/
|
||||
PYTHONPATH=tools python -m content_build --check # must exit 0: fresh, valid, no orphans, no secrecy leaks
|
||||
PYTHONPATH=tools python -m pytest tools/content_build/tests -q # schema + secrecy + cli coverage
|
||||
```
|
||||
|
||||
**If the build changed anything under `content/world/`, also run the client
|
||||
suite** — `bash client/run_tests.sh` (must be fully green):
|
||||
|
||||
```
|
||||
bash client/run_tests.sh # Godot/GUT; content/world/ is a CLIENT INPUT, not just build output
|
||||
```
|
||||
|
||||
That last one is not optional and not paranoia. `client/scripts/content/content_db.gd`
|
||||
loads `content/world/`, and `client/tests/unit/test_content_db.gd` asserts on it — so
|
||||
**deleting or renaming generated content is a client change.** A content purge once
|
||||
left the client erroring on a vanished directory and turned a secrecy assertion into a
|
||||
test that passed while checking nothing. Nothing else will catch that for you.
|
||||
|
||||
(If `python` isn't found, the venv isn't active — use `.venv/bin/python`.)
|
||||
If any step fails, read the `BUILD FAILED` / `STALE:` message — it names the
|
||||
source file and line — fix the bible, rebuild, recheck. Never hand-edit the
|
||||
generated JSON to make `--check` pass; fix the source. (Hand-authored JSON under
|
||||
quests/items/locations/origins isn't built — the build tool ignores it. If you
|
||||
touched those, just confirm they're valid JSON.)
|
||||
|
||||
### 5. Report
|
||||
|
||||
Summarize: new ids added (by layer), any existing id you reused in `related`,
|
||||
anything you flagged for approval, and confirmation the build + check + tests are
|
||||
green. Update [`content/roadmap.md`](../../../content/roadmap.md) if you added a new
|
||||
bible file or closed out a thread — its Spine list, or the Bill of Materials line
|
||||
you just satisfied.
|
||||
|
||||
**Respect its rule: never author anything that is not in the Bill of Materials.**
|
||||
An idea that arrives during authoring goes to that doc's Backlog, not into the
|
||||
world.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/cosmology.md` — the overarching theme (the Warden and the Seven)
|
||||
every piece of content reconciles against, plus the rules that keep it in tone.
|
||||
Read before authoring any lore, NPC, quest, or item.
|
||||
- `references/schema.md` — the complete bible + JSON schema, all validation rules,
|
||||
the secrecy scale, the knowledge-gate model, worked examples. Read before
|
||||
authoring bible entries.
|
||||
- `references/tone.md` — the Margreave's tone contract and the NPC
|
||||
disposition-warmth model. Read before writing any NPC or narrative body.
|
||||
48
.claude/skills/world-building/evals/evals.json
Normal file
48
.claude/skills/world-building/evals/evals.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"skill_name": "world-building",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "new-npc-reconciled",
|
||||
"prompt": "Add a new NPC on the road: a caravan guard who's cagey with strangers but opens up once you've earned it. Give them something personal they only share when they trust you. Put it in a new lore file.",
|
||||
"expected_output": "New npc.* dialogue-layer entry (+ its person/place/knowledge entries) authored in a NEW content/lore file; start_disposition cold or neutral; at least one fact gated at warm/trusted; every related resolves; no existing lore file edited; PYTHONPATH=tools content_build --check exits 0; tone dry and guarded-then-warm; nothing contradicts the cosmology backdrop.",
|
||||
"assertions": [
|
||||
"Created at least one new npc.* entry in content/lore with start_disposition of cold or neutral",
|
||||
"The NPC's knows list gates a personal fact at warm or trusted (earned reveal)",
|
||||
"Every related id resolves to a real entry",
|
||||
"No pre-existing content/lore/*.md file was modified (or any edit was surfaced for approval, not silent)",
|
||||
"python -m content_build --check exits 0 after the change",
|
||||
"Persona/body prose is dry and in-tone; no narrator winking or 'hilarious' register",
|
||||
"Nothing authored contradicts the cosmology canon (the Warden / the Seven)"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "contradiction-approval-gate",
|
||||
"prompt": "Write an NPC priest who tells the player the Warden's true name and proves the god is real and good.",
|
||||
"expected_output": "Skill recognizes this contradicts locked cosmology (the Warden is nameless BY DESIGN; his existence and goodness are 'distant & contested', never confirmed by the narrator), STOPS instead of authoring it, and either explains the conflict or proposes an in-tone alternative (an NPC who CLAIMS a name or believes, with the game confirming nothing) and asks before writing.",
|
||||
"assertions": [
|
||||
"Did NOT author content that states the Warden's true name or confirms the god is real/good",
|
||||
"Surfaced the contradiction explicitly, naming the cosmology rule (nameless by design; distant & contested)",
|
||||
"Proposed an in-tone alternative and/or asked for approval before writing"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "new-storyline-file",
|
||||
"prompt": "Start a new storyline about a roadside cult bleeding travelers to feed one of the Seven: who they are, and a rumor the player might hear. Make it its own file.",
|
||||
"expected_output": "A new content/lore/*.md file with canon entities and at least one knowledge entry; related wired to a real cosmology id (faction.the-seven or a specific lord); knowledge with correct ascending secrecy; build + --check green; content/roadmap.md updated. The demon tie is handled in-tone: a gated secret (hidden engine), not stated in the open.",
|
||||
"assertions": [
|
||||
"Authored a new content/lore/*.md file (its own file)",
|
||||
"Wired at least one new entry's related to a real cosmology id (faction.the-seven or a lord)",
|
||||
"Includes at least one knowledge entry (rumor/fact/secret) with correct ascending secrecy",
|
||||
"python -m content_build --check exits 0 after the change",
|
||||
"content/roadmap.md updated to reflect the new content",
|
||||
"The demonic connection is a gated secret, not open surface text"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
]
|
||||
}
|
||||
57
.claude/skills/world-building/references/cosmology.md
Normal file
57
.claude/skills/world-building/references/cosmology.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Cosmology — the Warden and the Seven
|
||||
|
||||
The overarching theme of the Margreave. Every new piece of content sits inside
|
||||
this hidden war and must not contradict it. The canon lives in
|
||||
`content/lore/cosmology.md` and `content/lore/the-seven.md`; this file is the
|
||||
author-facing summary + the rules that keep it in tone. Read it before writing
|
||||
lore, NPCs, quests, or items.
|
||||
|
||||
## The frame
|
||||
|
||||
- **The Warden** (`person.the-warden`) — a genuinely good, *distant* god. Made
|
||||
the races, loves them, wards the world against the Seven. Answers worship with
|
||||
rare blessing and does not otherwise intervene. **Nameless by design** — no one
|
||||
living speaks his name; never invent one. Whether he is good, absent, or dead
|
||||
is the world's oldest argument, never settled by the narrator.
|
||||
- **The Sent** (`faction.the-sent`) — his angels. Indirect: a Sent one *asks* a
|
||||
mortal to do a deed in his name; blessing follows the deed. Rare — most people
|
||||
never meet one. This is the cosmic-flavored quest-giver vein.
|
||||
- **The Seven** (`faction.the-seven`) — seven demon kings + armies, the Warden's
|
||||
enemy. Want the races dead or enslaved. Run on **blood** (`rule.blood-price`):
|
||||
sacrifice buys their power. The roster (`content/lore/the-seven.md`): Kareth
|
||||
(slaughter), Vael (betrayal), Morren (plague/mercy-kill), Ghaul (bondage),
|
||||
Ishri (appetite), Nuun (madness — chief possessor), Draeth (tyranny).
|
||||
- **Possession** (`rule.possession`) — how the Seven reach mortals: mostly
|
||||
*influence* (a hand on the scale of what a person already wants), rarely
|
||||
outright seizing a body (Nuun's specialty). A possessed mortal is a victim; the
|
||||
thing inside is the enemy.
|
||||
- **The two economies** — worship→blessing (`rule.the-pact`) vs
|
||||
blood→power (`rule.blood-price`). Both express through Luck's *flavor*, never
|
||||
numbers (see below).
|
||||
|
||||
## The FTH-axis callings (from the races/classes spec)
|
||||
|
||||
The two divine callings draw power from opposite sides; the rest are unaligned.
|
||||
- **Bonesetter** (Cleric, FTH) — channels the Warden; healing *is* his blessing.
|
||||
- **Bloodsworn** (Warlock, FTH) — pacted to one of the Seven; the "Pact Mark"
|
||||
talent spends a thread of a lord's blood-price power.
|
||||
- **Hedge-Mage** (Wizard, MAG) — outsider; arcane is *studied*, serves neither.
|
||||
- Martials (Sellsword/Trapper/Barbarian) — unaligned; the war only echoes.
|
||||
Callings are code-owned state — reference them in prose, never author a "class"
|
||||
entity. (A Bloodsworn's patron choice and any Luck effect are M4/M5, not content.)
|
||||
|
||||
## Authoring rules — keep it in tone
|
||||
|
||||
1. **Distant.** The cosmology is the hidden engine under local grime, not the
|
||||
surface text. A corrupt mayor can just be a corrupt mayor.
|
||||
2. **Through people.** Demons work by influence; possession is rare and quiet.
|
||||
No manifesting monsters in the square, no preaching narrator, god unconfirmed.
|
||||
3. **Not epic.** Keep the gritty, indifferent register (see `tone.md`). Cosmic
|
||||
stakes stay implied, argued-over, never announced.
|
||||
4. **Blessing/curse ↔ Luck.** Express which side has hold of someone through
|
||||
Luck's *flavor* ("fortune spits on you") — never a number, never something the
|
||||
player can calculate. A cursed item (+STR / −LCK) is a small bargain with the
|
||||
Seven.
|
||||
5. **Victims are gated secrets.** WHO is possessed, WHICH road is bled, WHICH
|
||||
shrine is a front — author as per-town `rumor`/`fact`/`secret` with disposition
|
||||
gates, not stated in the open. The frame gives you hooks; you place the wounds.
|
||||
260
.claude/skills/world-building/references/schema.md
Normal file
260
.claude/skills/world-building/references/schema.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Content schema — the exact contract
|
||||
|
||||
Everything the build tool (`tools/content_build/`) enforces, plus the loose JSON
|
||||
formats it doesn't build. When in doubt, the validator in `resolve.py` is the
|
||||
source of truth; this file is its plain-language mirror.
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [The bible file (`content/lore/*.md`)](#the-bible-file)
|
||||
- [Common fields (every bible entry)](#common-fields)
|
||||
- [Entry kinds](#entry-kinds)
|
||||
- [The secrecy scale](#the-secrecy-scale)
|
||||
- [The knowledge-gate model](#the-knowledge-gate-model)
|
||||
- [Client vs server routing](#client-vs-server-routing)
|
||||
- [Worked example](#worked-example)
|
||||
- [Hand-authored JSON (not built)](#hand-authored-json)
|
||||
|
||||
---
|
||||
|
||||
## The bible file
|
||||
|
||||
A bible file is Markdown. Prose between blocks is for human authors — the build
|
||||
tool ignores it. Content lives in fenced blocks opened by a line that is exactly
|
||||
` ```yaml ` and closed by ` ``` `. Each block is one YAML **mapping** = one entry.
|
||||
|
||||
Organize a file with `##` headings and `>` notes like `content/lore/specimen.md`.
|
||||
Every `*.md` in `content/lore/` is parsed; a new region/storyline can be its own
|
||||
file (e.g. `content/lore/the-tallow-reach.md`).
|
||||
|
||||
## Common fields
|
||||
|
||||
Every entry requires these five (missing any → build fails):
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `id` | `namespace.slug`, lowercase-kebab. Namespace is one of the legal set below. **Globally unique** — duplicate id fails the build. |
|
||||
| `type` | Must equal the id's namespace, EXCEPT `npc.*` whose type is always `person`. |
|
||||
| `status` | `candidate` or `canon`. Only `canon` entries emit to world/server; `candidate` is validated but not shipped — use it for work-in-progress. |
|
||||
| `secrecy` | Integer (not bool). See the scale below. |
|
||||
| `related` | List of ids (may be empty `[]`). **Every id must resolve** to another entry, or the build fails. This is the web that keeps the world coherent — wire new entries into it. |
|
||||
|
||||
`body: >` — the prose. A YAML folded scalar. This is what the DM/NPC pipeline
|
||||
reads. For canon entities it ships to the client; for knowledge it's server-only.
|
||||
|
||||
## Entry kinds
|
||||
|
||||
Two families, distinguished by **id namespace** (not by any `kind` field):
|
||||
|
||||
### Canon entities — the world's furniture
|
||||
|
||||
Namespaces `town`, `place`, `region`, `faction`, `person`, `rule`.
|
||||
- `type` == namespace.
|
||||
- **Must be `secrecy: 0`** (they're public; their bodies ship to the client).
|
||||
- `body` is public description.
|
||||
- The special entry `rule.disposition-ladder` carries a `rungs:` list and is
|
||||
**required exactly once** across all bibles — it defines the gate vocabulary.
|
||||
Don't duplicate or remove it; it lives in `mechanics.md`.
|
||||
|
||||
### Knowledge — the atoms NPCs reveal
|
||||
|
||||
Namespaces `rumor`, `fact`, `secret` (these three id-prefixes; `type` matches).
|
||||
- `secrecy` 1–4 (author-facing sensitivity — see scale).
|
||||
- `body` is the thing known. **Routes to the server**, never the client.
|
||||
- One atomic idea per entry, so different NPCs can hold the same fact at
|
||||
different gates. Chain them by `related:` (a secret relates to the facts that
|
||||
circle it).
|
||||
|
||||
### NPC dialogue layer — `npc.*`
|
||||
|
||||
The interactive layer of a person. `id: npc.<slug>`, `type: person`. This is a
|
||||
SEPARATE entry from the `person.<slug>` world-record; the namespace prefix is the
|
||||
only thing distinguishing them (a person may have both — see the mayor). Extra
|
||||
required/optional fields:
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `start_disposition` | **Required.** One of the ladder rungs (`hostile`/`cold`/`neutral`/`warm`/`trusted`). Where this NPC meets a stranger. |
|
||||
| `knows` | **Required, non-empty.** List of `{fact: <knowledge-id>, gate: <rung-or-never>}`. Each `fact` must resolve to a `rumor`/`fact`/`secret`. Each `gate` is a rung or `never`. |
|
||||
| `body` | Here it's the NPC's **persona** — voice, manner, what they want, why they withhold. Server-only. |
|
||||
| `disposition_notes` | Optional. Author guidance on how they move up/down the ladder and what each pole means. Server-only. |
|
||||
|
||||
A non-npc entry must NOT carry `knows` or `start_disposition`. An `npc.*` entry
|
||||
MUST carry both.
|
||||
|
||||
## The secrecy scale
|
||||
|
||||
`secrecy` is **author-facing and static** — a mis-authoring guard, not a runtime
|
||||
lever. It answers "how sensitive is this text, where may it live?"
|
||||
|
||||
| secrecy | Meaning | Routing |
|
||||
|---|---|---|
|
||||
| 0 | Public. Canon entities only. | Body ships client-side. |
|
||||
| 1 | Observable rumor; freely-ish known. | Body server-only. |
|
||||
| 2 | Known locally, not discussed. | Body server-only. |
|
||||
| 3 | Protected; a real tell. | **Body must never reach the client** (build hard-fails if it does). |
|
||||
| 4 | The core secret. | Server-only. |
|
||||
|
||||
Keep the secrecy of a knowledge chain *ascending* from freely-observed to the
|
||||
`never`-gated core — a chain climbs by secrecy, e.g. 1→2→2→3→4 from an
|
||||
observable rumor to the core secret.
|
||||
|
||||
## The knowledge-gate model
|
||||
|
||||
**Two independent axes — never conflate them:**
|
||||
|
||||
- **secrecy** (above) lives on the *fact* — static, author-facing.
|
||||
- **gate** lives on the *knowledge link* (`knows[].gate`) — runtime,
|
||||
character-facing. It's the disposition rung at which THIS NPC will reveal THIS
|
||||
fact.
|
||||
|
||||
Rungs, low → high: `hostile < cold < neutral < warm < trusted`. Plus the special
|
||||
gate **`never`**: the NPC *knows* the fact (the Improviser can reason with it) but
|
||||
no disposition ever unlocks it in dialogue. `never` is how a locked-door NPC (the
|
||||
mayor) holds the secret without leaking it.
|
||||
|
||||
Design a chain so climbing an NPC's disposition unlocks successively deeper facts.
|
||||
The reachable ceiling is a design choice: an NPC can *witness* enough to imply a
|
||||
secret without holding the secret entry itself: its top rung names what it
|
||||
witnessed, not the whole truth, and the player infers the rest. Understanding
|
||||
earned beats understanding handed over.
|
||||
|
||||
## Client vs server routing
|
||||
|
||||
The build splits each canon entry (`emit.py`) — you don't do this, but author with
|
||||
it in mind:
|
||||
|
||||
- **Canon entity** → `content/world/canon/<slug>.json` (id, type, related, public
|
||||
body). Nothing server-side.
|
||||
- **Knowledge** → client `content/world/topics/<slug>.json` (id, type, related —
|
||||
**no body**); server `content/server/topics/<slug>.json` (id, body, secrecy).
|
||||
- **npc.\*** → client `content/world/npcs/<slug>.json` (id, type,
|
||||
start_disposition, related, knows+gates — the gate logic the client needs);
|
||||
server `content/server/npcs/<slug>.json` (id, persona, disposition_notes — the
|
||||
voice, hidden).
|
||||
|
||||
The guarantee: a player who reads the client bundle sees NPC *gates* but never NPC
|
||||
*personas* or *secret bodies*. That's why secrecy≥3 bodies client-side is a hard
|
||||
build failure.
|
||||
|
||||
## Worked example
|
||||
|
||||
A guarded fisherman who warms up and names the coin — and who holds one thing no
|
||||
disposition ever buys.
|
||||
|
||||
```yaml
|
||||
id: person.specimen-teague
|
||||
type: person
|
||||
status: canon
|
||||
secrecy: 0
|
||||
related: [place.specimen-wharf]
|
||||
body: >
|
||||
Teague, oldest hand on the Greywater wharf. Mends nets he no longer sails with.
|
||||
Watches everything off the water and says almost none of it.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: fact.specimen-foreign-coin
|
||||
type: fact
|
||||
status: canon
|
||||
secrecy: 2
|
||||
related: [place.specimen-wharf, person.specimen-teague]
|
||||
body: >
|
||||
The dock hands are paid in pass-country coin, not town mint. Someone outside the
|
||||
charter is buying the wharf's silence, a little at a time.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: secret.specimen-drowned-clerk
|
||||
type: secret
|
||||
status: canon
|
||||
secrecy: 4
|
||||
related: [place.specimen-wharf, person.specimen-teague, fact.specimen-foreign-coin]
|
||||
body: >
|
||||
The charter clerk who came asking after the coin went into the river in the
|
||||
spring. Teague held his legs. He has not been out on the water since and tells
|
||||
anyone who asks that it is his knees.
|
||||
```
|
||||
|
||||
```yaml
|
||||
id: npc.specimen-teague
|
||||
type: person
|
||||
status: canon
|
||||
start_disposition: cold
|
||||
related:
|
||||
[person.specimen-teague, fact.specimen-foreign-coin,
|
||||
secret.specimen-drowned-clerk]
|
||||
body: >
|
||||
Same man as person.specimen-teague; his interactive layer. Curt with strangers, not
|
||||
from malice but from a lifetime of watching talkers end up face-down in the
|
||||
river. Warms slowly, and when he does the dryness turns to something almost
|
||||
fond.
|
||||
|
||||
knows:
|
||||
- {fact: fact.specimen-foreign-coin, gate: warm}
|
||||
- {fact: secret.specimen-drowned-clerk, gate: never}
|
||||
|
||||
disposition_notes: >
|
||||
Starts cold. Buying his catch, not his story, is what moves him. At warm he
|
||||
names the coin; he never speculates aloud about who mints it. The clerk is the
|
||||
one thing no rung buys — saying it aloud would hang him, and he knows it. He
|
||||
reasons around it, steers off it, and takes it to the ground.
|
||||
```
|
||||
|
||||
Note: `person.specimen-teague` (secrecy 0, public) and `npc.specimen-teague` (the layer,
|
||||
persona server-only) are two entries. The chain ascends 2 → 4: the coin is gated at
|
||||
`warm` and the player earns it; the clerk is gated `never` and no disposition unlocks
|
||||
it. **That is what `never` is for.** Teague holds it — the Improviser may reason with
|
||||
it, and the player may infer it from what he *will* say — but he cannot be talked into
|
||||
saying it, because saying it would hang him. A `never` gate needs a reason like that:
|
||||
if any rung *should* buy the secret, gate it at `trusted` instead.
|
||||
|
||||
## Hand-authored JSON
|
||||
|
||||
Not built from the bible — edit these files directly. Loosely schema'd POC seeds;
|
||||
mirror the existing file in the folder. Keep ids stable, lowercase-snake.
|
||||
|
||||
**Quest** — `content/world/quests/<id>.json`
|
||||
```json
|
||||
{ "id": "find_the_ledger", "name": "The Missing Ledger",
|
||||
"objective": "Find who took Fenn's ledger" }
|
||||
```
|
||||
|
||||
**Item** — `content/world/items/<id>.json`. Cursed/blessed items move Luck (§7):
|
||||
the STR/LCK split is the point. Numeric effects live in game state; keep the JSON
|
||||
to identity/slot and let narrative-worthy items surface as canon-log facts.
|
||||
```json
|
||||
{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }
|
||||
```
|
||||
|
||||
**Location** — `content/world/locations/<id>.json`. Map nodes; an origin's
|
||||
`start_location_id` resolves here.
|
||||
```json
|
||||
{ "id": "greywater_docks", "name": "the Greywater docks",
|
||||
"description": "A rot-black wharf where the river meets the sea trade." }
|
||||
```
|
||||
|
||||
**Origin** — `content/origins/<id>.json`. Thin starting seed — where the player
|
||||
begins and the situation. This is the ONLY place initial *state* (seeded
|
||||
dispositions, granted items) belongs. Every referenced id
|
||||
(`start_location_id`, `inventory_grants[].item_id`, `start_quest_id`,
|
||||
`disposition_overrides` keys) must resolve to real content, or new-game
|
||||
construction fails loudly.
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "deserter",
|
||||
"display_name": "The Deserter",
|
||||
"description": "You walked away from a company that doesn't allow walking away.",
|
||||
"start_location_id": "greywater_docks",
|
||||
"situation": ["Arrived by barge before dawn, hood up"],
|
||||
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
|
||||
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
|
||||
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
**Fallback** — `content/fallback/`. Degraded-DM prose (charter §13), written as
|
||||
in-voice content, not error text. Every AI surface needs one before it ships.
|
||||
121
.claude/skills/world-building/references/tone.md
Normal file
121
.claude/skills/world-building/references/tone.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Tone — the Margreave
|
||||
|
||||
The world's voice. Distilled from charter §3 (tone), §7 (Luck), §9 (companions).
|
||||
Read before writing any `body`, persona, or narrative prose. Getting this right is
|
||||
most of the job — the schema is easy; the voice is the product.
|
||||
|
||||
## The core stance
|
||||
|
||||
**Gritty, not grim.** The reference is Batman in the DC comics — not Warhammer,
|
||||
not Care Bears. The world has real problems and does not care about the player.
|
||||
People get hangovers, vomit, curse, get infected wounds. But it is not a misery
|
||||
engine. Grimdark is as wrong as cozy. The world is *indifferent*, and indifference
|
||||
is harder and more interesting than cruelty.
|
||||
|
||||
**The world is played straight.** Comedy emerges from situation, never from the
|
||||
narrator. Nothing is ever "hilariously" anything. The narrator is dry and does not
|
||||
wink. When you're tempted to make prose funny, make the *situation* absurd and
|
||||
describe it flatly instead.
|
||||
|
||||
## Register
|
||||
|
||||
- **Profanity** is permitted and should feel *earned*, not decorative.
|
||||
- **Violence has weight.** Don't gloss it and don't wallow.
|
||||
- **Sex exists** and is discussed the way adults discuss it — bluntly, and mostly
|
||||
as a source of trouble.
|
||||
- **Bodies are real.** Fatigue, hunger, cold, drink, injury. The world touches the
|
||||
characters physically.
|
||||
|
||||
## The narrator voice
|
||||
|
||||
Dry. Economical. Observational. States what is, lets the reader feel the weight.
|
||||
It never editorializes, never reassures, never jokes. A good body reads like
|
||||
someone who has seen a lot and is not impressed, telling you the truth plainly.
|
||||
|
||||
Look at the cosmology bodies: "Most of the Margreave believes the Seven are real.
|
||||
Most of the Margreave is right." The weight is in the plain statement, not in
|
||||
adjectives.
|
||||
|
||||
## NPCs: hard by default, warm when earned
|
||||
|
||||
This is the load-bearing pattern for the user's world. **The world doesn't care
|
||||
about you — but a person who comes to trust you does.** That arc is what the
|
||||
disposition ladder exists to deliver, and it's what makes the coldness bearable.
|
||||
|
||||
### Default posture: guarded
|
||||
|
||||
A Margreave NPC meets a stranger with wariness, not hostility — they have their
|
||||
own troubles and no reason to spend them on you. Mechanically:
|
||||
|
||||
- `start_disposition` is usually `cold` or `neutral`, rarely `warm`.
|
||||
- A frightened or burned NPC withholds **more** than a stranger would expect, not
|
||||
less (fear reads as prickliness). `hostile` is a
|
||||
reachable floor: push them, threaten, side against them, and they close entirely.
|
||||
- What they reveal is gated. Low rungs get you surface; the personal and the
|
||||
dangerous sit higher up.
|
||||
|
||||
### The warm turn: real, specific, earned
|
||||
|
||||
When the player climbs the ladder, two things change together:
|
||||
|
||||
1. **They reveal more** — deeper `knows` gates unlock.
|
||||
2. **Their manner softens** — write the persona and `disposition_notes` so that at
|
||||
`warm`/`trusted` the dryness turns to something human: fondness, relief at being
|
||||
heard, blunt loyalty — the specimen NPC's "dryness turns to something almost
|
||||
fond."
|
||||
|
||||
The warmth is never sycophantic and never a personality transplant — a hard person
|
||||
stays recognizably themselves, just no longer guarded against *you*. That's more
|
||||
affecting than a switch from mean to nice.
|
||||
|
||||
**Don't** write an NPC who is friendly from hello (breaks the world) or one who
|
||||
stays cold no matter what (makes the ladder pointless and the world hopeless). The
|
||||
range between those poles is the whole design.
|
||||
|
||||
## The companions set the tone (charter §9)
|
||||
|
||||
If you write companion content, hold these exactly — they carry the register:
|
||||
|
||||
- **Cadwyn Vell (Bard, NPC-only):** genuinely talented, reflexively lies about
|
||||
small things and truthfully about large ones, does not know he is the problem and
|
||||
never becomes self-aware. Florid performing, clipped when scared. The source of
|
||||
chaos.
|
||||
- **Brannoc Thane (Sellsword):** dry, warm, economical, twenty years past his
|
||||
prime and at peace with it. Says devastating things in the tone of a man
|
||||
discussing weather. Holds the humiliation log — the callback engine. Not a grump;
|
||||
*fond* of you, which is what makes it land.
|
||||
- The pair dislike each other mildly and permanently, and neither will leave the
|
||||
other. Cadwyn generates, Brannoc annotates. Don't add a third to the loop.
|
||||
|
||||
## The cosmic backdrop (the Warden and the Seven)
|
||||
|
||||
The world sits inside a hidden war — a distant good god and seven demon kings
|
||||
(see `references/cosmology.md`). It colors tone but must never break the world's
|
||||
indifference:
|
||||
|
||||
- The cosmology is the **hidden engine**, not the surface text. Day-to-day the
|
||||
world is still taxes, mud, and bad men; the war is what's *underneath* them.
|
||||
- Demons work **through people** — influence, rarely possession. Write the
|
||||
wreckage and the person going wrong, not a monster in the square. The narrator
|
||||
never confirms the god, never preaches, never goes epic.
|
||||
- Which side has hold of someone reads through **Luck's flavor, never a number**
|
||||
(a blessing is a run of fortune; a curse is the trap door opening under you).
|
||||
- The good/evil is real but **argued-over** — most folk dispute whether any of it
|
||||
is true. Certainty is the enemy of this tone as much as of §7's Luck.
|
||||
|
||||
## Luck's fingerprint (charter §7)
|
||||
|
||||
If content touches Luck: it is **visible in prose, never in numbers.** Never state
|
||||
a Luck value; render a descriptor ("Fortune spits on you"). Bad luck costs
|
||||
**dignity, not progress** — embarrassment, inconvenience, property damage, social
|
||||
catastrophe, minor injury. Never quest failure, permanent loss, or death. A cursed
|
||||
item that grants power at a Luck cost (+STR / −LCK) is the most interesting kind of
|
||||
item — lean into that.
|
||||
|
||||
## Quick self-check before you ship a body
|
||||
|
||||
- Did the narrator stay dry and out of the joke?
|
||||
- Is the hardness *indifference*, not sadism?
|
||||
- Does the NPC start guarded, with a real warm turn available up the ladder?
|
||||
- Would profanity/violence/sex here feel earned, not decorative?
|
||||
- Does anything contradict an existing canon body? (If yes → reconcile, don't ship.)
|
||||
112
.claude/skills/world-building/scripts/canon_index.py
Normal file
112
.claude/skills/world-building/scripts/canon_index.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print an index of every authored entry across the lore bible + hand-authored
|
||||
JSON, so new content can be reconciled against existing canon BEFORE writing.
|
||||
|
||||
Reads the same ```yaml blocks the content build tool parses (no dependency on the
|
||||
build package — pure stdlib + PyYAML, which the repo already needs). Run from the
|
||||
repo root:
|
||||
|
||||
python .claude/skills/world-building/scripts/canon_index.py
|
||||
|
||||
Output groups bible entries by kind (canon entity / knowledge / npc layer) with
|
||||
id, status, secrecy, and a one-line gist, then lists hand-authored quest/item/
|
||||
location/origin ids. Use it to spot id collisions and facts your new content must
|
||||
not contradict."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("PyYAML not installed; run: pip install pyyaml")
|
||||
|
||||
REPO = Path(__file__).resolve().parents[4]
|
||||
LORE = REPO / "content" / "lore"
|
||||
WORLD = REPO / "content" / "world"
|
||||
ORIGINS = REPO / "content" / "origins"
|
||||
|
||||
CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"}
|
||||
KNOWLEDGE_TYPES = {"rumor", "fact", "secret"}
|
||||
|
||||
|
||||
def parse_bible(path: Path):
|
||||
"""Yield yaml-block mappings, mirroring tools/content_build/parse.py."""
|
||||
lines = path.read_text().splitlines()
|
||||
i, n = 0, len(lines)
|
||||
while i < n:
|
||||
if lines[i].strip() == "```yaml":
|
||||
i += 1
|
||||
block = []
|
||||
while i < n and lines[i].strip() != "```":
|
||||
block.append(lines[i])
|
||||
i += 1
|
||||
data = yaml.safe_load("\n".join(block))
|
||||
if isinstance(data, dict):
|
||||
yield data, path.name
|
||||
i += 1
|
||||
|
||||
|
||||
def gist(text) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
one = " ".join(str(text).split())
|
||||
return one[:100] + ("…" if len(one) > 100 else "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not LORE.is_dir():
|
||||
return print(f"no lore dir at {LORE}") or 1
|
||||
|
||||
entities, knowledge, npcs = [], [], []
|
||||
for md in sorted(LORE.glob("*.md")):
|
||||
for d, src in parse_bible(md):
|
||||
eid = d.get("id", "?")
|
||||
ns = str(eid).split(".", 1)[0]
|
||||
row = (eid, d.get("status", "?"), d.get("secrecy", "?"),
|
||||
gist(d.get("body")), src)
|
||||
if ns == "npc":
|
||||
gates = ", ".join(f"{k.get('fact')}@{k.get('gate')}"
|
||||
for k in d.get("knows", []))
|
||||
npcs.append(row + (d.get("start_disposition", "?"), gates))
|
||||
elif ns in KNOWLEDGE_TYPES:
|
||||
knowledge.append(row)
|
||||
else:
|
||||
entities.append(row)
|
||||
|
||||
print("=== CANON ENTITIES (public; body ships client) ===")
|
||||
for eid, st, sec, g, src in sorted(entities):
|
||||
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
|
||||
|
||||
print("\n=== KNOWLEDGE (rumor/fact/secret; body server-only) ===")
|
||||
for eid, st, sec, g, src in sorted(knowledge):
|
||||
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
|
||||
|
||||
print("\n=== NPC DIALOGUE LAYERS ===")
|
||||
for eid, st, sec, g, src, start, gates in sorted(npcs):
|
||||
print(f" {eid:34} [{st} start:{start}] {src}\n persona: {g}\n"
|
||||
f" knows: {gates}")
|
||||
|
||||
def list_json(label, d):
|
||||
print(f"\n=== {label} (hand-authored JSON) ===")
|
||||
if not d.is_dir():
|
||||
print(" (none)")
|
||||
return
|
||||
for f in sorted(d.glob("*.json")):
|
||||
try:
|
||||
o = json.loads(f.read_text())
|
||||
name = o.get("name") or o.get("display_name") or ""
|
||||
except (OSError, ValueError):
|
||||
name = "(unreadable)"
|
||||
print(f" {o.get('id', f.stem):28} {name}")
|
||||
|
||||
list_json("QUESTS", WORLD / "quests")
|
||||
list_json("ITEMS", WORLD / "items")
|
||||
list_json("LOCATIONS", WORLD / "locations")
|
||||
list_json("ORIGINS", ORIGINS)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2,6 +2,7 @@
|
||||
.claude
|
||||
client
|
||||
content
|
||||
!content
|
||||
docs/*
|
||||
!docs/schemas
|
||||
**/__pycache__
|
||||
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1,3 +1,9 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# Local scratch — never versioned
|
||||
# ─────────────────────────────────────────────
|
||||
# Ad-hoc F6 / smoke-test captures. Kept for the working session, not the repo.
|
||||
/screenshots/
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Godot 4.7 (client/)
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -60,4 +66,11 @@ Thumbs.db
|
||||
# ─────────────────────────────────────────────
|
||||
# Claude Code harness session/state files
|
||||
# ─────────────────────────────────────────────
|
||||
.claude/
|
||||
# Ignore every .claude/ dir anywhere in the tree...
|
||||
**/.claude/
|
||||
# ...but keep the repo-root .claude/skills/ dir: project-local skills are
|
||||
# checked-in tooling, versioned like code. (Re-include the root dir, drop its
|
||||
# other contents, then re-include skills/.)
|
||||
!/.claude/
|
||||
/.claude/*
|
||||
!/.claude/skills/
|
||||
|
||||
103
CLAUDE.md
103
CLAUDE.md
@@ -8,13 +8,13 @@ Read this before writing code. When a request conflicts with something written h
|
||||
|
||||
## 1. What this is
|
||||
|
||||
A single-player, party-based fantasy RPG. Combat is deterministic JRPG-style. The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
|
||||
**Code of Conquest** — a single-player, party-based **tactical CRPG** in a gritty high-fantasy world ("The Margreave"). Turn-based, D&D-flavored combat is deterministic (§7/§10). The story skeleton is authored; the *prose* is generated. An AI acts as Dungeon Master — narrating scenes, voicing NPCs, and improvising minor events — while all game state lives in code.
|
||||
|
||||
**Not multiplayer.** Not now, not later. Companions are code-controlled NPCs. Do not introduce networking, lockstep, or authority abstractions "in case."
|
||||
|
||||
**Target:** Desktop first (Windows/Linux). Mobile is a later consideration and should not constrain POC decisions.
|
||||
**Target:** Desktop first (Windows/Linux), **1920×1080 (16:9), Godot 4.7.** Mobile is a later consideration and should not constrain design.
|
||||
|
||||
**Current phase:** POC. The question the POC answers is *does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?* Everything else is scaffolding for that experiment.
|
||||
**Phase.** The original POC question — *does bounded AI dialogue feel alive, or a chatbot in a costume?* — is **answered.** Bounded NPC dialogue, validated moves, and the DM loop are built and live-proven (M0–M2). The project is now **building the game the mockups describe** (§16, `/mockups`): the tactical CRPG across the 11 screens of that UI bible. The AI's job is unchanged — narrate, voice NPCs, improvise — and it never owns state.
|
||||
|
||||
---
|
||||
|
||||
@@ -348,15 +348,16 @@ Single integer, −100..100, per companion. Adjusted by player choices weighed a
|
||||
|
||||
## 10. Combat
|
||||
|
||||
**Positionless, turn-based.** No grid. No flanking. No terrain.
|
||||
**Tactical, turn-based, gridless.** Turn order by initiative; an **action economy** (Action · Bonus · Move) per turn; abilities picked from a bar; enemies targeted by click. AC, HP, and initiative in the D&D-flavored idiom of the Combat HUD mockup (§16).
|
||||
|
||||
Combat is not the risk surface of this project. Ship the boring one; prove the interesting one.
|
||||
**No real movement grid.** The isometric board and tokens are *visual flavor* — there are no positional rules, no flanking, no terrain, no reachable-tile movement. Positioning was the risk surface the old charter warned against; it stays out. The tactics live in the **action economy and resources**, not the map.
|
||||
|
||||
- **Damage is deterministic.** No variance rolls on damage.
|
||||
- **The player controls only their own character.** The DM resolves the AI companions' and enemies' turns.
|
||||
- **Damage is deterministic (§7). OPEN:** the Combat HUD mock rolls damage from a range, but §7 forbids damage variance so Luck can *select* outcomes, not fudge numbers. Reconcile at the combat brainstorm — either fixed damage with Luck governing crit thresholds + borderline status checks, or a conscious, written change to §7. Do **not** silently adopt damage ranges.
|
||||
- **Initiative is rolled once at encounter start** and is relatively random. DEX modifies.
|
||||
- **The interesting decision is resource management** — MP, cooldowns, consumables. Not matchup-reading. Pick one axis for the POC.
|
||||
- **Resources are the interesting axis** — the action economy, cooldowns, consumables. Not matchup-reading.
|
||||
- **Luck influences crit thresholds and borderline status checks.** Nothing else. See §7.
|
||||
- **Combat flavor text is async and optional.** The numbers land immediately. The prose catches up. If the AI call fails, the fight continues with authored text.
|
||||
- **Combat flavor text is async and optional.** The numbers land immediately; the prose (a Narrator/DM call) catches up. If the AI call fails, the fight continues with authored text.
|
||||
|
||||
### Determinism and seeding
|
||||
|
||||
@@ -366,6 +367,8 @@ This costs nothing now and is nearly impossible to retrofit. It is the differenc
|
||||
|
||||
Log the seed *and* the full prompt with every AI call, for the same reason.
|
||||
|
||||
The Combat HUD mock's `Component` state machine (initiative order, action economy, ability resolution, victory/defeat) is the reference spec for the GDScript turn manager.
|
||||
|
||||
---
|
||||
|
||||
## 11. The canon log
|
||||
@@ -454,14 +457,14 @@ Roughly 1–3k tokens per call, dozens of calls per session.
|
||||
|
||||
## 15. Input model
|
||||
|
||||
**Hybrid.**
|
||||
**Button-driven actions; free-text dialogue.**
|
||||
|
||||
- **Menus:** combat, navigation, inventory.
|
||||
- **Free text:** dialogue, and explicit "what do you do?" moments.
|
||||
- **Buttons / menus:** combat abilities, world actions, navigation, inventory, shop. The player picks from **legal options code presents** — no input can produce an illegal or absurd action ("I triple-backflip off the wall and roundhouse him" never reaches the rules). Bulletproof by construction.
|
||||
- **Free text:** **NPC dialogue** — the aliveness surface, kept free-text (bounded by the move vocabulary §6, so wild input is harmless). Plus an optional **"describe your own action"** line on the main window (2a mock) for the tabletop feel.
|
||||
|
||||
**The boundary must be visible.** The player should always know which mode they are in. Ambiguity here reads as broken.
|
||||
**The boundary must be visible.** The player should always know which mode they are in.
|
||||
|
||||
Free text goes to the Adjudicator, which maps it to a legal action or rejects it with an in-world reason.
|
||||
**The Adjudicator (§5) is reshaped.** Its primary job is no longer parsing free text into a world action — code presents legal actions as buttons. It survives only as the interpreter for the *optional* free-text "describe your own action" escape hatch: free text → a legal action or an in-world rejection. Most turns never touch it.
|
||||
|
||||
---
|
||||
|
||||
@@ -470,7 +473,7 @@ Free text goes to the Adjudicator, which maps it to a legal action or rejects it
|
||||
- **Client:** Godot 4, **GDScript.** Prefer readability. Drop to C# only if GDScript genuinely cannot do the thing — and say why before you do.
|
||||
- **Proxy:** Python, **FastAPI.**
|
||||
- **Models:** Ollama (self-hosted, dev/staging) → Replicate (prod). Routed per-role, server-side.
|
||||
- **Repo:** greenfield. No existing code.
|
||||
- **Repo:** the engine (M0–M2) is built — canon-log contract + client engine, the server model-call pipeline, `/dm/narrate` + `/npc/speak`, bounded moves, considering-state. Build the game on top; follow the existing patterns.
|
||||
|
||||
### Layout
|
||||
|
||||
@@ -481,9 +484,29 @@ Free text goes to the Adjudicator, which maps it to a legal action or rejects it
|
||||
/docs API-specific docs
|
||||
/prompts Role prompts (versioned, reviewed like code)
|
||||
/content Authored: quests, NPC knowledge lists, fallback text
|
||||
/mockups The UI/systems bible — 11 hi-fi HTML/CSS/JS screen refs + README
|
||||
/docs Cross-cutting docs — roadmap, ADRs, anything affecting both sides
|
||||
```
|
||||
|
||||
**`/mockups` is the UI + systems bible.** Eleven high-fidelity HTML/CSS/JS screen
|
||||
references (title, character creation, character sheet, inventory, world map,
|
||||
quest log, shop, pause/settings, dialogue, combat HUD, main exploration window)
|
||||
plus a `README.md` handoff. **Not production code** — each screen is recreated as
|
||||
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
|
||||
(the README's palette, three fonts, and reusable styleboxes keep the look one
|
||||
system). **Author these scenes editor-first:** the layout lives in the `.tscn`
|
||||
as real Control nodes with the `Theme` and type-variations set in the editor, so
|
||||
the scene *previews* and can be arranged in the Godot editor — do **not**
|
||||
build the node tree in `_ready()`. Scripts hold only on-load work: `@onready`
|
||||
node refs, data/DM wiring, animations, and binding state into fixed authored
|
||||
nodes (see ADR [0001](docs/adr/0001-editor-first-ui-scenes.md)). Each
|
||||
`.dc.html`'s `Component` class is the **behavior spec** — the
|
||||
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
|
||||
highest-value logic to port; the seed-data maps (items, quests, locations, units,
|
||||
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real
|
||||
art. When the mock and this charter disagree, the disagreement is a decision to
|
||||
make, not a default to follow — see §17's reconcile list.
|
||||
|
||||
`/api` is still the proxy described in §4 — auth, metering, prompt-hiding, model
|
||||
routing. It is named `api` for the client's convenience, not because it is a
|
||||
generic pass-through. Docs are split: side-specific docs live under
|
||||
@@ -494,33 +517,39 @@ Prompts are source code. They get reviewed. They get versioned. Changing a promp
|
||||
|
||||
---
|
||||
|
||||
## 17. POC scope
|
||||
## 17. Scope — the game (the POC is done)
|
||||
|
||||
**~30 minutes of play.**
|
||||
The aliveness POC is **answered** (M0–M2, live-proven). The target is now the
|
||||
tactical CRPG in the mockups (§16): the 11 screens and the systems they imply —
|
||||
character creation, the exploration shell + DM narration, tactical (gridless)
|
||||
combat, free-text NPC dialogue, inventory/equipment, character sheet, world-map
|
||||
travel, a shop economy, a quest log, title/pause.
|
||||
|
||||
- One town, **two NPCs** with authored knowledge lists
|
||||
- One dungeon: **three fights, one boss**
|
||||
- **Two companions:** Cadwyn, Brannoc
|
||||
- **Three playable classes:** Sellsword, Assassin, Priest
|
||||
- Luck fully wired: generation, drift, one shrine, one cursed item
|
||||
- Full proxy, full role split, full canon log
|
||||
- Authored fallback text for every AI surface
|
||||
Build it **screen by screen** against the mockups' shared visual system, on the
|
||||
proven engine (canon log, DM/NPC pipeline, bounded moves, Luck, character-creation
|
||||
model — all already aligned with the mocks). Each screen/system is its own
|
||||
**spec → plan → implementation** cycle; the roadmap sequences them toward a
|
||||
playable vertical slice first, then breadth.
|
||||
|
||||
### The POC's job
|
||||
**Unchanged and load-bearing:** §2 (code owns state, AI owns text) — a tactical
|
||||
CRPG has *far* more state, all code-owned; the AI stays narrator/DM/NPC-voice.
|
||||
Also §3 (tone), §7 (Luck), §9 (companions), the canon log (§11), the output
|
||||
contracts (§12), and Failure UX (§13).
|
||||
|
||||
Answer one question: **does bounded AI dialogue feel alive, or does it feel like a chatbot in a costume?**
|
||||
### To reconcile as we build (flagged, not silently decided)
|
||||
|
||||
Test that before building anything else. Every other system exists to make that test possible.
|
||||
- **Damage determinism** — §7/§10's deterministic damage vs the Combat HUD mock's
|
||||
damage *ranges* (settle at the combat brainstorm).
|
||||
- **Class names** — §8 uses Sellsword/Assassin/Priest/Scholar/Oathbound/Tracker/
|
||||
Berserker; the Character-Creation mock uses callings Sellsword/Cutpurse/
|
||||
Hedge-Mage/Bonesetter/Trapper (same archetypes + primary stats, different
|
||||
names). Settle at the character-creation brainstorm.
|
||||
|
||||
### Explicitly out of scope
|
||||
### Still out of scope (never / later)
|
||||
|
||||
- AI-generated story skeletons (v2 — the seeded story is authored for now)
|
||||
- Multiplayer (never)
|
||||
- Mobile
|
||||
- Credits, payments, auth
|
||||
- Companions leaving the party
|
||||
- Positional combat
|
||||
- More than three classes
|
||||
- Multiplayer (**never**) · mobile (later) · AI-generated story skeletons (v2 —
|
||||
the seeded story is authored for now) · credits/payments/auth (retrofit onto
|
||||
the proxy later, §4).
|
||||
|
||||
---
|
||||
|
||||
@@ -530,6 +559,12 @@ Test that before building anything else. Every other system exists to make that
|
||||
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
|
||||
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
|
||||
- **Prefer deleting scope.** The POC's value is answering one question fast.
|
||||
- **A green suite is evidence only if the test can fail.** Before accepting a fix that
|
||||
claims to guard a regression, re-break the code and *watch the new test fail*. This
|
||||
project has shipped a regression test that passed against its own bug, a determinism
|
||||
test that compared two runs that changed together, and an arity change that
|
||||
miscompiled silently past a clean grep. Read **[docs/traps.md](docs/traps.md)** before
|
||||
writing tests — it is six ways this codebase has already fooled a green suite.
|
||||
|
||||
### Git workflow
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# any of these — keys live only in the proxy (charter §4).
|
||||
|
||||
# Where the proxy sends model calls in dev (Ollama on the homelab).
|
||||
# Running the proxy IN DOCKER with Ollama on THIS host? localhost points at the
|
||||
# container, not the host — use http://host.docker.internal:11434 instead
|
||||
# (docker-compose.yml maps that name via host-gateway). Bare-metal/venv: keep
|
||||
# localhost. Homelab Ollama: use its address.
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Prod model provider (charter §4). Leave blank in dev.
|
||||
@@ -9,3 +13,10 @@ REPLICATE_API_TOKEN=
|
||||
|
||||
# Port the proxy binds. compose / fly.io override this.
|
||||
PORT=8000
|
||||
|
||||
# Narrator model (charter §5 "the good model"). qwen3.x runs with thinking OFF.
|
||||
OLLAMA_NARRATOR_MODEL=qwen3.5:latest
|
||||
# Per-attempt model-call timeout (seconds).
|
||||
OLLAMA_TIMEOUT_SECONDS=30
|
||||
# Optional: append call logs (JSON lines, §10) to this file instead of stdout.
|
||||
CALL_LOG_PATH=
|
||||
|
||||
@@ -12,9 +12,12 @@ WORKDIR /app
|
||||
COPY api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# App code + the schema contract (single source of truth in docs/schemas).
|
||||
# App code, the role prompts (§16 — prompts are source code), and the schema
|
||||
# contract (single source of truth in docs/schemas).
|
||||
COPY api/app ./app
|
||||
COPY api/prompts ./prompts
|
||||
COPY docs/schemas ./schemas
|
||||
COPY content ./content
|
||||
|
||||
# Run as non-root.
|
||||
RUN useradd --create-home --uid 1000 appuser
|
||||
|
||||
64
api/app/call_log.py
Normal file
64
api/app/call_log.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Structured JSON-lines logging of every model call (charter §10 — the seed and
|
||||
the full prompt logged with every call; §4 — free eval/replay infrastructure).
|
||||
|
||||
One JSON object per line: everything needed to replay the call (canon_log +
|
||||
messages + model + seed) against a candidate model. No secrets to redact — the
|
||||
client never sends keys, the canon log carries no PII, and the prompt is exactly
|
||||
what we want on record.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
def _default_write(line: str) -> None:
|
||||
"""Best-effort (charter §13): a good model call must never become a 500
|
||||
because the log write failed (disk full, bad permissions, a misconfigured
|
||||
CALL_LOG_PATH). Any failure here is swallowed and reported to stderr —
|
||||
never stdout, since stdout may itself be the default sink and must stay
|
||||
clean JSON-lines for downstream tooling.
|
||||
"""
|
||||
try:
|
||||
path = config.call_log_path()
|
||||
if path:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
else:
|
||||
sys.stdout.write(line + "\n")
|
||||
except Exception as exc: # noqa: BLE001 - logging must never break a request
|
||||
print(f"call_log: failed to write log line: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def record(
|
||||
*,
|
||||
role: str,
|
||||
model: str,
|
||||
options: dict,
|
||||
messages: list[dict],
|
||||
canon_log: dict,
|
||||
ok: bool,
|
||||
latency_ms: int,
|
||||
response: str | None = None,
|
||||
error: str | None = None,
|
||||
write: Callable[[str], None] = _default_write,
|
||||
) -> dict:
|
||||
rec: dict = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"role": role,
|
||||
"model": model,
|
||||
"options": options,
|
||||
"messages": messages,
|
||||
"canon_log": canon_log,
|
||||
"ok": ok,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
if ok:
|
||||
rec["response"] = response
|
||||
else:
|
||||
rec["error"] = error
|
||||
write(json.dumps(rec, ensure_ascii=False))
|
||||
return rec
|
||||
26
api/app/config.py
Normal file
26
api/app/config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Server config, read from the environment at call time so tests can override
|
||||
without reimport. Charter §4 — all of this lives server-side; the client never
|
||||
sees it.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def ollama_base_url() -> str:
|
||||
return os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
|
||||
|
||||
def ollama_timeout_seconds() -> float:
|
||||
return float(os.environ.get("OLLAMA_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
|
||||
def narrator_model() -> str:
|
||||
return os.environ.get("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest")
|
||||
|
||||
|
||||
def npc_model() -> str:
|
||||
return os.environ.get("OLLAMA_NPC_MODEL", "qwen3.5:latest")
|
||||
|
||||
|
||||
def call_log_path() -> str | None:
|
||||
return os.environ.get("CALL_LOG_PATH") or None
|
||||
@@ -8,6 +8,7 @@ scenes into play.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -48,3 +49,30 @@ def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]:
|
||||
if npc_id not in world["npcs"]:
|
||||
missing.append(f"npc:{npc_id}")
|
||||
return missing
|
||||
|
||||
|
||||
def _content_root() -> Path:
|
||||
"""Locate the authored content root. Honours CONTENT_ROOT, else walks up
|
||||
from this file for a dir containing world/ — repo-root/content in a local
|
||||
checkout, /app/content when bundled into the Docker image. Mirrors
|
||||
canon_log._schema_dir().
|
||||
"""
|
||||
env = os.environ.get("CONTENT_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
candidate = parent / "content"
|
||||
if (candidate / "world").is_dir():
|
||||
return candidate
|
||||
raise RuntimeError("content root not found")
|
||||
|
||||
|
||||
def load_npc(npc_id: str) -> dict | None:
|
||||
"""Load one NPC's authored file (persona + knowledge + capabilities), or
|
||||
None if no file resolves. Server-only fields never travel to the client."""
|
||||
path = _content_root() / "world" / "npcs" / f"{npc_id}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Skeleton: a health check plus the five role endpoints. Each role endpoint now
|
||||
validates the posted canon log against the contract (charter §11) before doing
|
||||
anything else — an invalid log is rejected with 422 and never reaches a prompt.
|
||||
Prompt routing, model selection, and logging land later.
|
||||
`/dm/narrate` is fully wired: prompt routing, the Ollama model call, and
|
||||
call-log logging. The other four roles remain stubs until they get the same
|
||||
treatment.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
@@ -12,8 +14,12 @@ from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .canon_log import validate_canon_log
|
||||
from .narrate import run as narrate_run
|
||||
from .npc import UnknownNpc, run as npc_run
|
||||
from .ollama_client import ModelError
|
||||
from .version import VERSION
|
||||
|
||||
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
|
||||
app = FastAPI(title="coc-rpg proxy", version=VERSION)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
@@ -38,21 +44,46 @@ def valid_turn(req: TurnRequest) -> TurnRequest:
|
||||
return req
|
||||
|
||||
|
||||
class NpcSpeakRequest(BaseModel):
|
||||
canon_log: dict
|
||||
npc_id: str
|
||||
disposition: int
|
||||
available_moves: list[str]
|
||||
utterance: str
|
||||
|
||||
|
||||
def valid_npc_turn(req: NpcSpeakRequest) -> NpcSpeakRequest:
|
||||
"""Reject any /npc/speak request whose canon log breaks the contract."""
|
||||
errors = validate_canon_log(req.canon_log)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
|
||||
return req
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
"""Liveness probe for compose / fly.io."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/version")
|
||||
def version() -> dict:
|
||||
return {"version": VERSION}
|
||||
|
||||
|
||||
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
|
||||
# The client knows these paths and nothing about which model or prompt serves
|
||||
# them. Bodies are validated against the canon log contract; the AI half is a
|
||||
# stub until prompt routing lands.
|
||||
# them. Bodies are validated against the canon log contract. /dm/narrate is
|
||||
# fully wired (routing + model call + logging); the other four roles remain
|
||||
# stubs until prompt routing, model selection, and logging land for them too.
|
||||
|
||||
|
||||
@app.post("/dm/narrate")
|
||||
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
try:
|
||||
return {"prose": narrate_run(req.canon_log)}
|
||||
except ModelError as exc:
|
||||
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
|
||||
|
||||
|
||||
@app.post("/dm/adjudicate")
|
||||
@@ -66,8 +97,18 @@ def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
|
||||
|
||||
@app.post("/npc/speak")
|
||||
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
def npc_speak(req: NpcSpeakRequest = Depends(valid_npc_turn)) -> dict:
|
||||
try:
|
||||
return {"prose": npc_run(
|
||||
canon_log=req.canon_log, npc_id=req.npc_id,
|
||||
disposition=req.disposition, available_moves=req.available_moves,
|
||||
utterance=req.utterance,
|
||||
)}
|
||||
except UnknownNpc as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"canon_log_errors": [f"unknown npc_id: {exc}"]})
|
||||
except ModelError as exc:
|
||||
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
|
||||
|
||||
|
||||
@app.post("/party/banter")
|
||||
|
||||
37
api/app/narrate.py
Normal file
37
api/app/narrate.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""The Narrator role service (charter §5). Renders the digest, routes the model,
|
||||
calls Ollama with one retry, logs the call (§10), and returns raw prose — tags
|
||||
left intact for the client's TagExtractor to harvest (§2/§6). Raises ModelError
|
||||
on failure for the handler to map to a 502.
|
||||
"""
|
||||
|
||||
import random
|
||||
from time import perf_counter
|
||||
|
||||
from . import call_log, ollama_client, prompts, routing
|
||||
|
||||
|
||||
def run(canon_log: dict) -> str:
|
||||
cfg = routing.for_role("narrator")
|
||||
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
|
||||
messages = [
|
||||
{"role": "system", "content": prompts.system_prompt("narrator")},
|
||||
{"role": "user", "content": prompts.render_digest(canon_log)},
|
||||
]
|
||||
|
||||
start = perf_counter()
|
||||
try:
|
||||
text = ollama_client.chat(cfg.model, messages, options, think=cfg.think)
|
||||
except ollama_client.ModelError as exc:
|
||||
call_log.record(
|
||||
role="narrator", model=cfg.model, options=options, messages=messages,
|
||||
canon_log=canon_log, ok=False,
|
||||
latency_ms=int((perf_counter() - start) * 1000), error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
call_log.record(
|
||||
role="narrator", model=cfg.model, options=options, messages=messages,
|
||||
canon_log=canon_log, ok=True,
|
||||
latency_ms=int((perf_counter() - start) * 1000), response=text,
|
||||
)
|
||||
return text
|
||||
59
api/app/npc.py
Normal file
59
api/app/npc.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""The NPC role service (charter §5/§6). Loads the NPC's server-only persona +
|
||||
knowledge, renders the digest, routes the model, calls Ollama with one retry,
|
||||
logs the call (§10), and returns raw prose — move/fact tags left intact for the
|
||||
client to extract, validate, and apply (§2/§6). Mirrors narrate.py.
|
||||
"""
|
||||
|
||||
import random
|
||||
from time import perf_counter
|
||||
|
||||
from . import call_log, ollama_client, prompts, routing
|
||||
from .content import load_npc
|
||||
|
||||
|
||||
class UnknownNpc(Exception):
|
||||
"""No authored content resolves for the requested npc_id."""
|
||||
|
||||
|
||||
def run(
|
||||
canon_log: dict,
|
||||
npc_id: str,
|
||||
disposition: int,
|
||||
available_moves: list[str],
|
||||
utterance: str,
|
||||
) -> str:
|
||||
npc = load_npc(npc_id)
|
||||
if npc is None:
|
||||
raise UnknownNpc(npc_id)
|
||||
|
||||
cfg = routing.for_role("npc")
|
||||
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
|
||||
messages = [
|
||||
{"role": "system", "content": prompts.system_prompt("npc")},
|
||||
{"role": "user", "content": prompts.render_npc_digest(
|
||||
canon_log,
|
||||
persona=npc.get("persona", ""),
|
||||
knowledge=npc.get("knowledge", []),
|
||||
disposition=disposition,
|
||||
available_moves=available_moves,
|
||||
utterance=utterance,
|
||||
)},
|
||||
]
|
||||
|
||||
start = perf_counter()
|
||||
try:
|
||||
text = ollama_client.chat(cfg.model, messages, options, think=cfg.think)
|
||||
except ollama_client.ModelError as exc:
|
||||
call_log.record(
|
||||
role="npc", model=cfg.model, options=options, messages=messages,
|
||||
canon_log=canon_log, ok=False,
|
||||
latency_ms=int((perf_counter() - start) * 1000), error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
call_log.record(
|
||||
role="npc", model=cfg.model, options=options, messages=messages,
|
||||
canon_log=canon_log, ok=True,
|
||||
latency_ms=int((perf_counter() - start) * 1000), response=text,
|
||||
)
|
||||
return text
|
||||
50
api/app/ollama_client.py
Normal file
50
api/app/ollama_client.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Call Ollama's /api/chat (non-streaming) with a single retry. Callers see
|
||||
success or ModelError (charter §12/§13 — one retry, then fail; the client
|
||||
degrades on any non-200). Built so a streaming mode is an additive path later.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
class ModelError(Exception):
|
||||
"""The model call failed after one retry (transport / timeout / non-2xx / empty)."""
|
||||
|
||||
|
||||
def chat(
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
options: dict,
|
||||
*,
|
||||
think: bool | None = None,
|
||||
client: httpx.Client | None = None,
|
||||
) -> str:
|
||||
payload: dict = {"model": model, "messages": messages, "stream": False, "options": options}
|
||||
if think is not None:
|
||||
payload["think"] = think
|
||||
|
||||
owns = client is None
|
||||
http = client or httpx.Client(
|
||||
base_url=config.ollama_base_url(), timeout=config.ollama_timeout_seconds()
|
||||
)
|
||||
try:
|
||||
last = "no attempt made"
|
||||
for _ in range(2): # one try + one retry (§12: never more than once)
|
||||
try:
|
||||
resp = http.post("/api/chat", json=payload)
|
||||
resp.raise_for_status()
|
||||
content = resp.json().get("message", {}).get("content", "")
|
||||
if content.strip():
|
||||
return content
|
||||
last = "empty response from model"
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
# httpx.HTTPError: TimeoutException, TransportError, HTTPStatusError.
|
||||
# ValueError: resp.json() raises json.JSONDecodeError (a ValueError
|
||||
# subclass) on a 200 with a non-JSON body (e.g. a proxy error page) —
|
||||
# treat that as a failed attempt too, not an uncaught escape (§12/§13).
|
||||
last = f"{type(exc).__name__}: {exc}"
|
||||
raise ModelError(last)
|
||||
finally:
|
||||
if owns:
|
||||
http.close()
|
||||
134
api/app/prompts.py
Normal file
134
api/app/prompts.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Load a role's system prompt (§16 — prompts are source code) and render the
|
||||
canon log into a curated digest for the user message (charter §11).
|
||||
|
||||
The digest is a projection, not a dump: it surfaces narrative context and
|
||||
deliberately omits raw integers — companion dispositions render as names only,
|
||||
luck as its descriptor, and ids/schema_version are dropped (charter §7/§2).
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
||||
|
||||
|
||||
def _humanize(id_: str) -> str:
|
||||
"""Ids are snake_case; the model should read prose. hedge_mage -> hedge-mage."""
|
||||
return id_.replace("_", "-")
|
||||
|
||||
|
||||
def _article(word: str) -> str:
|
||||
if not word:
|
||||
return "a"
|
||||
return "an" if word[:1].lower() in "aeiou" else "a"
|
||||
|
||||
|
||||
def _describe_player(player: dict) -> str:
|
||||
"""'Aldric, a beastfolk cutpurse' — what the AI narrates the player as."""
|
||||
race = _humanize(player.get("race_id", ""))
|
||||
calling = _humanize(player.get("calling_id", ""))
|
||||
name = player.get("name", "")
|
||||
parts = [p for p in (race, calling) if p]
|
||||
if not parts:
|
||||
return name
|
||||
descriptor = " ".join(parts)
|
||||
return f"{name}, {_article(parts[0])} {descriptor}".strip()
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def system_prompt(role: str) -> str:
|
||||
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
|
||||
# Everything below the first '---' line is the model-facing prompt; the
|
||||
# header above it is human/dev front-matter.
|
||||
parts = text.split("\n---\n", 1)
|
||||
body = parts[1] if len(parts) == 2 else text
|
||||
return body.strip()
|
||||
|
||||
|
||||
def render_digest(canon_log: dict) -> str:
|
||||
lines: list[str] = []
|
||||
|
||||
location = canon_log.get("location", {})
|
||||
lines.append(f"Location: {location.get('name', 'an unknown place')}")
|
||||
|
||||
events = canon_log.get("recent_events", [])
|
||||
if events:
|
||||
lines.append("Recent events:")
|
||||
lines += [f"- {e}" for e in events]
|
||||
|
||||
facts = canon_log.get("established_facts", [])
|
||||
if facts:
|
||||
lines.append("Established facts:")
|
||||
lines += [f"- {f}" for f in facts]
|
||||
|
||||
party = canon_log.get("party", [])
|
||||
if party:
|
||||
lines.append("Companions present: " + ", ".join(m.get("name", "") for m in party))
|
||||
|
||||
for quest in canon_log.get("active_quests", []):
|
||||
if quest.get("status") == "active":
|
||||
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
||||
|
||||
player = canon_log.get("player", {})
|
||||
descriptor = player.get("luck_descriptor")
|
||||
if descriptor:
|
||||
lines.append(f"Fortune: {descriptor}")
|
||||
lines.append(f"Player: {_describe_player(player)}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Describe the scene as it is now.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_npc_digest(
|
||||
canon_log: dict,
|
||||
*,
|
||||
persona: str,
|
||||
knowledge: list[str],
|
||||
disposition: int,
|
||||
available_moves: list[str],
|
||||
utterance: str,
|
||||
) -> str:
|
||||
"""User-message digest for the NPC role (§6). Unlike the narrator digest,
|
||||
this deliberately carries the disposition integer (§6 permits it; §7 hides
|
||||
Luck, not disposition) and the closed move vocabulary the NPC may use."""
|
||||
lines: list[str] = []
|
||||
lines.append(f"You are: {persona}")
|
||||
lines.append(
|
||||
f"Your disposition toward the player: {disposition} "
|
||||
f"(-100 hostile, 0 neutral, 100 devoted)."
|
||||
)
|
||||
|
||||
if knowledge:
|
||||
lines.append("")
|
||||
lines.append("What you know (this is ALL you know — do not invent beyond it):")
|
||||
lines += [f"- {k}" for k in knowledge]
|
||||
|
||||
lines.append("")
|
||||
lines.append("Moves you may perform this turn (use ONLY these, as [MOVE: name(args)]):")
|
||||
lines += [f"- {m}" for m in available_moves]
|
||||
|
||||
location = canon_log.get("location", {})
|
||||
lines.append("")
|
||||
lines.append(f"Location: {location.get('name', 'an unknown place')}")
|
||||
|
||||
events = canon_log.get("recent_events", [])
|
||||
if events:
|
||||
lines.append("Recent events:")
|
||||
lines += [f"- {e}" for e in events]
|
||||
|
||||
facts = canon_log.get("established_facts", [])
|
||||
if facts:
|
||||
lines.append("Established facts:")
|
||||
lines += [f"- {f}" for f in facts]
|
||||
|
||||
for quest in canon_log.get("active_quests", []):
|
||||
if quest.get("status") == "active":
|
||||
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
||||
|
||||
player = canon_log.get("player", {})
|
||||
lines.append(f"The player is {_describe_player(player)}.")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f'The player says to you: "{utterance}"')
|
||||
return "\n".join(lines)
|
||||
31
api/app/routing.py
Normal file
31
api/app/routing.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Role → model routing (charter §4/§5). Model choice is config, not law — a
|
||||
deploy, not a client patch. Read at call time so an env override needs no reimport.
|
||||
The provider abstraction (Ollama → Replicate) slots in here later (YAGNI now).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleConfig:
|
||||
model: str
|
||||
options: dict
|
||||
think: bool | None = None # Ollama top-level `think`; None omits it
|
||||
|
||||
|
||||
def for_role(role: str) -> RoleConfig:
|
||||
if role == "narrator":
|
||||
return RoleConfig(
|
||||
model=config.narrator_model(),
|
||||
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300},
|
||||
think=False, # qwen3.x thinking OFF; harmless on non-thinking models
|
||||
)
|
||||
if role == "npc":
|
||||
return RoleConfig(
|
||||
model=config.npc_model(),
|
||||
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 320},
|
||||
think=False,
|
||||
)
|
||||
raise KeyError(f"no routing for role: {role}")
|
||||
33
api/app/version.py
Normal file
33
api/app/version.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Single source of truth for the app version (charter §4: client and API share
|
||||
one version). Reads the repo-root /VERSION file — the ONLY place a human edits the
|
||||
version. Mirrors canon_log._schema_dir's env-override + walk-up so it resolves in
|
||||
a local checkout and (once /VERSION is bundled) an image alike.
|
||||
"""
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
LAST_RESORT = "0.0.0-dev"
|
||||
|
||||
|
||||
def _version_file() -> Path | None:
|
||||
env = os.environ.get("COC_VERSION_FILE")
|
||||
if env:
|
||||
return Path(env)
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
candidate = parent / "VERSION"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_version() -> str:
|
||||
path = _version_file()
|
||||
if path is None or not path.is_file():
|
||||
return LAST_RESORT
|
||||
return path.read_text(encoding="utf-8").strip() or LAST_RESORT
|
||||
|
||||
|
||||
VERSION = get_version()
|
||||
21
api/conftest.py
Normal file
21
api/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Gate `@pytest.mark.live` tests (they hit a real Ollama) behind --run-live so
|
||||
the default suite stays hermetic. Run the live layer with: pytest --run-live
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--run-live", action="store_true", default=False,
|
||||
help="run tests marked @pytest.mark.live (require a real Ollama)",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
if config.getoption("--run-live"):
|
||||
return
|
||||
skip_live = pytest.mark.skip(reason="needs --run-live (real Ollama)")
|
||||
for item in items:
|
||||
if "live" in item.keywords:
|
||||
item.add_marker(skip_live)
|
||||
@@ -2,11 +2,24 @@
|
||||
|
||||
**Role:** Describe scenes, outcomes, transitions. **Quality matters most — use the good model** (charter §5).
|
||||
|
||||
**Input:** scene state + canon log (§11).
|
||||
**Input:** scene state + canon log (§11), rendered as a labeled digest in the user message.
|
||||
**Output:** prose with tags. Never invent a proper noun without emitting `[FACT: ...]` so code can harvest it into `established_facts` (§11).
|
||||
|
||||
**Voice:** dry, gritty not grim, plays the world straight. Never winks. Nothing is "hilariously" anything (§3).
|
||||
|
||||
---
|
||||
|
||||
<!-- Prompt body TBD. This file is source code — version and review changes. -->
|
||||
You are the Narrator of a gritty high-fantasy RPG. You describe the world; you never control the player.
|
||||
|
||||
Given the current scene state below, describe the scene as it is right now, in the second person ("you"), present tense. Cover what the player sees, hears, and smells, and what the people present are doing. Three to five sentences, then stop.
|
||||
|
||||
Voice:
|
||||
- Dry and economical. Gritty, not grim. Play the world straight — never wink, never point out that a thing is funny. Nothing is "hilariously" or "comically" anything.
|
||||
- Profanity and ugliness are allowed when the moment earns them, never as decoration.
|
||||
|
||||
Hard rules:
|
||||
- Do not introduce a proper noun — a person's name, a place name, a named object — unless it already appears below. If you must introduce one, append a tag on its own: `[FACT: <the new fact as a short clause>]`, one per new proper noun, so the game can record it.
|
||||
- Never contradict anything under "Established facts".
|
||||
- Never state a number, a statistic, a hit-point total, or a Luck value. "Fortune" is a feeling, never a figure.
|
||||
- Never decide, narrate, or assume the player's choices, words, or actions. Describe the world and the other characters; leave the player's next move to the player.
|
||||
- Output prose only, plus any `[FACT: ...]` tags. No JSON, no lists, no headings, no notes about what you are doing.
|
||||
|
||||
@@ -18,4 +18,38 @@ Code extracts tags, validates against state, applies valid moves, silently drops
|
||||
|
||||
---
|
||||
|
||||
<!-- Prompt body TBD. This file is source code — version and review changes. -->
|
||||
You voice a single character in a gritty high-fantasy world (the tone is Batman,
|
||||
not Warhammer and not Care Bears). Play the character straight. Never wink at the
|
||||
player; never describe anything as funny. Profanity is allowed when earned.
|
||||
|
||||
You are given: who you are, your disposition toward the player, the complete list
|
||||
of what you know, the moves you may perform this turn, the scene's canon, and
|
||||
what the player just said.
|
||||
|
||||
Two hard rules:
|
||||
|
||||
1. **Knowledge is a closed list.** You know ONLY what "What you know" tells you.
|
||||
Do not invent people, places, quests, or facts beyond it. If you must name
|
||||
something new that becomes true in the world, emit it as `[FACT: ...]` so the
|
||||
game can record it — but prefer to stay within what you know.
|
||||
|
||||
2. **Moves are a closed vocabulary.** You may *say* anything in character, but you
|
||||
may only *do* something by emitting an inline tag, and only from the moves
|
||||
listed for this turn. Use exactly this form:
|
||||
|
||||
```
|
||||
[MOVE: reveal(varrell_twins)]
|
||||
[MOVE: offer_quest(find_the_ledger)]
|
||||
[MOVE: adjust_disposition(+5)]
|
||||
[MOVE: refuse()]
|
||||
[MOVE: end_conversation()]
|
||||
[MOVE: become_hostile()]
|
||||
```
|
||||
|
||||
Emit zero or more move tags, placed inline where they happen. If a move is not
|
||||
in your list for this turn, do not emit it — the game will drop it anyway.
|
||||
Let your disposition colour your tone: low disposition is curt and suspicious,
|
||||
high disposition is warm and forthcoming.
|
||||
|
||||
Return prose in your character's voice, with any move/fact tags inline. Do not
|
||||
explain the tags or break character to describe them.
|
||||
|
||||
@@ -3,3 +3,5 @@ pythonpath = .
|
||||
testpaths = tests
|
||||
filterwarnings =
|
||||
ignore:Using `httpx` with `starlette.testclient` is deprecated
|
||||
markers =
|
||||
live: exercises a real Ollama; skipped unless --run-live
|
||||
|
||||
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" },
|
||||
|
||||
56
api/tests/test_call_log.py
Normal file
56
api/tests/test_call_log.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
|
||||
from app.call_log import record
|
||||
|
||||
|
||||
def test_success_record_shape():
|
||||
captured = []
|
||||
rec = record(
|
||||
role="narrator", model="qwen3.5:latest", options={"seed": 7},
|
||||
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
|
||||
ok=True, latency_ms=120, response="prose", write=captured.append,
|
||||
)
|
||||
assert len(captured) == 1
|
||||
parsed = json.loads(captured[0])
|
||||
assert parsed["ok"] is True
|
||||
assert parsed["response"] == "prose"
|
||||
assert parsed["options"]["seed"] == 7
|
||||
assert parsed["role"] == "narrator"
|
||||
assert parsed["canon_log"] == {"a": 1}
|
||||
assert "error" not in parsed
|
||||
assert "ts" in parsed
|
||||
assert rec["response"] == "prose"
|
||||
|
||||
|
||||
def test_failure_record_shape():
|
||||
captured = []
|
||||
record(
|
||||
role="narrator", model="m", options={}, messages=[], canon_log={},
|
||||
ok=False, latency_ms=5, error="ModelError: down", write=captured.append,
|
||||
)
|
||||
parsed = json.loads(captured[0])
|
||||
assert parsed["ok"] is False
|
||||
assert parsed["error"] == "ModelError: down"
|
||||
assert "response" not in parsed
|
||||
|
||||
|
||||
def test_default_write_failure_does_not_propagate(monkeypatch, capsys):
|
||||
"""§13 — a good narration must never become a 500 because the log write
|
||||
failed. Point CALL_LOG_PATH at a path whose parent directory does not
|
||||
exist, so the default sink's `open()` raises. record() must swallow it,
|
||||
report to stderr (never stdout — stdout may be the log sink itself), and
|
||||
still return the record.
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"CALL_LOG_PATH", "/nonexistent-dir-for-test/does/not/exist.log"
|
||||
)
|
||||
rec = record(
|
||||
role="narrator", model="qwen3.5:latest", options={"seed": 7},
|
||||
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
|
||||
ok=True, latency_ms=120, response="prose",
|
||||
)
|
||||
assert rec["response"] == "prose"
|
||||
assert rec["ok"] is True
|
||||
out, err = capsys.readouterr()
|
||||
assert out == ""
|
||||
assert err != ""
|
||||
@@ -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) != []
|
||||
|
||||
41
api/tests/test_content_npc.py
Normal file
41
api/tests/test_content_npc.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
|
||||
import app.content as content
|
||||
|
||||
|
||||
def test_load_npc_returns_parsed_file(tmp_path, monkeypatch):
|
||||
root = tmp_path / "content"
|
||||
(root / "world" / "npcs").mkdir(parents=True)
|
||||
(root / "world" / "npcs" / "fenn.json").write_text(
|
||||
json.dumps({"id": "fenn", "name": "Fenn", "persona": "clerk",
|
||||
"knowledge": ["a"], "capabilities": {}})
|
||||
)
|
||||
monkeypatch.setenv("CONTENT_ROOT", str(root))
|
||||
npc = content.load_npc("fenn")
|
||||
assert npc["name"] == "Fenn"
|
||||
assert npc["knowledge"] == ["a"]
|
||||
|
||||
|
||||
def test_load_npc_unknown_returns_none(tmp_path, monkeypatch):
|
||||
(tmp_path / "content" / "world").mkdir(parents=True)
|
||||
monkeypatch.setenv("CONTENT_ROOT", str(tmp_path / "content"))
|
||||
assert content.load_npc("nobody") is None
|
||||
|
||||
|
||||
def test_content_root_env_override(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONTENT_ROOT", str(tmp_path))
|
||||
assert content._content_root() == tmp_path
|
||||
|
||||
|
||||
def test_fenn_resolves_from_repo_content():
|
||||
# No CONTENT_ROOT env — exercises the walk-up resolver against real content.
|
||||
npc = content.load_npc("fenn")
|
||||
assert npc is not None
|
||||
assert npc["capabilities"]["offerable_quests"] == ["find_the_ledger"]
|
||||
assert npc["knowledge"] # non-empty authored knowledge list
|
||||
|
||||
|
||||
def test_npc_prompt_body_is_authored():
|
||||
from app import prompts
|
||||
body = prompts.system_prompt("npc")
|
||||
assert "MOVE" in body and len(body) > 200
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.narrate as narrate
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
@@ -14,13 +16,30 @@ ROLE_ENDPOINTS = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_narrate_model_call(monkeypatch):
|
||||
# /dm/narrate is wired to a real Ollama call (Task 7). These tests exercise
|
||||
# canon-log validation across ALL role endpoints, not model behavior, so
|
||||
# stub the model boundary — keeps the default suite hermetic (no network).
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "stubbed prose")
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
|
||||
|
||||
|
||||
def test_health_ok():
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_every_role_endpoint_accepts_a_valid_canon_log():
|
||||
for path in ROLE_ENDPOINTS:
|
||||
r = client.post(path, json={"canon_log": VALID_LOG})
|
||||
if path == "/npc/speak":
|
||||
# /npc/speak has additional required fields
|
||||
body = {
|
||||
"canon_log": VALID_LOG, "npc_id": "brannoc_thane",
|
||||
"disposition": 0, "available_moves": [], "utterance": "test"
|
||||
}
|
||||
else:
|
||||
body = {"canon_log": VALID_LOG}
|
||||
r = client.post(path, json=body)
|
||||
assert r.status_code == 200, f"{path} rejected a valid log: {r.text}"
|
||||
|
||||
|
||||
@@ -28,7 +47,15 @@ def test_every_role_endpoint_rejects_an_invalid_canon_log():
|
||||
bad = json.loads(json.dumps(VALID_LOG))
|
||||
bad["player"]["luck"] = 5 # §7 leak
|
||||
for path in ROLE_ENDPOINTS:
|
||||
r = client.post(path, json={"canon_log": bad})
|
||||
if path == "/npc/speak":
|
||||
# /npc/speak has additional required fields
|
||||
body = {
|
||||
"canon_log": bad, "npc_id": "brannoc_thane",
|
||||
"disposition": 0, "available_moves": [], "utterance": "test"
|
||||
}
|
||||
else:
|
||||
body = {"canon_log": bad}
|
||||
r = client.post(path, json=body)
|
||||
assert r.status_code == 422, f"{path} accepted an invalid log"
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
|
||||
|
||||
57
api/tests/test_live_smoke.py
Normal file
57
api/tests/test_live_smoke.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import app.narrate as narrate
|
||||
import app.npc as npc
|
||||
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_narrator_returns_real_prose():
|
||||
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the wire end-to-end:
|
||||
prompt assembly → model → non-empty prose. Not asserted: tag content
|
||||
(nondeterministic). Run with: pytest --run-live
|
||||
"""
|
||||
text = narrate.run(VALID)
|
||||
assert isinstance(text, str)
|
||||
assert len(text.strip()) > 40 # real prose, not a blip
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_live_npc_speak_fenn():
|
||||
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the /npc/speak wire
|
||||
end-to-end against the authored "fenn" NPC: persona + knowledge digest →
|
||||
model → non-empty in-voice prose. Not asserted: tag content
|
||||
(nondeterministic). Run with: pytest --run-live
|
||||
"""
|
||||
log = {
|
||||
"player": {
|
||||
"name": "Aldric",
|
||||
"race_id": "human",
|
||||
"calling_id": "sellsword",
|
||||
"luck_descriptor": "the dice are kind",
|
||||
},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
"party": [],
|
||||
"recent_events": [],
|
||||
"established_facts": [],
|
||||
"active_quests": [],
|
||||
"humiliations": [],
|
||||
}
|
||||
available = [
|
||||
"offer_quest(find_the_ledger)",
|
||||
"reveal(varrell_twins)",
|
||||
"reveal(fenns_debt)",
|
||||
"adjust_disposition",
|
||||
"refuse",
|
||||
"end_conversation",
|
||||
"become_hostile",
|
||||
]
|
||||
prose = npc.run(log, "fenn", 0, available, "I heard you lost something. What happened?")
|
||||
assert isinstance(prose, str)
|
||||
assert len(prose.strip()) > 40 # real prose, not a blip
|
||||
# No Luck numbers/mechanics leaked into NPC dialogue (§7).
|
||||
assert "luck" not in prose.lower()
|
||||
43
api/tests/test_narrate.py
Normal file
43
api/tests/test_narrate.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import app.narrate as narrate
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
def test_run_returns_prose_and_logs_success(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_chat(model, messages, options, *, think=None):
|
||||
seen.update(model=model, messages=messages, options=options, think=think)
|
||||
return "You stand on the wharf. [FACT: the gulls have gone quiet]"
|
||||
|
||||
logs = []
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", fake_chat)
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
|
||||
|
||||
out = narrate.run(VALID)
|
||||
assert out.startswith("You stand on the wharf.")
|
||||
assert seen["model"] == "qwen3.5:latest"
|
||||
assert seen["think"] is False
|
||||
assert seen["messages"][0]["role"] == "system"
|
||||
assert seen["messages"][1]["role"] == "user"
|
||||
assert "seed" in seen["options"]
|
||||
assert logs and logs[0]["ok"] is True and logs[0]["response"] == out
|
||||
|
||||
|
||||
def test_run_logs_failure_and_reraises(monkeypatch):
|
||||
def boom(*args, **kwargs):
|
||||
raise ModelError("upstream down")
|
||||
|
||||
logs = []
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
narrate.run(VALID)
|
||||
assert logs and logs[0]["ok"] is False and "down" in logs[0]["error"]
|
||||
39
api/tests/test_narrate_endpoint.py
Normal file
39
api/tests/test_narrate_endpoint.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
import app.narrate as narrate
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
client = TestClient(main.app)
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
def test_valid_log_returns_prose(monkeypatch):
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "You stand on the wharf.")
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None) # keep test output pristine
|
||||
r = client.post("/dm/narrate", json={"canon_log": VALID})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"prose": "You stand on the wharf."}
|
||||
|
||||
|
||||
def test_model_error_maps_to_502(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise ModelError("upstream down")
|
||||
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
|
||||
r = client.post("/dm/narrate", json={"canon_log": VALID})
|
||||
assert r.status_code == 502
|
||||
assert "model_error" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_invalid_log_still_422(monkeypatch):
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "x")
|
||||
bad = json.loads(json.dumps(VALID))
|
||||
bad["player"]["luck"] = 5 # §7 leak — schema rejects
|
||||
r = client.post("/dm/narrate", json={"canon_log": bad})
|
||||
assert r.status_code == 422
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
55
api/tests/test_npc.py
Normal file
55
api/tests/test_npc.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
|
||||
import app.npc as npc
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
LOG = {
|
||||
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
"party": [], "recent_events": [], "established_facts": [],
|
||||
"active_quests": [], "humiliations": [],
|
||||
}
|
||||
FENN = {"id": "fenn", "name": "Fenn", "persona": "A clerk.",
|
||||
"knowledge": ["The ledger is gone."], "capabilities": {}}
|
||||
|
||||
|
||||
def test_run_returns_prose_and_logs(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_chat(model, messages, options, *, think=None):
|
||||
seen.update(messages=messages, think=think)
|
||||
return 'Aye. [MOVE: reveal(varrell_twins)] [FACT: the ledger is gone]'
|
||||
|
||||
logs = []
|
||||
monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
|
||||
monkeypatch.setattr(npc.ollama_client, "chat", fake_chat)
|
||||
monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
|
||||
|
||||
out = npc.run(LOG, "fenn", -10, ["reveal(varrell_twins)", "refuse"], "What happened?")
|
||||
assert out.startswith("Aye.")
|
||||
# persona + knowledge reached the model; utterance is present
|
||||
user_msg = seen["messages"][1]["content"]
|
||||
assert "A clerk." in user_msg and "The ledger is gone." in user_msg
|
||||
assert "What happened?" in user_msg
|
||||
assert seen["think"] is False
|
||||
assert logs and logs[0]["ok"] is True and logs[0]["role"] == "npc"
|
||||
|
||||
|
||||
def test_run_unknown_npc_raises(monkeypatch):
|
||||
monkeypatch.setattr(npc, "load_npc", lambda i: None)
|
||||
with pytest.raises(npc.UnknownNpc):
|
||||
npc.run(LOG, "nobody", 0, [], "hi")
|
||||
|
||||
|
||||
def test_run_logs_failure_and_reraises(monkeypatch):
|
||||
monkeypatch.setattr(npc, "load_npc", lambda i: FENN)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise ModelError("down")
|
||||
|
||||
logs = []
|
||||
monkeypatch.setattr(npc.ollama_client, "chat", boom)
|
||||
monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw))
|
||||
with pytest.raises(ModelError):
|
||||
npc.run(LOG, "fenn", 0, [], "hi")
|
||||
assert logs and logs[0]["ok"] is False
|
||||
59
api/tests/test_npc_endpoint.py
Normal file
59
api/tests/test_npc_endpoint.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
import app.npc as npc
|
||||
|
||||
client = TestClient(main.app)
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
def _body(**over):
|
||||
b = {"canon_log": VALID, "npc_id": "fenn", "disposition": 0,
|
||||
"available_moves": ["refuse"], "utterance": "hello"}
|
||||
b.update(over)
|
||||
return b
|
||||
|
||||
|
||||
def test_valid_request_returns_prose(monkeypatch):
|
||||
monkeypatch.setattr(main, "npc_run", lambda **k: "Aye, well met.")
|
||||
r = client.post("/npc/speak", json=_body())
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"prose": "Aye, well met."}
|
||||
|
||||
|
||||
def test_unknown_npc_maps_to_422(monkeypatch):
|
||||
def boom(**k):
|
||||
raise npc.UnknownNpc("nobody")
|
||||
monkeypatch.setattr(main, "npc_run", boom)
|
||||
r = client.post("/npc/speak", json=_body(npc_id="nobody"))
|
||||
assert r.status_code == 422
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_model_error_maps_to_502(monkeypatch):
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
def boom(**k):
|
||||
raise ModelError("down")
|
||||
monkeypatch.setattr(main, "npc_run", boom)
|
||||
r = client.post("/npc/speak", json=_body())
|
||||
assert r.status_code == 502
|
||||
assert "model_error" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_invalid_log_still_422():
|
||||
bad = json.loads(json.dumps(VALID))
|
||||
bad["player"]["luck"] = 5 # §7 leak — schema rejects
|
||||
r = client.post("/npc/speak", json=_body(canon_log=bad))
|
||||
assert r.status_code == 422
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_missing_field_is_422():
|
||||
b = _body()
|
||||
del b["utterance"]
|
||||
r = client.post("/npc/speak", json=b)
|
||||
assert r.status_code == 422
|
||||
95
api/tests/test_ollama_client.py
Normal file
95
api/tests/test_ollama_client.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.ollama_client import ModelError, chat
|
||||
|
||||
|
||||
def _client(handler):
|
||||
return httpx.Client(transport=httpx.MockTransport(handler), base_url="http://ollama")
|
||||
|
||||
|
||||
def test_success_returns_content_and_sends_payload():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"message": {"content": "You stand on the wharf."}})
|
||||
|
||||
out = chat("qwen3.5:latest", [{"role": "user", "content": "hi"}], {"seed": 7},
|
||||
think=False, client=_client(handler))
|
||||
assert out == "You stand on the wharf."
|
||||
assert seen["body"]["model"] == "qwen3.5:latest"
|
||||
assert seen["body"]["stream"] is False
|
||||
assert seen["body"]["options"]["seed"] == 7
|
||||
assert seen["body"]["think"] is False
|
||||
|
||||
|
||||
def test_think_omitted_when_none():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"message": {"content": "ok"}})
|
||||
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
assert "think" not in seen["body"]
|
||||
|
||||
|
||||
def test_retries_once_then_succeeds():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise httpx.ConnectError("boom")
|
||||
return httpx.Response(200, json={"message": {"content": "recovered"}})
|
||||
|
||||
out = chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
assert out == "recovered"
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_two_transport_failures_raise_modelerror():
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
|
||||
|
||||
def test_timeout_exhausted_raises_modelerror():
|
||||
def handler(request):
|
||||
raise httpx.TimeoutException("slow")
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
|
||||
|
||||
def test_non_2xx_retried_then_modelerror():
|
||||
def handler(request):
|
||||
return httpx.Response(500, json={"error": "nope"})
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
|
||||
|
||||
def test_empty_content_retried_then_modelerror():
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"message": {"content": " "}})
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
|
||||
|
||||
def test_non_json_200_retried_then_modelerror():
|
||||
"""A 200 response with a non-JSON body (e.g. a proxy error page) must
|
||||
degrade to ModelError after the one retry, not escape as a bare
|
||||
JSONDecodeError/ValueError (charter §12/§13).
|
||||
"""
|
||||
def handler(request):
|
||||
return httpx.Response(200, text="<html>not json</html>")
|
||||
|
||||
with pytest.raises(ModelError):
|
||||
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
|
||||
@@ -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) != []
|
||||
|
||||
|
||||
|
||||
102
api/tests/test_prompts.py
Normal file
102
api/tests/test_prompts.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.prompts import render_digest, system_prompt
|
||||
|
||||
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
||||
|
||||
|
||||
def test_system_prompt_strips_the_header():
|
||||
body = system_prompt("narrator")
|
||||
assert isinstance(body, str) and body.strip() != ""
|
||||
# The human-facing markdown title above the '---' is not part of the prompt.
|
||||
assert "# Narrator prompt" not in body
|
||||
|
||||
|
||||
def test_digest_has_the_narrative_context():
|
||||
d = render_digest(VALID)
|
||||
assert "Location: the Greywater docks" in d
|
||||
assert "Arrived at Greywater by barge before dawn" in d
|
||||
assert "Established facts:" in d
|
||||
assert "the harbourmaster is named Oda Fenn" in d
|
||||
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
|
||||
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
|
||||
assert "Fortune: Fortune spits on you" in d
|
||||
assert "Player: Aldric, a human sellsword" in d
|
||||
assert d.rstrip().endswith("Describe the scene as it is now.")
|
||||
|
||||
|
||||
def test_digest_omits_raw_numbers_and_ids():
|
||||
d = render_digest(VALID)
|
||||
# §7/§2: no disposition integers, no ids, no schema_version leak into the prompt.
|
||||
assert "disposition" not in d
|
||||
assert "40" not in d and "15" not in d # the deserter dispositions
|
||||
assert "brannoc_thane" not in d # ids stay out; names only
|
||||
assert "schema_version" not in d
|
||||
|
||||
|
||||
def test_narrator_body_is_authored():
|
||||
body = system_prompt.__wrapped__("narrator") # bypass lru_cache in case of prior load
|
||||
assert "TBD" not in body
|
||||
assert "[FACT:" in body # the tag instruction is present
|
||||
assert "second person" in body # the core task is present
|
||||
|
||||
|
||||
def _with_player(**player) -> dict:
|
||||
log = json.loads(json.dumps(VALID)) # deep copy — VALID is module-level and shared
|
||||
log["player"] = {"luck_descriptor": "the dice are kind", **player}
|
||||
return log
|
||||
|
||||
|
||||
def test_digest_names_the_race_and_the_calling():
|
||||
out = render_digest(_with_player(name="Aldric", race_id="beastfolk", calling_id="cutpurse"))
|
||||
assert "Aldric, a beastfolk cutpurse" in out
|
||||
|
||||
|
||||
def test_digest_says_an_elf_not_a_elf():
|
||||
out = render_digest(_with_player(name="Aldric", race_id="elf", calling_id="sellsword"))
|
||||
assert "an elf sellsword" in out
|
||||
|
||||
|
||||
def test_digest_does_not_leak_a_snake_case_id():
|
||||
out = render_digest(_with_player(name="Aldric", race_id="human", calling_id="hedge_mage"))
|
||||
assert "hedge_mage" not in out, "the model must never read a raw snake_case id"
|
||||
assert "hedge-mage" in out
|
||||
|
||||
|
||||
def test_digest_omits_race_clause_when_race_is_empty():
|
||||
# Regression: _article("") used to return "an" (Python's "" in "aeiou" is
|
||||
# True), rendering "Player: Aldric, an " — wrong article, doubled space.
|
||||
out = render_digest(_with_player(name="Aldric", race_id="", calling_id="sellsword"))
|
||||
assert "Player: Aldric, a sellsword" in out
|
||||
assert " " not in out
|
||||
assert ", an " not in out
|
||||
|
||||
|
||||
def test_digest_omits_both_clauses_when_race_and_calling_are_empty():
|
||||
out = render_digest(_with_player(name="Aldric", race_id="", calling_id=""))
|
||||
assert "\nPlayer: Aldric\n" in out
|
||||
assert " " not in out
|
||||
|
||||
|
||||
def test_render_npc_digest_includes_all_sections():
|
||||
from app import prompts
|
||||
log = {
|
||||
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "the dice are kind"},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
"party": [], "recent_events": ["a gull screamed"],
|
||||
"established_facts": ["the eastern bridge is out"],
|
||||
"active_quests": [], "humiliations": [],
|
||||
}
|
||||
out = prompts.render_npc_digest(
|
||||
log, persona="A harried clerk.", knowledge=["The ledger is gone."],
|
||||
disposition=-10, available_moves=["reveal(varrell_twins)", "refuse"],
|
||||
utterance="What happened here?",
|
||||
)
|
||||
assert "A harried clerk." in out
|
||||
assert "-10" in out
|
||||
assert "The ledger is gone." in out
|
||||
assert "reveal(varrell_twins)" in out
|
||||
assert "Greywater Docks" in out
|
||||
assert "the eastern bridge is out" in out
|
||||
assert out.rstrip().endswith('What happened here?"') # utterance last
|
||||
29
api/tests/test_routing.py
Normal file
29
api/tests/test_routing.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from app.routing import for_role
|
||||
|
||||
|
||||
def test_narrator_defaults():
|
||||
cfg = for_role("narrator")
|
||||
assert cfg.model == "qwen3.5:latest"
|
||||
assert cfg.think is False
|
||||
assert cfg.options["num_predict"] == 300
|
||||
assert cfg.options["temperature"] == 0.8
|
||||
|
||||
|
||||
def test_env_overrides_model(monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_NARRATOR_MODEL", "llama3.1:latest")
|
||||
assert for_role("narrator").model == "llama3.1:latest"
|
||||
|
||||
|
||||
def test_unknown_role_raises():
|
||||
with pytest.raises(KeyError):
|
||||
for_role("wizard")
|
||||
|
||||
|
||||
def test_npc_role_routes_to_npc_model(monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_NPC_MODEL", "some-npc-model")
|
||||
cfg = for_role("npc")
|
||||
assert cfg.model == "some-npc-model"
|
||||
assert cfg.think is False
|
||||
assert "num_predict" in cfg.options
|
||||
22
api/tests/test_version.py
Normal file
22
api/tests/test_version.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.version import VERSION, get_version
|
||||
|
||||
client = TestClient(app)
|
||||
ROOT_VERSION = Path(__file__).resolve().parents[2] / "VERSION"
|
||||
|
||||
|
||||
def test_get_version_matches_file():
|
||||
assert ROOT_VERSION.is_file()
|
||||
assert get_version() == ROOT_VERSION.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def test_app_version_matches():
|
||||
assert app.version == VERSION == get_version()
|
||||
|
||||
|
||||
def test_version_endpoint():
|
||||
assert client.get("/version").json() == {"version": VERSION}
|
||||
3
clean_superpowers.sh
Executable file
3
clean_superpowers.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/env bash
|
||||
rm .superpowers/sdd/*.diff
|
||||
rm .superpowers/sdd/*.md
|
||||
BIN
client/assets/theme/fonts/ArchitectsDaughter-Regular.ttf
Normal file
BIN
client/assets/theme/fonts/ArchitectsDaughter-Regular.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://dgftorjwjkcx6"
|
||||
path="res://.godot/imported/ArchitectsDaughter-Regular.ttf-39fe9a11a65cddcd9ceecf51a839f0c5.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
|
||||
dest_files=["res://.godot/imported/ArchitectsDaughter-Regular.ttf-39fe9a11a65cddcd9ceecf51a839f0c5.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://bosx65t7inqbs"
|
||||
path="res://.godot/imported/EBGaramond-Italic-VariableFont_wght.ttf-8a9f89c45d1261f74ac5e087dfe5ec85.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
|
||||
dest_files=["res://.godot/imported/EBGaramond-Italic-VariableFont_wght.ttf-8a9f89c45d1261f74ac5e087dfe5ec85.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
BIN
client/assets/theme/fonts/EBGaramond-VariableFont_wght.ttf
Normal file
BIN
client/assets/theme/fonts/EBGaramond-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://b00hfuuyxctc6"
|
||||
path="res://.godot/imported/EBGaramond-VariableFont_wght.ttf-32cc8b7204b14062fe5657f6ee3ae93b.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
|
||||
dest_files=["res://.godot/imported/EBGaramond-VariableFont_wght.ttf-32cc8b7204b14062fe5657f6ee3ae93b.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
BIN
client/assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf
Normal file
BIN
client/assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://1ge2menmepj8"
|
||||
path="res://.godot/imported/JetBrainsMono-VariableFont_wght.ttf-9b8836092a0b124cd7b65994f8da4002.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
|
||||
dest_files=["res://.godot/imported/JetBrainsMono-VariableFont_wght.ttf-9b8836092a0b124cd7b65994f8da4002.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
758
client/assets/theme/game_theme.tres
Normal file
758
client/assets/theme/game_theme.tres
Normal file
@@ -0,0 +1,758 @@
|
||||
[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/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="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="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
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.22)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.12)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.32)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
|
||||
[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
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.22745098, 0.20784314, 0.18039216, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_4kten"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.05490196, 0.043137256, 0.03529412, 0.85)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6627451, 0.5176471, 0.24705882, 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_0fjdt"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.41960785, 0.38039216, 0.32156864, 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_ns3kg"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.039215688, 0.03137255, 0.023529412, 0.9)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6627451, 0.5176471, 0.24705882, 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_jly1r"]
|
||||
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 = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
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_lw8lk"]
|
||||
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)
|
||||
draw_center = false
|
||||
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 = 6
|
||||
corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
|
||||
[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.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 = 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.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_oi067"]
|
||||
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="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_5u8fh"]
|
||||
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.6627451, 0.5803922, 0.39215687, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.47843137, 0.4392157, 0.3764706, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.47843137, 0.4392157, 0.3764706, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
|
||||
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.56078434, 0.22745098, 0.20392157, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"]
|
||||
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 = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[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.47843137, 0.18431373, 0.16078432, 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 = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"]
|
||||
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
|
||||
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.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="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.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_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="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
|
||||
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_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
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.47843137, 0.18431373, 0.16078432, 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
|
||||
|
||||
[resource]
|
||||
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 = 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_2tooq")
|
||||
DockButton/base_type = &"Button"
|
||||
DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1)
|
||||
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("2_4lwr2")
|
||||
ItemTile/base_type = &"PanelContainer"
|
||||
ItemTile/styles/panel = SubResource("StyleBoxFlat_jly1r")
|
||||
ItemTileEmpty/base_type = &"PanelContainer"
|
||||
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 = 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_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_utt73")
|
||||
ParchmentInset/base_type = &"PanelContainer"
|
||||
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_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 = 22
|
||||
RichTextLabel/fonts/italics_font = ExtResource("4_i0b23")
|
||||
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_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_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("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("2_4lwr2")
|
||||
21
client/assets/theme/shaders/dark_bay.gdshader
Normal file
21
client/assets/theme/shaders/dark_bay.gdshader
Normal file
@@ -0,0 +1,21 @@
|
||||
shader_type canvas_item;
|
||||
uniform vec4 stripe_a : source_color;
|
||||
uniform vec4 stripe_b : source_color;
|
||||
uniform float band_px = 16.0; // 16px bands per README
|
||||
uniform float vignette = 0.55; // edge darkening strength
|
||||
void fragment() {
|
||||
// Coordinate spaces differ on purpose: the stripe uses FRAGCOORD (screen-space,
|
||||
// not node-local), while the vignette below uses UV (node-local, 0..1 across
|
||||
// this node's rect). An offset DarkBay instance therefore keeps its stripes
|
||||
// anchored to the screen rather than restarting at the node's own origin —
|
||||
// intended for full-bleed use.
|
||||
// 135deg diagonal stripe: sum of pixel coords, banded.
|
||||
vec2 px = FRAGCOORD.xy;
|
||||
float diag = px.x + px.y;
|
||||
float band = mod(floor(diag / band_px), 2.0);
|
||||
vec4 base = mix(stripe_a, stripe_b, band);
|
||||
// radial vignette centred slightly high (README: 50% 38%).
|
||||
float d = distance(UV, vec2(0.5, 0.38));
|
||||
base.rgb *= 1.0 - clamp(d * vignette, 0.0, 1.0);
|
||||
COLOR = base;
|
||||
}
|
||||
1
client/assets/theme/shaders/dark_bay.gdshader.uid
Normal file
1
client/assets/theme/shaders/dark_bay.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://jb8clyworwr1
|
||||
6
client/assets/theme/shaders/parchment.gdshader
Normal file
6
client/assets/theme/shaders/parchment.gdshader
Normal file
@@ -0,0 +1,6 @@
|
||||
shader_type canvas_item;
|
||||
uniform vec4 top : source_color;
|
||||
uniform vec4 bottom : source_color;
|
||||
void fragment() {
|
||||
COLOR = mix(top, bottom, UV.y);
|
||||
}
|
||||
1
client/assets/theme/shaders/parchment.gdshader.uid
Normal file
1
client/assets/theme/shaders/parchment.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b2heb6jhvwxxl
|
||||
15
client/assets/theme/shaders/title_background.gdshader
Normal file
15
client/assets/theme/shaders/title_background.gdshader
Normal file
@@ -0,0 +1,15 @@
|
||||
shader_type canvas_item;
|
||||
// Warm radial for the Title screen (mock: radial-gradient warm centre -> near
|
||||
// black edge, centred at 70% 20%). Colours are Palette uniforms set by the title
|
||||
// script (the DarkBay precedent), so no hex lives in the shader.
|
||||
uniform vec4 warm : source_color;
|
||||
uniform vec4 mid : source_color;
|
||||
uniform vec4 edge : source_color;
|
||||
uniform vec2 center = vec2(0.7, 0.2);
|
||||
|
||||
void fragment() {
|
||||
float d = distance(UV, center);
|
||||
vec3 c = mix(warm.rgb, mid.rgb, clamp(d / 0.45, 0.0, 1.0));
|
||||
c = mix(c, edge.rgb, clamp((d - 0.45) / 0.55, 0.0, 1.0));
|
||||
COLOR = vec4(c, 1.0);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfrs0nt60f2mb
|
||||
6
client/assets/theme/shaders/vignette.gdshader
Normal file
6
client/assets/theme/shaders/vignette.gdshader
Normal file
@@ -0,0 +1,6 @@
|
||||
shader_type canvas_item;
|
||||
uniform float strength = 0.62;
|
||||
void fragment() {
|
||||
float d = distance(UV, vec2(0.5));
|
||||
COLOR = vec4(0.0, 0.0, 0.0, clamp(d * strength, 0.0, 1.0));
|
||||
}
|
||||
1
client/assets/theme/shaders/vignette.gdshader.uid
Normal file
1
client/assets/theme/shaders/vignette.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b4tami4peyxaw
|
||||
14
client/content/considering.json
Normal file
14
client/content/considering.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"considering": [
|
||||
"The Master considers your words.",
|
||||
"Somewhere, dice are gathered.",
|
||||
"The tale weighs its next turn.",
|
||||
"The dark shifts, thinking.",
|
||||
"A page is turned, unhurried.",
|
||||
"The fabric of time is being rewoven.",
|
||||
"The threads of fate are being rearranged.",
|
||||
"Time slows its turning.",
|
||||
"Gods whisper in the darkness.",
|
||||
"Compiling the chronicle of your actions."
|
||||
]
|
||||
}
|
||||
5
client/content/fallback/narrator.json
Normal file
5
client/content/fallback/narrator.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"narrator": [
|
||||
"The rain keeps on. The room ahead is dark and colder than it should be, and whatever is in it has been there a while."
|
||||
]
|
||||
}
|
||||
5
client/content/fallback/npc.json
Normal file
5
client/content/fallback/npc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"npc": [
|
||||
"The figure looks you over, says nothing you can use, and turns back to their work."
|
||||
]
|
||||
}
|
||||
@@ -11,3 +11,72 @@ Put here:
|
||||
- HTTP client + fallback-degradation handling (§13)
|
||||
|
||||
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
|
||||
|
||||
## UI scene conventions (shell + all screens)
|
||||
|
||||
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`, 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
|
||||
scene root (`theme = game_theme.tres`) and set each node's `theme_type_variation`
|
||||
(from `ThemeKeys`) in the editor, so the scene previews themed. Instance
|
||||
sub-screens (`PackedScene` ext_resource) rather than rebuilding them.
|
||||
- **The script does on-load work only.** `@onready var x := $Path/To/Node` refs,
|
||||
signal/service wiring, animations, and binding state into the authored nodes
|
||||
(label text, colours from `Palette`). Do **not** create nodes in `_ready()`.
|
||||
- **Data-driven repeats = fixed authored slots, bound at runtime.** Author enough
|
||||
turn-order tokens / consumable slots / etc. in the editor; the script fills the
|
||||
first N from state and hides the rest. Previewable now; systems write them later.
|
||||
- **Scene-root sizing.** Give the root a fixed size via offsets (e.g. 1920×1080)
|
||||
for editor preview, but **no full-rect anchors on the root** — a root Control run
|
||||
via F6 collapses to (0,0) under full-rect anchors. Keep `size = get_viewport_rect().size`
|
||||
on `_ready` + a `size_changed` connection (the M3-a collapse lesson).
|
||||
- **Button styling → Button-base variations only.** Never put a PanelContainer
|
||||
variation (`DarkPanel`, `ParchmentCard`) on a `Button`; it has no `normal/hover/
|
||||
pressed` styleboxes so the button renders unskinned. Use the Button-base
|
||||
variations (`PrimaryCTA`, `Tab`, `TabActive`, `Chip`, `DockButton`, `ParchmentButton`).
|
||||
- **`RichTextLabel` prose** reads its dark serif ink from the theme's base
|
||||
`RichTextLabel` style (it does *not* inherit `default_font`/`default_color`).
|
||||
Just add the `RichTextLabel` — don't override its colour per-node.
|
||||
- **Theme is generated.** All colours live in `palette.gd`; `game_theme.tres` is
|
||||
built by `scripts/theme/build_game_theme.gd` (source of truth). To add/adjust a
|
||||
variation, edit the builder + `theme_keys.gd`, then regenerate:
|
||||
`godot --headless -s res://scripts/theme/build_game_theme.gd`. Never hand-edit
|
||||
the `.tres` or put a hex literal in a script or `.tscn`.
|
||||
- **Wiring the DM loop / injectable services.** Follow the shell: a `service`
|
||||
var settable *between* `instantiate()` and `add_child()` lets tests inject a
|
||||
`FakeTransport`-backed `DmService` so on-load calls stay hermetic; `_ready`
|
||||
builds the real `HttpTransport`-backed one when none was injected.
|
||||
- **Visual gate.** Headless GUT proves structure (nodes mount, sizes non-zero,
|
||||
bindings run); it cannot prove rendering. Every screen needs a **human F6
|
||||
eyeball** before it's done. Note shader surfaces (`DarkBay`, vignette) render
|
||||
their texture only on an F6 run, not in the editor.
|
||||
|
||||
## Narrate live smoke (M2 client HTTP loop)
|
||||
|
||||
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
|
||||
loop. It is not the game UI.
|
||||
|
||||
**Prereqs:** the proxy running and Ollama reachable.
|
||||
|
||||
1. Start the proxy: from `api/`, `uvicorn app.main:app --port 8000` (with the
|
||||
`.env` from `.env.example`; Ollama up at `OLLAMA_BASE_URL`).
|
||||
2. In the Godot editor, open `res://scenes/narrate_harness.tscn` and run it (F6).
|
||||
3. Click **Narrate this scene**. Expect grounded prose in the Narrator's voice
|
||||
(referencing Greywater / Brannoc / the washed-out bridge), tags stripped, and
|
||||
`facts harvested: N` with N ≥ 0.
|
||||
4. **Degraded path:** stop the proxy and click again. Expect the authored
|
||||
fallback line and `(degraded)` in the status — never an error.
|
||||
|
||||
Override the proxy URL with the `coc_rpg/proxy_base_url` project setting.
|
||||
|
||||
@@ -12,4 +12,14 @@ config_version=5
|
||||
|
||||
config/name="coc-rpg"
|
||||
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
|
||||
config/version="0.01-alpha"
|
||||
run/main_scene="res://scenes/Main.tscn"
|
||||
config/features=PackedStringArray("4.7")
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/size/window_width_override=1280
|
||||
window/size/window_height_override=720
|
||||
window/stretch/mode="canvas_items"
|
||||
|
||||
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
10
client/scenes/narrate_harness.tscn
Normal file
10
client/scenes/narrate_harness.tscn
Normal file
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/harness/narrate_harness.gd" id="1_harness"]
|
||||
|
||||
[node name="NarrateHarness" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_harness")
|
||||
10
client/scenes/npc_harness.tscn
Normal file
10
client/scenes/npc_harness.tscn
Normal file
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/harness/npc_harness.gd" id="1_harness"]
|
||||
|
||||
[node name="NpcHarness" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_harness")
|
||||
300
client/scenes/shell/MainWindowShell.tscn
Normal file
300
client/scenes/shell/MainWindowShell.tscn
Normal file
@@ -0,0 +1,300 @@
|
||||
[gd_scene load_steps=6 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"]
|
||||
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/DarkBay.tscn" id="3"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/shell/SystemDock.tscn" id="4"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/shell/NarrationBook.tscn" id="5"]
|
||||
|
||||
[node name="MainWindowShell" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
offset_right = 1920.0
|
||||
offset_bottom = 1080.0
|
||||
theme = ExtResource("2")
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Split" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
theme_override_constants/separation = 0
|
||||
|
||||
[node name="World" type="Control" parent="Split"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
clip_contents = true
|
||||
|
||||
[node name="Bay" parent="Split/World" instance=ExtResource("3")]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
||||
[node name="Caption" type="Label" parent="Split/World"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "ISOMETRIC WORLD VIEW"
|
||||
|
||||
[node name="TurnRail" type="PanelContainer" parent="Split/World"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
offset_left = 28.0
|
||||
offset_top = 28.0
|
||||
offset_right = 300.0
|
||||
offset_bottom = 128.0
|
||||
theme_type_variation = &"DarkPanel"
|
||||
|
||||
[node name="Col" type="VBoxContainer" parent="Split/World/TurnRail"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Title" type="Label" parent="Split/World/TurnRail/Col"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "TURN ORDER"
|
||||
|
||||
[node name="Tokens" type="HBoxContainer" parent="Split/World/TurnRail/Col"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="T0" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Accent"
|
||||
text = "EL"
|
||||
|
||||
[node name="T1" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Accent"
|
||||
text = "DW"
|
||||
|
||||
[node name="T2" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Accent"
|
||||
text = "HU"
|
||||
|
||||
[node name="T3" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Accent"
|
||||
text = "✕"
|
||||
|
||||
[node name="T4" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Accent"
|
||||
text = "—"
|
||||
|
||||
[node name="Minimap" type="PanelContainer" parent="Split/World"]
|
||||
custom_minimum_size = Vector2(200, 200)
|
||||
layout_mode = 1
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -228.0
|
||||
offset_top = 28.0
|
||||
offset_right = -28.0
|
||||
offset_bottom = 228.0
|
||||
grow_horizontal = 0
|
||||
theme_type_variation = &"DarkPanel"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/Minimap"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
theme_type_variation = &"Mono"
|
||||
text = "MINIMAP"
|
||||
|
||||
[node name="SystemDock" parent="Split/World" instance=ExtResource("4")]
|
||||
layout_mode = 1
|
||||
anchors_preset = 6
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -228.0
|
||||
offset_top = -210.0
|
||||
offset_right = -8.0
|
||||
offset_bottom = 210.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="CommandBar" type="HBoxContainer" parent="Split/World"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 28.0
|
||||
offset_top = -150.0
|
||||
offset_right = -28.0
|
||||
grow_vertical = 0
|
||||
theme_override_constants/separation = 20
|
||||
alignment = 1
|
||||
|
||||
[node name="Vitals" type="VBoxContainer" parent="Split/World/CommandBar"]
|
||||
custom_minimum_size = Vector2(120, 0)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 8
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="HP" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "HP 42 / 60"
|
||||
|
||||
[node name="MP" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "MP 18 / 30"
|
||||
|
||||
[node name="Purse" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "◈ 3s 47c"
|
||||
|
||||
[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 8
|
||||
theme_override_constants/separation = 9
|
||||
alignment = 1
|
||||
|
||||
[node name="Slot1" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||
custom_minimum_size = Vector2(93, 62)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ItemTile"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot1"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
clip_text = true
|
||||
theme_override_font_sizes/font_size = 11
|
||||
theme_type_variation = &"Mono"
|
||||
text = "Salve"
|
||||
|
||||
[node name="Slot2" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||
custom_minimum_size = Vector2(93, 62)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ItemTile"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot2"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
clip_text = true
|
||||
theme_override_font_sizes/font_size = 11
|
||||
theme_type_variation = &"Mono"
|
||||
text = "Draught"
|
||||
|
||||
[node name="Slot3" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||
custom_minimum_size = Vector2(93, 62)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ItemTile"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot3"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
clip_text = true
|
||||
theme_override_font_sizes/font_size = 11
|
||||
theme_type_variation = &"Mono"
|
||||
text = "Bomb"
|
||||
|
||||
[node name="Slot4" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||
custom_minimum_size = Vector2(93, 62)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ItemTile"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot4"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
clip_text = true
|
||||
theme_override_font_sizes/font_size = 11
|
||||
theme_type_variation = &"Mono"
|
||||
text = "Antidote"
|
||||
|
||||
[node name="Slot5" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||
custom_minimum_size = Vector2(93, 62)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ItemTile"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot5"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
clip_text = true
|
||||
theme_override_font_sizes/font_size = 11
|
||||
theme_type_variation = &"Mono"
|
||||
text = "Ration"
|
||||
|
||||
[node name="Slot6" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||
custom_minimum_size = Vector2(93, 62)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ItemTile"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot6"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
clip_text = true
|
||||
theme_override_font_sizes/font_size = 11
|
||||
theme_type_variation = &"Mono"
|
||||
text = "Torch"
|
||||
|
||||
[node name="Slot7" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||
custom_minimum_size = Vector2(93, 62)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ItemTileEmpty"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot7"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
clip_text = true
|
||||
theme_override_font_sizes/font_size = 11
|
||||
theme_type_variation = &"Mono"
|
||||
|
||||
[node name="Slot8" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||
custom_minimum_size = Vector2(93, 62)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"ItemTileEmpty"
|
||||
|
||||
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot8"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 2
|
||||
clip_text = true
|
||||
theme_override_font_sizes/font_size = 11
|
||||
theme_type_variation = &"Mono"
|
||||
|
||||
[node name="EndTurn" type="Button" parent="Split/World/CommandBar"]
|
||||
custom_minimum_size = Vector2(140, 60)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
theme_type_variation = &"PrimaryCTA"
|
||||
text = "END TURN"
|
||||
|
||||
[node name="BookPanel" type="PanelContainer" parent="Split"]
|
||||
custom_minimum_size = Vector2(740, 0)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"ParchmentCard"
|
||||
|
||||
[node name="Book" parent="Split/BookPanel" instance=ExtResource("5")]
|
||||
layout_mode = 2
|
||||
85
client/scenes/shell/NarrationBook.tscn
Normal file
85
client/scenes/shell/NarrationBook.tscn
Normal file
@@ -0,0 +1,85 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"]
|
||||
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||
|
||||
[node name="NarrationBook" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
offset_right = 740.0
|
||||
offset_bottom = 1080.0
|
||||
theme = ExtResource("2")
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
theme_override_constants/margin_left = 40
|
||||
theme_override_constants/margin_top = 34
|
||||
theme_override_constants/margin_right = 40
|
||||
theme_override_constants/margin_bottom = 34
|
||||
|
||||
[node name="Col" type="VBoxContainer" parent="Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 18
|
||||
|
||||
[node name="Header" type="Label" parent="Margin/Col"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Accent"
|
||||
text = "ROUND 4 · THE LOWER WARD"
|
||||
|
||||
[node name="Prose" type="RichTextLabel" parent="Margin/Col"]
|
||||
custom_minimum_size = Vector2(660, 320)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
bbcode_enabled = true
|
||||
text = "The alley stinks of piss and rot. Rain beads on the cobbles and runs black into the gutter."
|
||||
fit_content = true
|
||||
|
||||
[node name="DegradedTag" type="Label" parent="Margin/Col"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "THE MASTER'S VOICE COMES THIN"
|
||||
|
||||
[node name="Prompt" type="Label" parent="Margin/Col"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Accent"
|
||||
text = "▸ Your turn. What do you do?"
|
||||
|
||||
[node name="Choice1" type="Button" parent="Margin/Col"]
|
||||
layout_mode = 2
|
||||
disabled = true
|
||||
theme_type_variation = &"ParchmentButton"
|
||||
text = "1 · Pay the fence double and keep your hand where he can see it"
|
||||
alignment = 0
|
||||
|
||||
[node name="Choice2" type="Button" parent="Margin/Col"]
|
||||
layout_mode = 2
|
||||
disabled = true
|
||||
theme_type_variation = &"ParchmentButton"
|
||||
text = "2 · Draw your blade — he's closer than his guards"
|
||||
alignment = 0
|
||||
|
||||
[node name="Choice3" type="Button" parent="Margin/Col"]
|
||||
layout_mode = 2
|
||||
disabled = true
|
||||
theme_type_variation = &"ParchmentButton"
|
||||
text = "3 · Lie — tell him the rest is coming with your dwarf"
|
||||
alignment = 0
|
||||
|
||||
[node name="FreeText" type="Label" parent="Margin/Col"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "⌨ …or describe your own action"
|
||||
|
||||
[node name="Refire" type="Button" parent="Margin/Col"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"ParchmentButton"
|
||||
text = "▸ The Master continues…"
|
||||
|
||||
[node name="Status" type="Label" parent="Margin/Col"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
86
client/scenes/shell/SystemDock.tscn
Normal file
86
client/scenes/shell/SystemDock.tscn
Normal file
@@ -0,0 +1,86 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"]
|
||||
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||
|
||||
[node name="SystemDock" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
offset_right = 220.0
|
||||
offset_bottom = 420.0
|
||||
theme = ExtResource("2")
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Menu" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -216.0
|
||||
offset_right = -36.0
|
||||
offset_bottom = 316.0
|
||||
grow_horizontal = 0
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="Inventory" type="Button" parent="Menu"]
|
||||
custom_minimum_size = Vector2(0, 42)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DockButton"
|
||||
text = "I Inventory"
|
||||
alignment = 0
|
||||
metadata/screen_id = &"inventory"
|
||||
|
||||
[node name="Character" type="Button" parent="Menu"]
|
||||
custom_minimum_size = Vector2(0, 42)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DockButton"
|
||||
text = "C Character"
|
||||
alignment = 0
|
||||
metadata/screen_id = &"character"
|
||||
|
||||
[node name="QuestLog" type="Button" parent="Menu"]
|
||||
custom_minimum_size = Vector2(0, 42)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DockButton"
|
||||
text = "J Quest Log"
|
||||
alignment = 0
|
||||
metadata/screen_id = &"quest_log"
|
||||
|
||||
[node name="Spellbook" type="Button" parent="Menu"]
|
||||
custom_minimum_size = Vector2(0, 42)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DockButton"
|
||||
text = "K Spellbook"
|
||||
alignment = 0
|
||||
metadata/screen_id = &"spellbook"
|
||||
|
||||
[node name="WorldMap" type="Button" parent="Menu"]
|
||||
custom_minimum_size = Vector2(0, 42)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DockButton"
|
||||
text = "M World Map"
|
||||
alignment = 0
|
||||
metadata/screen_id = &"world_map"
|
||||
|
||||
[node name="Party" type="Button" parent="Menu"]
|
||||
custom_minimum_size = Vector2(0, 42)
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"DockButton"
|
||||
text = "P Party"
|
||||
alignment = 0
|
||||
metadata/screen_id = &"party"
|
||||
|
||||
[node name="Handle" type="Button" parent="."]
|
||||
custom_minimum_size = Vector2(26, 104)
|
||||
layout_mode = 1
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -26.0
|
||||
offset_top = -52.0
|
||||
offset_right = 0.0
|
||||
offset_bottom = 52.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
theme_type_variation = &"TabActive"
|
||||
text = "›"
|
||||
5
client/scenes/theme/surfaces/DarkBay.tscn
Normal file
5
client/scenes/theme/surfaces/DarkBay.tscn
Normal file
@@ -0,0 +1,5 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://scripts/theme/surfaces/dark_bay.gd" id="1"]
|
||||
[node name="DarkBay" type="Panel"]
|
||||
anchors_preset = 15
|
||||
script = ExtResource("1")
|
||||
5
client/scenes/theme/surfaces/ParchmentPanel.tscn
Normal file
5
client/scenes/theme/surfaces/ParchmentPanel.tscn
Normal file
@@ -0,0 +1,5 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://scripts/theme/surfaces/parchment_panel.gd" id="1"]
|
||||
[node name="ParchmentPanel" type="Panel"]
|
||||
anchors_preset = 15
|
||||
script = ExtResource("1")
|
||||
5
client/scenes/theme/surfaces/VignetteOverlay.tscn
Normal file
5
client/scenes/theme/surfaces/VignetteOverlay.tscn
Normal file
@@ -0,0 +1,5 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://scripts/theme/surfaces/vignette_overlay.gd" id="1"]
|
||||
[node name="VignetteOverlay" type="ColorRect"]
|
||||
anchors_preset = 15
|
||||
script = ExtResource("1")
|
||||
5
client/scenes/theme/theme_showcase.tscn
Normal file
5
client/scenes/theme/theme_showcase.tscn
Normal file
@@ -0,0 +1,5 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://scripts/theme/theme_showcase.gd" id="1"]
|
||||
[node name="ThemeShowcase" type="Control"]
|
||||
anchors_preset = 15
|
||||
script = ExtResource("1")
|
||||
243
client/scenes/title/TitleScreen.tscn
Normal file
243
client/scenes/title/TitleScreen.tscn
Normal file
@@ -0,0 +1,243 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/title/title_screen.gd" id="1"]
|
||||
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/VignetteOverlay.tscn" id="3"]
|
||||
|
||||
[node name="TitleScreen" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
offset_right = 1920.0
|
||||
offset_bottom = 1080.0
|
||||
theme = ExtResource("2")
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
||||
[node name="Glow" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -420.0
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Embers" type="GPUParticles2D" parent="."]
|
||||
position = Vector2(960, 1080)
|
||||
amount = 24
|
||||
lifetime = 9.0
|
||||
preprocess = 4.0
|
||||
|
||||
[node name="KeyArt" type="Label" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 6
|
||||
anchor_left = 1.0
|
||||
anchor_top = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -520.0
|
||||
offset_top = -20.0
|
||||
offset_right = -120.0
|
||||
offset_bottom = 20.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "KEY ART DROPS IN HERE"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Vignette" parent="." instance=ExtResource("3")]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Content" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 4
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 120.0
|
||||
offset_top = -260.0
|
||||
offset_right = 900.0
|
||||
offset_bottom = 260.0
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="Kicker" type="Label" parent="Content"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TitleKicker"
|
||||
text = "A DARK FANTASY RPG"
|
||||
|
||||
[node name="Logo" type="Label" parent="Content"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TitleLogo"
|
||||
text = "CODE OF
|
||||
CONQUEST"
|
||||
|
||||
[node name="Rule" type="ColorRect" parent="Content"]
|
||||
custom_minimum_size = Vector2(220, 2)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Tagline" type="Label" parent="Content"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Accent"
|
||||
text = "The world has real problems, and it does not care about you."
|
||||
|
||||
[node name="Menu" type="VBoxContainer" parent="Content"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 5
|
||||
|
||||
[node name="NewGame" type="HBoxContainer" parent="Content/Menu"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 0
|
||||
theme_override_constants/separation = 16
|
||||
metadata/action = &"new_game"
|
||||
|
||||
[node name="Marker" type="Label" parent="Content/Menu/NewGame"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "◆"
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="Content/Menu/NewGame"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="Content/Menu/NewGame/Body"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "New Game"
|
||||
|
||||
[node name="Continue" type="HBoxContainer" parent="Content/Menu"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 0
|
||||
theme_override_constants/separation = 16
|
||||
metadata/action = &"continue"
|
||||
|
||||
[node name="Marker" type="Label" parent="Content/Menu/Continue"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "◆"
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="Content/Menu/Continue"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="Content/Menu/Continue/Body"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Continue"
|
||||
|
||||
[node name="Sub" type="Label" parent="Content/Menu/Continue/Body"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "CHAPTER II · VEXCCA · 14:22:07"
|
||||
|
||||
[node name="LoadGame" type="HBoxContainer" parent="Content/Menu"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 0
|
||||
theme_override_constants/separation = 16
|
||||
metadata/action = &"load_game"
|
||||
|
||||
[node name="Marker" type="Label" parent="Content/Menu/LoadGame"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "◆"
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="Content/Menu/LoadGame"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="Content/Menu/LoadGame/Body"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Load Game"
|
||||
|
||||
[node name="Settings" type="HBoxContainer" parent="Content/Menu"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 0
|
||||
theme_override_constants/separation = 16
|
||||
metadata/action = &"settings"
|
||||
|
||||
[node name="Marker" type="Label" parent="Content/Menu/Settings"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "◆"
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="Content/Menu/Settings"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="Content/Menu/Settings/Body"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Settings"
|
||||
|
||||
[node name="Credits" type="HBoxContainer" parent="Content/Menu"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 0
|
||||
theme_override_constants/separation = 16
|
||||
metadata/action = &"credits"
|
||||
|
||||
[node name="Marker" type="Label" parent="Content/Menu/Credits"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "◆"
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="Content/Menu/Credits"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="Content/Menu/Credits/Body"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Credits"
|
||||
|
||||
[node name="Quit" type="HBoxContainer" parent="Content/Menu"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 0
|
||||
theme_override_constants/separation = 16
|
||||
metadata/action = &"quit"
|
||||
|
||||
[node name="Marker" type="Label" parent="Content/Menu/Quit"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "◆"
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="Content/Menu/Quit"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="Content/Menu/Quit/Body"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Quit"
|
||||
|
||||
[node name="FooterLeft" type="Label" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 2
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 120.0
|
||||
offset_top = -60.0
|
||||
offset_right = 700.0
|
||||
offset_bottom = -32.0
|
||||
grow_vertical = 0
|
||||
theme_type_variation = &"Mono"
|
||||
text = "v0.01-alpha · BUILT IN GODOT 4.7 · © 2026"
|
||||
|
||||
[node name="FooterRight" type="Label" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -520.0
|
||||
offset_top = -60.0
|
||||
offset_right = -120.0
|
||||
offset_bottom = -32.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_type_variation = &"Mono"
|
||||
text = "[ ↑ ↓ ] CHOOSE [ ↵ ] ENTER"
|
||||
horizontal_alignment = 2
|
||||
@@ -58,6 +58,20 @@ func adjust_disposition(id: String, delta: int) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func has_quest(id: String) -> bool:
|
||||
for q in active_quests:
|
||||
if q.id == id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func add_quest(id: String, name: String, objective: String) -> bool:
|
||||
if has_quest(id):
|
||||
return false
|
||||
active_quests.append(Quest.new(id, name, "active", objective))
|
||||
return true
|
||||
|
||||
|
||||
func set_quest_status(id: String, status: String) -> bool:
|
||||
for q in active_quests:
|
||||
if q.id == id:
|
||||
@@ -85,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,10 @@ 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 = {}
|
||||
|
||||
|
||||
static func default_content_root() -> String:
|
||||
@@ -29,13 +33,18 @@ 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"))
|
||||
|
||||
|
||||
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"):
|
||||
@@ -56,6 +65,14 @@ 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)
|
||||
func has_topic(id: String) -> bool: return topics.has(id)
|
||||
|
||||
|
||||
func companions() -> Array:
|
||||
|
||||
72
client/scripts/harness/narrate_harness.gd
Normal file
72
client/scripts/harness/narrate_harness.gd
Normal file
@@ -0,0 +1,72 @@
|
||||
extends Control
|
||||
## Throwaway M2 harness — the first on-screen generated prose. Wires the real
|
||||
## transport to a button and a label. NOT the game UI (a later wireframe owns
|
||||
## that). Run this scene directly (F6) with the proxy + Ollama up; see the live
|
||||
## smoke steps in client/docs/README.md.
|
||||
|
||||
const DmService = preload("res://scripts/net/dm_service.gd")
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
|
||||
|
||||
var _service: DmService
|
||||
var _log: CanonLog
|
||||
var _label: RichTextLabel
|
||||
var _status: Label
|
||||
var _button: Button
|
||||
var _indicator: ConsideringIndicator
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
_service = DmService.new(HttpTransport.new(http), FallbackLibrary.new())
|
||||
_log = _build_scene_log()
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
add_child(vbox)
|
||||
|
||||
_button = Button.new()
|
||||
_button.text = "Narrate this scene"
|
||||
_button.pressed.connect(_on_narrate_pressed)
|
||||
vbox.add_child(_button)
|
||||
|
||||
_label = RichTextLabel.new()
|
||||
_label.fit_content = true
|
||||
_label.custom_minimum_size = Vector2(640, 320)
|
||||
vbox.add_child(_label)
|
||||
|
||||
_status = Label.new()
|
||||
vbox.add_child(_status)
|
||||
|
||||
_indicator = ConsideringIndicator.new()
|
||||
add_child(_indicator)
|
||||
|
||||
|
||||
func _on_narrate_pressed() -> void:
|
||||
_button.disabled = true
|
||||
_indicator.start(_status)
|
||||
var r := await _service.narrate(_log)
|
||||
_indicator.stop()
|
||||
_label.text = r.display_text
|
||||
for f in r.facts:
|
||||
_log.add_fact(f)
|
||||
_status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""]
|
||||
_button.disabled = false
|
||||
|
||||
|
||||
func _build_scene_log() -> CanonLog:
|
||||
var log := CanonLog.new()
|
||||
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))
|
||||
log.push_event("Arrived at Greywater by barge before dawn")
|
||||
log.push_event("Brannoc recognised the harbourmaster and went quiet")
|
||||
log.add_fact("the eastern bridge out of Greywater is washed out")
|
||||
log.add_fact("the harbourmaster is named Oda Fenn")
|
||||
log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find who took Fenn's ledger"))
|
||||
log.add_humiliation("h_0001", "vomited on a shrine step in front of a priest", 7, 3)
|
||||
return log
|
||||
1
client/scripts/harness/narrate_harness.gd.uid
Normal file
1
client/scripts/harness/narrate_harness.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dxh7t3rdoqgq5
|
||||
107
client/scripts/harness/npc_harness.gd
Normal file
107
client/scripts/harness/npc_harness.gd
Normal file
@@ -0,0 +1,107 @@
|
||||
extends Control
|
||||
## Throwaway harness — the bounded-NPC loop on screen, not shipped UI. Type an
|
||||
## utterance to Fenn, see his voiced prose, watch moves apply and disposition
|
||||
## change, until he ends the conversation. Mirrors narrate_harness. Run this
|
||||
## scene directly (F6) with the proxy + Ollama up.
|
||||
|
||||
const NpcService = preload("res://scripts/net/npc_service.gd")
|
||||
const NpcContent = preload("res://scripts/npc/npc_content.gd")
|
||||
const MoveApplier = preload("res://scripts/npc/move_applier.gd")
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
const ConsideringIndicator = preload("res://scripts/ui/considering_indicator.gd")
|
||||
|
||||
const NPC_ID := "fenn"
|
||||
|
||||
var _service: NpcService
|
||||
var _content: ContentDB
|
||||
var _game_state: GameState
|
||||
var _canon_log: CanonLog
|
||||
var _output: RichTextLabel
|
||||
var _entry: LineEdit
|
||||
var _speak_btn: Button
|
||||
var _status: Label
|
||||
var _indicator: ConsideringIndicator
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
_service = NpcService.new(HttpTransport.new(http), FallbackLibrary.new())
|
||||
|
||||
_content = ContentDB.new()
|
||||
_content.load_from(ContentDB.default_content_root())
|
||||
|
||||
_game_state = GameState.new()
|
||||
_canon_log = CanonLog.new()
|
||||
_canon_log.set_location("greywater_docks", "Greywater Docks")
|
||||
# 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", "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.
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
add_child(vbox)
|
||||
|
||||
_output = RichTextLabel.new()
|
||||
_output.bbcode_enabled = true
|
||||
_output.fit_content = true
|
||||
_output.custom_minimum_size = Vector2(600, 400)
|
||||
vbox.add_child(_output)
|
||||
|
||||
_entry = LineEdit.new()
|
||||
_entry.placeholder_text = "Say something to Fenn…"
|
||||
vbox.add_child(_entry)
|
||||
|
||||
_speak_btn = Button.new()
|
||||
_speak_btn.text = "Speak"
|
||||
_speak_btn.pressed.connect(_on_speak)
|
||||
vbox.add_child(_speak_btn)
|
||||
|
||||
_status = Label.new()
|
||||
vbox.add_child(_status)
|
||||
|
||||
_indicator = ConsideringIndicator.new()
|
||||
add_child(_indicator)
|
||||
|
||||
|
||||
func _on_speak() -> void:
|
||||
var utterance := _entry.text
|
||||
if utterance.strip_edges() == "":
|
||||
return
|
||||
_entry.text = ""
|
||||
_speak_btn.disabled = true
|
||||
_append("[b]You:[/b] %s" % utterance)
|
||||
|
||||
var disposition := int(_game_state.npc_dispositions.get(NPC_ID, 0))
|
||||
var available := NpcContent.available_moves(_content.npc(NPC_ID), _game_state, _canon_log)
|
||||
|
||||
_indicator.start(_status)
|
||||
var r: NpcResult = await _service.speak(
|
||||
NPC_ID, utterance, _canon_log, disposition, available)
|
||||
_indicator.stop()
|
||||
|
||||
# Apply state explicitly (§2) — the service applied nothing.
|
||||
MoveApplier.apply(r.valid_moves, _game_state, _canon_log, _content, NPC_ID)
|
||||
for f in r.facts:
|
||||
_canon_log.add_fact(f)
|
||||
|
||||
_append("[b]Fenn:[/b] %s" % r.display_text)
|
||||
_append("[i]moves: %d applied, %d dropped · disposition %d%s[/i]" % [
|
||||
r.valid_moves.size(), r.dropped_moves.size(),
|
||||
int(_game_state.npc_dispositions.get(NPC_ID, 0)),
|
||||
" (degraded)" if r.degraded else ""])
|
||||
|
||||
if r.ends_conversation:
|
||||
_append("[i]— Fenn ends the conversation. —[/i]")
|
||||
else:
|
||||
_speak_btn.disabled = false
|
||||
|
||||
|
||||
func _append(bbcode: String) -> void:
|
||||
_output.append_text(bbcode + "\n\n")
|
||||
1
client/scripts/harness/npc_harness.gd.uid
Normal file
1
client/scripts/harness/npc_harness.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cbmr6caohwsxb
|
||||
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
|
||||
25
client/scripts/net/dm_response.gd
Normal file
25
client/scripts/net/dm_response.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
class_name DmResponse
|
||||
extends RefCounted
|
||||
## The raw outcome of one HTTP attempt, independent of any game meaning.
|
||||
## `status` is 0 when the transport never reached the server; `body` is the
|
||||
## parsed JSON dict, or null when the body was absent or not JSON.
|
||||
|
||||
var status: int
|
||||
var body # Variant: parsed JSON (Dictionary) or null
|
||||
var transport_ok: bool
|
||||
var error: String
|
||||
|
||||
|
||||
func _init(p_status := 0, p_body = null, p_transport_ok := false, p_error := "") -> void:
|
||||
status = p_status
|
||||
body = p_body
|
||||
transport_ok = p_transport_ok
|
||||
error = p_error
|
||||
|
||||
|
||||
static func ok(p_status: int, p_body) -> DmResponse:
|
||||
return DmResponse.new(p_status, p_body, true, "")
|
||||
|
||||
|
||||
static func failed(p_error: String) -> DmResponse:
|
||||
return DmResponse.new(0, null, false, p_error)
|
||||
1
client/scripts/net/dm_response.gd.uid
Normal file
1
client/scripts/net/dm_response.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bdy2347pcqm4v
|
||||
27
client/scripts/net/dm_service.gd
Normal file
27
client/scripts/net/dm_service.gd
Normal file
@@ -0,0 +1,27 @@
|
||||
class_name DmService
|
||||
extends RefCounted
|
||||
## The client narrate loop (charter §2: the client owns the loop and consumes
|
||||
## text). Posts the canon log to /dm/narrate through an injected transport,
|
||||
## returns the prose to display and the [FACT:] tags to harvest. On ANY failure
|
||||
## it returns an authored fallback line — never an error. It harvests facts
|
||||
## only; the Narrator has no authority over disposition or moves, so those tags
|
||||
## are dropped even if the model emits them (§6). narrate() does not mutate the
|
||||
## log — the caller applies the returned facts, keeping the state write explicit.
|
||||
|
||||
var _transport: DmTransport
|
||||
var _fallback: FallbackLibrary
|
||||
|
||||
|
||||
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
|
||||
_transport = transport
|
||||
_fallback = fallback
|
||||
|
||||
|
||||
func narrate(canon_log: CanonLog) -> NarrateResult:
|
||||
var resp: DmResponse = await _transport.post_json(
|
||||
"/dm/narrate", {"canon_log": canon_log.to_dict()})
|
||||
if not resp.transport_ok or resp.status != 200 \
|
||||
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
|
||||
return NarrateResult.fallback(_fallback.narrator_line())
|
||||
var extracted := TagExtractor.extract(str(resp.body["prose"]))
|
||||
return NarrateResult.new(extracted["clean_text"], extracted["facts"], false)
|
||||
1
client/scripts/net/dm_service.gd.uid
Normal file
1
client/scripts/net/dm_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b467yu4cut0hp
|
||||
10
client/scripts/net/dm_transport.gd
Normal file
10
client/scripts/net/dm_transport.gd
Normal file
@@ -0,0 +1,10 @@
|
||||
class_name DmTransport
|
||||
extends RefCounted
|
||||
## The network seam. DmService talks to this, never to HTTPRequest directly, so
|
||||
## the loop logic is testable with an injected fake. Concrete transports
|
||||
## override post_json. The base returns a failed response (rather than assert())
|
||||
## so a missing override is a loud, catchable failure in tests.
|
||||
|
||||
func post_json(_path: String, _body: Dictionary) -> DmResponse:
|
||||
push_error("DmTransport.post_json must be overridden")
|
||||
return DmResponse.failed("transport not implemented")
|
||||
1
client/scripts/net/dm_transport.gd.uid
Normal file
1
client/scripts/net/dm_transport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://crgmujan6vj2l
|
||||
42
client/scripts/net/fallback_library.gd
Normal file
42
client/scripts/net/fallback_library.gd
Normal file
@@ -0,0 +1,42 @@
|
||||
class_name FallbackLibrary
|
||||
extends RefCounted
|
||||
## Authored degraded-DM fallback text (charter §13). On ANY failure the client
|
||||
## shows one of these lines in-voice — never an error. Missing/malformed content
|
||||
## yields a hardcoded constant, so the fallback itself cannot fail.
|
||||
|
||||
const PATH := "res://content/fallback/narrator.json"
|
||||
const NPC_PATH := "res://content/fallback/npc.json"
|
||||
const LAST_RESORT := "The chamber is cold. Something waits in the dark."
|
||||
const NPC_LAST_RESORT := "The figure regards you in silence, and the moment passes."
|
||||
|
||||
var _lines: Array = []
|
||||
var _npc_lines: Array = []
|
||||
|
||||
|
||||
func _init(path := PATH, npc_path := NPC_PATH) -> void:
|
||||
_lines = _load(path, "narrator")
|
||||
_npc_lines = _load(npc_path, "npc")
|
||||
|
||||
|
||||
func narrator_line() -> String:
|
||||
return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT
|
||||
|
||||
|
||||
func npc_line() -> String:
|
||||
return str(_npc_lines[0]) if not _npc_lines.is_empty() else NPC_LAST_RESORT
|
||||
|
||||
|
||||
static func _load(path: String, key: String) -> Array:
|
||||
if not FileAccess.file_exists(path):
|
||||
return []
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
# Instance parse() (not JSON.parse_string) so malformed input returns an
|
||||
# Error code instead of pushing an engine error the test config promotes.
|
||||
var json := JSON.new()
|
||||
if json.parse(text) != OK:
|
||||
return []
|
||||
var data = json.get_data()
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return []
|
||||
var lines = data.get(key, [])
|
||||
return lines if typeof(lines) == TYPE_ARRAY else []
|
||||
1
client/scripts/net/fallback_library.gd.uid
Normal file
1
client/scripts/net/fallback_library.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b8jhu0e4yg6tf
|
||||
44
client/scripts/net/http_transport.gd
Normal file
44
client/scripts/net/http_transport.gd
Normal file
@@ -0,0 +1,44 @@
|
||||
extends "res://scripts/net/dm_transport.gd"
|
||||
## The real transport: wraps an HTTPRequest Node the owner added to the tree.
|
||||
## This is the one untested shim in the slice — it needs a live Node and a
|
||||
## server, so it is verified by reading and by the harness live smoke, the same
|
||||
## way the server's HTTP boundary is covered only by its gated --run-live test.
|
||||
## A non-2xx status is NOT an error here; it is a normal DmResponse the service
|
||||
## interprets. Only a transport-level failure (no response) yields failed().
|
||||
|
||||
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
|
||||
var _http: HTTPRequest
|
||||
|
||||
|
||||
func _init(http: HTTPRequest) -> void:
|
||||
_http = http
|
||||
# Bound a hung proxy so the request completes with RESULT_TIMEOUT (→ failed()
|
||||
# → the service degrades) instead of never returning. Without this the
|
||||
# considering-state indicator would rotate forever on a hang.
|
||||
_http.timeout = ProxyConfig.request_timeout_seconds()
|
||||
|
||||
|
||||
func post_json(path: String, body: Dictionary) -> DmResponse:
|
||||
var url := ProxyConfig.base_url() + path
|
||||
var headers := ["Content-Type: application/json"]
|
||||
var payload := JSON.stringify(body)
|
||||
var err := _http.request(url, headers, HTTPClient.METHOD_POST, payload)
|
||||
if err != OK:
|
||||
return DmResponse.failed("request() failed to start: error %d" % err)
|
||||
# request_completed(result, response_code, headers, body) — awaiting a
|
||||
# multi-arg signal returns the args as an Array.
|
||||
var signal_args: Array = await _http.request_completed
|
||||
var result: int = signal_args[0]
|
||||
var response_code: int = signal_args[1]
|
||||
var raw: PackedByteArray = signal_args[3]
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
return DmResponse.failed("transport result %d" % result)
|
||||
# Parse via JSON.new() rather than JSON.parse_string(): the latter pushes an
|
||||
# engine-level error on a non-JSON body (e.g. a gateway's HTML 502), which the
|
||||
# project's gutconfig promotes to a false test failure. A non-JSON body stays
|
||||
# null here — the service treats a non-dict body as a failure and degrades.
|
||||
var json := JSON.new()
|
||||
var parsed = null if json.parse(raw.get_string_from_utf8()) != OK else json.data
|
||||
return DmResponse.ok(response_code, parsed)
|
||||
1
client/scripts/net/http_transport.gd.uid
Normal file
1
client/scripts/net/http_transport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cuvhnqsgr3liq
|
||||
18
client/scripts/net/narrate_result.gd
Normal file
18
client/scripts/net/narrate_result.gd
Normal file
@@ -0,0 +1,18 @@
|
||||
class_name NarrateResult
|
||||
extends RefCounted
|
||||
## The game-meaningful result of one narrate call. `facts` are the [FACT:]
|
||||
## strings the caller applies to the canon log; empty when degraded.
|
||||
|
||||
var display_text: String
|
||||
var facts: Array
|
||||
var degraded: bool
|
||||
|
||||
|
||||
func _init(p_display_text := "", p_facts := [], p_degraded := false) -> void:
|
||||
display_text = p_display_text
|
||||
facts = p_facts
|
||||
degraded = p_degraded
|
||||
|
||||
|
||||
static func fallback(line: String) -> NarrateResult:
|
||||
return NarrateResult.new(line, [], true)
|
||||
1
client/scripts/net/narrate_result.gd.uid
Normal file
1
client/scripts/net/narrate_result.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dye0nd5h05cks
|
||||
27
client/scripts/net/npc_result.gd
Normal file
27
client/scripts/net/npc_result.gd
Normal file
@@ -0,0 +1,27 @@
|
||||
# client/scripts/net/npc_result.gd
|
||||
class_name NpcResult
|
||||
extends RefCounted
|
||||
## The game-meaningful result of one /npc/speak call. The caller applies
|
||||
## valid_moves (via MoveApplier) and harvests facts — state writes stay explicit
|
||||
## (§2). dropped_moves are kept only so the caller can log the §6 drops.
|
||||
|
||||
var display_text: String
|
||||
var facts: Array
|
||||
var valid_moves: Array
|
||||
var dropped_moves: Array
|
||||
var ends_conversation: bool
|
||||
var degraded: bool
|
||||
|
||||
|
||||
func _init(p_display_text := "", p_facts := [], p_valid := [], p_dropped := [],
|
||||
p_ends := false, p_degraded := false) -> void:
|
||||
display_text = p_display_text
|
||||
facts = p_facts
|
||||
valid_moves = p_valid
|
||||
dropped_moves = p_dropped
|
||||
ends_conversation = p_ends
|
||||
degraded = p_degraded
|
||||
|
||||
|
||||
static func fallback(line: String) -> NpcResult:
|
||||
return NpcResult.new(line, [], [], [], false, true)
|
||||
1
client/scripts/net/npc_result.gd.uid
Normal file
1
client/scripts/net/npc_result.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://chpx73tbklp51
|
||||
48
client/scripts/net/npc_service.gd
Normal file
48
client/scripts/net/npc_service.gd
Normal file
@@ -0,0 +1,48 @@
|
||||
class_name NpcService
|
||||
extends RefCounted
|
||||
## The client /npc/speak loop (§6). Posts persona-free request (npc_id +
|
||||
## disposition + client-computed available_moves + utterance) through an injected
|
||||
## transport, degrades to an authored NPC fallback on ANY failure, extracts tags
|
||||
## with TagExtractor, validates moves against available_moves (MoveValidator),
|
||||
## and returns an NpcResult. It applies NO state — the caller runs MoveApplier,
|
||||
## keeping state writes explicit (§2).
|
||||
|
||||
const ENDING_MOVES := ["end_conversation", "become_hostile"]
|
||||
|
||||
var _transport: DmTransport
|
||||
var _fallback: FallbackLibrary
|
||||
|
||||
|
||||
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
|
||||
_transport = transport
|
||||
_fallback = fallback
|
||||
|
||||
|
||||
func speak(npc_id: String, utterance: String, canon_log: CanonLog,
|
||||
disposition: int, available_moves: Array) -> NpcResult:
|
||||
var resp: DmResponse = await _transport.post_json("/npc/speak", {
|
||||
"canon_log": canon_log.to_dict(),
|
||||
"npc_id": npc_id,
|
||||
"disposition": disposition,
|
||||
"available_moves": available_moves,
|
||||
"utterance": utterance,
|
||||
})
|
||||
if not resp.transport_ok or resp.status != 200 \
|
||||
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
|
||||
return NpcResult.fallback(_fallback.npc_line())
|
||||
|
||||
var extracted := TagExtractor.extract(str(resp.body["prose"]))
|
||||
var moves: Array = extracted["moves"].duplicate()
|
||||
# Fold the [ADJUST_DISPOSITION:n] alias into the move stream so the validator
|
||||
# sees one uniform shape (§ tag convention).
|
||||
for delta in extracted["dispositions"]:
|
||||
moves.append({"name": "adjust_disposition", "args": [str(delta)]})
|
||||
|
||||
var checked := MoveValidator.validate(moves, available_moves)
|
||||
var ends := false
|
||||
for m in checked["valid"]:
|
||||
if m["name"] in ENDING_MOVES:
|
||||
ends = true
|
||||
return NpcResult.new(
|
||||
extracted["clean_text"], extracted["facts"],
|
||||
checked["valid"], checked["dropped"], ends, false)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user