Compare commits
251 Commits
| 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 | |||
| 1f39ec709e | |||
| bc83e92f44 | |||
| 63ce3efb74 | |||
| 2d52d74963 | |||
| 2673126003 | |||
| 5388979bf1 | |||
| 7c5eaf3ba3 | |||
| 8ca89a1ab2 | |||
| fe03d1f787 | |||
| 0a9742a984 | |||
| 4166370f9d | |||
| a682022f17 | |||
| 5486ed00fa | |||
| 1c5f08c5e7 | |||
| 5a1f1ac0ca | |||
| 272995fb6c | |||
| 7f1d9fa39a | |||
| 0e9d1b2a43 | |||
| e39d1a446e | |||
| 4ab0e564ef | |||
| 6d5510773a | |||
| 4aa65c9d4c | |||
| e80a4071f6 | |||
| 843ab11a5c | |||
| 710745e548 | |||
| 38c659cbfe | |||
| 87c5748295 | |||
| 800811dac2 | |||
| d340e06dad | |||
| c5eccbb2a5 | |||
| d4ada9bedb |
@@ -1,46 +0,0 @@
|
||||
[2026-07-09 10:54:03.926] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:54:03.929] Processing: hook_event=UserPromptSubmit, tool=
|
||||
[2026-07-09 10:56:09.126] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:09.128] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:18.474] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:18.476] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:21.852] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:21.855] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:25.336] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:25.338] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:28.741] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:28.744] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:32.070] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:32.073] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:35.419] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:35.422] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:39.157] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:39.159] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:42.516] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:42.518] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:48.542] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:48.545] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:51.814] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:51.816] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:55.095] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:55.098] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:56:59.075] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:56:59.077] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:57:02.336] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:57:02.338] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:57:05.616] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:57:05.618] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:57:08.886] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:57:08.888] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:57:12.207] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:57:12.209] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:57:16.216] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:57:16.218] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:57:19.540] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:57:19.542] Processing: hook_event=PostToolUse, tool=Write
|
||||
[2026-07-09 10:57:31.383] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:57:31.386] Processing: hook_event=PostToolUse, tool=Edit
|
||||
[2026-07-09 10:57:51.755] Hook called with args: ['/home/ptarrant/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.6/hooks/security_reminder_hook.py']
|
||||
[2026-07-09 10:57:51.758] Processing: hook_event=Stop, tool=
|
||||
[2026-07-09 10:57:51.761] _git_status_porcelain error: [Errno 138] emscripten does not support processes.
|
||||
[2026-07-09 10:57:51.761] Stop hook: empty review set
|
||||
@@ -1 +0,0 @@
|
||||
{"shown_warnings": [], "touched_paths": []}
|
||||
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())
|
||||
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.claude
|
||||
client
|
||||
content
|
||||
!content
|
||||
docs/*
|
||||
!docs/schemas
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
.venv
|
||||
venv
|
||||
**/.pytest_cache
|
||||
api/tests
|
||||
api/.env
|
||||
api/.env.*
|
||||
76
.gitignore
vendored
Normal file
76
.gitignore
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# Local scratch — never versioned
|
||||
# ─────────────────────────────────────────────
|
||||
# Ad-hoc F6 / smoke-test captures. Kept for the working session, not the repo.
|
||||
/screenshots/
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Godot 4.7 (client/)
|
||||
# ─────────────────────────────────────────────
|
||||
# Editor + import cache (4.x replaced .import/ with .godot/)
|
||||
.godot/
|
||||
# Exported builds
|
||||
/client/build/
|
||||
export.cfg
|
||||
# export_presets.cfg holds keystore paths / signing config — keep secrets out of history
|
||||
export_presets.cfg
|
||||
# Mono/C# (only if we drop to C# per charter §16)
|
||||
.mono/
|
||||
data_*/
|
||||
*.mono/
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Python / FastAPI (api/)
|
||||
# ─────────────────────────────────────────────
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
# Virtual envs
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
# Tooling caches
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Secrets — the API key lives here in dev, NEVER in the client (charter §4)
|
||||
# ─────────────────────────────────────────────
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Editors / OS
|
||||
# ─────────────────────────────────────────────
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Logs (charter §4/§10 logs go to a store, not the repo)
|
||||
# ─────────────────────────────────────────────
|
||||
*.log
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Claude Code harness session/state files
|
||||
# ─────────────────────────────────────────────
|
||||
# Ignore every .claude/ dir anywhere in the tree...
|
||||
**/.claude/
|
||||
# ...but keep the repo-root .claude/skills/ dir: project-local skills are
|
||||
# checked-in tooling, versioned like code. (Re-include the root dir, drop its
|
||||
# other contents, then re-include skills/.)
|
||||
!/.claude/
|
||||
/.claude/*
|
||||
!/.claude/skills/
|
||||
115
CLAUDE.md
115
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,3 +559,21 @@ Test that before building anything else. Every other system exists to make that
|
||||
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
|
||||
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
|
||||
- **Prefer deleting scope.** The POC's value is answering one question fast.
|
||||
- **A green suite is evidence only if the test can fail.** Before accepting a fix that
|
||||
claims to guard a regression, re-break the code and *watch the new test fail*. This
|
||||
project has shipped a regression test that passed against its own bug, a determinism
|
||||
test that compared two runs that changed together, and an arity change that
|
||||
miscompiled silently past a clean grep. Read **[docs/traps.md](docs/traps.md)** before
|
||||
writing tests — it is six ways this codebase has already fooled a green suite.
|
||||
|
||||
### Git workflow
|
||||
|
||||
The standard flow for this project. Claude follows it; the human owns `master`.
|
||||
|
||||
- **Branches.** `master` and `dev` are long-lived. `master` is release; `dev` is integration. All work branches off `dev`.
|
||||
- **Never commit non-doc changes directly to `master` or `dev`.** Anything that is not purely docs gets its own branch (`feature/…`, `fix/…`, `chore/…`, etc.), branched off `dev`.
|
||||
- **Doc-only edits may commit directly to `dev`** — no branch required.
|
||||
- **Never commit to `master`.** Ever. The human merges `dev` → `master` as they see fit. Claude never touches that merge.
|
||||
- **Merging a working branch → `dev`** happens only after the work is done, smoke-tested, and **the human confirms**. Merge locally with `--no-ff`.
|
||||
- **After merging to `dev`, delete the working branch locally.**
|
||||
- **Pushing.** Origin is set. Claude pushes **only `dev`**, and only after a merge. Never push working branches or `master` — the human always pushes `master`.
|
||||
|
||||
22
api/.env.example
Normal file
22
api/.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Copy to .env for local dev. NEVER commit the real .env. The client never sees
|
||||
# any of these — keys live only in the proxy (charter §4).
|
||||
|
||||
# Where the proxy sends model calls in dev (Ollama on the homelab).
|
||||
# Running the proxy IN DOCKER with Ollama on THIS host? localhost points at the
|
||||
# container, not the host — use http://host.docker.internal:11434 instead
|
||||
# (docker-compose.yml maps that name via host-gateway). Bare-metal/venv: keep
|
||||
# localhost. Homelab Ollama: use its address.
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Prod model provider (charter §4). Leave blank in dev.
|
||||
REPLICATE_API_TOKEN=
|
||||
|
||||
# Port the proxy binds. compose / fly.io override this.
|
||||
PORT=8000
|
||||
|
||||
# Narrator model (charter §5 "the good model"). qwen3.x runs with thinking OFF.
|
||||
OLLAMA_NARRATOR_MODEL=qwen3.5:latest
|
||||
# Per-attempt model-call timeout (seconds).
|
||||
OLLAMA_TIMEOUT_SECONDS=30
|
||||
# Optional: append call logs (JSON lines, §10) to this file instead of stdout.
|
||||
CALL_LOG_PATH=
|
||||
29
api/Dockerfile
Normal file
29
api/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
# /api — FastAPI proxy (charter §4). Build context is the repo root so the
|
||||
# canon log schemas (/docs/schemas) are bundled into the image.
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps first so the layer caches when only app code changes.
|
||||
COPY api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# App code, the role prompts (§16 — prompts are source code), and the schema
|
||||
# contract (single source of truth in docs/schemas).
|
||||
COPY api/app ./app
|
||||
COPY api/prompts ./prompts
|
||||
COPY docs/schemas ./schemas
|
||||
COPY content ./content
|
||||
|
||||
# Run as non-root.
|
||||
RUN useradd --create-home --uid 1000 appuser
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# fly.io / compose set PORT; default to 8000 (charter localhost:8000).
|
||||
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
||||
64
api/app/call_log.py
Normal file
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
|
||||
51
api/app/canon_log.py
Normal file
51
api/app/canon_log.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Load the JSON Schema contracts and validate canon logs / origin seeds.
|
||||
|
||||
Schemas are the single source of truth in /docs/schemas. This module is the
|
||||
api's half of the contract (charter §11): every canon log the client posts is
|
||||
validated here before it could ever reach a prompt.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
|
||||
def _schema_dir() -> Path:
|
||||
"""Locate the schema directory.
|
||||
|
||||
Honours CANON_SCHEMA_DIR, else walks up from this file looking for
|
||||
docs/schemas (local checkout) or schemas (bundled into the Docker image).
|
||||
"""
|
||||
env = os.environ.get("CANON_SCHEMA_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
for candidate in (parent / "docs" / "schemas", parent / "schemas"):
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
raise RuntimeError("schema directory not found")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _validator(schema_name: str) -> Draft202012Validator:
|
||||
with open(_schema_dir() / schema_name) as f:
|
||||
return Draft202012Validator(json.load(f))
|
||||
|
||||
|
||||
def validation_errors(doc: dict, schema_name: str) -> list[str]:
|
||||
validator = _validator(schema_name)
|
||||
# Stringify path segments before sorting: a path can mix str property names
|
||||
# and int array indices, which are not orderable against each other.
|
||||
return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: [str(p) for p in e.path])]
|
||||
|
||||
|
||||
def validate_origin(doc: dict) -> list[str]:
|
||||
return validation_errors(doc, "origin.schema.json")
|
||||
|
||||
|
||||
def validate_canon_log(doc: dict) -> list[str]:
|
||||
return validation_errors(doc, "canon-log.schema.json")
|
||||
26
api/app/config.py
Normal file
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
|
||||
78
api/app/content.py
Normal file
78
api/app/content.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Load authored world content and cross-check origin references.
|
||||
|
||||
Language-neutral content integrity: every id an origin references (start
|
||||
location, start quest, granted items, seeded npc dispositions) must resolve in
|
||||
world content. New-game construction (client-side, Plan B) relies on this
|
||||
holding; catching it here fails a broken origin at authoring time, not three
|
||||
scenes into play.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_ids(dir_path: Path) -> set[str]:
|
||||
ids: set[str] = set()
|
||||
for f in dir_path.glob("*.json"):
|
||||
with open(f) as fh:
|
||||
ids.add(json.load(fh)["id"])
|
||||
return ids
|
||||
|
||||
|
||||
def load_world(content_root: Path) -> dict[str, set[str]]:
|
||||
world = content_root / "world"
|
||||
return {
|
||||
"locations": _load_ids(world / "locations"),
|
||||
"npcs": _load_ids(world / "npcs"),
|
||||
"quests": _load_ids(world / "quests"),
|
||||
"items": _load_ids(world / "items"),
|
||||
}
|
||||
|
||||
|
||||
def load_origin(path: Path) -> dict:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if origin["start_location_id"] not in world["locations"]:
|
||||
missing.append(f"location:{origin['start_location_id']}")
|
||||
quest = origin.get("start_quest_id")
|
||||
if quest is not None and quest not in world["quests"]:
|
||||
missing.append(f"quest:{quest}")
|
||||
for grant in origin["inventory_grants"]:
|
||||
if grant["item_id"] not in world["items"]:
|
||||
missing.append(f"item:{grant['item_id']}")
|
||||
for npc_id in origin["disposition_overrides"]:
|
||||
if npc_id not in world["npcs"]:
|
||||
missing.append(f"npc:{npc_id}")
|
||||
return missing
|
||||
|
||||
|
||||
def _content_root() -> Path:
|
||||
"""Locate the authored content root. Honours CONTENT_ROOT, else walks up
|
||||
from this file for a dir containing world/ — repo-root/content in a local
|
||||
checkout, /app/content when bundled into the Docker image. Mirrors
|
||||
canon_log._schema_dir().
|
||||
"""
|
||||
env = os.environ.get("CONTENT_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
candidate = parent / "content"
|
||||
if (candidate / "world").is_dir():
|
||||
return candidate
|
||||
raise RuntimeError("content root not found")
|
||||
|
||||
|
||||
def load_npc(npc_id: str) -> dict | None:
|
||||
"""Load one NPC's authored file (persona + knowledge + capabilities), or
|
||||
None if no file resolves. Server-only fields never travel to the client."""
|
||||
path = _content_root() / "world" / "npcs" / f"{npc_id}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
116
api/app/main.py
Normal file
116
api/app/main.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""FastAPI proxy entrypoint — the guarding proxy of charter §4.
|
||||
|
||||
Skeleton: a health check plus the five role endpoints. Each role endpoint now
|
||||
validates the posted canon log against the contract (charter §11) before doing
|
||||
anything else — an invalid log is rejected with 422 and never reaches a prompt.
|
||||
`/dm/narrate` is fully wired: prompt routing, the Ollama model call, and
|
||||
call-log logging. The other four roles remain stubs until they get the same
|
||||
treatment.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .canon_log import validate_canon_log
|
||||
from .narrate import run as narrate_run
|
||||
from .npc import UnknownNpc, run as npc_run
|
||||
from .ollama_client import ModelError
|
||||
from .version import VERSION
|
||||
|
||||
app = FastAPI(title="coc-rpg proxy", version=VERSION)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def unify_validation_errors(request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""Reshape pydantic body-validation failures into the same envelope as a
|
||||
canon-log schema failure, so the client parses ONE 422 shape (charter §11):
|
||||
{"detail": {"canon_log_errors": [...]}}.
|
||||
"""
|
||||
errors = [f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in exc.errors()]
|
||||
return JSONResponse(status_code=422, content={"detail": {"canon_log_errors": errors}})
|
||||
|
||||
|
||||
class TurnRequest(BaseModel):
|
||||
canon_log: dict
|
||||
|
||||
|
||||
def valid_turn(req: TurnRequest) -> TurnRequest:
|
||||
"""Shared dependency: reject any request whose canon log breaks the contract."""
|
||||
errors = validate_canon_log(req.canon_log)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
|
||||
return req
|
||||
|
||||
|
||||
class NpcSpeakRequest(BaseModel):
|
||||
canon_log: dict
|
||||
npc_id: str
|
||||
disposition: int
|
||||
available_moves: list[str]
|
||||
utterance: str
|
||||
|
||||
|
||||
def valid_npc_turn(req: NpcSpeakRequest) -> NpcSpeakRequest:
|
||||
"""Reject any /npc/speak request whose canon log breaks the contract."""
|
||||
errors = validate_canon_log(req.canon_log)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
|
||||
return req
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
"""Liveness probe for compose / fly.io."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/version")
|
||||
def version() -> dict:
|
||||
return {"version": VERSION}
|
||||
|
||||
|
||||
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
|
||||
# The client knows these paths and nothing about which model or prompt serves
|
||||
# them. Bodies are validated against the canon log contract. /dm/narrate is
|
||||
# fully wired (routing + model call + logging); the other four roles remain
|
||||
# stubs until prompt routing, model selection, and logging land for them too.
|
||||
|
||||
|
||||
@app.post("/dm/narrate")
|
||||
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
try:
|
||||
return {"prose": narrate_run(req.canon_log)}
|
||||
except ModelError as exc:
|
||||
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
|
||||
|
||||
|
||||
@app.post("/dm/adjudicate")
|
||||
def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
|
||||
|
||||
@app.post("/dm/improvise")
|
||||
def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
|
||||
|
||||
@app.post("/npc/speak")
|
||||
def npc_speak(req: NpcSpeakRequest = Depends(valid_npc_turn)) -> dict:
|
||||
try:
|
||||
return {"prose": npc_run(
|
||||
canon_log=req.canon_log, npc_id=req.npc_id,
|
||||
disposition=req.disposition, available_moves=req.available_moves,
|
||||
utterance=req.utterance,
|
||||
)}
|
||||
except UnknownNpc as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"canon_log_errors": [f"unknown npc_id: {exc}"]})
|
||||
except ModelError as exc:
|
||||
raise HTTPException(status_code=502, detail={"model_error": str(exc)})
|
||||
|
||||
|
||||
@app.post("/party/banter")
|
||||
def banter(req: TurnRequest = Depends(valid_turn)) -> dict:
|
||||
return {"detail": "not implemented"}
|
||||
37
api/app/narrate.py
Normal file
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)
|
||||
21
api/fly.toml
Normal file
21
api/fly.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
# fly.io deploy config for the proxy (charter §4, prod). Stub — set app name and
|
||||
# region, then `fly deploy` from api/. Secrets (REPLICATE_API_TOKEN) go via
|
||||
# `fly secrets set`, never in this file.
|
||||
app = "coc-rpg-proxy" # TODO: claim a real app name
|
||||
primary_region = "ord" # TODO: pick a region
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[http_service]
|
||||
internal_port = 8000
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
|
||||
[[http_service.checks]]
|
||||
method = "GET"
|
||||
path = "/health"
|
||||
interval = "15s"
|
||||
timeout = "2s"
|
||||
@@ -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.
|
||||
|
||||
7
api/pytest.ini
Normal file
7
api/pytest.ini
Normal file
@@ -0,0 +1,7 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
filterwarnings =
|
||||
ignore:Using `httpx` with `starlette.testclient` is deprecated
|
||||
markers =
|
||||
live: exercises a real Ollama; skipped unless --run-live
|
||||
2
api/requirements-dev.txt
Normal file
2
api/requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
# Dev/test deps. Installed on top of requirements.txt.
|
||||
pytest==8.3.2
|
||||
6
api/requirements.txt
Normal file
6
api/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
# FastAPI proxy runtime deps. Pinned from first resolve; bump deliberately.
|
||||
fastapi==0.139.0
|
||||
uvicorn[standard]==0.51.0
|
||||
httpx==0.28.1 # calling Ollama / Replicate
|
||||
pydantic==2.13.4 # request/response contracts
|
||||
jsonschema==4.23.0 # canon log / origin contract validation
|
||||
0
api/tests/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
30
api/tests/fixtures/canon_log_valid.json
vendored
Normal file
30
api/tests/fixtures/canon_log_valid.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"player": {
|
||||
"name": "Aldric",
|
||||
"race_id": "human",
|
||||
"calling_id": "sellsword",
|
||||
"luck_descriptor": "Fortune spits on you"
|
||||
},
|
||||
"location": { "id": "greywater_docks", "name": "the Greywater docks" },
|
||||
"party": [
|
||||
{ "id": "brannoc_thane", "name": "Brannoc Thane", "disposition": 40 },
|
||||
{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "disposition": 15 }
|
||||
],
|
||||
"recent_events": [
|
||||
"Arrived at Greywater by barge before dawn",
|
||||
"Brannoc recognised the harbourmaster and went quiet"
|
||||
],
|
||||
"established_facts": [
|
||||
"the eastern bridge out of Greywater is washed out",
|
||||
"the harbourmaster is named Oda Fenn"
|
||||
],
|
||||
"active_quests": [
|
||||
{ "id": "find_the_ledger", "name": "The Missing Ledger",
|
||||
"status": "active", "objective": "Find who took Fenn's ledger" }
|
||||
],
|
||||
"humiliations": [
|
||||
{ "id": "h_0001", "text": "vomited on a shrine step in front of a priest",
|
||||
"weight": 7, "turn": 3 }
|
||||
]
|
||||
}
|
||||
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 != ""
|
||||
87
api/tests/test_canon_log_schema.py
Normal file
87
api/tests/test_canon_log_schema.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.canon_log import validate_canon_log
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "canon_log_valid.json"
|
||||
|
||||
|
||||
def _valid():
|
||||
with open(FIXTURE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def test_valid_canon_log_passes():
|
||||
assert validate_canon_log(_valid()) == []
|
||||
|
||||
|
||||
def test_numeric_luck_is_rejected():
|
||||
# Charter §7: the AI must never be able to calculate Luck. A stray numeric
|
||||
# luck field must fail validation, not slip through.
|
||||
doc = _valid()
|
||||
doc["player"]["luck"] = 5
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_recent_events_over_five_is_rejected():
|
||||
doc = _valid()
|
||||
doc["recent_events"] = [f"event {i}" for i in range(6)]
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_disposition_out_of_range_is_rejected():
|
||||
doc = _valid()
|
||||
doc["party"][0]["disposition"] = 200
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_unknown_quest_status_is_rejected():
|
||||
doc = _valid()
|
||||
doc["active_quests"][0]["status"] = "abandoned"
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_humiliation_weight_bounds_enforced():
|
||||
doc = _valid()
|
||||
doc["humiliations"][0]["weight"] = 11
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_empty_optional_arrays_are_allowed():
|
||||
doc = _valid()
|
||||
doc["party"] = []
|
||||
doc["recent_events"] = []
|
||||
doc["established_facts"] = []
|
||||
doc["active_quests"] = []
|
||||
doc["humiliations"] = []
|
||||
assert validate_canon_log(doc) == []
|
||||
|
||||
|
||||
def test_root_level_stray_field_is_rejected():
|
||||
# §2/§7: stats/HP/inventory must never enter the log — additionalProperties:false at root.
|
||||
doc = _valid()
|
||||
doc["hp"] = 10
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_negative_turn_is_rejected():
|
||||
doc = _valid()
|
||||
doc["humiliations"][0]["turn"] = -1
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_dead_class_id_field_is_rejected():
|
||||
# The player block migrated from {name, class_id, luck_descriptor} to
|
||||
# {name, race_id, calling_id, luck_descriptor}. class_id must not slip
|
||||
# back in via additionalProperties: false.
|
||||
doc = _valid()
|
||||
doc["player"]["class_id"] = "sellsword"
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_unknown_calling_id_is_rejected():
|
||||
# assassin/priest are dead ids from the pre-migration class roster and
|
||||
# must not validate as calling_id values.
|
||||
doc = _valid()
|
||||
doc["player"]["calling_id"] = "priest"
|
||||
assert validate_canon_log(doc) != []
|
||||
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
|
||||
47
api/tests/test_content_resolution.py
Normal file
47
api/tests/test_content_resolution.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.canon_log import validate_origin
|
||||
from app.content import load_world, load_origin, unresolved_refs
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
CONTENT_ROOT = REPO_ROOT / "content"
|
||||
DESERTER = CONTENT_ROOT / "origins" / "deserter.json"
|
||||
|
||||
|
||||
def test_deserter_origin_matches_schema():
|
||||
assert validate_origin(load_origin(DESERTER)) == []
|
||||
|
||||
|
||||
def test_deserter_ids_all_resolve():
|
||||
world = load_world(CONTENT_ROOT)
|
||||
assert unresolved_refs(load_origin(DESERTER), world) == []
|
||||
|
||||
|
||||
def test_broken_location_ref_is_detected():
|
||||
world = load_world(CONTENT_ROOT)
|
||||
origin = copy.deepcopy(load_origin(DESERTER))
|
||||
origin["start_location_id"] = "nowhere"
|
||||
assert "location:nowhere" in unresolved_refs(origin, world)
|
||||
|
||||
|
||||
def test_broken_item_ref_is_detected():
|
||||
world = load_world(CONTENT_ROOT)
|
||||
origin = copy.deepcopy(load_origin(DESERTER))
|
||||
origin["inventory_grants"].append({"item_id": "ghost_blade", "qty": 1})
|
||||
assert "item:ghost_blade" in unresolved_refs(origin, world)
|
||||
|
||||
|
||||
def test_null_quest_ref_resolves():
|
||||
world = load_world(CONTENT_ROOT)
|
||||
origin = copy.deepcopy(load_origin(DESERTER))
|
||||
origin["start_quest_id"] = None
|
||||
assert unresolved_refs(origin, world) == []
|
||||
|
||||
|
||||
def test_broken_disposition_npc_ref_is_detected():
|
||||
world = load_world(CONTENT_ROOT)
|
||||
origin = copy.deepcopy(load_origin(DESERTER))
|
||||
origin["disposition_overrides"]["ghost_npc"] = 10
|
||||
assert "npc:ghost_npc" in unresolved_refs(origin, world)
|
||||
74
api/tests/test_endpoints.py
Normal file
74
api/tests/test_endpoints.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.narrate as narrate
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
VALID_LOG = json.load(open(Path(__file__).parent / "fixtures" / "canon_log_valid.json"))
|
||||
|
||||
ROLE_ENDPOINTS = [
|
||||
"/dm/narrate", "/dm/adjudicate", "/dm/improvise",
|
||||
"/npc/speak", "/party/banter",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_narrate_model_call(monkeypatch):
|
||||
# /dm/narrate is wired to a real Ollama call (Task 7). These tests exercise
|
||||
# canon-log validation across ALL role endpoints, not model behavior, so
|
||||
# stub the model boundary — keeps the default suite hermetic (no network).
|
||||
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "stubbed prose")
|
||||
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
|
||||
|
||||
|
||||
def test_health_ok():
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_every_role_endpoint_accepts_a_valid_canon_log():
|
||||
for path in ROLE_ENDPOINTS:
|
||||
if path == "/npc/speak":
|
||||
# /npc/speak has additional required fields
|
||||
body = {
|
||||
"canon_log": VALID_LOG, "npc_id": "brannoc_thane",
|
||||
"disposition": 0, "available_moves": [], "utterance": "test"
|
||||
}
|
||||
else:
|
||||
body = {"canon_log": VALID_LOG}
|
||||
r = client.post(path, json=body)
|
||||
assert r.status_code == 200, f"{path} rejected a valid log: {r.text}"
|
||||
|
||||
|
||||
def test_every_role_endpoint_rejects_an_invalid_canon_log():
|
||||
bad = json.loads(json.dumps(VALID_LOG))
|
||||
bad["player"]["luck"] = 5 # §7 leak
|
||||
for path in ROLE_ENDPOINTS:
|
||||
if path == "/npc/speak":
|
||||
# /npc/speak has additional required fields
|
||||
body = {
|
||||
"canon_log": bad, "npc_id": "brannoc_thane",
|
||||
"disposition": 0, "available_moves": [], "utterance": "test"
|
||||
}
|
||||
else:
|
||||
body = {"canon_log": bad}
|
||||
r = client.post(path, json=body)
|
||||
assert r.status_code == 422, f"{path} accepted an invalid log"
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_missing_canon_log_is_a_422():
|
||||
r = client.post("/dm/narrate", json={})
|
||||
assert r.status_code == 422
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_malformed_body_uses_the_unified_error_shape():
|
||||
# A pydantic body failure returns the SAME envelope as a schema failure,
|
||||
# so the client only ever parses one 422 shape.
|
||||
r = client.post("/dm/narrate", json={"canon_log": "not-a-dict"})
|
||||
assert r.status_code == 422
|
||||
assert "canon_log_errors" in r.json()["detail"]
|
||||
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))
|
||||
54
api/tests/test_origin_schema.py
Normal file
54
api/tests/test_origin_schema.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import copy
|
||||
|
||||
from app.canon_log import validate_origin
|
||||
|
||||
VALID_ORIGIN = {
|
||||
"schema_version": 1,
|
||||
"id": "deserter",
|
||||
"display_name": "The Deserter",
|
||||
"description": "You walked away from a company that doesn't allow walking away.",
|
||||
"start_location_id": "greywater_docks",
|
||||
"situation": ["Arrived at Greywater by barge before dawn, hood up"],
|
||||
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
|
||||
"disposition_overrides": {"brannoc_thane": 40, "cadwyn_vell": 15},
|
||||
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": {
|
||||
"allowed_callings": ["sellsword", "cutpurse", "bonesetter"],
|
||||
"luck_modifier": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_valid_origin_passes():
|
||||
assert validate_origin(VALID_ORIGIN) == []
|
||||
|
||||
|
||||
def test_null_start_quest_is_allowed():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
doc["start_quest_id"] = None
|
||||
assert validate_origin(doc) == []
|
||||
|
||||
|
||||
def test_unknown_calling_is_rejected():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
doc["build_constraints"]["allowed_callings"] = ["bard"]
|
||||
assert validate_origin(doc) != []
|
||||
|
||||
|
||||
def test_missing_required_field_is_rejected():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
del doc["start_location_id"]
|
||||
assert validate_origin(doc) != []
|
||||
|
||||
|
||||
def test_extra_field_is_rejected():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
doc["surprise"] = True
|
||||
assert validate_origin(doc) != []
|
||||
|
||||
|
||||
def test_disposition_override_key_must_match_id_pattern():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
doc["disposition_overrides"] = {"Brannoc Thane": 40}
|
||||
assert validate_origin(doc) != []
|
||||
102
api/tests/test_prompts.py
Normal file
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
|
||||
7
client/.gutconfig.json
Normal file
7
client/.gutconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dirs": ["res://tests/unit"],
|
||||
"include_subdirs": true,
|
||||
"log_level": 1,
|
||||
"should_exit": true,
|
||||
"failure_error_types": ["engine", "gut"]
|
||||
}
|
||||
132
client/addons/gut/GutScene.gd
Normal file
132
client/addons/gut/GutScene.gd
Normal file
@@ -0,0 +1,132 @@
|
||||
extends Node2D
|
||||
# ##############################################################################
|
||||
# This is a wrapper around the normal and compact gui controls and serves as
|
||||
# the interface between gut.gd and the gui. The GutRunner creates an instance
|
||||
# of this and then this takes care of managing the different GUI controls.
|
||||
# ##############################################################################
|
||||
@onready var _normal_gui = $Normal
|
||||
@onready var _compact_gui = $Compact
|
||||
|
||||
var gut = null :
|
||||
set(val):
|
||||
gut = val
|
||||
_set_gut(val)
|
||||
|
||||
|
||||
func _ready():
|
||||
_normal_gui.switch_modes.connect(use_compact_mode.bind(true))
|
||||
_compact_gui.switch_modes.connect(use_compact_mode.bind(false))
|
||||
|
||||
_normal_gui.set_title("GUT")
|
||||
_compact_gui.set_title("GUT")
|
||||
|
||||
_normal_gui.align_right()
|
||||
_compact_gui.to_bottom_right()
|
||||
|
||||
use_compact_mode(false)
|
||||
|
||||
if(get_parent() == get_tree().root):
|
||||
_test_running_setup()
|
||||
|
||||
func _test_running_setup():
|
||||
set_font_size(100)
|
||||
_normal_gui.get_textbox().text = "hello world, how are you doing?"
|
||||
|
||||
# ------------------------
|
||||
# Private
|
||||
# ------------------------
|
||||
func _set_gut(val):
|
||||
if(_normal_gui.get_gut() == val):
|
||||
return
|
||||
_normal_gui.set_gut(val)
|
||||
_compact_gui.set_gut(val)
|
||||
|
||||
val.start_run.connect(_on_gut_start_run)
|
||||
val.end_run.connect(_on_gut_end_run)
|
||||
val.start_pause_before_teardown.connect(_on_gut_pause)
|
||||
val.end_pause_before_teardown.connect(_on_pause_end)
|
||||
|
||||
func _set_both_titles(text):
|
||||
_normal_gui.set_title(text)
|
||||
_compact_gui.set_title(text)
|
||||
|
||||
|
||||
# ------------------------
|
||||
# Events
|
||||
# ------------------------
|
||||
func _on_gut_start_run():
|
||||
_set_both_titles('Running')
|
||||
|
||||
func _on_gut_end_run():
|
||||
_set_both_titles('Finished')
|
||||
|
||||
func _on_gut_pause():
|
||||
_set_both_titles('-- Paused --')
|
||||
|
||||
func _on_pause_end():
|
||||
_set_both_titles('Running')
|
||||
|
||||
|
||||
# ------------------------
|
||||
# Public
|
||||
# ------------------------
|
||||
func get_textbox():
|
||||
return _normal_gui.get_textbox()
|
||||
|
||||
|
||||
func set_font_size(new_size):
|
||||
var rtl = _normal_gui.get_textbox()
|
||||
|
||||
rtl.set('theme_override_font_sizes/bold_italics_font_size', new_size)
|
||||
rtl.set('theme_override_font_sizes/bold_font_size', new_size)
|
||||
rtl.set('theme_override_font_sizes/italics_font_size', new_size)
|
||||
rtl.set('theme_override_font_sizes/normal_font_size', new_size)
|
||||
|
||||
|
||||
func set_font(font_name):
|
||||
_set_all_fonts_in_rtl(_normal_gui.get_textbox(), font_name)
|
||||
|
||||
|
||||
func _set_font(rtl, font_name, custom_name):
|
||||
if(font_name == null):
|
||||
rtl.remove_theme_font_override(custom_name)
|
||||
else:
|
||||
var font_path = 'res://addons/gut/fonts/' + font_name + '.ttf'
|
||||
if(FileAccess.file_exists(font_path)):
|
||||
var dyn_font = FontFile.new()
|
||||
dyn_font.load_dynamic_font('res://addons/gut/fonts/' + font_name + '.ttf')
|
||||
rtl.add_theme_font_override(custom_name, dyn_font)
|
||||
|
||||
|
||||
func _set_all_fonts_in_rtl(rtl, base_name):
|
||||
if(base_name == 'Default'):
|
||||
_set_font(rtl, null, 'normal_font')
|
||||
_set_font(rtl, null, 'bold_font')
|
||||
_set_font(rtl, null, 'italics_font')
|
||||
_set_font(rtl, null, 'bold_italics_font')
|
||||
else:
|
||||
_set_font(rtl, base_name + '-Regular', 'normal_font')
|
||||
_set_font(rtl, base_name + '-Bold', 'bold_font')
|
||||
_set_font(rtl, base_name + '-Italic', 'italics_font')
|
||||
_set_font(rtl, base_name + '-BoldItalic', 'bold_italics_font')
|
||||
|
||||
|
||||
func set_default_font_color(color):
|
||||
_normal_gui.get_textbox().set('custom_colors/default_color', color)
|
||||
|
||||
|
||||
func set_background_color(color):
|
||||
_normal_gui.set_bg_color(color)
|
||||
|
||||
|
||||
func use_compact_mode(should=true):
|
||||
_compact_gui.visible = should
|
||||
_normal_gui.visible = !should
|
||||
|
||||
|
||||
func set_opacity(val):
|
||||
_normal_gui.modulate.a = val
|
||||
_compact_gui.modulate.a = val
|
||||
|
||||
func set_title(text):
|
||||
_set_both_titles(text)
|
||||
1
client/addons/gut/GutScene.gd.uid
Normal file
1
client/addons/gut/GutScene.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bw7tukh738kw1
|
||||
16
client/addons/gut/GutScene.tscn
Normal file
16
client/addons/gut/GutScene.tscn
Normal file
@@ -0,0 +1,16 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://m28heqtswbuq"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://bw7tukh738kw1" path="res://addons/gut/GutScene.gd" id="1_b4m8y"]
|
||||
[ext_resource type="PackedScene" uid="uid://duxblir3vu8x7" path="res://addons/gut/gui/NormalGui.tscn" id="2_j6ywb"]
|
||||
[ext_resource type="PackedScene" uid="uid://cnqqdfsn80ise" path="res://addons/gut/gui/MinGui.tscn" id="3_3glw1"]
|
||||
|
||||
[node name="GutScene" type="Node2D"]
|
||||
script = ExtResource("1_b4m8y")
|
||||
|
||||
[node name="Normal" parent="." instance=ExtResource("2_j6ywb")]
|
||||
|
||||
[node name="Compact" parent="." instance=ExtResource("3_3glw1")]
|
||||
offset_left = 5.0
|
||||
offset_top = 273.0
|
||||
offset_right = 265.0
|
||||
offset_bottom = 403.0
|
||||
22
client/addons/gut/LICENSE.md
Normal file
22
client/addons/gut/LICENSE.md
Normal file
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
=====================
|
||||
|
||||
Copyright (c) 2018 Tom "Butch" Wesley
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
52
client/addons/gut/UserFileViewer.gd
Normal file
52
client/addons/gut/UserFileViewer.gd
Normal file
@@ -0,0 +1,52 @@
|
||||
extends Window
|
||||
|
||||
@onready var rtl = $TextDisplay/RichTextLabel
|
||||
|
||||
func _get_file_as_text(path):
|
||||
var to_return = null
|
||||
var f = FileAccess.open(path, FileAccess.READ)
|
||||
if(f != null):
|
||||
to_return = f.get_as_text()
|
||||
else:
|
||||
to_return = str('ERROR: Could not open file. Error code ', FileAccess.get_open_error())
|
||||
return to_return
|
||||
|
||||
func _ready():
|
||||
rtl.clear()
|
||||
|
||||
func _on_OpenFile_pressed():
|
||||
$FileDialog.popup_centered()
|
||||
|
||||
func _on_FileDialog_file_selected(path):
|
||||
show_file(path)
|
||||
|
||||
func _on_Close_pressed():
|
||||
self.hide()
|
||||
|
||||
func show_file(path):
|
||||
var text = _get_file_as_text(path)
|
||||
if(text == ''):
|
||||
text = '<Empty File>'
|
||||
rtl.set_text(text)
|
||||
self.window_title = path
|
||||
|
||||
func show_open():
|
||||
self.popup_centered()
|
||||
$FileDialog.popup_centered()
|
||||
|
||||
func get_rich_text_label():
|
||||
return $TextDisplay/RichTextLabel
|
||||
|
||||
func _on_Home_pressed():
|
||||
rtl.scroll_to_line(0)
|
||||
|
||||
func _on_End_pressed():
|
||||
rtl.scroll_to_line(rtl.get_line_count() -1)
|
||||
|
||||
func _on_Copy_pressed():
|
||||
return
|
||||
# OS.clipboard = rtl.text
|
||||
|
||||
func _on_file_dialog_visibility_changed():
|
||||
if rtl.text.length() == 0 and not $FileDialog.visible:
|
||||
self.hide()
|
||||
1
client/addons/gut/UserFileViewer.gd.uid
Normal file
1
client/addons/gut/UserFileViewer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://x51wilphva3d
|
||||
92
client/addons/gut/UserFileViewer.tscn
Normal file
92
client/addons/gut/UserFileViewer.tscn
Normal file
@@ -0,0 +1,92 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bsm7wtt1gie4v"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://x51wilphva3d" path="res://addons/gut/UserFileViewer.gd" id="1"]
|
||||
|
||||
[node name="UserFileViewer" type="Window"]
|
||||
exclusive = true
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="FileDialog" type="FileDialog" parent="."]
|
||||
access = 1
|
||||
show_hidden_files = true
|
||||
__meta__ = {
|
||||
"_edit_use_anchors_": false
|
||||
}
|
||||
|
||||
[node name="TextDisplay" type="ColorRect" parent="."]
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 8.0
|
||||
offset_right = -10.0
|
||||
offset_bottom = -65.0
|
||||
color = Color(0.2, 0.188235, 0.188235, 1)
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="TextDisplay"]
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
focus_mode = 2
|
||||
text = "In publishing and graphic design, Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content. Lorem ipsum may be used before final copy is available, but it may also be used to temporarily replace copy in a process called greeking, which allows designers to consider form without the meaning of the text influencing the design.
|
||||
|
||||
Lorem ipsum is typically a corrupted version of De finibus bonorum et malorum, a first-century BCE text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical, improper Latin.
|
||||
|
||||
Versions of the Lorem ipsum text have been used in typesetting at least since the 1960s, when it was popularized by advertisements for Letraset transfer sheets. Lorem ipsum was introduced to the digital world in the mid-1980s when Aldus employed it in graphic and word-processing templates for its desktop publishing program PageMaker. Other popular word processors including Pages and Microsoft Word have since adopted Lorem ipsum as well."
|
||||
selection_enabled = true
|
||||
|
||||
[node name="OpenFile" type="Button" parent="."]
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -158.0
|
||||
offset_top = -50.0
|
||||
offset_right = -84.0
|
||||
offset_bottom = -30.0
|
||||
text = "Open File"
|
||||
|
||||
[node name="Home" type="Button" parent="."]
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -478.0
|
||||
offset_top = -50.0
|
||||
offset_right = -404.0
|
||||
offset_bottom = -30.0
|
||||
text = "Home"
|
||||
|
||||
[node name="Copy" type="Button" parent="."]
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 160.0
|
||||
offset_top = -50.0
|
||||
offset_right = 234.0
|
||||
offset_bottom = -30.0
|
||||
text = "Copy"
|
||||
|
||||
[node name="End" type="Button" parent="."]
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -318.0
|
||||
offset_top = -50.0
|
||||
offset_right = -244.0
|
||||
offset_bottom = -30.0
|
||||
text = "End"
|
||||
|
||||
[node name="Close" type="Button" parent="."]
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 10.0
|
||||
offset_top = -50.0
|
||||
offset_right = 80.0
|
||||
offset_bottom = -30.0
|
||||
text = "Close"
|
||||
|
||||
[connection signal="file_selected" from="FileDialog" to="." method="_on_FileDialog_file_selected"]
|
||||
[connection signal="visibility_changed" from="FileDialog" to="." method="_on_file_dialog_visibility_changed"]
|
||||
[connection signal="pressed" from="OpenFile" to="." method="_on_OpenFile_pressed"]
|
||||
[connection signal="pressed" from="Home" to="." method="_on_Home_pressed"]
|
||||
[connection signal="pressed" from="Copy" to="." method="_on_Copy_pressed"]
|
||||
[connection signal="pressed" from="End" to="." method="_on_End_pressed"]
|
||||
[connection signal="pressed" from="Close" to="." method="_on_Close_pressed"]
|
||||
86
client/addons/gut/autofree.gd
Normal file
86
client/addons/gut/autofree.gd
Normal file
@@ -0,0 +1,86 @@
|
||||
# ##############################################################################
|
||||
#(G)odot (U)nit (T)est class
|
||||
#
|
||||
# ##############################################################################
|
||||
# The MIT License (MIT)
|
||||
# =====================
|
||||
#
|
||||
# Copyright (c) 2025 Tom "Butch" Wesley
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
# ##############################################################################
|
||||
# Class used to keep track of objects to be freed and utilities to free them.
|
||||
# ##############################################################################
|
||||
var _to_free = []
|
||||
var _to_queue_free = []
|
||||
var _ref_counted_doubles = []
|
||||
var _all_instance_ids = []
|
||||
|
||||
|
||||
func _add_instance_id(thing):
|
||||
if(thing.has_method("get_instance_id")):
|
||||
_all_instance_ids.append(thing.get_instance_id())
|
||||
|
||||
|
||||
func add_free(thing):
|
||||
if(typeof(thing) == TYPE_OBJECT):
|
||||
_add_instance_id(thing)
|
||||
if(!thing is RefCounted):
|
||||
_to_free.append(thing)
|
||||
elif(GutUtils.is_double(thing)):
|
||||
_ref_counted_doubles.append(thing)
|
||||
|
||||
|
||||
func add_queue_free(thing):
|
||||
if(typeof(thing) == TYPE_OBJECT):
|
||||
_add_instance_id(thing)
|
||||
_to_queue_free.append(thing)
|
||||
|
||||
|
||||
func get_queue_free_count():
|
||||
return _to_queue_free.size()
|
||||
|
||||
|
||||
func get_free_count():
|
||||
return _to_free.size()
|
||||
|
||||
|
||||
func free_all():
|
||||
for node in _to_free:
|
||||
if(is_instance_valid(node)):
|
||||
if(GutUtils.is_double(node)):
|
||||
node.__gutdbl_done()
|
||||
node.free()
|
||||
_to_free.clear()
|
||||
|
||||
for i in range(_to_queue_free.size()):
|
||||
if(is_instance_valid(_to_queue_free[i])):
|
||||
_to_queue_free[i].queue_free()
|
||||
_to_queue_free.clear()
|
||||
|
||||
for ref_dbl in _ref_counted_doubles:
|
||||
ref_dbl.__gutdbl_done()
|
||||
_ref_counted_doubles.clear()
|
||||
|
||||
_all_instance_ids.clear()
|
||||
|
||||
|
||||
func has_instance_id(id):
|
||||
return _all_instance_ids.has(id)
|
||||
1
client/addons/gut/autofree.gd.uid
Normal file
1
client/addons/gut/autofree.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bxjfriqxgwe0r
|
||||
201
client/addons/gut/awaiter.gd
Normal file
201
client/addons/gut/awaiter.gd
Normal file
@@ -0,0 +1,201 @@
|
||||
extends Node
|
||||
|
||||
class GutAwaiterLogger:
|
||||
var _time_waited = 0.0
|
||||
var logger = GutUtils.get_logger()
|
||||
var waiting_on = "nothing"
|
||||
var logged_initial_message = false
|
||||
var wait_log_delay := 1.0
|
||||
var disabled = false
|
||||
|
||||
func waited(x):
|
||||
_time_waited += x
|
||||
if(!logged_initial_message and _time_waited >= wait_log_delay):
|
||||
log_it()
|
||||
logged_initial_message = true
|
||||
|
||||
|
||||
func reset():
|
||||
_time_waited = 0.0
|
||||
logged_initial_message = false
|
||||
|
||||
|
||||
func log_it():
|
||||
if(!disabled):
|
||||
var msg = str("--- Awaiting ", waiting_on, " ---")
|
||||
logger.wait_msg(msg)
|
||||
|
||||
|
||||
|
||||
|
||||
signal timeout
|
||||
signal wait_started
|
||||
|
||||
var await_logger = GutAwaiterLogger.new()
|
||||
var _wait_time := 0.0
|
||||
var _wait_process_frames := 0
|
||||
var _wait_physics_frames := 0
|
||||
var _signal_to_wait_on = null
|
||||
|
||||
var _predicate_method = null
|
||||
var _waiting_for_predicate_to_be = null
|
||||
|
||||
var _predicate_time_between := 0.0
|
||||
var _predicate_time_between_elpased := 0.0
|
||||
|
||||
var _elapsed_time := 0.0
|
||||
var _elapsed_frames := 0
|
||||
|
||||
var _did_last_wait_timeout = false
|
||||
var did_last_wait_timeout = false :
|
||||
get: return _did_last_wait_timeout
|
||||
set(val): push_error("Cannot set did_last_wait_timeout")
|
||||
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
get_tree().process_frame.connect(_on_tree_process_frame)
|
||||
get_tree().physics_frame.connect(_on_tree_physics_frame)
|
||||
|
||||
|
||||
func _on_tree_process_frame():
|
||||
# Count frames here instead of in _process so that tree order never
|
||||
# makes a difference and the count/signaling happens outside of
|
||||
# _process being called.
|
||||
if(_wait_process_frames > 0):
|
||||
_elapsed_frames += 1
|
||||
if(_elapsed_frames > _wait_process_frames):
|
||||
_end_wait()
|
||||
|
||||
|
||||
func _on_tree_physics_frame():
|
||||
# Count frames here instead of in _physics_process so that tree order never
|
||||
# makes a difference and the count/signaling happens outside of
|
||||
# _physics_process being called.
|
||||
if(_wait_physics_frames != 0):
|
||||
_elapsed_frames += 1
|
||||
if(_elapsed_frames > _wait_physics_frames):
|
||||
_end_wait()
|
||||
|
||||
|
||||
func _physics_process(delta):
|
||||
if(is_waiting()):
|
||||
await_logger.waited(delta)
|
||||
|
||||
if(_wait_time != 0.0):
|
||||
_elapsed_time += delta
|
||||
if(_elapsed_time >= _wait_time):
|
||||
_end_wait()
|
||||
|
||||
if(_predicate_method != null):
|
||||
_predicate_time_between_elpased += delta
|
||||
if(_predicate_time_between_elpased >= _predicate_time_between):
|
||||
_predicate_time_between_elpased = 0.0
|
||||
var result = _predicate_method.call()
|
||||
if(_waiting_for_predicate_to_be == false):
|
||||
if(typeof(result) != TYPE_BOOL or result != true):
|
||||
_end_wait()
|
||||
else:
|
||||
if(typeof(result) == TYPE_BOOL and result == _waiting_for_predicate_to_be):
|
||||
_end_wait()
|
||||
|
||||
|
||||
func _end_wait():
|
||||
await_logger.reset()
|
||||
# Check for time before checking for frames so that the extra frames added
|
||||
# when waiting on a signal do not cause a false negative for timing out.
|
||||
if(_wait_time > 0):
|
||||
_did_last_wait_timeout = _elapsed_time >= _wait_time
|
||||
elif(_wait_physics_frames > 0):
|
||||
_did_last_wait_timeout = _elapsed_frames >= _wait_physics_frames
|
||||
elif(_wait_process_frames > 0):
|
||||
_did_last_wait_timeout = _elapsed_frames >= _wait_process_frames
|
||||
|
||||
if(_signal_to_wait_on != null and \
|
||||
is_instance_valid(_signal_to_wait_on.get_object()) and \
|
||||
_signal_to_wait_on.is_connected(_signal_callback)):
|
||||
_signal_to_wait_on.disconnect(_signal_callback)
|
||||
|
||||
_wait_process_frames = 0
|
||||
_wait_time = 0.0
|
||||
_wait_physics_frames = 0
|
||||
_signal_to_wait_on = null
|
||||
_predicate_method = null
|
||||
_elapsed_time = 0.0
|
||||
_elapsed_frames = 0
|
||||
timeout.emit()
|
||||
|
||||
|
||||
const ARG_NOT_SET = '_*_argument_*_is_*_not_set_*_'
|
||||
func _signal_callback(
|
||||
_arg1=ARG_NOT_SET, _arg2=ARG_NOT_SET, _arg3=ARG_NOT_SET,
|
||||
_arg4=ARG_NOT_SET, _arg5=ARG_NOT_SET, _arg6=ARG_NOT_SET,
|
||||
_arg7=ARG_NOT_SET, _arg8=ARG_NOT_SET, _arg9=ARG_NOT_SET):
|
||||
|
||||
_signal_to_wait_on.disconnect(_signal_callback)
|
||||
# DO NOT _end_wait here. For other parts of the test to get the signal that
|
||||
# was waited on, we have to wait for another frames. For example, the
|
||||
# signal_watcher doesn't get the signal in time if we don't do this.
|
||||
_wait_process_frames = 1
|
||||
|
||||
|
||||
func wait_seconds(x, msg=''):
|
||||
await_logger.waiting_on = str(x, " seconds ", msg)
|
||||
_did_last_wait_timeout = false
|
||||
_wait_time = x
|
||||
wait_started.emit()
|
||||
|
||||
|
||||
func wait_process_frames(x, msg=''):
|
||||
await_logger.waiting_on = str(x, " idle frames ", msg)
|
||||
_did_last_wait_timeout = false
|
||||
_wait_process_frames = x
|
||||
wait_started.emit()
|
||||
|
||||
|
||||
func wait_physics_frames(x, msg=''):
|
||||
await_logger.waiting_on = str(x, " physics frames ", msg)
|
||||
_did_last_wait_timeout = false
|
||||
_wait_physics_frames = x
|
||||
wait_started.emit()
|
||||
|
||||
|
||||
func wait_for_signal(the_signal : Signal, max_time, msg=''):
|
||||
await_logger.waiting_on = str("signal ", the_signal.get_name(), " or ", max_time, "s ", msg)
|
||||
_did_last_wait_timeout = false
|
||||
the_signal.connect(_signal_callback)
|
||||
_signal_to_wait_on = the_signal
|
||||
_wait_time = max_time
|
||||
wait_started.emit()
|
||||
|
||||
|
||||
func wait_until(predicate_function: Callable, max_time, time_between_calls:=0.0, msg=''):
|
||||
await_logger.waiting_on = str("callable to return TRUE or ", max_time, "s. ", msg)
|
||||
_predicate_time_between = time_between_calls
|
||||
_predicate_method = predicate_function
|
||||
_wait_time = max_time
|
||||
|
||||
_waiting_for_predicate_to_be = true
|
||||
_predicate_time_between_elpased = 0.0
|
||||
_did_last_wait_timeout = false
|
||||
|
||||
wait_started.emit()
|
||||
|
||||
|
||||
func wait_while(predicate_function: Callable, max_time, time_between_calls:=0.0, msg=''):
|
||||
await_logger.waiting_on = str("callable to return FALSE or ", max_time, "s. ", msg)
|
||||
_predicate_time_between = time_between_calls
|
||||
_predicate_method = predicate_function
|
||||
_wait_time = max_time
|
||||
|
||||
_waiting_for_predicate_to_be = false
|
||||
_predicate_time_between_elpased = 0.0
|
||||
_did_last_wait_timeout = false
|
||||
|
||||
wait_started.emit()
|
||||
|
||||
|
||||
func is_waiting():
|
||||
return _wait_time != 0.0 || \
|
||||
_wait_physics_frames != 0 || \
|
||||
_wait_process_frames != 0
|
||||
1
client/addons/gut/awaiter.gd.uid
Normal file
1
client/addons/gut/awaiter.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ccu4ww35edtdi
|
||||
239
client/addons/gut/cli/change_project_warnings.gd
Normal file
239
client/addons/gut/cli/change_project_warnings.gd
Normal file
@@ -0,0 +1,239 @@
|
||||
extends SceneTree
|
||||
|
||||
var Optparse = load('res://addons/gut/cli/optparse.gd')
|
||||
var WarningsManager = load("res://addons/gut/warnings_manager.gd")
|
||||
const WARN_VALUE_PRINT_POSITION = 36
|
||||
|
||||
var godot_default_warnings = {
|
||||
"assert_always_false": 1, "assert_always_true": 1, "confusable_identifier": 1,
|
||||
"confusable_local_declaration": 1, "confusable_local_usage": 1, "constant_used_as_function": 1,
|
||||
"deprecated_keyword": 1, "empty_file": 1, "enable": true,
|
||||
"exclude_addons": true, "function_used_as_property": 1, "get_node_default_without_onready": 2,
|
||||
"incompatible_ternary": 1, "inference_on_variant": 2, "inferred_declaration": 0,
|
||||
"int_as_enum_without_cast": 1, "int_as_enum_without_match": 1, "integer_division": 1,
|
||||
"narrowing_conversion": 1, "native_method_override": 2, "onready_with_export": 2,
|
||||
"property_used_as_function": 1, "redundant_await": 1, "redundant_static_unload": 1,
|
||||
"renamed_in_godot_4_hint": 1, "return_value_discarded": 0, "shadowed_global_identifier": 1,
|
||||
"shadowed_variable": 1, "shadowed_variable_base_class": 1, "standalone_expression": 1,
|
||||
"standalone_ternary": 1, "static_called_on_instance": 1, "unassigned_variable": 1,
|
||||
"unassigned_variable_op_assign": 1, "unreachable_code": 1, "unreachable_pattern": 1,
|
||||
"unsafe_call_argument": 0, "unsafe_cast": 0, "unsafe_method_access": 0,
|
||||
"unsafe_property_access": 0, "unsafe_void_return": 1, "untyped_declaration": 0,
|
||||
"unused_local_constant": 1, "unused_parameter": 1, "unused_private_class_variable": 1,
|
||||
"unused_signal": 1, "unused_variable": 1
|
||||
}
|
||||
|
||||
var gut_default_changes = {
|
||||
"exclude_addons": false, "redundant_await": 0,
|
||||
}
|
||||
|
||||
var warning_settings = {}
|
||||
|
||||
func _setup_warning_settings():
|
||||
warning_settings["godot_default"] = godot_default_warnings
|
||||
warning_settings["current"] = WarningsManager.create_warnings_dictionary_from_project_settings()
|
||||
warning_settings["all_warn"] = WarningsManager.create_warn_all_warnings_dictionary()
|
||||
|
||||
var gut_default = godot_default_warnings.duplicate()
|
||||
gut_default.merge(gut_default_changes, true)
|
||||
warning_settings["gut_default"] = gut_default
|
||||
|
||||
|
||||
func _warn_value_to_s(value):
|
||||
var readable = str(value).capitalize()
|
||||
if(typeof(value) == TYPE_INT):
|
||||
readable = WarningsManager.WARNING_LOOKUP.get(value, str(readable, ' ???'))
|
||||
readable = readable.capitalize()
|
||||
return readable
|
||||
|
||||
|
||||
func _human_readable(warnings):
|
||||
var to_return = ""
|
||||
for key in warnings:
|
||||
var readable = _warn_value_to_s(warnings[key])
|
||||
to_return += str(key.capitalize().rpad(35, ' '), readable, "\n")
|
||||
return to_return
|
||||
|
||||
|
||||
func _dump_settings(which):
|
||||
if(warning_settings.has(which)):
|
||||
GutUtils.pretty_print(warning_settings[which])
|
||||
else:
|
||||
print("UNKNOWN print option ", which)
|
||||
|
||||
|
||||
func _print_settings(which):
|
||||
if(warning_settings.has(which)):
|
||||
print(_human_readable(warning_settings[which]))
|
||||
else:
|
||||
print("UNKNOWN print option ", which)
|
||||
|
||||
|
||||
func _apply_settings(which):
|
||||
if(!warning_settings.has(which)):
|
||||
print("UNKNOWN set option ", which)
|
||||
return
|
||||
|
||||
var pre_settings = warning_settings["current"]
|
||||
var new_settings = warning_settings[which]
|
||||
|
||||
if(new_settings == pre_settings):
|
||||
print("-- Settings are the same, no changes were made --")
|
||||
return
|
||||
|
||||
WarningsManager.apply_warnings_dictionary(new_settings)
|
||||
ProjectSettings.save()
|
||||
print("-- Project Warning Settings have been updated --")
|
||||
print(_diff_changes_text(pre_settings))
|
||||
|
||||
|
||||
func _diff_text(w1, w2, diff_col_pad=10):
|
||||
var to_return = ""
|
||||
for key in w1:
|
||||
var v1_text = _warn_value_to_s(w1[key])
|
||||
var v2_text = _warn_value_to_s(w2[key])
|
||||
var diff_text = v1_text
|
||||
var prefix = " "
|
||||
|
||||
if(v1_text != v2_text):
|
||||
var diff_prefix = " "
|
||||
if(w1[key] > w2[key]):
|
||||
diff_prefix = "-"
|
||||
else:
|
||||
diff_prefix = "+"
|
||||
prefix = "* "
|
||||
diff_text = str(v1_text.rpad(diff_col_pad, ' '), diff_prefix, v2_text)
|
||||
|
||||
to_return += str(str(prefix, key.capitalize()).rpad(WARN_VALUE_PRINT_POSITION, ' '), diff_text, "\n")
|
||||
|
||||
return to_return.rstrip("\n")
|
||||
|
||||
|
||||
func _diff_changes_text(pre_settings):
|
||||
var orig_diff_text = _diff_text(
|
||||
pre_settings,
|
||||
WarningsManager.create_warnings_dictionary_from_project_settings(),
|
||||
0)
|
||||
# these next two lines are fragile and brute force...enjoy
|
||||
var diff_text = orig_diff_text.replace("-", " -> ")
|
||||
diff_text = diff_text.replace("+", " -> ")
|
||||
|
||||
if(orig_diff_text == diff_text):
|
||||
diff_text += "\n-- No changes were made --"
|
||||
else:
|
||||
diff_text += "\nChanges will not be visible in Godot until it is restarted.\n"
|
||||
diff_text += "Even if it asks you to reload...Maybe. Probably."
|
||||
|
||||
return diff_text
|
||||
|
||||
|
||||
|
||||
func _diff(name_1, name_2):
|
||||
if(warning_settings.has(name_1) and warning_settings.has(name_2)):
|
||||
var c2_pad = name_1.length() + 2
|
||||
var heading = str(" ".repeat(WARN_VALUE_PRINT_POSITION), name_1.rpad(c2_pad, ' '), name_2, "\n")
|
||||
heading += str(
|
||||
" ".repeat(WARN_VALUE_PRINT_POSITION),
|
||||
"-".repeat(name_1.length()).rpad(c2_pad, " "),
|
||||
"-".repeat(name_2.length()),
|
||||
"\n")
|
||||
|
||||
var text = _diff_text(warning_settings[name_1], warning_settings[name_2], c2_pad)
|
||||
|
||||
print(heading)
|
||||
print(text)
|
||||
|
||||
var diff_count = 0
|
||||
for line in text.split("\n"):
|
||||
if(!line.begins_with(" ")):
|
||||
diff_count += 1
|
||||
|
||||
if(diff_count == 0):
|
||||
print('-- [', name_1, "] and [", name_2, "] are the same --")
|
||||
else:
|
||||
print('-- There are ', diff_count, ' differences between [', name_1, "] and [", name_2, "] --")
|
||||
else:
|
||||
print("One or more unknown Warning Level Names:, [", name_1, "] [", name_2, "]")
|
||||
|
||||
|
||||
func _set_settings(nvps):
|
||||
var pre_settings = warning_settings["current"]
|
||||
for i in range(nvps.size()/2):
|
||||
var s_name = nvps[i * 2]
|
||||
var s_value = nvps[i * 2 + 1]
|
||||
if(godot_default_warnings.has(s_name)):
|
||||
var t = typeof(godot_default_warnings[s_name])
|
||||
if(t == TYPE_INT):
|
||||
s_value = s_value.to_int()
|
||||
elif(t == TYPE_BOOL):
|
||||
s_value = s_value.to_lower() == 'true'
|
||||
|
||||
WarningsManager.set_project_setting_warning(s_name, s_value)
|
||||
ProjectSettings.save()
|
||||
print(_diff_changes_text(pre_settings))
|
||||
|
||||
|
||||
|
||||
func _setup_options():
|
||||
var opts = Optparse.new()
|
||||
opts.banner = """
|
||||
This script prints info about or sets the warning settings for the project.
|
||||
Each action requires one or more Warning Level Names.
|
||||
|
||||
Warning Level Names:
|
||||
* current The current settings for the project.
|
||||
* godot_default The default settings for Godot.
|
||||
* gut_default The warning settings that is used when developing GUT.
|
||||
* all_warn Everything set to warn.
|
||||
""".dedent()
|
||||
|
||||
opts.add('-h', false, 'Print this help')
|
||||
opts.add('-set', [], "Sets a single setting in the project settings and saves.\n" +
|
||||
"Use -dump to see a list of setting names and values.\n" +
|
||||
"Example: -set enabled,true -set unsafe_cast,2 -set unreachable_code,0")
|
||||
opts.add_heading(" Actions (require Warning Level Name)")
|
||||
opts.add('-diff', [], "Shows the difference between two Warning Level Names.\n" +
|
||||
"Example: -diff current,all_warn")
|
||||
opts.add('-dump', 'none', "Prints a dictionary of the warning values.")
|
||||
opts.add('-print', 'none', "Print human readable warning values.")
|
||||
opts.add('-apply', 'none', "Applys one of the Warning Level Names to the project settings. You should restart after using this")
|
||||
|
||||
return opts
|
||||
|
||||
func _print_help(opts):
|
||||
opts.print_help()
|
||||
|
||||
|
||||
|
||||
func _init():
|
||||
# Testing might set this flag but it should never be disabled for this tool
|
||||
# or it cannot save project settings, but says it did. Sneakily use the
|
||||
# private property to get around this property being read-only. Don't
|
||||
# try this at home.
|
||||
WarningsManager._disabled = false
|
||||
|
||||
_setup_warning_settings()
|
||||
|
||||
var opts = _setup_options()
|
||||
opts.parse()
|
||||
|
||||
if(opts.unused.size() != 0):
|
||||
opts.print_help()
|
||||
print("Unknown arguments ", opts.unused)
|
||||
if(opts.values.h):
|
||||
opts.print_help()
|
||||
elif(opts.values.print != 'none'):
|
||||
_print_settings(opts.values.print)
|
||||
elif(opts.values.dump != 'none'):
|
||||
_dump_settings(opts.values.dump)
|
||||
elif(opts.values.apply != 'none'):
|
||||
_apply_settings(opts.values.apply )
|
||||
elif(opts.values.diff.size() == 2):
|
||||
_diff(opts.values.diff[0], opts.values.diff[1])
|
||||
elif(opts.values.set.size() % 2 == 0):
|
||||
_set_settings(opts.values.set)
|
||||
else:
|
||||
opts.print_help()
|
||||
print("You didn't specify any options or too many or not the right size or something invalid. I don't know what you want to do.")
|
||||
|
||||
quit()
|
||||
1
client/addons/gut/cli/change_project_warnings.gd.uid
Normal file
1
client/addons/gut/cli/change_project_warnings.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://1pauyfnd1cre
|
||||
328
client/addons/gut/cli/gut_cli.gd
Normal file
328
client/addons/gut/cli/gut_cli.gd
Normal file
@@ -0,0 +1,328 @@
|
||||
extends Node
|
||||
|
||||
var Optparse = load('res://addons/gut/cli/optparse.gd')
|
||||
var Gut = load('res://addons/gut/gut.gd')
|
||||
var GutRunner = load('res://addons/gut/gui/GutRunner.tscn')
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Helper class to resolve the various different places where an option can
|
||||
# be set. Using the get_value method will enforce the order of precedence of:
|
||||
# 1. command line value
|
||||
# 2. config file value
|
||||
# 3. default value
|
||||
#
|
||||
# The idea is that you set the base_opts. That will get you a copies of the
|
||||
# hash with null values for the other types of values. Lower precedented hashes
|
||||
# will punch through null values of higher precedented hashes.
|
||||
# ------------------------------------------------------------------------------
|
||||
class GutCliOptionResolver:
|
||||
var base_opts = {}
|
||||
var cmd_opts = {}
|
||||
var config_opts = {}
|
||||
|
||||
|
||||
func get_value(key):
|
||||
return _nvl(cmd_opts[key], _nvl(config_opts[key], base_opts[key]))
|
||||
|
||||
func set_base_opts(opts):
|
||||
base_opts = opts
|
||||
cmd_opts = _null_copy(opts)
|
||||
config_opts = _null_copy(opts)
|
||||
|
||||
# creates a copy of a hash with all values null.
|
||||
func _null_copy(h):
|
||||
var new_hash = {}
|
||||
for key in h:
|
||||
new_hash[key] = null
|
||||
return new_hash
|
||||
|
||||
func _nvl(a, b):
|
||||
if(a == null):
|
||||
return b
|
||||
else:
|
||||
return a
|
||||
|
||||
func _string_it(h):
|
||||
var to_return = ''
|
||||
for key in h:
|
||||
to_return += str('(',key, ':', _nvl(h[key], 'NULL'), ')')
|
||||
return to_return
|
||||
|
||||
func to_s():
|
||||
return str("base:\n", _string_it(base_opts), "\n", \
|
||||
"config:\n", _string_it(config_opts), "\n", \
|
||||
"cmd:\n", _string_it(cmd_opts), "\n", \
|
||||
"resolved:\n", _string_it(get_resolved_values()))
|
||||
|
||||
func get_resolved_values():
|
||||
var to_return = {}
|
||||
for key in base_opts:
|
||||
to_return[key] = get_value(key)
|
||||
return to_return
|
||||
|
||||
func to_s_verbose():
|
||||
var to_return = ''
|
||||
var resolved = get_resolved_values()
|
||||
for key in base_opts:
|
||||
to_return += str(key, "\n")
|
||||
to_return += str(' default: ', _nvl(base_opts[key], 'NULL'), "\n")
|
||||
to_return += str(' config: ', _nvl(config_opts[key], ' --'), "\n")
|
||||
to_return += str(' cmd: ', _nvl(cmd_opts[key], ' --'), "\n")
|
||||
to_return += str(' final: ', _nvl(resolved[key], 'NULL'), "\n")
|
||||
|
||||
return to_return
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Here starts the actual script that uses the Options class to kick off Gut
|
||||
# and run your tests.
|
||||
# ------------------------------------------------------------------------------
|
||||
var _gut_config = load('res://addons/gut/gut_config.gd').new()
|
||||
|
||||
# array of command line options specified
|
||||
var _final_opts = []
|
||||
|
||||
|
||||
func setup_options(options, font_names):
|
||||
var opts = Optparse.new()
|
||||
opts.banner =\
|
||||
"""
|
||||
The GUT CLI
|
||||
-----------
|
||||
The default behavior for GUT is to load options from a res://.gutconfig.json if
|
||||
it exists. Any options specified on the command line will take precedence over
|
||||
options specified in the gutconfig file. You can specify a different gutconfig
|
||||
file with the -gconfig option.
|
||||
|
||||
To generate a .gutconfig.json file you can use -gprint_gutconfig_sample
|
||||
To see the effective values of a CLI command and a gutconfig use -gpo
|
||||
|
||||
Values for options can be supplied using:
|
||||
option=value # no space around "="
|
||||
option value # a space between option and value w/o =
|
||||
|
||||
Options whose values are lists/arrays can be specified multiple times:
|
||||
-gdir=a,b
|
||||
-gdir c,d
|
||||
-gdir e
|
||||
# results in -gdir equaling [a, b, c, d, e]
|
||||
|
||||
To not use an empty value instead of a default value, specifiy the option with
|
||||
an immediate "=":
|
||||
-gconfig=
|
||||
"""
|
||||
opts.add_heading("Test Config:")
|
||||
opts.add('-gdir', options.dirs, 'List of directories to search for test scripts in.')
|
||||
opts.add('-ginclude_subdirs', false, 'Flag to include all subdirectories specified with -gdir.')
|
||||
opts.add('-gtest', [], 'List of full paths to test scripts to run.')
|
||||
opts.add('-gprefix', options.prefix, 'Prefix used to find tests when specifying -gdir. Default "[default]".')
|
||||
opts.add('-gsuffix', options.suffix, 'Test script suffix, including .gd extension. Default "[default]".')
|
||||
opts.add('-gconfig', 'res://.gutconfig.json', 'The config file to load options from. The default is [default]. Use "-gconfig=" to not use a config file.')
|
||||
opts.add('-gpre_run_script', '', 'pre-run hook script path')
|
||||
opts.add('-gpost_run_script', '', 'post-run hook script path')
|
||||
opts.add('-gerrors_do_not_cause_failure', false, 'When an internal GUT error occurs tests will fail. With this option set, that does not happen.')
|
||||
opts.add('-gdouble_strategy', 'SCRIPT_ONLY', 'Default strategy to use when doubling. Valid values are [INCLUDE_NATIVE, SCRIPT_ONLY]. Default "[default]"')
|
||||
|
||||
opts.add_heading("Run Options:")
|
||||
opts.add('-gselect', '', 'All scripts that contain the specified string in their filename will be ran')
|
||||
opts.add('-ginner_class', '', 'Only run inner classes that contain the specified string in their name.')
|
||||
opts.add('-gunit_test_name', '', 'Any test that contains the specified text will be run, all others will be skipped.')
|
||||
opts.add('-gexit', false, 'Exit after running tests. If not specified you have to manually close the window.')
|
||||
opts.add('-gexit_on_success', false, 'Only exit if zero tests fail.')
|
||||
opts.add('-gignore_pause', false, 'Ignores any calls to pause_before_teardown.')
|
||||
opts.add('-gno_error_tracking', false, 'Disable error tracking.')
|
||||
opts.add('-gfailure_error_types', options.failure_error_types, 'Error types that will cause tests to fail if the are encountered during the execution of a test. Default "[default]"')
|
||||
|
||||
opts.add_heading("Display Settings:")
|
||||
opts.add('-glog', options.log_level, 'Log level [0-3]. Default [default]')
|
||||
opts.add('-ghide_orphans', false, 'Display orphan counts for tests and scripts. Default [default].')
|
||||
opts.add('-gmaximize', false, 'Maximizes test runner window to fit the viewport.')
|
||||
opts.add('-gcompact_mode', false, 'The runner will be in compact mode. This overrides -gmaximize.')
|
||||
opts.add('-gopacity', options.opacity, 'Set opacity of test runner window. Use range 0 - 100. 0 = transparent, 100 = opaque.')
|
||||
opts.add('-gdisable_colors', false, 'Disable command line colors.')
|
||||
opts.add('-gfont_name', options.font_name, str('Valid values are: ', font_names, '. Default "[default]"'))
|
||||
opts.add('-gfont_size', options.font_size, 'Font size, default "[default]"')
|
||||
opts.add('-gbackground_color', options.background_color, 'Background color as an html color, default "[default]"')
|
||||
opts.add('-gfont_color',options.font_color, 'Font color as an html color, default "[default]"')
|
||||
opts.add('-gpaint_after', options.paint_after, 'Delay before GUT will add a 1 frame pause to paint the screen/GUI. default [default]')
|
||||
opts.add('-gwait_log_delay', options.wait_log_delay, 'Delay before GUT will print a message to indicate a test is awaiting one of the wait_* methods. Default [default]')
|
||||
|
||||
opts.add_heading("Result Export:")
|
||||
opts.add('-gjunit_xml_file', options.junit_xml_file, 'Export results of run to this file in the Junit XML format.')
|
||||
opts.add('-gjunit_xml_timestamp', options.junit_xml_timestamp, 'Include a timestamp in the -gjunit_xml_file, default [default]')
|
||||
|
||||
opts.add_heading("Help:")
|
||||
opts.add('-gh', false, 'Print this help. You did this to see this, so you probably understand.')
|
||||
opts.add('-gpo', false, 'Print option values from all sources and the value used.')
|
||||
opts.add('-gprint_gutconfig_sample', false, 'Print out json that can be used to make a gutconfig file.')
|
||||
opts.add("-gcheck_update", false, "Check for update")
|
||||
|
||||
# run as in editor, for shelling out purposes through Editor.
|
||||
var o = opts.add('-graie', false, 'do not use')
|
||||
o.show_in_help = false
|
||||
return opts
|
||||
|
||||
|
||||
# Parses options, applying them to the _tester or setting values
|
||||
# in the options struct.
|
||||
func extract_command_line_options(from, to):
|
||||
to.compact_mode = from.get_value_or_null('-gcompact_mode')
|
||||
to.config_file = from.get_value_or_null('-gconfig')
|
||||
to.dirs = from.get_value_or_null('-gdir')
|
||||
to.disable_colors = from.get_value_or_null('-gdisable_colors')
|
||||
to.double_strategy = from.get_value_or_null('-gdouble_strategy')
|
||||
to.errors_do_not_cause_failure = from.get_value_or_null('-gerrors_do_not_cause_failure')
|
||||
to.hide_orphans = from.get_value_or_null('-ghide_orphans')
|
||||
to.ignore_pause = from.get_value_or_null('-gignore_pause')
|
||||
to.include_subdirs = from.get_value_or_null('-ginclude_subdirs')
|
||||
to.inner_class = from.get_value_or_null('-ginner_class')
|
||||
to.log_level = from.get_value_or_null('-glog')
|
||||
to.opacity = from.get_value_or_null('-gopacity')
|
||||
to.post_run_script = from.get_value_or_null('-gpost_run_script')
|
||||
to.pre_run_script = from.get_value_or_null('-gpre_run_script')
|
||||
to.prefix = from.get_value_or_null('-gprefix')
|
||||
to.selected = from.get_value_or_null('-gselect')
|
||||
to.should_exit = from.get_value_or_null('-gexit')
|
||||
to.should_exit_on_success = from.get_value_or_null('-gexit_on_success')
|
||||
to.should_maximize = from.get_value_or_null('-gmaximize')
|
||||
to.suffix = from.get_value_or_null('-gsuffix')
|
||||
to.tests = from.get_value_or_null('-gtest')
|
||||
to.unit_test_name = from.get_value_or_null('-gunit_test_name')
|
||||
to.wait_log_delay = from.get_value_or_null('-gwait_log_delay')
|
||||
|
||||
to.background_color = from.get_value_or_null('-gbackground_color')
|
||||
to.font_color = from.get_value_or_null('-gfont_color')
|
||||
to.font_name = from.get_value_or_null('-gfont_name')
|
||||
to.font_size = from.get_value_or_null('-gfont_size')
|
||||
to.paint_after = from.get_value_or_null('-gpaint_after')
|
||||
|
||||
to.junit_xml_file = from.get_value_or_null('-gjunit_xml_file')
|
||||
to.junit_xml_timestamp = from.get_value_or_null('-gjunit_xml_timestamp')
|
||||
|
||||
to.failure_error_types = from.get_value_or_null('-gfailure_error_types')
|
||||
to.no_error_tracking = from.get_value_or_null('-gno_error_tracking')
|
||||
to.raie = from.get_value_or_null('-graie')
|
||||
|
||||
|
||||
|
||||
func _print_gutconfigs(values):
|
||||
var header = """Here is a sample of a full .gutconfig.json file.
|
||||
You do not need to specify all values in your own file. The values supplied in
|
||||
this sample are what would be used if you ran gut w/o the -gprint_gutconfig_sample
|
||||
option. Option priority is: command-line, .gutconfig, default)."""
|
||||
print("\n", header.replace("\n", ' '), "\n")
|
||||
var resolved = values
|
||||
|
||||
# remove_at some options that don't make sense to be in config
|
||||
resolved.erase("config_file")
|
||||
resolved.erase("show_help")
|
||||
|
||||
print(JSON.stringify(resolved, ' '))
|
||||
|
||||
for key in resolved:
|
||||
resolved[key] = null
|
||||
|
||||
print("\n\nAnd here's an empty config for you fill in what you want.")
|
||||
print(JSON.stringify(resolved, ' '))
|
||||
|
||||
|
||||
func _run_tests(opt_resolver):
|
||||
_final_opts = opt_resolver.get_resolved_values();
|
||||
_gut_config.options = _final_opts
|
||||
|
||||
var runner = GutRunner.instantiate()
|
||||
runner.set_gut_config(_gut_config)
|
||||
get_tree().root.add_child(runner)
|
||||
|
||||
if(opt_resolver.cmd_opts.raie):
|
||||
runner.run_from_editor()
|
||||
else:
|
||||
runner.run_tests()
|
||||
|
||||
|
||||
var update_detector = null
|
||||
func _check_for_update():
|
||||
print(str("Checking for update for GUT ", GutUtils.version_numbers.gut_version))
|
||||
update_detector = GutUtils.UpdateDetector.new()
|
||||
add_child(update_detector)
|
||||
await update_detector.check_for_update_with_fetch()
|
||||
print(update_detector.get_update_string())
|
||||
|
||||
|
||||
# parse options and run Gut
|
||||
func main():
|
||||
var opt_resolver = GutCliOptionResolver.new()
|
||||
opt_resolver.set_base_opts(_gut_config.default_options)
|
||||
|
||||
var cli_opts = setup_options(_gut_config.default_options, _gut_config.valid_fonts)
|
||||
|
||||
cli_opts.parse()
|
||||
var all_options_valid = cli_opts.unused.size() == 0
|
||||
extract_command_line_options(cli_opts, opt_resolver.cmd_opts)
|
||||
|
||||
var config_path = opt_resolver.get_value('config_file')
|
||||
var load_result = 1
|
||||
# Checking for an empty config path allows us to not use a config file via
|
||||
# the -gconfig_file option since using "-gconfig_file=" or -gconfig_file=''"
|
||||
# will result in an empty string.
|
||||
if(config_path != ''):
|
||||
load_result = _gut_config.load_options_no_defaults(config_path)
|
||||
|
||||
# SHORTCIRCUIT
|
||||
if(!all_options_valid):
|
||||
print('Unknown arguments: ', cli_opts.unused)
|
||||
get_tree().quit(1)
|
||||
elif(load_result == -1):
|
||||
print('Invalid gutconfig ', load_result)
|
||||
get_tree().quit(1)
|
||||
else:
|
||||
opt_resolver.config_opts = _gut_config.options
|
||||
|
||||
if(cli_opts.get_value('-gh')):
|
||||
print(GutUtils.version_numbers.get_version_text())
|
||||
cli_opts.print_help()
|
||||
get_tree().quit(0)
|
||||
elif(cli_opts.get_value('-gpo')):
|
||||
print('All config options and where they are specified. ' +
|
||||
'The "final" value shows which value will actually be used ' +
|
||||
'based on order of precedence (default < .gutconfig < cmd line).' + "\n")
|
||||
print(opt_resolver.to_s_verbose())
|
||||
get_tree().quit(0)
|
||||
elif(cli_opts.get_value('-gprint_gutconfig_sample')):
|
||||
_print_gutconfigs(opt_resolver.get_resolved_values())
|
||||
get_tree().quit(0)
|
||||
elif(cli_opts.get_value('-gcheck_update')):
|
||||
await _check_for_update()
|
||||
get_tree().quit(0)
|
||||
else:
|
||||
_run_tests(opt_resolver)
|
||||
|
||||
|
||||
|
||||
# ##############################################################################
|
||||
#(G)odot (U)nit (T)est class
|
||||
#
|
||||
# ##############################################################################
|
||||
# The MIT License (MIT)
|
||||
# =====================
|
||||
#
|
||||
# Copyright (c) 2025 Tom "Butch" Wesley
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
# ##############################################################################
|
||||
1
client/addons/gut/cli/gut_cli.gd.uid
Normal file
1
client/addons/gut/cli/gut_cli.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bhuudqinp4bth
|
||||
678
client/addons/gut/cli/optparse.gd
Normal file
678
client/addons/gut/cli/optparse.gd
Normal file
@@ -0,0 +1,678 @@
|
||||
## Parses command line arguments, as one might expect.
|
||||
##
|
||||
## Parses command line arguments with a bunch of options including generating
|
||||
## text that displays all the arguments your script accepts. This
|
||||
## is included in the GUT ClassRef since it might be usable by others and is
|
||||
## portable (everything it needs is in this one file).
|
||||
## [br]
|
||||
## This does alot, if you want to see it in action have a look at
|
||||
## [url=https://github.com/bitwes/Gut/blob/main/scratch/optparse_example.gd]scratch/optparse_example.gd[/url]
|
||||
## [codeblock lang=text]
|
||||
##
|
||||
## Godot Argument Lists
|
||||
## -------------------------
|
||||
## There are two sets of command line arguments that Godot populates:
|
||||
## OS.get_cmdline_args
|
||||
## OS.get_cmdline_user_args.
|
||||
##
|
||||
## OS.get_cmdline_args contains any arguments that are not used by the engine
|
||||
## itself. This means options like --help and -d will never appear in this list
|
||||
## since these are used by the engine. The one exception is the -s option which
|
||||
## is always included as the first entry and the script path as the second.
|
||||
## Optparse ignores these values for argument processing but can be accessed
|
||||
## with my_optparse.options.script_option. This list does not contain any
|
||||
## arguments that appear in OS.get_cmdline_user_args.
|
||||
##
|
||||
## OS.get_cmdline_user_args contains any arguments that appear on the command
|
||||
## line AFTER " -- " or " ++ ". This list CAN contain options that the engine
|
||||
## would otherwise use, and are ignored completely by the engine.
|
||||
##
|
||||
## The parse method, by default, includes arguments from OS.get_cmdline_args and
|
||||
## OS.get_cmdline_user_args. You can optionally pass one of these to the parse
|
||||
## method to limit which arguments are parsed. You can also conjure up your own
|
||||
## array of arguments and pass that to parse.
|
||||
##
|
||||
## See Godot's documentation for get_cmdline_args and get_cmdline_user_args for
|
||||
## more information.
|
||||
##
|
||||
##
|
||||
## Adding Options
|
||||
## --------------
|
||||
## Use the following to add options to be parsed. These methods return the
|
||||
## created Option instance. See that class above for more info. You can use
|
||||
## the returned instance to get values, or use get_value/get_value_or_null.
|
||||
## add("--name", "default", "Description goes here")
|
||||
## add(["--name", "--aliases"], "default", "Description goes here")
|
||||
## add_required(["--name", "--aliases"], "default", "Description goes here")
|
||||
## add_positional("--name", "default", "Description goes here")
|
||||
## add_positional_required("--name", "default", "Description goes here")
|
||||
##
|
||||
## get_value will return the value of the option or the default if it was not
|
||||
## set. get_value_or_null will return the value of the option or null if it was
|
||||
## not set.
|
||||
##
|
||||
## The Datatype for an option is determined from the default value supplied to
|
||||
## the various add methods. Supported types are
|
||||
## String
|
||||
## Int
|
||||
## Float
|
||||
## Array of strings
|
||||
## Boolean
|
||||
##
|
||||
##
|
||||
## Value Parsing
|
||||
## -------------
|
||||
## optparse uses option_name_prefix to differentiate between option names and
|
||||
## values. Any argument that starts with this value will be treated as an
|
||||
## argument name. The default is "-". Set this before calling parse if you want
|
||||
## to change it.
|
||||
##
|
||||
## Values for options can be supplied on the command line with or without an "=":
|
||||
## option=value # no space around "="
|
||||
## option value # a space between option and value w/o =
|
||||
## There is no way to escape "=" at this time.
|
||||
##
|
||||
## Array options can be specified multiple times and/or set from a comma delimited
|
||||
## list.
|
||||
## -gdir=a,b
|
||||
## -gdir c,d
|
||||
## -gdir e
|
||||
## Results in -gdir equaling [a, b, c, d, e]. There is no way to escape commas
|
||||
## at this time.
|
||||
##
|
||||
## To specify an empty list via the command line follow the option with an equal
|
||||
## sign
|
||||
## -gdir=
|
||||
##
|
||||
## Boolean options will have thier value set to !default when they are supplied
|
||||
## on the command line. Boolean options cannot have a value on the command line.
|
||||
## They are either supplied or not.
|
||||
##
|
||||
## If a value is not an array and is specified multiple times on the command line
|
||||
## then the last entry will be used as the value.
|
||||
##
|
||||
## Positional argument values are parsed after all named arguments are parsed.
|
||||
## This means that other options can appear before, between, and after positional
|
||||
## arguments.
|
||||
## --foo=bar positional_0_value --disabled --bar foo positional_1_value --a_flag
|
||||
##
|
||||
## Anything that is not used by named or positional arguments will appear in the
|
||||
## unused property. You can use this to detect unrecognized arguments or treat
|
||||
## everything else provided as a list of things, or whatever you want. You can
|
||||
## use is_option on the elements of unused (or whatever you want really) to see
|
||||
## if optparse would treat it as an option name.
|
||||
##
|
||||
## Use get_missing_required_options to get an array of Option with all required
|
||||
## options that were not found when parsing.
|
||||
##
|
||||
## The parsed_args property holds the list of arguments that were parsed.
|
||||
##
|
||||
##
|
||||
## Help Generation
|
||||
## ---------------
|
||||
## You can call get_help to generate help text, or you can just call print_help
|
||||
## and this will print it for you.
|
||||
##
|
||||
## Set the banner property to any text you want to appear before the usage and
|
||||
## options sections.
|
||||
##
|
||||
## Options are printed in the order they are added. You can add a heading for
|
||||
## different options sections with add_heading.
|
||||
## add("--asdf", 1, "This will have no heading")
|
||||
## add_heading("foo")
|
||||
## add("--foo", false, "This will have the foo heading")
|
||||
## add("--another_foo", 1.5, "This too.")
|
||||
## add_heading("This is after foo")
|
||||
## add("--bar", true, "You probably get it by now.")
|
||||
##
|
||||
## If you include "[default]" in the description of a option, then the help will
|
||||
## substitue it with the default value.
|
||||
## [/codeblock]
|
||||
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Holds all the properties of a command line option
|
||||
#
|
||||
# value will return the default when it has not been set.
|
||||
#-------------------------------------------------------------------------------
|
||||
class OptParseOption:
|
||||
var _has_been_set = false
|
||||
var _value = null
|
||||
# REMEMBER that when this option is an array, you have to set the value
|
||||
# before you alter the contents of the array (append etc) or has_been_set
|
||||
# will return false and it might not be used right. For example
|
||||
# get_value_or_null will return null when you've actually changed the value.
|
||||
var value = _value:
|
||||
get:
|
||||
return _value
|
||||
|
||||
set(val):
|
||||
_has_been_set = true
|
||||
_value = val
|
||||
|
||||
var option_name = ''
|
||||
var default = null
|
||||
var description = ''
|
||||
var required = false
|
||||
var aliases: Array[String] = []
|
||||
var show_in_help = true
|
||||
|
||||
|
||||
func _init(name,default_value,desc=''):
|
||||
option_name = name
|
||||
default = default_value
|
||||
description = desc
|
||||
_value = default
|
||||
|
||||
|
||||
func wrap_text(text, left_indent, max_length, wiggle_room=15):
|
||||
var line_indent = str("\n", " ".repeat(left_indent + 1))
|
||||
var wrapped = ''
|
||||
var position = 0
|
||||
var split_length = max_length
|
||||
while(position < text.length()):
|
||||
if(position > 0):
|
||||
wrapped += line_indent
|
||||
|
||||
var split_by = split_length
|
||||
if(position + split_by + wiggle_room >= text.length()):
|
||||
split_by = text.length() - position
|
||||
else:
|
||||
var min_space = text.rfind(' ', position + split_length)
|
||||
var max_space = text.find(' ', position + split_length)
|
||||
if(max_space <= position + split_length + wiggle_room):
|
||||
split_by = max_space - position
|
||||
else:
|
||||
split_by = min_space - position
|
||||
|
||||
wrapped += text.substr(position, split_by).lstrip(' ')
|
||||
|
||||
if(position == 0):
|
||||
split_length = max_length - left_indent
|
||||
|
||||
position += split_by
|
||||
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
|
||||
func to_s(min_space=0, wrap_length=100):
|
||||
var line_indent = str("\n", " ".repeat(min_space + 1))
|
||||
var subbed_desc = description
|
||||
if not aliases.is_empty():
|
||||
subbed_desc += "\naliases: " + ", ".join(aliases)
|
||||
subbed_desc = subbed_desc.replace('[default]', str(default))
|
||||
subbed_desc = subbed_desc.replace("\n", line_indent)
|
||||
|
||||
var final = str(option_name.rpad(min_space), ' ', subbed_desc)
|
||||
if(wrap_length != -1):
|
||||
final = wrap_text(final, min_space, wrap_length)
|
||||
|
||||
return final
|
||||
|
||||
|
||||
func has_been_set():
|
||||
return _has_been_set
|
||||
|
||||
|
||||
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# A struct for organizing options by a heading
|
||||
#-------------------------------------------------------------------------------
|
||||
class OptParseOptionHeading:
|
||||
var options = []
|
||||
var display = 'default'
|
||||
|
||||
|
||||
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Organizes options by order, heading, position. Also responsible for all
|
||||
# help related text generation.
|
||||
#-------------------------------------------------------------------------------
|
||||
class OptParseOptions:
|
||||
var options = []
|
||||
var positional = []
|
||||
var default_heading = OptParseOptionHeading.new()
|
||||
var script_option = OptParseOption.new('-s', '?', 'script option provided by Godot')
|
||||
|
||||
var _options_by_name = {"--script": script_option, "-s": script_option}
|
||||
var _options_by_heading = [default_heading]
|
||||
var _cur_heading = default_heading
|
||||
|
||||
|
||||
func add_heading(display):
|
||||
var heading = OptParseOptionHeading.new()
|
||||
heading.display = display
|
||||
_cur_heading = heading
|
||||
_options_by_heading.append(heading)
|
||||
|
||||
|
||||
func add(option, aliases=null):
|
||||
options.append(option)
|
||||
_options_by_name[option.option_name] = option
|
||||
_cur_heading.options.append(option)
|
||||
|
||||
if aliases != null:
|
||||
for a in aliases:
|
||||
_options_by_name[a] = option
|
||||
option.aliases.assign(aliases)
|
||||
|
||||
|
||||
func add_positional(option):
|
||||
positional.append(option)
|
||||
_options_by_name[option.option_name] = option
|
||||
|
||||
|
||||
func get_by_name(option_name):
|
||||
var found_param = null
|
||||
if(_options_by_name.has(option_name)):
|
||||
found_param = _options_by_name[option_name]
|
||||
|
||||
return found_param
|
||||
|
||||
|
||||
func get_help_text():
|
||||
var longest = 0
|
||||
var text = ""
|
||||
for i in range(options.size()):
|
||||
if(options[i].option_name.length() > longest):
|
||||
longest = options[i].option_name.length()
|
||||
|
||||
for heading in _options_by_heading:
|
||||
if(heading != default_heading):
|
||||
text += str("\n", heading.display, "\n")
|
||||
for option in heading.options:
|
||||
if(option.show_in_help):
|
||||
text += str(' ', option.to_s(longest + 2).replace("\n", "\n "), "\n")
|
||||
|
||||
return text
|
||||
|
||||
|
||||
func get_option_value_text():
|
||||
var text = ""
|
||||
var i = 0
|
||||
for option in positional:
|
||||
text += str(i, '. ', option.option_name, ' = ', option.value)
|
||||
|
||||
if(!option.has_been_set()):
|
||||
text += " (default)"
|
||||
text += "\n"
|
||||
i += 1
|
||||
|
||||
for option in options:
|
||||
text += str(option.option_name, ' = ', option.value)
|
||||
|
||||
if(!option.has_been_set()):
|
||||
text += " (default)"
|
||||
text += "\n"
|
||||
return text
|
||||
|
||||
|
||||
func print_option_values():
|
||||
print(get_option_value_text())
|
||||
|
||||
|
||||
func get_missing_required_options():
|
||||
var to_return = []
|
||||
for opt in options:
|
||||
if(opt.required and !opt.has_been_set()):
|
||||
to_return.append(opt)
|
||||
|
||||
for opt in positional:
|
||||
if(opt.required and !opt.has_been_set()):
|
||||
to_return.append(opt)
|
||||
|
||||
return to_return
|
||||
|
||||
|
||||
func get_usage_text():
|
||||
var pos_text = ""
|
||||
for opt in positional:
|
||||
pos_text += str("[", opt.description, "] ")
|
||||
|
||||
if(pos_text != ""):
|
||||
pos_text += " [opts] "
|
||||
|
||||
return "<path to godot> -s " + script_option.value + " [opts] " + pos_text
|
||||
|
||||
|
||||
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
#
|
||||
# optarse
|
||||
#
|
||||
#-------------------------------------------------------------------------------
|
||||
## @ignore
|
||||
var options := OptParseOptions.new()
|
||||
## Set the banner property to any text you want to appear before the usage and
|
||||
## options sections when printing the options help.
|
||||
var banner := ''
|
||||
## optparse uses option_name_prefix to differentiate between option names and
|
||||
## values. Any argument that starts with this value will be treated as an
|
||||
## argument name. The default is "-". Set this before calling parse if you want
|
||||
## to change it.
|
||||
var option_name_prefix := '-'
|
||||
## @ignore
|
||||
var unused = []
|
||||
## @ignore
|
||||
var parsed_args = []
|
||||
## @ignore
|
||||
var values: Dictionary = {}
|
||||
|
||||
|
||||
func _populate_values_dictionary():
|
||||
for entry in options.options:
|
||||
var value_key = entry.option_name.lstrip('-')
|
||||
values[value_key] = entry.value
|
||||
|
||||
for entry in options.positional:
|
||||
var value_key = entry.option_name.lstrip('-')
|
||||
values[value_key] = entry.value
|
||||
|
||||
|
||||
func _convert_value_to_array(raw_value):
|
||||
var split = raw_value.split(',')
|
||||
# This is what an empty set looks like from the command line. If we do
|
||||
# not do this then we will always get back [''] which is not what it
|
||||
# shoudl be.
|
||||
if(split.size() == 1 and split[0] == ''):
|
||||
split = []
|
||||
return split
|
||||
|
||||
# REMEMBER raw_value not used for bools.
|
||||
func _set_option_value(option, raw_value):
|
||||
var t = typeof(option.default)
|
||||
# only set values that were specified at the command line so that
|
||||
# we can punch through default and config values correctly later.
|
||||
# Without this check, you can't tell the difference between the
|
||||
# defaults and what was specified, so you can't punch through
|
||||
# higher level options.
|
||||
if(t == TYPE_INT):
|
||||
option.value = int(raw_value)
|
||||
elif(t == TYPE_STRING):
|
||||
option.value = str(raw_value)
|
||||
elif(t == TYPE_ARRAY):
|
||||
var values = _convert_value_to_array(raw_value)
|
||||
if(!option.has_been_set()):
|
||||
option.value = []
|
||||
option.value.append_array(values)
|
||||
elif(t == TYPE_BOOL):
|
||||
option.value = !option.default
|
||||
elif(t == TYPE_FLOAT):
|
||||
option.value = float(raw_value)
|
||||
elif(t == TYPE_NIL):
|
||||
print(option.option_name + ' cannot be processed, it has a nil datatype')
|
||||
else:
|
||||
print(option.option_name + ' cannot be processed, it has unknown datatype:' + str(t))
|
||||
|
||||
|
||||
func _parse_command_line_arguments(args):
|
||||
var parsed_opts = args.duplicate()
|
||||
var i = 0
|
||||
var positional_index = 0
|
||||
|
||||
while i < parsed_opts.size():
|
||||
var opt = ''
|
||||
var value = ''
|
||||
var entry = parsed_opts[i]
|
||||
|
||||
if(is_option(entry)):
|
||||
if(entry.find('=') != -1):
|
||||
var parts = entry.split('=')
|
||||
opt = parts[0]
|
||||
value = parts[1]
|
||||
var the_option = options.get_by_name(opt)
|
||||
if(the_option != null):
|
||||
parsed_opts.remove_at(i)
|
||||
_set_option_value(the_option, value)
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
var the_option = options.get_by_name(entry)
|
||||
if(the_option != null):
|
||||
parsed_opts.remove_at(i)
|
||||
if(typeof(the_option.default) == TYPE_BOOL):
|
||||
_set_option_value(the_option, null)
|
||||
elif(i < parsed_opts.size() and !is_option(parsed_opts[i])):
|
||||
value = parsed_opts[i]
|
||||
parsed_opts.remove_at(i)
|
||||
_set_option_value(the_option, value)
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
if(positional_index < options.positional.size()):
|
||||
_set_option_value(options.positional[positional_index], entry)
|
||||
parsed_opts.remove_at(i)
|
||||
positional_index += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# this is the leftovers that were not extracted.
|
||||
return parsed_opts
|
||||
|
||||
|
||||
## Test if something is an existing argument. If [code]str(arg)[/code] begins
|
||||
## with the [member option_name_prefix], it will considered true,
|
||||
## otherwise it will be considered false.
|
||||
func is_option(arg) -> bool:
|
||||
return str(arg).begins_with(option_name_prefix)
|
||||
|
||||
|
||||
## Adds a command line option.
|
||||
## If [param op_names] is a String, this is set as the argument's name.
|
||||
## If [param op_names] is an Array of Strings, all elements of the array
|
||||
## will be aliases for the same argument and will be treated as such during
|
||||
## parsing.
|
||||
## [param default] is the default value the option will be set to if it is not
|
||||
## explicitly set during parsing.
|
||||
## [param desc] is a human readable text description of the option.
|
||||
## If the option is successfully added, the Option object will be returned.
|
||||
## If the option is not successfully added (e.g. a name collision with another
|
||||
## option occurs), an error message will be printed and [code]null[/code]
|
||||
## will be returned.
|
||||
func add(op_names, default, desc: String) -> OptParseOption:
|
||||
var op_name: String
|
||||
var aliases: Array[String] = []
|
||||
var new_op: OptParseOption = null
|
||||
|
||||
if(typeof(op_names) == TYPE_STRING):
|
||||
op_name = op_names
|
||||
else:
|
||||
op_name = op_names[0]
|
||||
aliases.assign(op_names.slice(1))
|
||||
|
||||
var bad_alias: int = aliases.map(
|
||||
func (a: String) -> bool: return options.get_by_name(a) != null
|
||||
).find(true)
|
||||
|
||||
if(options.get_by_name(op_name) != null):
|
||||
push_error(str('Option [', op_name, '] already exists.'))
|
||||
elif bad_alias != -1:
|
||||
push_error(str('Option [', aliases[bad_alias], '] already exists.'))
|
||||
else:
|
||||
new_op = OptParseOption.new(op_name, default, desc)
|
||||
options.add(new_op, aliases)
|
||||
|
||||
return new_op
|
||||
|
||||
|
||||
## Adds a required command line option.
|
||||
## Required options that have not been set may be collected after parsing
|
||||
## by calling [method get_missing_required_options].
|
||||
## If [param op_names] is a String, this is set as the argument's name.
|
||||
## If [param op_names] is an Array of Strings, all elements of the array
|
||||
## will be aliases for the same argument and will be treated as such during
|
||||
## parsing.
|
||||
## [param default] is the default value the option will be set to if it is not
|
||||
## explicitly set during parsing.
|
||||
## [param desc] is a human readable text description of the option.
|
||||
## If the option is successfully added, the Option object will be returned.
|
||||
## If the option is not successfully added (e.g. a name collision with another
|
||||
## option occurs), an error message will be printed and [code]null[/code]
|
||||
## will be returned.
|
||||
func add_required(op_names, default, desc: String) -> OptParseOption:
|
||||
var op := add(op_names, default, desc)
|
||||
if(op != null):
|
||||
op.required = true
|
||||
return op
|
||||
|
||||
|
||||
## Adds a positional command line option.
|
||||
## Positional options are parsed by their position in the list of arguments
|
||||
## are are not assigned by name by the user.
|
||||
## If [param op_name] is a String, this is set as the argument's name.
|
||||
## If [param op_name] is an Array of Strings, all elements of the array
|
||||
## will be aliases for the same argument and will be treated as such during
|
||||
## parsing.
|
||||
## [param default] is the default value the option will be set to if it is not
|
||||
## explicitly set during parsing.
|
||||
## [param desc] is a human readable text description of the option.
|
||||
## If the option is successfully added, the Option object will be returned.
|
||||
## If the option is not successfully added (e.g. a name collision with another
|
||||
## option occurs), an error message will be printed and [code]null[/code]
|
||||
## will be returned.
|
||||
func add_positional(op_name, default, desc: String) -> OptParseOption:
|
||||
var new_op = null
|
||||
if(options.get_by_name(op_name) != null):
|
||||
push_error(str('Positional option [', op_name, '] already exists.'))
|
||||
else:
|
||||
new_op = OptParseOption.new(op_name, default, desc)
|
||||
options.add_positional(new_op)
|
||||
return new_op
|
||||
|
||||
|
||||
## Adds a required positional command line option.
|
||||
## If [param op_name] is a String, this is set as the argument's name.
|
||||
## Required options that have not been set may be collected after parsing
|
||||
## by calling [method get_missing_required_options].
|
||||
## Positional options are parsed by their position in the list of arguments
|
||||
## are are not assigned by name by the user.
|
||||
## If [param op_name] is an Array of Strings, all elements of the array
|
||||
## will be aliases for the same argument and will be treated as such during
|
||||
## parsing.
|
||||
## [param default] is the default value the option will be set to if it is not
|
||||
## explicitly set during parsing.
|
||||
## [param desc] is a human readable text description of the option.
|
||||
## If the option is successfully added, the Option object will be returned.
|
||||
## If the option is not successfully added (e.g. a name collision with another
|
||||
## option occurs), an error message will be printed and [code]null[/code]
|
||||
## will be returned.
|
||||
func add_positional_required(op_name, default, desc: String) -> OptParseOption:
|
||||
var op = add_positional(op_name, default, desc)
|
||||
if(op != null):
|
||||
op.required = true
|
||||
return op
|
||||
|
||||
|
||||
## Headings are used to separate logical groups of command line options
|
||||
## when printing out options from the help menu.
|
||||
## Headings are printed out between option descriptions in the order
|
||||
## that [method add_heading] was called.
|
||||
func add_heading(display_text: String) -> void:
|
||||
options.add_heading(display_text)
|
||||
|
||||
|
||||
## Gets the value assigned to an option after parsing.
|
||||
## [param name] can be the name of the option or an alias of it.
|
||||
## [param name] specifies the option whose value you wish to query.
|
||||
## If the option exists, the value assigned to it during parsing is returned.
|
||||
## Otherwise, an error message is printed and [code]null[/code] is returned.
|
||||
func get_value(name: String):
|
||||
var found_param: OptParseOption = options.get_by_name(name)
|
||||
|
||||
if(found_param != null):
|
||||
return found_param.value
|
||||
else:
|
||||
push_error("COULD NOT FIND OPTION " + name)
|
||||
return null
|
||||
|
||||
|
||||
## Gets the value assigned to an option after parsing,
|
||||
## returning null if the option was not assigned instead of its default value.
|
||||
## [param name] specifies the option whose value you wish to query.
|
||||
## This can be useful when providing an order of precedence to your values.
|
||||
## For example if
|
||||
## [codeblock]
|
||||
## default value < config file < command line
|
||||
## [/codeblock]
|
||||
## then you do not want to get the default value for a command line option or
|
||||
## it will overwrite the value in a config file.
|
||||
func get_value_or_null(name: String):
|
||||
var found_param: OptParseOption = options.get_by_name(name)
|
||||
|
||||
if(found_param != null and found_param.has_been_set()):
|
||||
return found_param.value
|
||||
else:
|
||||
return null
|
||||
|
||||
|
||||
## Returns the help text for all defined options.
|
||||
func get_help() -> String:
|
||||
var sep := '---------------------------------------------------------'
|
||||
|
||||
var text := str(sep, "\n", banner, "\n\n")
|
||||
text += "Usage\n-----------\n"
|
||||
text += " " + options.get_usage_text() + "\n\n"
|
||||
text += "\nOptions\n-----------\n"
|
||||
text += options.get_help_text()
|
||||
text += str(sep, "\n")
|
||||
return text
|
||||
|
||||
|
||||
## Prints out the help text for all defined options.
|
||||
func print_help() -> void:
|
||||
print(get_help())
|
||||
|
||||
|
||||
## Parses a string for all options that have been set in this optparse.
|
||||
## if [param cli_args] is passed as a String, then it is parsed.
|
||||
## Otherwise if [param cli_args] is null,
|
||||
## aruments passed to the Godot engine at startup are parsed.
|
||||
## See the explanation at the top of addons/gut/cli/optparse.gd to understand
|
||||
## which arguments this will have access to.
|
||||
func parse(cli_args=null) -> void:
|
||||
parsed_args = cli_args
|
||||
|
||||
if(parsed_args == null):
|
||||
parsed_args = OS.get_cmdline_args()
|
||||
parsed_args.append_array(OS.get_cmdline_user_args())
|
||||
|
||||
unused = _parse_command_line_arguments(parsed_args)
|
||||
_populate_values_dictionary()
|
||||
|
||||
|
||||
## Get all options that were required and were not set during parsing.
|
||||
## The return value is an Array of Options.
|
||||
func get_missing_required_options() -> Array:
|
||||
return options.get_missing_required_options()
|
||||
|
||||
|
||||
# ##############################################################################
|
||||
# The MIT License (MIT)
|
||||
# =====================
|
||||
#
|
||||
# Copyright (c) 2025 Tom "Butch" Wesley
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
# ##############################################################################
|
||||
1
client/addons/gut/cli/optparse.gd.uid
Normal file
1
client/addons/gut/cli/optparse.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c8m4fojwln6bq
|
||||
208
client/addons/gut/collected_script.gd
Normal file
208
client/addons/gut/collected_script.gd
Normal file
@@ -0,0 +1,208 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# This holds all the meta information for a test script. It contains the
|
||||
# name of the inner class and an array of CollectedTests. This does not parse
|
||||
# anything, it just holds the data about parsed scripts and tests. The
|
||||
# TestCollector is responsible for populating this object.
|
||||
#
|
||||
# This class also facilitates all the exporting and importing of tests.
|
||||
# ------------------------------------------------------------------------------
|
||||
var CollectedTest = GutUtils.CollectedTest
|
||||
|
||||
var _lgr = null
|
||||
|
||||
# One entry per test found in the script. Added externally by TestCollector
|
||||
var tests = []
|
||||
# One entry for before_all and after_all (maybe add before_each and after_each).
|
||||
# These are added by Gut when running before_all and after_all for the script.
|
||||
var setup_teardown_tests = []
|
||||
var inner_class_name:StringName
|
||||
var path:String
|
||||
|
||||
|
||||
# Set externally by test_collector after it can verify that the script was
|
||||
# actually loaded. This could probably be changed to just hold the GutTest
|
||||
# script that was loaded, cutting down on complexity elsewhere.
|
||||
var is_loaded = false
|
||||
|
||||
# Set by Gut when it decides that a script should be skipped.
|
||||
# Right now this is whenever the script has the variable skip_script declared.
|
||||
# the value of skip_script is put into skip_reason.
|
||||
var was_skipped = false
|
||||
var skip_reason = ''
|
||||
var was_run = false
|
||||
|
||||
|
||||
var name = '' :
|
||||
get: return path
|
||||
set(val):pass
|
||||
|
||||
|
||||
func _init(logger=null):
|
||||
_lgr = logger
|
||||
|
||||
|
||||
func get_new():
|
||||
var inst = load_script().new()
|
||||
inst.collected_script = self
|
||||
return inst
|
||||
|
||||
|
||||
func load_script():
|
||||
var to_return = load(path)
|
||||
|
||||
if(inner_class_name != null and inner_class_name != ''):
|
||||
# If we wanted to do inner classes in inner classses
|
||||
# then this would have to become some kind of loop or recursive
|
||||
# call to go all the way down the chain or this class would
|
||||
# have to change to hold onto the loaded class instead of
|
||||
# just path information.
|
||||
to_return = to_return.get(inner_class_name)
|
||||
|
||||
return to_return
|
||||
|
||||
# script.gd.InnerClass
|
||||
func get_filename_and_inner():
|
||||
var to_return = get_filename()
|
||||
if(inner_class_name != ''):
|
||||
to_return += '.' + String(inner_class_name)
|
||||
return to_return
|
||||
|
||||
|
||||
# res://foo/bar.gd.FooBar
|
||||
func get_full_name():
|
||||
var to_return = path
|
||||
if(inner_class_name != ''):
|
||||
to_return += '.' + String(inner_class_name)
|
||||
return to_return
|
||||
|
||||
|
||||
func get_filename():
|
||||
return path.get_file()
|
||||
|
||||
|
||||
func has_inner_class():
|
||||
return inner_class_name != ''
|
||||
|
||||
|
||||
# Note: although this no longer needs to export the inner_class names since
|
||||
# they are pulled from metadata now, it is easier to leave that in
|
||||
# so we don't have to cut the export down to unique script names.
|
||||
func export_to(config_file, section):
|
||||
config_file.set_value(section, 'path', path)
|
||||
config_file.set_value(section, 'inner_class', inner_class_name)
|
||||
var names = []
|
||||
for i in range(tests.size()):
|
||||
names.append(tests[i].name)
|
||||
config_file.set_value(section, 'tests', names)
|
||||
|
||||
|
||||
func _remap_path(source_path):
|
||||
var to_return = source_path
|
||||
if(!FileAccess.file_exists(source_path)):
|
||||
_lgr.debug('Checking for remap for: ' + source_path)
|
||||
var remap_path = source_path.get_basename() + '.gd.remap'
|
||||
if(FileAccess.file_exists(remap_path)):
|
||||
var cf = ConfigFile.new()
|
||||
cf.load(remap_path)
|
||||
to_return = cf.get_value('remap', 'path')
|
||||
else:
|
||||
_lgr.warn('Could not find remap file ' + remap_path)
|
||||
return to_return
|
||||
|
||||
|
||||
func import_from(config_file, section):
|
||||
path = config_file.get_value(section, 'path')
|
||||
path = _remap_path(path)
|
||||
# Null is an acceptable value, but you can't pass null as a default to
|
||||
# get_value since it thinks you didn't send a default...then it spits
|
||||
# out red text. This works around that.
|
||||
var inner_name = config_file.get_value(section, 'inner_class', 'Placeholder')
|
||||
if(inner_name != 'Placeholder'):
|
||||
inner_class_name = inner_name
|
||||
else: # just being explicit
|
||||
inner_class_name = StringName("")
|
||||
|
||||
|
||||
func get_test_named(test_name):
|
||||
return GutUtils.search_array(tests, 'name', test_name)
|
||||
|
||||
|
||||
func get_ran_test_count():
|
||||
var count = 0
|
||||
for t in tests:
|
||||
if(t.was_run):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func get_assert_count():
|
||||
var count = 0
|
||||
for t in tests:
|
||||
count += t.pass_texts.size()
|
||||
count += t.fail_texts.size()
|
||||
for t in setup_teardown_tests:
|
||||
count += t.pass_texts.size()
|
||||
count += t.fail_texts.size()
|
||||
return count
|
||||
|
||||
|
||||
func get_pass_count():
|
||||
var count = 0
|
||||
for t in tests:
|
||||
count += t.pass_texts.size()
|
||||
for t in setup_teardown_tests:
|
||||
count += t.pass_texts.size()
|
||||
return count
|
||||
|
||||
|
||||
func get_fail_count():
|
||||
var count = 0
|
||||
for t in tests:
|
||||
count += t.fail_texts.size()
|
||||
for t in setup_teardown_tests:
|
||||
count += t.fail_texts.size()
|
||||
return count
|
||||
|
||||
|
||||
func get_pending_count():
|
||||
var count = 0
|
||||
for t in tests:
|
||||
count += t.pending_texts.size()
|
||||
return count
|
||||
|
||||
|
||||
func get_passing_test_count():
|
||||
var count = 0
|
||||
for t in tests:
|
||||
if(t.is_passing()):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func get_failing_test_count():
|
||||
var count = 0
|
||||
for t in tests:
|
||||
if(t.is_failing()):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func get_risky_count():
|
||||
var count = 0
|
||||
if(was_skipped):
|
||||
count = 1
|
||||
else:
|
||||
for t in tests:
|
||||
if(t.is_risky()):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func to_s():
|
||||
var to_return = path
|
||||
if(inner_class_name != null):
|
||||
to_return += str('.', inner_class_name)
|
||||
to_return += "\n"
|
||||
for i in range(tests.size()):
|
||||
to_return += str(' ', tests[i].to_s())
|
||||
return to_return
|
||||
1
client/addons/gut/collected_script.gd.uid
Normal file
1
client/addons/gut/collected_script.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bjjcnr1oqvag6
|
||||
120
client/addons/gut/collected_test.gd
Normal file
120
client/addons/gut/collected_test.gd
Normal file
@@ -0,0 +1,120 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# Used to keep track of info about each test ran.
|
||||
# ------------------------------------------------------------------------------
|
||||
# the name of the function
|
||||
var name = ""
|
||||
|
||||
# flag to know if the name has been printed yet. Used by the logger.
|
||||
var has_printed_name = false
|
||||
|
||||
# the number of arguments the method has
|
||||
var arg_count = 0
|
||||
|
||||
# the time it took to execute the test in seconds
|
||||
var time_taken : float = 0
|
||||
|
||||
# The number of asserts in the test. Converted to a property for backwards
|
||||
# compatibility. This now reflects the text sizes instead of being a value
|
||||
# that can be altered externally.
|
||||
var assert_count = 0 :
|
||||
get: return pass_texts.size() + fail_texts.size()
|
||||
set(val): pass
|
||||
|
||||
# Converted to propety for backwards compatibility. This now cannot be set
|
||||
# externally
|
||||
var pending = false :
|
||||
get: return is_pending()
|
||||
set(val): pass
|
||||
|
||||
# the line number when the test fails
|
||||
var line_number = -1
|
||||
|
||||
# Set internally by Gut using whatever reason Gut wants to use to set this.
|
||||
# Gut will skip these marked true and the test will be listed as risky.
|
||||
var should_skip = false # -- Currently not used by GUT don't believe ^
|
||||
|
||||
var pass_texts = []
|
||||
var fail_texts = []
|
||||
var pending_texts = []
|
||||
var orphans = 0
|
||||
|
||||
var was_run = false
|
||||
|
||||
var collected_script : WeakRef = null
|
||||
|
||||
|
||||
func did_pass():
|
||||
return is_passing()
|
||||
|
||||
|
||||
func add_fail(fail_text):
|
||||
fail_texts.append(fail_text)
|
||||
|
||||
|
||||
func add_pending(pending_text):
|
||||
pending_texts.append(pending_text)
|
||||
|
||||
|
||||
func add_pass(passing_text):
|
||||
pass_texts.append(passing_text)
|
||||
|
||||
|
||||
# must have passed an assert and not have any other status to be passing
|
||||
func is_passing():
|
||||
return pass_texts.size() > 0 and fail_texts.size() == 0 and pending_texts.size() == 0
|
||||
|
||||
|
||||
# failing takes precedence over everything else, so any failures makes the
|
||||
# test a failure.
|
||||
func is_failing():
|
||||
return fail_texts.size() > 0
|
||||
|
||||
|
||||
# test is only pending if pending was called and the test is not failing.
|
||||
func is_pending():
|
||||
return pending_texts.size() > 0 and fail_texts.size() == 0
|
||||
|
||||
|
||||
func is_risky():
|
||||
return should_skip or (was_run and !did_something())
|
||||
|
||||
|
||||
func did_something():
|
||||
return is_passing() or is_failing() or is_pending()
|
||||
|
||||
|
||||
func get_status_text():
|
||||
var to_return = GutUtils.TEST_STATUSES.NO_ASSERTS
|
||||
|
||||
if(should_skip):
|
||||
to_return = GutUtils.TEST_STATUSES.SKIPPED
|
||||
elif(!was_run):
|
||||
to_return = GutUtils.TEST_STATUSES.NOT_RUN
|
||||
elif(pending_texts.size() > 0):
|
||||
to_return = GutUtils.TEST_STATUSES.PENDING
|
||||
elif(fail_texts.size() > 0):
|
||||
to_return = GutUtils.TEST_STATUSES.FAILED
|
||||
elif(pass_texts.size() > 0):
|
||||
to_return = GutUtils.TEST_STATUSES.PASSED
|
||||
|
||||
return to_return
|
||||
|
||||
|
||||
# Deprecated
|
||||
func get_status():
|
||||
return get_status_text()
|
||||
|
||||
|
||||
func to_s():
|
||||
var pad = ' '
|
||||
var to_return = str(name, "[", get_status_text(), "]\n")
|
||||
|
||||
for i in range(fail_texts.size()):
|
||||
to_return += str(pad, 'Fail: ', fail_texts[i])
|
||||
for i in range(pending_texts.size()):
|
||||
to_return += str(pad, 'Pending: ', pending_texts[i], "\n")
|
||||
for i in range(pass_texts.size()):
|
||||
to_return += str(pad, 'Pass: ', pass_texts[i], "\n")
|
||||
return to_return
|
||||
|
||||
|
||||
1
client/addons/gut/collected_test.gd.uid
Normal file
1
client/addons/gut/collected_test.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cl854f1m26a2a
|
||||
125
client/addons/gut/comparator.gd
Normal file
125
client/addons/gut/comparator.gd
Normal file
@@ -0,0 +1,125 @@
|
||||
var _strutils = GutUtils.Strutils.new()
|
||||
var _max_length = 100
|
||||
var _should_compare_int_to_float = true
|
||||
|
||||
const MISSING = '|__missing__gut__compare__value__|'
|
||||
|
||||
|
||||
func _cannot_compare_text(v1, v2):
|
||||
return str('Cannot compare ', _strutils.types[typeof(v1)], ' with ',
|
||||
_strutils.types[typeof(v2)], '.')
|
||||
|
||||
|
||||
func _make_missing_string(text):
|
||||
return '<missing ' + text + '>'
|
||||
|
||||
|
||||
func _create_missing_result(v1, v2, text):
|
||||
var to_return = null
|
||||
var v1_str = format_value(v1)
|
||||
var v2_str = format_value(v2)
|
||||
|
||||
if(typeof(v1) == TYPE_STRING and v1 == MISSING):
|
||||
v1_str = _make_missing_string(text)
|
||||
to_return = GutUtils.CompareResult.new()
|
||||
elif(typeof(v2) == TYPE_STRING and v2 == MISSING):
|
||||
v2_str = _make_missing_string(text)
|
||||
to_return = GutUtils.CompareResult.new()
|
||||
|
||||
if(to_return != null):
|
||||
to_return.summary = str(v1_str, ' != ', v2_str)
|
||||
to_return.are_equal = false
|
||||
|
||||
return to_return
|
||||
|
||||
|
||||
func simple(v1, v2, missing_string=''):
|
||||
var missing_result = _create_missing_result(v1, v2, missing_string)
|
||||
if(missing_result != null):
|
||||
return missing_result
|
||||
|
||||
var result = GutUtils.CompareResult.new()
|
||||
var cmp_str = null
|
||||
var extra = ''
|
||||
|
||||
var tv1 = typeof(v1)
|
||||
var tv2 = typeof(v2)
|
||||
|
||||
# print(tv1, '::', tv2, ' ', _strutils.types[tv1], '::', _strutils.types[tv2])
|
||||
if(_should_compare_int_to_float and [TYPE_INT, TYPE_FLOAT].has(tv1) and [TYPE_INT, TYPE_FLOAT].has(tv2)):
|
||||
result.are_equal = v1 == v2
|
||||
elif([TYPE_STRING, TYPE_STRING_NAME].has(tv1) and [TYPE_STRING, TYPE_STRING_NAME].has(tv2)):
|
||||
result.are_equal = v1 == v2
|
||||
elif(GutUtils.are_datatypes_same(v1, v2)):
|
||||
result.are_equal = v1 == v2
|
||||
|
||||
if(typeof(v1) == TYPE_DICTIONARY or typeof(v1) == TYPE_ARRAY):
|
||||
var sub_result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
|
||||
result.summary = sub_result.get_short_summary()
|
||||
if(!sub_result.are_equal):
|
||||
extra = ".\n" + sub_result.get_short_summary()
|
||||
else:
|
||||
cmp_str = '!='
|
||||
result.are_equal = false
|
||||
extra = str('. ', _cannot_compare_text(v1, v2))
|
||||
|
||||
cmp_str = get_compare_symbol(result.are_equal)
|
||||
result.summary = str(format_value(v1), ' ', cmp_str, ' ', format_value(v2), extra)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func shallow(v1, v2):
|
||||
var result = null
|
||||
if(GutUtils.are_datatypes_same(v1, v2)):
|
||||
if(typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]):
|
||||
result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
|
||||
else:
|
||||
result = simple(v1, v2)
|
||||
else:
|
||||
result = simple(v1, v2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func deep(v1, v2):
|
||||
var result = null
|
||||
|
||||
if(GutUtils.are_datatypes_same(v1, v2)):
|
||||
if(typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]):
|
||||
result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
|
||||
else:
|
||||
result = simple(v1, v2)
|
||||
else:
|
||||
result = simple(v1, v2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func format_value(val, max_val_length=_max_length):
|
||||
return _strutils.truncate_string(_strutils.type2str(val), max_val_length)
|
||||
|
||||
|
||||
func compare(v1, v2, diff_type=GutUtils.DIFF.SIMPLE):
|
||||
var result = null
|
||||
if(diff_type == GutUtils.DIFF.SIMPLE):
|
||||
result = simple(v1, v2)
|
||||
elif(diff_type == GutUtils.DIFF.DEEP):
|
||||
result = deep(v1, v2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func get_should_compare_int_to_float():
|
||||
return _should_compare_int_to_float
|
||||
|
||||
|
||||
func set_should_compare_int_to_float(should_compare_int_float):
|
||||
_should_compare_int_to_float = should_compare_int_float
|
||||
|
||||
|
||||
func get_compare_symbol(is_equal):
|
||||
if(is_equal):
|
||||
return '=='
|
||||
else:
|
||||
return '!='
|
||||
1
client/addons/gut/comparator.gd.uid
Normal file
1
client/addons/gut/comparator.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bohry7fhscy7y
|
||||
70
client/addons/gut/compare_result.gd
Normal file
70
client/addons/gut/compare_result.gd
Normal file
@@ -0,0 +1,70 @@
|
||||
var _are_equal = false
|
||||
var are_equal = false :
|
||||
get:
|
||||
return get_are_equal()
|
||||
set(val):
|
||||
set_are_equal(val)
|
||||
|
||||
var _summary = null
|
||||
var summary = null :
|
||||
get:
|
||||
return get_summary()
|
||||
set(val):
|
||||
set_summary(val)
|
||||
|
||||
var _max_differences = 30
|
||||
var max_differences = 30 :
|
||||
get:
|
||||
return get_max_differences()
|
||||
set(val):
|
||||
set_max_differences(val)
|
||||
|
||||
var _differences = {}
|
||||
var differences :
|
||||
get:
|
||||
return get_differences()
|
||||
set(val):
|
||||
set_differences(val)
|
||||
|
||||
func _block_set(which, val):
|
||||
push_error(str('cannot set ', which, ', value [', val, '] ignored.'))
|
||||
|
||||
func _to_string():
|
||||
return str(get_summary()) # could be null, gotta str it.
|
||||
|
||||
func get_are_equal():
|
||||
return _are_equal
|
||||
|
||||
func set_are_equal(r_eq):
|
||||
_are_equal = r_eq
|
||||
|
||||
func get_summary():
|
||||
return _summary
|
||||
|
||||
func set_summary(smry):
|
||||
_summary = smry
|
||||
|
||||
func get_total_count():
|
||||
pass
|
||||
|
||||
func get_different_count():
|
||||
pass
|
||||
|
||||
func get_short_summary():
|
||||
return summary
|
||||
|
||||
func get_max_differences():
|
||||
return _max_differences
|
||||
|
||||
func set_max_differences(max_diff):
|
||||
_max_differences = max_diff
|
||||
|
||||
func get_differences():
|
||||
return _differences
|
||||
|
||||
func set_differences(diffs):
|
||||
_block_set('differences', diffs)
|
||||
|
||||
func get_brackets():
|
||||
return null
|
||||
|
||||
1
client/addons/gut/compare_result.gd.uid
Normal file
1
client/addons/gut/compare_result.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cow1xqmqqvn4e
|
||||
63
client/addons/gut/diff_formatter.gd
Normal file
63
client/addons/gut/diff_formatter.gd
Normal file
@@ -0,0 +1,63 @@
|
||||
var _strutils = GutUtils.Strutils.new()
|
||||
const INDENT = ' '
|
||||
var _max_to_display = 30
|
||||
const ABSOLUTE_MAX_DISPLAYED = 10000
|
||||
const UNLIMITED = -1
|
||||
|
||||
|
||||
func _single_diff(diff, depth=0):
|
||||
var to_return = ""
|
||||
var brackets = diff.get_brackets()
|
||||
|
||||
if(brackets != null and !diff.are_equal):
|
||||
to_return = ''
|
||||
to_return += str(brackets.open, "\n",
|
||||
_strutils.indent_text(differences_to_s(diff.differences, depth), depth+1, INDENT), "\n",
|
||||
brackets.close)
|
||||
else:
|
||||
to_return = str(diff)
|
||||
|
||||
return to_return
|
||||
|
||||
|
||||
func make_it(diff):
|
||||
var to_return = ''
|
||||
if(diff.are_equal):
|
||||
to_return = diff.summary
|
||||
else:
|
||||
if(_max_to_display == ABSOLUTE_MAX_DISPLAYED):
|
||||
to_return = str(diff.get_value_1(), ' != ', diff.get_value_2())
|
||||
else:
|
||||
to_return = diff.get_short_summary()
|
||||
to_return += str("\n", _strutils.indent_text(_single_diff(diff, 0), 1, ' '))
|
||||
return to_return
|
||||
|
||||
|
||||
func differences_to_s(differences, depth=0):
|
||||
var to_return = ''
|
||||
var keys = differences.keys()
|
||||
keys.sort()
|
||||
var limit = min(_max_to_display, differences.size())
|
||||
|
||||
for i in range(limit):
|
||||
var key = keys[i]
|
||||
to_return += str(key, ": ", _single_diff(differences[key], depth))
|
||||
|
||||
if(i != limit -1):
|
||||
to_return += "\n"
|
||||
|
||||
if(differences.size() > _max_to_display):
|
||||
to_return += str("\n\n... ", differences.size() - _max_to_display, " more.")
|
||||
|
||||
return to_return
|
||||
|
||||
|
||||
func get_max_to_display():
|
||||
return _max_to_display
|
||||
|
||||
|
||||
func set_max_to_display(max_to_display):
|
||||
_max_to_display = max_to_display
|
||||
if(_max_to_display == UNLIMITED):
|
||||
_max_to_display = ABSOLUTE_MAX_DISPLAYED
|
||||
|
||||
1
client/addons/gut/diff_formatter.gd.uid
Normal file
1
client/addons/gut/diff_formatter.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ch2km05phxacd
|
||||
156
client/addons/gut/diff_tool.gd
Normal file
156
client/addons/gut/diff_tool.gd
Normal file
@@ -0,0 +1,156 @@
|
||||
extends 'res://addons/gut/compare_result.gd'
|
||||
const INDENT = ' '
|
||||
enum {
|
||||
DEEP,
|
||||
SIMPLE
|
||||
}
|
||||
|
||||
var _strutils = GutUtils.Strutils.new()
|
||||
var _compare = GutUtils.Comparator.new()
|
||||
|
||||
var _value_1 = null
|
||||
var _value_2 = null
|
||||
var _total_count = 0
|
||||
var _diff_type = null
|
||||
var _brackets = null
|
||||
var _valid = true
|
||||
var _desc_things = 'somethings'
|
||||
|
||||
# -------- comapre_result.gd "interface" ---------------------
|
||||
func set_are_equal(val):
|
||||
_block_set('are_equal', val)
|
||||
|
||||
func get_are_equal():
|
||||
if(!_valid):
|
||||
return null
|
||||
else:
|
||||
return differences.size() == 0
|
||||
|
||||
|
||||
func set_summary(val):
|
||||
_block_set('summary', val)
|
||||
|
||||
func get_summary():
|
||||
return summarize()
|
||||
|
||||
func get_different_count():
|
||||
return differences.size()
|
||||
|
||||
func get_total_count():
|
||||
return _total_count
|
||||
|
||||
func get_short_summary():
|
||||
var text = str(_strutils.truncate_string(str(_value_1), 50),
|
||||
' ', _compare.get_compare_symbol(are_equal), ' ',
|
||||
_strutils.truncate_string(str(_value_2), 50))
|
||||
if(!are_equal):
|
||||
text += str(' ', get_different_count(), ' of ', get_total_count(),
|
||||
' ', _desc_things, ' do not match.')
|
||||
return text
|
||||
|
||||
func get_brackets():
|
||||
return _brackets
|
||||
# -------- comapre_result.gd "interface" ---------------------
|
||||
|
||||
|
||||
func _invalidate():
|
||||
_valid = false
|
||||
differences = null
|
||||
|
||||
|
||||
func _init(v1,v2,diff_type=DEEP):
|
||||
_value_1 = v1
|
||||
_value_2 = v2
|
||||
_diff_type = diff_type
|
||||
_compare.set_should_compare_int_to_float(false)
|
||||
_find_differences(_value_1, _value_2)
|
||||
|
||||
|
||||
func _find_differences(v1, v2):
|
||||
if(GutUtils.are_datatypes_same(v1, v2)):
|
||||
if(typeof(v1) == TYPE_ARRAY):
|
||||
_brackets = {'open':'[', 'close':']'}
|
||||
_desc_things = 'indexes'
|
||||
_diff_array(v1, v2)
|
||||
elif(typeof(v2) == TYPE_DICTIONARY):
|
||||
_brackets = {'open':'{', 'close':'}'}
|
||||
_desc_things = 'keys'
|
||||
_diff_dictionary(v1, v2)
|
||||
else:
|
||||
_invalidate()
|
||||
GutUtils.get_logger().error('Only Arrays and Dictionaries are supported.')
|
||||
else:
|
||||
_invalidate()
|
||||
GutUtils.get_logger().error('Only Arrays and Dictionaries are supported.')
|
||||
|
||||
|
||||
func _diff_array(a1, a2):
|
||||
_total_count = max(a1.size(), a2.size())
|
||||
for i in range(a1.size()):
|
||||
var result = null
|
||||
if(i < a2.size()):
|
||||
if(_diff_type == DEEP):
|
||||
result = _compare.deep(a1[i], a2[i])
|
||||
else:
|
||||
result = _compare.simple(a1[i], a2[i])
|
||||
else:
|
||||
result = _compare.simple(a1[i], _compare.MISSING, 'index')
|
||||
|
||||
if(!result.are_equal):
|
||||
differences[i] = result
|
||||
|
||||
if(a1.size() < a2.size()):
|
||||
for i in range(a1.size(), a2.size()):
|
||||
differences[i] = _compare.simple(_compare.MISSING, a2[i], 'index')
|
||||
|
||||
|
||||
func _diff_dictionary(d1, d2):
|
||||
var d1_keys = d1.keys()
|
||||
var d2_keys = d2.keys()
|
||||
|
||||
# Process all the keys in d1
|
||||
_total_count += d1_keys.size()
|
||||
for key in d1_keys:
|
||||
if(!d2.has(key)):
|
||||
differences[key] = _compare.simple(d1[key], _compare.MISSING, 'key')
|
||||
else:
|
||||
d2_keys.remove_at(d2_keys.find(key))
|
||||
|
||||
var result = null
|
||||
if(_diff_type == DEEP):
|
||||
result = _compare.deep(d1[key], d2[key])
|
||||
else:
|
||||
result = _compare.simple(d1[key], d2[key])
|
||||
|
||||
if(!result.are_equal):
|
||||
differences[key] = result
|
||||
|
||||
# Process all the keys in d2 that didn't exist in d1
|
||||
_total_count += d2_keys.size()
|
||||
for i in range(d2_keys.size()):
|
||||
differences[d2_keys[i]] = _compare.simple(_compare.MISSING, d2[d2_keys[i]], 'key')
|
||||
|
||||
|
||||
func summarize():
|
||||
var summary = ''
|
||||
|
||||
if(are_equal):
|
||||
summary = get_short_summary()
|
||||
else:
|
||||
var formatter = load('res://addons/gut/diff_formatter.gd').new()
|
||||
formatter.set_max_to_display(max_differences)
|
||||
summary = formatter.make_it(self)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
func get_diff_type():
|
||||
return _diff_type
|
||||
|
||||
|
||||
func get_value_1():
|
||||
return _value_1
|
||||
|
||||
|
||||
func get_value_2():
|
||||
return _value_2
|
||||
1
client/addons/gut/diff_tool.gd.uid
Normal file
1
client/addons/gut/diff_tool.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://beoxokvl1hjs8
|
||||
22
client/addons/gut/double_templates/double_data_template.txt
Normal file
22
client/addons/gut/double_templates/double_data_template.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
var __gutdbl_values = {
|
||||
thepath = '{path}',
|
||||
subpath = '{subpath}',
|
||||
stubber = {stubber_id},
|
||||
spy = {spy_id},
|
||||
gut = {gut_id},
|
||||
singleton_name = '{singleton_name}',
|
||||
singleton = {singleton_id},
|
||||
is_partial = {is_partial},
|
||||
doubled_methods = {doubled_methods},
|
||||
}
|
||||
var __gutdbl = load('res://addons/gut/double_tools.gd').new(self)
|
||||
|
||||
# Here so other things can check for a method to know if this is a double.
|
||||
func __gutdbl_check_method__():
|
||||
pass
|
||||
|
||||
# Cleanup called by GUT after tests have finished. Important for RefCounted
|
||||
# objects. Nodes are freed, and won't have this method called on them.
|
||||
func __gutdbl_done():
|
||||
__gutdbl = null
|
||||
__gutdbl_values.clear()
|
||||
9
client/addons/gut/double_templates/function_template.txt
Normal file
9
client/addons/gut/double_templates/function_template.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
{func_decleration}
|
||||
if(__gutdbl == null):
|
||||
return
|
||||
|
||||
__gutdbl.spy_on('{method_name}', {param_array})
|
||||
if(__gutdbl.is_stubbed_to_call_super('{method_name}', {param_array})):
|
||||
{super_call}
|
||||
else:
|
||||
return await __gutdbl.handle_other_stubs('{method_name}', {param_array})
|
||||
4
client/addons/gut/double_templates/init_template.txt
Normal file
4
client/addons/gut/double_templates/init_template.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
{func_decleration}:
|
||||
super({super_params})
|
||||
__gutdbl.spy_on('{method_name}', {param_array})
|
||||
|
||||
13
client/addons/gut/double_templates/script_template.txt
Normal file
13
client/addons/gut/double_templates/script_template.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# ##############################################################################
|
||||
# Gut Doubled Script
|
||||
# ##############################################################################
|
||||
{extends}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# GUT stuff
|
||||
# ------------------------------------------------------------------------------
|
||||
{double_data}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Doubled Methods
|
||||
# ------------------------------------------------------------------------------
|
||||
16
client/addons/gut/double_templates/singleton_template.txt
Normal file
16
client/addons/gut/double_templates/singleton_template.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
{extends}
|
||||
|
||||
{constants}
|
||||
|
||||
{signals}
|
||||
|
||||
{properties}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# GUT stuff
|
||||
# ------------------------------------------------------------------------------
|
||||
{double_data}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Doubled Methods
|
||||
# ------------------------------------------------------------------------------
|
||||
83
client/addons/gut/double_tools.gd
Normal file
83
client/addons/gut/double_tools.gd
Normal file
@@ -0,0 +1,83 @@
|
||||
var thepath = ''
|
||||
var subpath = ''
|
||||
var singleton_name = null
|
||||
var is_partial = null
|
||||
|
||||
var double_ref : WeakRef = null
|
||||
var stubber_ref : WeakRef = null
|
||||
var spy_ref : WeakRef = null
|
||||
var gut_ref : WeakRef = null
|
||||
var singleton_ref : WeakRef = null
|
||||
var __gutdbl_values = {}
|
||||
|
||||
const NO_DEFAULT_VALUE = '!__gut__no__default__value__!'
|
||||
func _init(double = null):
|
||||
if(double != null):
|
||||
var values = double.__gutdbl_values
|
||||
__gutdbl_values = double.__gutdbl_values
|
||||
double_ref = weakref(double)
|
||||
thepath = values.thepath
|
||||
subpath = values.subpath
|
||||
stubber_ref = weakref_from_id(values.stubber)
|
||||
spy_ref = weakref_from_id(values.spy)
|
||||
gut_ref = weakref_from_id(values.gut)
|
||||
singleton_ref = weakref_from_id(values.singleton)
|
||||
singleton_name = values.singleton_name
|
||||
is_partial = values.is_partial
|
||||
|
||||
if(gut_ref.get_ref() != null):
|
||||
gut_ref.get_ref().get_autofree().add_free(double_ref.get_ref())
|
||||
|
||||
|
||||
func _get_stubbed_method_to_call(method_name, called_with):
|
||||
var method = stubber_ref.get_ref().get_call_this(double_ref.get_ref(), method_name, called_with)
|
||||
if(method != null):
|
||||
method = method.bindv(called_with)
|
||||
return method
|
||||
return method
|
||||
|
||||
|
||||
func weakref_from_id(inst_id):
|
||||
if(inst_id == -1):
|
||||
return weakref(null)
|
||||
else:
|
||||
return weakref(instance_from_id(inst_id))
|
||||
|
||||
|
||||
func is_stubbed_to_call_super(method_name, called_with):
|
||||
if(stubber_ref.get_ref() != null):
|
||||
return stubber_ref.get_ref().should_call_super(double_ref.get_ref(), method_name, called_with)
|
||||
else:
|
||||
return false
|
||||
|
||||
|
||||
func handle_other_stubs(method_name, called_with):
|
||||
if(stubber_ref.get_ref() == null):
|
||||
return
|
||||
|
||||
var method = _get_stubbed_method_to_call(method_name, called_with)
|
||||
if(method != null):
|
||||
return await method.call()
|
||||
else:
|
||||
return stubber_ref.get_ref().get_return(double_ref.get_ref(), method_name, called_with)
|
||||
|
||||
|
||||
func spy_on(method_name, called_with):
|
||||
if(spy_ref.get_ref() != null):
|
||||
spy_ref.get_ref().add_call(double_ref.get_ref(), method_name, called_with)
|
||||
|
||||
|
||||
func default_val(method_name, p_index):
|
||||
if(stubber_ref.get_ref() == null):
|
||||
return null
|
||||
else:
|
||||
var result = stubber_ref.get_ref().get_default_value(double_ref.get_ref(), method_name, p_index)
|
||||
return result
|
||||
|
||||
|
||||
func get_singleton():
|
||||
var to_return = singleton_ref.get_ref()
|
||||
if(to_return == null):
|
||||
push_error("Trying to get a singleton reference on a non-singleton double: ",
|
||||
__gutdbl_values.singleton_name, "/", __gutdbl_values.singleton)
|
||||
return to_return
|
||||
1
client/addons/gut/double_tools.gd.uid
Normal file
1
client/addons/gut/double_tools.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://tr4khoco1hef
|
||||
396
client/addons/gut/doubler.gd
Normal file
396
client/addons/gut/doubler.gd
Normal file
@@ -0,0 +1,396 @@
|
||||
extends RefCounted
|
||||
|
||||
|
||||
static var _base_script_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/script_template.txt')
|
||||
static var _singleton_script_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/singleton_template.txt')
|
||||
static var _double_data_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/double_data_template.txt')
|
||||
|
||||
var _script_collector = GutUtils.ScriptCollector.new()
|
||||
var _singleton_parser = GutUtils.SingletonParser.new()
|
||||
|
||||
# used by tests for debugging purposes.
|
||||
var print_source = false
|
||||
var inner_class_registry = GutUtils.inner_class_registry
|
||||
|
||||
# ###############
|
||||
# Properties
|
||||
# ###############
|
||||
var _stubber = GutUtils.Stubber.new()
|
||||
func get_stubber():
|
||||
return _stubber
|
||||
func set_stubber(stubber):
|
||||
_stubber = stubber
|
||||
|
||||
var _lgr = GutUtils.get_logger()
|
||||
func get_logger():
|
||||
return _lgr
|
||||
func set_logger(logger):
|
||||
_lgr = logger
|
||||
_method_maker.set_logger(logger)
|
||||
|
||||
var _spy = null
|
||||
func get_spy():
|
||||
return _spy
|
||||
func set_spy(spy):
|
||||
_spy = spy
|
||||
|
||||
var _gut = null
|
||||
func get_gut():
|
||||
return _gut
|
||||
func set_gut(gut):
|
||||
_gut = gut
|
||||
|
||||
var _strategy = null
|
||||
func get_strategy():
|
||||
return _strategy
|
||||
func set_strategy(strategy):
|
||||
if(GutUtils.DOUBLE_STRATEGY.values().has(strategy)):
|
||||
_strategy = strategy
|
||||
else:
|
||||
_lgr.error(str('doubler.gd: invalid double strategy ', strategy))
|
||||
|
||||
var _method_maker = GutUtils.MethodMaker.new()
|
||||
func get_method_maker():
|
||||
return _method_maker
|
||||
|
||||
var _ignored_methods = GutUtils.OneToMany.new()
|
||||
func get_ignored_methods():
|
||||
return _ignored_methods
|
||||
|
||||
|
||||
# ###############
|
||||
# Private
|
||||
# ###############
|
||||
func _init(strategy=GutUtils.DOUBLE_STRATEGY.SCRIPT_ONLY):
|
||||
set_logger(GutUtils.get_logger())
|
||||
_strategy = strategy
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if(what == NOTIFICATION_PREDELETE):
|
||||
if(_stubber != null):
|
||||
_stubber.clear()
|
||||
|
||||
func _get_indented_line(indents, text):
|
||||
var to_return = ''
|
||||
for _i in range(indents):
|
||||
to_return += "\t"
|
||||
return str(to_return, text, "\n")
|
||||
|
||||
|
||||
func _stub_to_call_super(parsed, method_name):
|
||||
if(!parsed.get_method(method_name).is_eligible_for_doubling()):
|
||||
return
|
||||
|
||||
var params = null
|
||||
if(parsed.is_native):
|
||||
params = GutUtils.StubParams.new(parsed._native_class, method_name, parsed.subpath)
|
||||
else:
|
||||
params = GutUtils.StubParams.new(parsed.script_path, method_name, parsed.subpath)
|
||||
params.to_call_super()
|
||||
params.is_script_default = true
|
||||
_stubber.add_stub(params)
|
||||
|
||||
|
||||
func _get_base_script_text(parsed, override_path, partial, included_methods):
|
||||
var path = parsed.script_path
|
||||
if(override_path != null):
|
||||
path = override_path
|
||||
|
||||
var stubber_id = -1
|
||||
if(_stubber != null):
|
||||
stubber_id = _stubber.get_instance_id()
|
||||
|
||||
var spy_id = -1
|
||||
if(_spy != null):
|
||||
spy_id = _spy.get_instance_id()
|
||||
|
||||
var gut_id = -1
|
||||
if(_gut != null):
|
||||
gut_id = _gut.get_instance_id()
|
||||
|
||||
var extends_text = parsed.get_extends_text()
|
||||
var double_data_values = {
|
||||
"path":path,
|
||||
"subpath":GutUtils.nvl(parsed.subpath, ''),
|
||||
"stubber_id":stubber_id,
|
||||
"spy_id":spy_id,
|
||||
"gut_id":gut_id,
|
||||
"singleton_name":'',
|
||||
"singleton_id":-1,
|
||||
"is_partial":partial,
|
||||
"doubled_methods":included_methods,
|
||||
}
|
||||
|
||||
var values = {
|
||||
"extends":extends_text,
|
||||
"double_data":_double_data_text.format(double_data_values),
|
||||
}
|
||||
|
||||
return _base_script_text.format(values)
|
||||
|
||||
|
||||
func _get_singleton_text(parsed, included_methods, is_partial):
|
||||
var stubber_id = -1
|
||||
if(_stubber != null):
|
||||
stubber_id = _stubber.get_instance_id()
|
||||
|
||||
var spy_id = -1
|
||||
if(_spy != null):
|
||||
spy_id = _spy.get_instance_id()
|
||||
|
||||
var gut_id = -1
|
||||
if(_gut != null):
|
||||
gut_id = _gut.get_instance_id()
|
||||
|
||||
var double_data_values = {
|
||||
"path":'',
|
||||
"subpath":'',
|
||||
"stubber_id":stubber_id,
|
||||
"spy_id":spy_id,
|
||||
"gut_id":gut_id,
|
||||
"singleton_name":parsed.singleton_name,
|
||||
"singleton_id":parsed.singleton_id,
|
||||
"is_partial":is_partial,
|
||||
"doubled_methods":included_methods,
|
||||
}
|
||||
|
||||
var values = {
|
||||
"extends":"extends RefCounted",
|
||||
"double_data":_double_data_text.format(double_data_values),
|
||||
"signals":parsed.get_all_signal_text(),
|
||||
"constants":parsed.get_all_constants_text(),
|
||||
"properties":parsed.get_all_properties_text()
|
||||
}
|
||||
|
||||
var src = _singleton_script_text.format(values)
|
||||
return src
|
||||
|
||||
|
||||
func _is_method_eligible_for_doubling(parsed_script, parsed_method):
|
||||
return !parsed_method.is_accessor() and \
|
||||
parsed_method.is_eligible_for_doubling() and \
|
||||
!_ignored_methods.has(parsed_script.resource, parsed_method.meta.name)
|
||||
|
||||
|
||||
# Disable the native_method_override setting so that doubles do not generate
|
||||
# errors or warnings when doubling with INCLUDE_NATIVE or when a method has
|
||||
# been added because of param_count stub.
|
||||
func _create_script_no_warnings(src):
|
||||
var prev_native_override_value = null
|
||||
var native_method_override = 'debug/gdscript/warnings/native_method_override'
|
||||
prev_native_override_value = ProjectSettings.get_setting(native_method_override)
|
||||
ProjectSettings.set_setting(native_method_override, 0)
|
||||
|
||||
var DblClass = GutUtils.create_script_from_source(src)
|
||||
|
||||
ProjectSettings.set_setting(native_method_override, prev_native_override_value)
|
||||
return DblClass
|
||||
|
||||
|
||||
func _create_double(parsed, strategy, override_path, partial):
|
||||
var dbl_src = ""
|
||||
var included_methods = []
|
||||
|
||||
for method in parsed.get_local_methods():
|
||||
if(_is_method_eligible_for_doubling(parsed, method)):
|
||||
included_methods.append(method.meta.name)
|
||||
dbl_src += _get_func_text(method.meta)
|
||||
|
||||
if(strategy == GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE):
|
||||
for method in parsed.get_super_methods():
|
||||
if(_is_method_eligible_for_doubling(parsed, method)):
|
||||
included_methods.append(method.meta.name)
|
||||
_stub_to_call_super(parsed, method.meta.name)
|
||||
dbl_src += _get_func_text(method.meta)
|
||||
|
||||
var base_script = _get_base_script_text(parsed, override_path, partial, included_methods)
|
||||
dbl_src = base_script + "\n\n" + dbl_src
|
||||
|
||||
if(print_source):
|
||||
var to_print :String = GutUtils.add_line_numbers(dbl_src)
|
||||
to_print = to_print.rstrip("\n")
|
||||
_lgr.log(str(to_print))
|
||||
|
||||
var DblClass = _create_script_no_warnings(dbl_src)
|
||||
if(_stubber != null):
|
||||
_stub_method_default_values(parsed)
|
||||
|
||||
if(print_source):
|
||||
_lgr.log(str(" path | ", DblClass.resource_path, "\n"))
|
||||
|
||||
return DblClass
|
||||
|
||||
|
||||
func _create_singleton_double(singleton, is_partial):
|
||||
var parsed = _singleton_parser.parse(singleton)
|
||||
var dbl_src = _get_singleton_text(parsed, parsed.methods_by_name.keys(), is_partial)
|
||||
|
||||
for key in parsed.methods_by_name:
|
||||
if(!_ignored_methods.has(singleton, key)):
|
||||
dbl_src += _method_maker.get_function_text(parsed.methods_by_name[key], singleton) + "\n"
|
||||
|
||||
if(print_source):
|
||||
var to_print :String = GutUtils.add_line_numbers(dbl_src)
|
||||
to_print = to_print.rstrip("\n")
|
||||
_lgr.log(str(to_print))
|
||||
|
||||
var DblClass = GutUtils.create_script_from_source(dbl_src)
|
||||
if(_stubber != null):
|
||||
for key in parsed.methods_by_name:
|
||||
var meta = parsed.methods_by_name[key]
|
||||
if(meta != {} and !meta.flags & METHOD_FLAG_VARARG):
|
||||
_stubber.stub_defaults_from_meta(singleton, meta)
|
||||
|
||||
return DblClass
|
||||
|
||||
|
||||
func _stub_method_default_values(parsed):
|
||||
for method in parsed.get_local_methods():
|
||||
if(method.is_eligible_for_doubling() and !_ignored_methods.has(parsed.resource, method.meta.name)):
|
||||
_stubber.stub_defaults_from_meta(parsed.script_path, method.meta)
|
||||
|
||||
|
||||
func _double_scene_and_script(scene, strategy, partial):
|
||||
var dbl_bundle = scene._bundled.duplicate(true)
|
||||
var script_obj = GutUtils.get_scene_script_object(scene)
|
||||
# I'm not sure if the script object for the root node of a packed scene is
|
||||
# always the first entry in "variants" so this tries to find it.
|
||||
var script_index = dbl_bundle["variants"].find(script_obj)
|
||||
var script_dbl = null
|
||||
|
||||
if(script_obj != null):
|
||||
if(partial):
|
||||
script_dbl = _partial_double(script_obj, strategy, scene.get_path())
|
||||
else:
|
||||
script_dbl = _double(script_obj, strategy, scene.get_path())
|
||||
|
||||
if(script_index != -1):
|
||||
dbl_bundle["variants"][script_index] = script_dbl
|
||||
|
||||
var doubled_scene = PackedScene.new()
|
||||
doubled_scene._set_bundled_scene(dbl_bundle)
|
||||
|
||||
return doubled_scene
|
||||
|
||||
|
||||
func _get_inst_id_ref_str(inst):
|
||||
var ref_str = 'null'
|
||||
if(inst):
|
||||
ref_str = str('instance_from_id(', inst.get_instance_id(),')')
|
||||
return ref_str
|
||||
|
||||
|
||||
func _get_func_text(method_hash):
|
||||
return _method_maker.get_function_text(method_hash) + "\n"
|
||||
|
||||
|
||||
func _parse_script(obj):
|
||||
var parsed = null
|
||||
|
||||
if(GutUtils.is_inner_class(obj)):
|
||||
if(inner_class_registry.has(obj)):
|
||||
parsed = _script_collector.parse(inner_class_registry.get_base_resource(obj), obj)
|
||||
else:
|
||||
_lgr.error('Doubling Inner Classes requires you register them first. Call register_inner_classes passing the script that contains the inner class.')
|
||||
else:
|
||||
parsed = _script_collector.parse(obj)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
# Override path is used with scenes.
|
||||
func _double(obj, strategy, override_path=null):
|
||||
var parsed = _parse_script(obj)
|
||||
if(parsed != null):
|
||||
return _create_double(parsed, strategy, override_path, false)
|
||||
|
||||
|
||||
func _partial_double(obj, strategy, override_path=null):
|
||||
var parsed = _parse_script(obj)
|
||||
if(parsed != null):
|
||||
return _create_double(parsed, strategy, override_path, true)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Public
|
||||
# -------------------------
|
||||
|
||||
# double a script/object
|
||||
func double(obj, strategy=_strategy):
|
||||
return _double(obj, strategy)
|
||||
|
||||
|
||||
func partial_double(obj, strategy=_strategy):
|
||||
return _partial_double(obj, strategy)
|
||||
|
||||
|
||||
# double a scene
|
||||
func double_scene(scene, strategy=_strategy):
|
||||
return _double_scene_and_script(scene, strategy, false)
|
||||
|
||||
|
||||
func partial_double_scene(scene, strategy=_strategy):
|
||||
return _double_scene_and_script(scene, strategy, true)
|
||||
|
||||
|
||||
func double_gdnative(which):
|
||||
return _double(which, GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE)
|
||||
|
||||
|
||||
func partial_double_gdnative(which):
|
||||
return _partial_double(which, GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE)
|
||||
|
||||
|
||||
func double_inner(parent, inner, strategy=_strategy):
|
||||
var parsed = _script_collector.parse(parent, inner)
|
||||
return _create_double(parsed, strategy, null, false)
|
||||
|
||||
|
||||
func partial_double_inner(parent, inner, strategy=_strategy):
|
||||
var parsed = _script_collector.parse(parent, inner)
|
||||
return _create_double(parsed, strategy, null, true)
|
||||
|
||||
|
||||
func double_singleton(obj):
|
||||
return _create_singleton_double(obj, false)
|
||||
|
||||
|
||||
func partial_double_singleton(obj):
|
||||
return _create_singleton_double(obj, true)
|
||||
|
||||
|
||||
func add_ignored_method(obj, method_name):
|
||||
_ignored_methods.add(obj, method_name)
|
||||
|
||||
|
||||
|
||||
|
||||
# ##############################################################################
|
||||
#(G)odot (U)nit (T)est class
|
||||
#
|
||||
# ##############################################################################
|
||||
# The MIT License (MIT)
|
||||
# =====================
|
||||
#
|
||||
# Copyright (c) 2025 Tom "Butch" Wesley
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
# ##############################################################################
|
||||
1
client/addons/gut/doubler.gd.uid
Normal file
1
client/addons/gut/doubler.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cpy013l0wqwmg
|
||||
33
client/addons/gut/dynamic_gdscript.gd
Normal file
33
client/addons/gut/dynamic_gdscript.gd
Normal file
@@ -0,0 +1,33 @@
|
||||
@tool
|
||||
var default_script_name_no_extension = 'gut_dynamic_script'
|
||||
var default_script_resource_path = 'res://addons/gut/not_a_real_file/'
|
||||
var default_script_extension = "gd"
|
||||
|
||||
var _created_script_count = 0
|
||||
|
||||
|
||||
# Creates a loaded script from the passed in source. This loaded script is
|
||||
# returned unless there is an error. When an error occcurs the error number
|
||||
# is returned instead.
|
||||
func create_script_from_source(source, override_path=null):
|
||||
_created_script_count += 1
|
||||
var r_path = str(default_script_resource_path,
|
||||
default_script_name_no_extension, '_', _created_script_count, ".",
|
||||
default_script_extension)
|
||||
|
||||
if(override_path != null):
|
||||
r_path = override_path
|
||||
|
||||
var DynamicScript = GDScript.new()
|
||||
DynamicScript.source_code = source.dedent()
|
||||
# The resource_path must be unique or Godot thinks it is trying
|
||||
# to load something it has already loaded and generates an error like
|
||||
# ERROR: Another resource is loaded from path 'workaround for godot
|
||||
# issue #65263' (possible cyclic resource inclusion).
|
||||
DynamicScript.resource_path = r_path
|
||||
var result = DynamicScript.reload()
|
||||
if(result != OK):
|
||||
DynamicScript = result
|
||||
|
||||
return DynamicScript
|
||||
|
||||
1
client/addons/gut/dynamic_gdscript.gd.uid
Normal file
1
client/addons/gut/dynamic_gdscript.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cnbjsrik0p5uf
|
||||
212
client/addons/gut/editor_caret_context_notifier.gd
Normal file
212
client/addons/gut/editor_caret_context_notifier.gd
Normal file
@@ -0,0 +1,212 @@
|
||||
@tool
|
||||
extends Node
|
||||
# ##############################################################################
|
||||
#
|
||||
# Watches script editors and emits a signal whenever the method, inner class,
|
||||
# or script changes based on cursor position and other stuff.
|
||||
#
|
||||
# Basically, whenever this thing's signal is emitted, then the RunAtCursor
|
||||
# buttons should be updated to match the data passed to the signal.
|
||||
# ##############################################################################
|
||||
# In the editor, whenever a script is opened you get these new things that
|
||||
# hang off of EditorInterface.get_script_editor()
|
||||
# * ScriptEditorBase
|
||||
# * CodeEdit
|
||||
# ##############################################################################
|
||||
|
||||
|
||||
var _last_info : Dictionary = {}
|
||||
var _last_line = -1
|
||||
# This is the control that holds all the individual editors.
|
||||
var _current_script_editor : ScriptEditor = null
|
||||
# Reference to the GDScript for the last script we were notified about.
|
||||
var _current_script = null
|
||||
var _current_script_is_test_script = false
|
||||
var _current_editor_base : ScriptEditorBase = null
|
||||
var _current_editor : CodeEdit = null
|
||||
# Quick lookup of editors based on the current script.
|
||||
var _editors_for_scripts : Dictionary= {}
|
||||
|
||||
|
||||
# In order to keep the data that comes back from the emitted signal way more
|
||||
# usable, we have to know what GUT looks for for an inner-test-class prefix.
|
||||
# If we didn't do this, then this thing would have to return all the inner
|
||||
# classes and then we'd have to determine if we were in an inner-test-class
|
||||
# outside of here by traversing all the classes returned. It makes this thing
|
||||
# less generic and know too much, but this is probably already too generic as
|
||||
# it is.
|
||||
var inner_class_prefix = "Test"
|
||||
var method_prefix = "test_"
|
||||
var script_prefix = "test_"
|
||||
var script_suffix = ".gd"
|
||||
|
||||
|
||||
# Based on cursor and open editors, this will be emitted. You do what you
|
||||
# want with it.
|
||||
signal it_changed(change_data)
|
||||
|
||||
|
||||
func _ready():
|
||||
# This will not change, and should not change, over the course of a session.
|
||||
_current_script_editor = EditorInterface.get_script_editor()
|
||||
_current_script_editor.editor_script_changed.connect(_on_editor_script_changed)
|
||||
_current_script_editor.script_close.connect(_on_editor_script_close)
|
||||
|
||||
|
||||
func _handle_caret_location(which):
|
||||
var current_line = which.get_caret_line(0) + 1
|
||||
if(_last_line != current_line):
|
||||
_last_line = current_line
|
||||
|
||||
if(_current_script_is_test_script):
|
||||
var new_info = _make_info(which, _current_script, _current_script_is_test_script)
|
||||
if(_last_info != new_info):
|
||||
_last_info = new_info
|
||||
it_changed.emit(_last_info.duplicate())
|
||||
|
||||
|
||||
func _get_func_name_from_line(text):
|
||||
text = text.strip_edges()
|
||||
var left = text.split("(")[0]
|
||||
var func_name = left.split(" ")[1]
|
||||
return func_name
|
||||
|
||||
|
||||
func _get_class_name_from_line(text):
|
||||
text = text.strip_edges()
|
||||
var right = text.split(" ")[1]
|
||||
var the_name = right.rstrip(":")
|
||||
return the_name
|
||||
|
||||
|
||||
func _make_info(editor, script, test_script_flag):
|
||||
if(editor == null):
|
||||
return
|
||||
|
||||
var info = {
|
||||
script = script,
|
||||
inner_class = null,
|
||||
method = null,
|
||||
is_test_script = test_script_flag
|
||||
}
|
||||
|
||||
var start_line = editor.get_caret_line()
|
||||
var line = start_line
|
||||
var done_func = false
|
||||
var done_inner = false
|
||||
while(line > 0 and (!done_func or !done_inner)):
|
||||
if(editor.can_fold_line(line)):
|
||||
var text = editor.get_line(line)
|
||||
var strip_text = text.strip_edges(true, false) # only left
|
||||
|
||||
if(!done_func and strip_text.begins_with("func ")):
|
||||
info.method = _get_func_name_from_line(text)
|
||||
done_func = true
|
||||
# If the func line is left justified then there won't be any
|
||||
# inner classes above it.
|
||||
if(editor.get_indent_level(line) == 0):
|
||||
done_inner = true
|
||||
|
||||
if(!done_inner and strip_text.begins_with("class")):
|
||||
var inner_name = _get_class_name_from_line(text)
|
||||
# See note about inner_class_prefix, this knows too much, but
|
||||
# if it was to know less it would insanely more difficult
|
||||
# everywhere.
|
||||
if(inner_name.begins_with(inner_class_prefix)):
|
||||
info.inner_class = inner_name
|
||||
done_inner = true
|
||||
done_func = true
|
||||
line -= 1
|
||||
|
||||
# print('parsed lines: ', start_line - line, '(', info.inner_class, ':', info.method, ')')
|
||||
return info
|
||||
# -------------
|
||||
# Events
|
||||
# -------------
|
||||
|
||||
# Fired whenever the script changes. This does not fire for help files. If
|
||||
# you click a help file and then back to the same file, then this will fire
|
||||
# for the same script
|
||||
#
|
||||
# This does fire for some non-script files such as .cfg, .json and .md files,
|
||||
# but the passed in value will be null.
|
||||
#
|
||||
# This can fire multiple times for the same script when a script is opened.
|
||||
func _on_editor_script_changed(script):
|
||||
if(script == null):
|
||||
return
|
||||
_last_line = -1
|
||||
_current_script = script
|
||||
_current_editor_base = _current_script_editor.get_current_editor()
|
||||
if(_current_editor_base.get_base_editor() is CodeEdit):
|
||||
_current_editor = _current_editor_base.get_base_editor()
|
||||
if(!_current_editor.caret_changed.is_connected(_on_caret_changed)):
|
||||
_current_editor.caret_changed.connect(_on_caret_changed.bind(_current_editor))
|
||||
else:
|
||||
_current_editor = null
|
||||
_editors_for_scripts[script] = _current_editor
|
||||
_current_script_is_test_script = is_test_script(_current_script)
|
||||
|
||||
_handle_caret_location(_current_editor)
|
||||
|
||||
|
||||
func _on_editor_script_close(script):
|
||||
var script_editor = _editors_for_scripts.get(script, null)
|
||||
if(script_editor != null):
|
||||
if(script_editor.caret_changed.is_connected(_on_caret_changed)):
|
||||
script_editor.caret_changed.disconnect(_on_caret_changed)
|
||||
_editors_for_scripts.erase(script)
|
||||
|
||||
|
||||
func _on_caret_changed(which):
|
||||
# Sometimes this is fired for editors that are not the current. I could
|
||||
# make this fire by saving a file in an external editor. I was unable to
|
||||
# get useful data out when it wasn't the current editor so I'm only doing
|
||||
# anything when it is the current editor.
|
||||
if(which == _current_editor):
|
||||
_handle_caret_location(which)
|
||||
|
||||
|
||||
func _could_be_test_script(script):
|
||||
return script.resource_path.get_file().begins_with(script_prefix) and \
|
||||
script.resource_path.get_file().ends_with(script_suffix)
|
||||
|
||||
# -------------
|
||||
# Public
|
||||
# -------------
|
||||
var _scripts_that_have_been_warned_about = []
|
||||
var _we_have_warned_enough = false
|
||||
var _max_warnings = 5
|
||||
func is_test_script(script):
|
||||
var base = script.get_base_script()
|
||||
if(base == null and script.get_script_method_list().size() == 0 and _could_be_test_script(script)):
|
||||
if(OS.is_stdout_verbose() or (!_scripts_that_have_been_warned_about.has(script.resource_path) and !_we_have_warned_enough)):
|
||||
_scripts_that_have_been_warned_about.append(script.resource_path)
|
||||
push_warning(str('[GUT] Treating ', script.resource_path, " as test script: ",
|
||||
"GUT was not able to retrieve information about this script. If this is ",
|
||||
"a new script you can ignore this warning. Otherwise, this may ",
|
||||
"have to do with having VSCode open. Restarting Godot sometimes helps. See ",
|
||||
"https://github.com/bitwes/Gut/issues/754"))
|
||||
if(!OS.is_stdout_verbose() and _scripts_that_have_been_warned_about.size() >= _max_warnings):
|
||||
print("[GUT] Disabling warning.")
|
||||
_we_have_warned_enough = true
|
||||
|
||||
# We can't know if this is a test script. It's more usable if we
|
||||
# assume this is a test script.
|
||||
return true
|
||||
else:
|
||||
while(base and base.resource_path != 'res://addons/gut/test.gd'):
|
||||
base = base.get_base_script()
|
||||
return base != null
|
||||
|
||||
|
||||
func get_info():
|
||||
return _last_info.duplicate()
|
||||
|
||||
|
||||
func log_values():
|
||||
print("---------------------------------------------------------------")
|
||||
print("script ", _current_script)
|
||||
print("script_editor ", _current_script_editor)
|
||||
print("editor_base ", _current_editor_base)
|
||||
print("editor ", _current_editor)
|
||||
1
client/addons/gut/editor_caret_context_notifier.gd.uid
Normal file
1
client/addons/gut/editor_caret_context_notifier.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c110s7a32x4su
|
||||
192
client/addons/gut/error_tracker.gd
Normal file
192
client/addons/gut/error_tracker.gd
Normal file
@@ -0,0 +1,192 @@
|
||||
extends Logger
|
||||
class_name GutErrorTracker
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Static methods wrap around add/remove logger to make disabling the logger
|
||||
# easier and to help avoid misusing add/remove in tests. If GUT needs to
|
||||
# add/remove a logger then this is how it should do it.
|
||||
# ------------------------------------------------------------------------------
|
||||
static var registered_loggers := {}
|
||||
static var register_loggers = true
|
||||
|
||||
static func register_logger(which):
|
||||
if(register_loggers and !registered_loggers.has(which)):
|
||||
OS.add_logger(which)
|
||||
registered_loggers[which] = get_stack()
|
||||
|
||||
|
||||
static func deregister_logger(which):
|
||||
if(registered_loggers.has(which)):
|
||||
OS.remove_logger(which)
|
||||
registered_loggers.erase(which)
|
||||
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# GutErrorTracker
|
||||
# ------------------------------------------------------------------------------
|
||||
var _current_test_id = GutUtils.NO_TEST
|
||||
var _mutex = Mutex.new()
|
||||
|
||||
var errors = GutUtils.OneToMany.new()
|
||||
|
||||
var treat_gut_errors_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
|
||||
var treat_engine_errors_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
|
||||
var treat_push_error_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
|
||||
var disabled = false
|
||||
|
||||
|
||||
# ----------------
|
||||
#region Private
|
||||
# ----------------
|
||||
|
||||
func _get_stack_data(current_test_name):
|
||||
var test_entry = {}
|
||||
var stackTrace = get_stack()
|
||||
|
||||
if(stackTrace!=null):
|
||||
var index = 0
|
||||
while(index < stackTrace.size() and test_entry == {}):
|
||||
var line = stackTrace[index]
|
||||
var function = line.get("function")
|
||||
if function == current_test_name:
|
||||
test_entry = stackTrace[index]
|
||||
else:
|
||||
index += 1
|
||||
|
||||
for i in range(index):
|
||||
stackTrace.remove_at(0)
|
||||
|
||||
return {
|
||||
"test_entry": test_entry,
|
||||
"full_stack": stackTrace
|
||||
}
|
||||
|
||||
|
||||
func _is_error_failable(error : GutTrackedError):
|
||||
var is_it = false
|
||||
if(error.handled == false):
|
||||
if(error.is_gut_error()):
|
||||
is_it = treat_gut_errors_as == GutUtils.TREAT_AS.FAILURE
|
||||
elif(error.is_push_error()):
|
||||
is_it = treat_push_error_as == GutUtils.TREAT_AS.FAILURE
|
||||
elif(error.is_engine_error()):
|
||||
is_it = treat_engine_errors_as == GutUtils.TREAT_AS.FAILURE
|
||||
return is_it
|
||||
|
||||
# ----------------
|
||||
#endregion
|
||||
#region Godot's Logger Overrides
|
||||
# ----------------
|
||||
|
||||
# Godot's Logger virtual method for errors
|
||||
func _log_error(function: String, file: String, line: int,
|
||||
code: String, rationale: String, editor_notify: bool,
|
||||
error_type: int, script_backtraces: Array[ScriptBacktrace]) -> void:
|
||||
add_error(function, file, line,
|
||||
code, rationale, editor_notify,
|
||||
error_type, script_backtraces)
|
||||
|
||||
# Godot's Logger virtual method for any output?
|
||||
# func _log_message(message: String, error: bool) -> void:
|
||||
# pass
|
||||
|
||||
# ----------------
|
||||
#endregion
|
||||
#region Public
|
||||
# ----------------
|
||||
|
||||
func start_test(test_id):
|
||||
_current_test_id = test_id
|
||||
|
||||
|
||||
func end_test():
|
||||
_current_test_id = GutUtils.NO_TEST
|
||||
|
||||
|
||||
func did_test_error(test_id=_current_test_id):
|
||||
return errors.size(test_id) > 0
|
||||
|
||||
|
||||
func get_current_test_errors():
|
||||
return errors.items.get(_current_test_id, [])
|
||||
|
||||
|
||||
# This should look through all the errors for a test and see if a failure
|
||||
# should happen based off of flags.
|
||||
func should_test_fail_from_errors(test_id = _current_test_id):
|
||||
var to_return = false
|
||||
if(errors.items.has(test_id)):
|
||||
var errs = errors.items[test_id]
|
||||
var index = 0
|
||||
while(index < errs.size() and !to_return):
|
||||
var error = errs[index]
|
||||
to_return = _is_error_failable(error)
|
||||
index += 1
|
||||
return to_return
|
||||
|
||||
|
||||
func get_errors_for_test(test_id=_current_test_id):
|
||||
var to_return = []
|
||||
if(errors.items.has(test_id)):
|
||||
to_return = errors.items[test_id].duplicate()
|
||||
|
||||
return to_return
|
||||
|
||||
|
||||
# Returns emtpy string or text for errors that occurred during the test that
|
||||
# should cause failure based on this class' flags.
|
||||
func get_fail_text_for_errors(test_id=_current_test_id) -> String:
|
||||
var error_texts = []
|
||||
|
||||
if(errors.items.has(test_id)):
|
||||
for error in errors.items[test_id]:
|
||||
if(_is_error_failable(error)):
|
||||
error_texts.append(str('<', error.get_error_type_name(), '>', error.code))
|
||||
|
||||
var to_return = ""
|
||||
for i in error_texts.size():
|
||||
if(to_return != ""):
|
||||
to_return += "\n"
|
||||
to_return += str("[", i + 1, "] ", error_texts[i])
|
||||
|
||||
return to_return
|
||||
|
||||
|
||||
func add_gut_error(text) -> GutTrackedError:
|
||||
if(_current_test_id != GutUtils.NO_TEST):
|
||||
var data = _get_stack_data(_current_test_id)
|
||||
if(data.test_entry != {}):
|
||||
return add_error(_current_test_id, data.test_entry.source, data.test_entry.line,
|
||||
text, '', false,
|
||||
GutUtils.GUT_ERROR_TYPE, data.full_stack)
|
||||
|
||||
return add_error(_current_test_id, "unknown", -1,
|
||||
text, '', false,
|
||||
GutUtils.GUT_ERROR_TYPE, get_stack())
|
||||
|
||||
|
||||
func add_error(function: String, file: String, line: int,
|
||||
code: String, rationale: String, editor_notify: bool,
|
||||
error_type: int, script_backtraces: Array) -> GutTrackedError:
|
||||
if(disabled):
|
||||
return
|
||||
|
||||
_mutex.lock()
|
||||
|
||||
var err := GutTrackedError.new()
|
||||
err.backtrace = script_backtraces
|
||||
err.code = code
|
||||
err.rationale = rationale
|
||||
err.error_type = error_type
|
||||
err.editor_notify = editor_notify
|
||||
err.file = file
|
||||
err.function = function
|
||||
err.line = line
|
||||
|
||||
errors.add(_current_test_id, err)
|
||||
|
||||
_mutex.unlock()
|
||||
|
||||
return err
|
||||
1
client/addons/gut/error_tracker.gd.uid
Normal file
1
client/addons/gut/error_tracker.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://35kxgqotjmlu
|
||||
BIN
client/addons/gut/fonts/AnonymousPro-Bold.ttf
Normal file
BIN
client/addons/gut/fonts/AnonymousPro-Bold.ttf
Normal file
Binary file not shown.
36
client/addons/gut/fonts/AnonymousPro-Bold.ttf.import
Normal file
36
client/addons/gut/fonts/AnonymousPro-Bold.ttf.import
Normal file
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://c8axnpxc0nrk4"
|
||||
path="res://.godot/imported/AnonymousPro-Bold.ttf-9d8fef4d357af5b52cd60afbe608aa49.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/gut/fonts/AnonymousPro-Bold.ttf"
|
||||
dest_files=["res://.godot/imported/AnonymousPro-Bold.ttf-9d8fef4d357af5b52cd60afbe608aa49.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
BIN
client/addons/gut/fonts/AnonymousPro-BoldItalic.ttf
Normal file
BIN
client/addons/gut/fonts/AnonymousPro-BoldItalic.ttf
Normal file
Binary file not shown.
36
client/addons/gut/fonts/AnonymousPro-BoldItalic.ttf.import
Normal file
36
client/addons/gut/fonts/AnonymousPro-BoldItalic.ttf.import
Normal file
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://msst1l2s2s"
|
||||
path="res://.godot/imported/AnonymousPro-BoldItalic.ttf-4274bf704d3d6b9cd32c4f0754d8c83d.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/gut/fonts/AnonymousPro-BoldItalic.ttf"
|
||||
dest_files=["res://.godot/imported/AnonymousPro-BoldItalic.ttf-4274bf704d3d6b9cd32c4f0754d8c83d.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=1
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user