Compare commits

..

6 Commits

Author SHA1 Message Date
Patrick Buckley 8e11929ba0 chore: bump version to 1.6.1 2026-06-11 14:09:31 -07:00
Patrick Buckley d9e9a41b17 test(console): make dedupe-pin slice bounds reformat-tolerant
Review feedback: the next-case end markers were exact-indentation
string finds that raised a bare ValueError when unmatched. Use
whitespace-tolerant regexes with actionable assertion messages, and
bound the history-replay window structurally (next role branch, with
a generous fallback) instead of a fixed 600 chars.
2026-06-11 14:05:19 -07:00
Patrick Buckley 848b2cc1fb fix(ui): single-path Enter activation + hls.js teardown on player error
Review feedback: (1) the Enter keydown re-dispatched through btn.click(),
relying on the disabled-guard to suppress the browser's own
Enter-to-click — preventDefault + direct activation makes the keyboard
path provably single-fire; (2) the branch-scoped Hls instance was
unreachable from the media error handler, leaking its listeners and
loader timers when the player node was replaced with the retry UI —
hoist the ref and destroy it before replacement.
2026-06-11 14:05:19 -07:00
Patrick Buckley 1946002618 fix(ui): lift media player activation into the shared interactive pane
The interactive Pane renders media embeds (buildMediaEmbed / buildPlayButton),
but the Play activation — _loadHls / _isHlsUrl / _activatePlayer and the
click/keydown delegate — stayed behind in the standalone ui/static/app.js as
DOCUMENT-level listeners. The console L-shell mounts the same interactive.js
module but never loads ui/static/app.js, so the Play button was dead in
console-hosted interactive panes.

Lift the activation into shared_static/interactive.js (alongside the existing
buildMediaEmbed/buildPlayButton — media embeds are interactive-pane-only; the
coordinator pane renders none) and wire it as a pane-owned, root-scoped
this.el click/keydown listener, mirroring the approval-keydown pattern the
fork collapse established. The standalone copy is deleted so no duplicate
implementation remains; both deployments now activate through the one shared
handler.

The hls.js vendor is fetched lazily by absolute /shared/ URL (the same
mechanism renderer.js uses for mermaid), and /shared is mounted at the root in
both turnstone/server.py and turnstone/console/server.py, so the vendor —
which ships in shared_static/hls-1.6.16/ — resolves in both deployments with
no HTML change.

Pins: assert the lift + pane-ownership in test_interactive_pane_js.py and the
standalone-stays-clean guard in test_app_js.py.
2026-06-11 14:05:19 -07:00
Patrick Buckley 4bce6abc7c test(console): pin system-turn dedupe wiring on both read paths
The live-SSE/history system-turn dedupe (renderedSystemEventIds /
_renderedSystemEventIds) was already in place on both panes and merged
to main (21af6c4 aligned the persisted row event_id with its SSE event;
09e41d1 added the belt-and-braces Set on the coordinator). The existing
pin tests only assert the Set's .has()/.add()/.clear() symbols appear
somewhere in the file, so a refactor that keeps the Set but short-circuits
the live-handler consultation (guard -> false) re-opens the double-render
while the pins stay green.

Scope the new assertions to their blocks: the live system_turn case must
CONSULT and RECORD against the Set, and the history render path
(replayHistory / refetchHistory's system-role branch) must record each
replayed row's event_id. Bounded at the next switch case rather than the
first break; the dedup-skip path itself breaks before the .add(), so a
break-bounded slice would drop the record half.

Verified the new slice checks fail on a dedupe-neutered factory (a
headless-Chrome harness driving the real createCoordinatorPane confirms
that neutering produces two rendered nodes for one event id; intact code
renders one, and the no-event-id legacy path still renders both).
2026-06-11 14:05:19 -07:00
Patrick Buckley c19432f12a fix(memory): touch access metadata on composition and tool reads
The touch_structured_memories facade and both storage backends were
implemented but had zero call sites, so access_count never moved and
last_accessed never advanced past write time on any deployment.

Wire two touch points:
- proactive composition touches the injected top-k (post-rerank) set,
  deduped per turn since _init_system_messages recomposes many times
  within a single turn;
- the memory tool's search and get reads touch their returned rows,
  counted per call. save/delete/list do not touch.

Touches are best-effort through the facade, which already swallows
storage errors, so a failed touch never breaks composition or a tool
call.
2026-06-11 14:05:18 -07:00
483 changed files with 14323 additions and 101693 deletions
-5
View File
@@ -1,5 +0,0 @@
# Funding platforms for the GitHub "Sponsor" button.
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: [eous]
custom: ["https://paypal.me/eousphoros"]
+20 -34
View File
@@ -14,8 +14,8 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- run: pip install pre-commit
@@ -25,8 +25,8 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- run: pip install mypy
@@ -35,15 +35,12 @@ jobs:
test:
runs-on: ubuntu-latest
# Cap a hung run at 20 min instead of riding GitHub's 6-hour default
# (a flaky-hang run otherwise streams -v output for hours).
timeout-minutes: 20
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}
# Node is required by tests/test_renderer_js.py — without
@@ -54,10 +51,7 @@ jobs:
with:
node-version: "24"
- run: pip install -e ".[test]"
# -v lists each test id as it starts (pytest prints the nodeid at
# logstart), so a hang names the culprit on the last line instead of
# riding the job timeout with only a trail of "..." dots.
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -v
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
@@ -66,7 +60,6 @@ jobs:
test-postgres:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:18
@@ -82,23 +75,23 @@ jobs:
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -v
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
env:
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
wheel-completeness:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- run: pip install build
@@ -113,16 +106,9 @@ jobs:
| grep -v '\.py$' | grep -v '\.dist-info' | grep -v '\.pyc' | grep -v '^File$' \
| sort)
# Files intentionally excluded from the wheel (one per line).
# The vllm-litellm/ deploy example ships in the repo, not the wheel
# (you clone the repo to run it; the package doesn't reference it).
# Files intentionally excluded from the wheel (one per line)
ALLOW="
turnstone/core/storage/migrations/script.py.mako
turnstone/deploy/vllm-litellm/.env.example
turnstone/deploy/vllm-litellm/README.md
turnstone/deploy/vllm-litellm/docker-compose.yml
turnstone/deploy/vllm-litellm/gemma.Dockerfile
turnstone/deploy/vllm-litellm/litellm-config.yaml
"
MISSING=$(comm -23 <(echo "$SOURCE") <(echo "$WHEEL") \
@@ -146,13 +132,13 @@ jobs:
/tmp/smoke/bin/turnstone-console --help
/tmp/smoke/bin/turnstone-admin --help
/tmp/smoke/bin/turnstone-channel --help
/tmp/smoke/bin/turnstone-doctor --help
/tmp/smoke/bin/turnstone-bootstrap --help
lock-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
uv-version: "0.9.18"
- run: uv lock --check
@@ -160,11 +146,11 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
uv-version: "0.9.18"
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- run: uv sync --frozen --all-extras
@@ -188,7 +174,7 @@ jobs:
run:
working-directory: sdk/typescript
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
-46
View File
@@ -1,46 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
if: github.event.pull_request.head.repo.full_name == github.repository
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # post the review + inline comments
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: 'renovate[bot]' # let Renovate PRs get reviewed
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
-63
View File
@@ -1,63 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) || (
github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) || (
github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)
) || (
github.event_name == 'issues' &&
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # post comments/reviews when @-mentioned on a PR
issues: write # post comments when @-mentioned on an issue
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
+6 -17
View File
@@ -7,9 +7,7 @@ on:
concurrency:
group: docker-${{ github.event.workflow_run.head_sha }}
# Never cancel mid-push: an interrupted multi-tag push can leave the
# registry with a partial tag set (e.g. :latest moved, :stable not).
cancel-in-progress: false
cancel-in-progress: true
permissions:
contents: read
@@ -21,24 +19,15 @@ env:
jobs:
docker:
# Same gate as publish.yml: workflow_run fires for every CI completion
# (including fork and same-repo PR runs) with this repo's token and
# packages:write. Only same-repo tag pushes may publish images; CI's
# push trigger matches main/stable/* and v* tags, so a head_branch
# starting with "v" is necessarily a tag run.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'v')
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
# The docker build only reads the tree; keep the token out of it.
persist-credentials: false
- name: Resolve release tag
id: tag
@@ -54,7 +43,7 @@ jobs:
- name: Log in to GHCR
if: steps.tag.outputs.skip == 'false'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -78,12 +67,12 @@ jobs:
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
if: steps.tag.outputs.skip == 'false'
- name: Build and push
if: steps.tag.outputs.skip == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
push: true
+5 -19
View File
@@ -7,9 +7,7 @@ on:
concurrency:
group: publish-${{ github.event.workflow_run.head_sha }}
# Never cancel a publish mid-upload: a half-uploaded release (sdist up,
# wheel missing) cannot be re-run cleanly because PyPI rejects duplicates.
cancel-in-progress: false
cancel-in-progress: true
permissions:
contents: write
@@ -17,26 +15,14 @@ permissions:
jobs:
publish:
# workflow_run fires for EVERY CI completion — including CI runs for
# pull_requests from forks — and always executes here with this repo's
# secrets, tokens, and the pypi environment. Gate to same-repo tag
# pushes only: CI's push trigger matches branches main/stable/* and
# tags v*, so a head_branch starting with "v" is necessarily a tag run.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'v')
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
# python -m build executes the tree's build backend; don't leave
# the contents:write token sitting in .git/config while it runs.
persist-credentials: false
- name: Resolve release tag
id: tag
@@ -50,7 +36,7 @@ jobs:
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
if: steps.tag.outputs.skip == 'false'
with:
python-version: "3.14"
@@ -63,7 +49,7 @@ jobs:
- name: Create GitHub Release
if: steps.tag.outputs.skip == 'false'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with:
tag_name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
-42
View File
@@ -1,42 +0,0 @@
name: Understone example
# The door-game example is a standalone package with no dependency on
# turnstone core, and the root test suite does not collect it
# (testpaths=["tests"]). Without this workflow its suite never runs in CI.
# Path-filtered so it only runs when the example (or this workflow) changes.
on:
push:
branches: [main, "stable/*"]
paths:
- "examples/door-game/**"
- ".github/workflows/understone-example.yml"
pull_request:
branches: [main, "stable/*"]
paths:
- "examples/door-game/**"
- ".github/workflows/understone-example.yml"
permissions:
contents: read
jobs:
understone:
runs-on: ubuntu-latest
defaults:
run:
working-directory: examples/door-game
strategy:
matrix:
# Floor and ceiling of the example's requires-python (>=3.11).
python-version: ["3.11", "3.13"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[test,dev]"
- run: pytest tests/ -q
- run: ruff check .
- run: ruff format --check .
- run: mypy understone/
+5 -26
View File
@@ -25,43 +25,22 @@ permissions:
jobs:
vendor-js:
# Same-repo PRs only: this job checks out the PR head and pushes to it
# with contents:write, so it must never act on a fork's branch.
# Gate on the PR author (immutable), not github.actor (names whoever
# caused the latest event, which can be someone else re-running it).
if: >-
(github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'renovate[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository) ||
github.event_name == 'workflow_dispatch'
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Resolve PR head ref
id: ref
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Branch names may contain shell metacharacters; pass via env,
# never interpolate ${{ }} into the script body.
HEAD_REF: ${{ github.head_ref }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
# The dispatch input is an arbitrary PR number; refuse fork PRs.
# A fork's headRefName is a bare branch name that may collide
# with a branch in this repo, and checkout+push would then hit
# that unrelated branch ("same-repo PRs only" applies here too).
pr_json=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefName,isCrossRepository)
if [[ "$(jq -r '.isCrossRepository' <<< "$pr_json")" != "false" ]]; then
echo "::error::PR #${PR_NUMBER} head is not a branch in this repository; refusing to complete it."
exit 1
fi
ref=$(jq -r '.headRefName' <<< "$pr_json")
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
else
ref="$HEAD_REF"
ref="${{ github.head_ref }}"
fi
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ steps.ref.outputs.head_ref }}
-1
View File
@@ -28,4 +28,3 @@ tools/skill_audit_analysis/data/
tools/skill_audit_analysis/output/
design_ideas/
.claude/
docs/design/
+4 -374
View File
@@ -6,383 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) for
version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
Two active release tracks are maintained — the current stable and the
experimental line:
Three release tracks are maintained — the current stable, one prior
stable, and the experimental line:
- **`stable/1.7`** — patch-only (`v1.7.x`)
- **`stable/1.5`** — patch-only (`v1.5.x`)
- **`stable/1.6`** — patch-only (`v1.6.x`)
- **`main`** — experimental (next major)
Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
## [1.7.4]
A feature-bearing patch for the 1.7 line, rolling up work that had stabilised
on `main`. No schema migrations (head stays 066) and no new configuration knobs.
### Added
- **Background shells for the `bash` tool** — `run_in_background=true` starts a
command as a detached shell and returns a `bash_N` handle; new `bash_output`
(delta output since last read, optional regex filter, status/exit code) and
`kill_shell` (terminates the shell's process group) tools manage it. Output is
buffered with a drop-oldest cap, a system notice lands when a shell exits, and
shells die with their workstream — never outliving a `task_agent` that started
them.
- **`task_agent` carries the model's native reasoning across its own tool loop** —
a task agent's replayed turns now preserve the provider-native reasoning lane
(Anthropic thinking blocks with signatures, OpenAI reasoning items, Gemini
`thought_signature`, vLLM/llama.cpp reasoning text) instead of rebuilding each
turn from text alone, restoring reasoning continuity for thinking models.
- **Model-shelf response controls** — the console model shelf exposes verbosity
and reasoning-mode controls per identity.
### Changed
- **GPT-5.6 aligned with the GA API surface** — the Responses provider matches
GPT-5.6's GA shape (typed `reasoning.mode`, `prompt_cache_options`,
cache-write accounting); the `openai` floor moves to `>=2.45`.
### Fixed
- **`bash` never hangs on a backgrounded child** — a command that left a
long-lived process running no longer wedges the workstream; the tool waits on
the tracked process (bounded by the timeout) and reaps its whole process group.
- **`task_agent` sub-tool ids are session-unique** — ids are minted
`{parent}::r{run}s{step}::{id}` so a local model reissuing sequential ids
(`call_0` each turn) no longer aliases two steps onto one live-card row while
`/history` keeps them apart.
- **Judge completions honour model-definition capabilities** — a judge's
completion now threads its model's declared capabilities instead of assuming a
default surface.
- **`create-admin` CLI** — adds an explicit admin-creation command; `run.sh` no
longer onboards into a role-less user.
- **Install script Docker handling** — installs Docker on distros
`get.docker.com` rejects, and gates that path by `$ID` instead of trapping all
failures.
## [1.7.3]
A small feature and maintenance patch for the 1.7 line. No schema migrations
and no new configuration knobs.
### Added
- **OpenAI GPT-5.6 (Sol/Terra/Luna) support** — the Responses provider
understands the GPT-5.6 family: the `reasoning.mode` control, the new
`max` effort tier, and `text.verbosity`, with golden wire payloads pinning
the request shapes. The `openai` dependency floor moves to `>=2.44`.
### Changed
- **Engineer base prompt hardened with process discipline** — the default
base prompt for non-coordinator sessions now works in phases scaled to the
size of the change, defaults to red-green for testable work, scopes to the
smallest sufficient diff, stops to report after repeated failed attempts
instead of thrashing, reports only observed results, and delegates
exploration to `task_agent`. Persona prompts freeze into the workstream
stamp at creation, so this reaches new workstreams only.
### Fixed
- **Unknown reasoning-mode warnings name the allowed modes** — a model
definition with an unrecognized reasoning mode now logs the valid options
instead of leaving the operator to guess.
### Documentation
- **HYPOTHESIS.md / PRIMER.md** — the control normal form is tightened and
the factored Q_E reading is carried into the glossary; the plain-language
PRIMER stays in sync.
## [1.7.2]
A feature-bearing patch for the 1.7 line. Rather than hold this work for the
larger 1.8 churn, the fixes and the smaller features that had already
stabilised on `main` are rolled into the stable line now: a rich preview
pane, persona/project settings on scheduled tasks, and a batch of streaming,
rendering, and nudge-delivery hardening.
> **⚠️ Before upgrading:** 1.7.2 adds Alembic migration `066`, applied
> automatically on first start. It adds two `Text NOT NULL DEFAULT ''`
> columns (`persona`, `project_id`) to the `scheduled_tasks` table; existing
> rows migrate to the empty default, which is byte-identical to pre-066
> dispatch behaviour. The change is additive and reversible, but — as always
> — back up your storage before upgrading (`pg_dump` for PostgreSQL; copy the
> database file for SQLite).
### Added
- **Rich preview pane + `open_preview` tool** — a workstream can now open a
rendered preview (HTML, Markdown, and other kinds) in a pane beside the
conversation via the new `open_preview` tool. Guarded fetches stream under
a byte budget whose ceiling tracks the widest per-kind cap, preview blob
ids are salted, and a preflight probe handles legacy charsets and a
remote-assets opt-in. See `docs/tools.md`.
- **`allow_private_network` opt-in for `web_fetch` / `open_preview`** —
private-address fetch and preview targets stay blocked by default; an
operator can opt a workstream in through the settings registry when a
private endpoint is genuinely intended. (Distinct from the 1.7.1 `[oidc]`
flag of the same name, which governs identity-provider discovery.)
- **Persona + project settings on scheduled tasks** (migration `066`) — a
scheduled task can now pin the **persona** and **project** of the
workstream it dispatches, matching the levers a manually-created workstream
already carries. Both default to empty (kind-default persona / no project),
so existing schedules dispatch exactly as before.
### Fixed
- **Streaming fast-path overflow recovery** — fast-stream tokens are now
batched and overflowed SSE listeners recover instead of stalling (and
`connectSSE` no longer opens into a hidden background tab). The same
overflow-recovery companions were carried to the coordinator pane, so a
coordinator watching many children recovers dropped listeners the same way
the live-session view does.
- **Renderer containment** — markdown sentinel-forgery and recursive-frame
content loss are contained, and an indented fence close no longer drags its
indent into the enclosed code content.
- **Idle nudge / wake delivery** — nudge and wake delivery is hardened across
session eviction, cancellation, and identity rebinds; the wake gate now
requires a real nudge queue, refused wakes are logged, and
`initial_message_status` is typed as a closed enum on the wire.
- **`web_fetch` extraction inherits model settings** — the completion that
extracts content from a fetched page now inherits the workstream's model
settings instead of falling back to defaults.
- **UI panes** — ephemeral panes close on split-dismiss instead of orphaning
a tab, and an unsplit skips the redundant refresh after an ephemeral pane
closes.
- **Shared code-highlight CSS** — renderer-output CSS is shared so the console
and coordinator panes highlight code identically.
### Security
- **`Content-Disposition` filenames made wire-safe** — download filenames
derived from user-controlled text are sanitised (latin-1- and
control-char-safe, quoting-safe) before they reach the `Content-Disposition`
response header, including the fallback path.
### Documentation
- **HYPOTHESIS.md: daemons + the outer loop, plus a plain-language PRIMER** —
the harness north-star document gains its daemon / outer-loop treatment and
a new top-level `PRIMER.md`.
## [1.7.1]
A maintenance and hardening patch for the 1.7 line. No schema migrations;
the credential-redaction work below is additive and needs no configuration
change. The one new operator-facing knob is the opt-in `[oidc]
allow_private_network` flag (default off).
### Security
- **Credential redaction hardened across the tool-call surface** — the
redactor that scrubs secrets from tool arguments and log previews was
reworked on both the backend and the browser to close several leak paths
and to fix false-positive and performance issues. Malformed tool-call
arguments are now legalised before they reach the wire; the tool-args log
preview scrubs credentials and control characters; and the coordinator's
tool-call cards gain a matching client-side redaction pass so the JS and
backend redactors stay at parity. Pattern coverage now includes
`secret_access_key` / `aws_secret_access_key` multi-segment keys, bare
`token=` / `key=` forms (guarded by a negative lookbehind to avoid
false positives), and SQLAlchemy `+driver`-qualified connection-string
schemes matched case-insensitively.
- **OIDC SSRF guard: `[oidc] allow_private_network` opt-in** — self-hosted
identity providers on private networks can now be reached by setting
`allow_private_network = true` under `[oidc]` (default off; the MCP OAuth
path stays strict). Rejections of discovered endpoints carry the opt-in
hint so the misconfiguration is self-explanatory. See `docs/oidc.md`.
### Added
- **Persona discoverability + forgiving name resolution** — personas are
now discoverable by agents, and persona-name resolution tolerates
case/whitespace variation; a not-found resolution reports the offending
input verbatim instead of a bare error.
### Fixed
- **MCP transport lifecycles routed through per-entry owner tasks**
(#787/#788) — static and pooled MCP transport lifecycles are now driven
by per-server / per-entry owner tasks, with a hardened disarm-sweep loop
guard and targeted exception handling in place of a broad `BaseException`
arm, so a dying transport can no longer spin the CPU or strand delivery.
- **Client-construction failures surface as misconfiguration, not raw
500s** — a model whose client cannot be constructed now reports a factory
misconfiguration, and the raw exception text is kept out of the resulting
503 response.
- **Postgres history search survives oversized rows** — a conversation row
exceeding Postgres' full-text limits no longer aborts history search.
- **Agent-tool render is idempotent** — tool rendering no longer deep-copies
a tool definition until a description actually changes, so no-persona
sessions share the tool constant (correctness plus a hot-path allocation
win).
- **Private-project workstream visibility scoped to members** — workstreams
in a private project are visible to project members only, not to every
admin; coordinator tenancy checks now use request-scoped storage.
- **Pane hotkeys work off macOS and match across surfaces** — the pane
keyboard shortcuts no longer collide with browser accelerators on
non-macOS platforms and behave consistently across surfaces.
## [1.7.0]
The headline of the 1.7 line is **Personas** — operator-authored control
over how each workstream composes its system message and capability
envelope. The rest of the release hardens the pieces a persona leans on:
concurrent approvals, cross-provider reasoning-effort control, cooperative
compaction, multi-user session safety, and MCP resilience for unattended
work.
> **⚠️ Before upgrading:** 1.7.0 adds Alembic migrations `062``065`,
> applied automatically on first start (projects, personas, and two
> smaller schema tidy-ups). Migration `063` creates the `personas` table
> with its six seed personas and converts existing `creative_mode`
> workstreams to the `writer` persona in place. The changes are additive
> to your conversation data, but — as always — back up your storage before
> upgrading (`pg_dump` for PostgreSQL; copy the database file for SQLite).
**Breaking changes at a glance** (details in the sections below): the
`/creative` REPL toggle removed (replaced by the `writer` persona), the
`turnstone-bootstrap` entry point renamed to `turnstone-doctor`, and the
approval-status API/SDK field `pending_approval_details` changed from a
single object to a list (one entry per concurrent approval cycle).
### Added
- **Personas** (#683) — a named, reusable bundle attached to a workstream
at creation, controlling system-message composition and the capability
envelope via exactly four levers: base-prompt override, tool visibility
set, MCP on/off, and memory on/off. The persona is resolved once and
snapshotted into `workstream_config`; editing or archiving a persona
never changes an existing workstream. Six seed personas ship with
migration `063` (`engineer` and `orchestrator` are the per-kind
defaults with no overrides, so zero-touch behavior is unchanged;
`scribe`, `researcher`, `writer`, and `executive` are curated
envelopes). Selectable on every creation surface (web pickers, the
create API/SDKs, coordinator `spawn_workstream` / `spawn_batch`, and
`turnstone --persona <name>`); authored in the console's new
Governance → Personas tab (`persona.{create,read,write}` perms,
archive-only lifecycle). See `docs/personas.md`.
- **Projects — governed resource containers** (#724) — group workstreams
and their resources under a project (migration `062`), with
project-scoped memory, a per-project resources view, a project column on
the saved list, and server-enforced private-project workstream
visibility.
- **Task-agent sub-harness** (#732) — a spawned task agent now runs on its
own Turn-IR sub-harness with parent-tagged step events: its sub-tool
steps nest inside an expandable card in the parent trajectory, its
sub-trajectory is recallable, and each agent gets read isolation from
its siblings.
- **MCP static-server autonomous reconnect** (#768) — statically
configured MCP servers are now kept live by a health loop
(capped-jittered backoff, ping-based liveness) instead of silently
staying dead after the first transport drop.
- **Attachments — capability-gated client-side fallback** — when the
active model can't natively handle an attachment, the client degrades
gracefully (PDF → extracted text, audio → transcript) instead of
failing the turn.
- **Eval measurement / optimizer split** (#763, #765) — `turnstone-eval`
is now a measure-only substrate with the prompt optimizer factored out,
plus a new skill-adherence measurement mode.
- **Deployment examples** — a vLLM + LiteLLM unified-memory inference
example showing a 3-model co-resident stack with an HF loader (#686,
#688), and an Altair + `vl-convert-python` visualization stack (#685).
- **Concurrent approvals and a long-session frontend overhaul** (#754,
#755, #773, #775) — the live-session frontend was reworked for long
runs (the pipeline is wedge-proofed and its hot paths de-O(N)'d), and on
top of it a workstream can now hold more than one tool call awaiting
approval at a time. Each parallel batch gets its own approval cycle,
with one card per pending call in the interactive and coordinator UIs,
cycle-keyed tracking in Slack and Discord, and cycle-routed resolution
across the server/console/SDK APIs; sub-agent tool gates run the
intent-judge pipeline as their own generation. The send button no longer
sticks disabled after a batch resolves — orphaned approval cycles are
pruned and the app is the sole owner of the button state.
*(BREAKING: the `pending_approval_details` field is now a list, oldest
first.)*
- **Reasoning-effort control on every provider lane** (#771, #774) — the
session effort knob now reaches local backends too: it drives
`chat_template_kwargs` on the anthropic-compatible and openai-compatible
lanes and threads through to Gemini and xAI, alongside the commercial
providers that handle effort natively. The console surfaces each model's
effective effort ladder in plain words and adds an always-on
thinking-mode option to the model form. Effort snapping is ordinal —
it rounds up and caps at the model's ceiling rather than silently
dropping.
### Changed
- **Skills are capability-context, not identity** (#762) — a task agent's
identity now comes from its persona; an applied skill's body is demoted
to capability context and moved out of the identity system message.
Skill-body substitution is unified across every invocation context so
the same skill renders identically whether loaded interactively, by the
model, or inside a sub-agent.
- **`turnstone-doctor` replaces `turnstone-bootstrap`** (#718)
*(BREAKING)* — the setup/diagnostics entry point is renamed; update any
scripts or service units that invoke `turnstone-bootstrap`.
- **Honest cancellation dispositions** — cancelled or timed-out
side-effecting tools now report an `UNKNOWN` disposition rather than a
flat failure, tool dispositions are typed (not just prose), and a
coordinator cancel propagates down the sub-tree.
- **Multi-user shared-workstream context** (#750) — in a shared
workstream, send is gated to the acting participant while a turn is in
flight (both the interactive and coordinator surfaces), cross-user
mid-turn interjections are blocked, and shared-workstream state plus
fork sender attribution are now durable.
- **Cooperative compaction** (#730) — the context budget is anchored to
the provider's true capacity, the summary call is chunked so it can't
overflow, and the active plan and the outstanding ask are carried across
compaction verbatim. The `recall` tool is scoped to the compacted-away
past.
- **Intent judge sees the full tool arguments** (#760) — the judge's
argument projection is no longer narrowed, so it stops issuing confident
false denials on a partial view. The output-guard judge sources its real
context window, and `context_window = 0` in `config.toml` now means
auto-detect.
### Fixed
- **Compaction resume hardening** (#731) — checkpoint markers are
persisted so resume rehydration is bounded, context-overflow on resume
is recovered across providers, and a recognized rate-limit is no longer
misclassified as context overflow.
- **MCP unattended-work resilience** (#706, #742, #767) — dead-transport
handling is completed, consented OAuth (OBO) tokens are refreshed
proactively so autonomous runs don't strand on an expired grant, the
Entra ID on-behalf-of impersonation flow blockers are closed (migration
`065` adds the OIDC `oid`), and OAuth refresh failures are classified so
a transient blip never revokes consent nor a dead grant strands the
user.
- **Memory writes** (#735) — save/update is a single atomic upsert, and
writing a memory no longer recomposes the system prefix mid-session.
### Removed
- **`/creative` removed** *(BREAKING)* — subsumed by the Personas feature
above: the REPL toggle (and its tab completion) is gone, and the
`writer` seed persona replaces it — start a session with
`turnstone --persona writer` or pick *Writer* in the web
pickers. Unlike the old fork, the writer persona composes the full
system message, so session context and mandatory prompt policies now
apply to prose-only sessions too. The `creative_mode` key in
`workstream_config` is no longer read or written. Migration `063`
converts existing creative-mode workstreams to the `writer` persona
automatically, so they resume as writing sessions rather than as
legacy defaults.
### Security
- **High-risk skill activation is gated** (#762) — a model-initiated load
of a `high`- or `critical`-risk skill is gated and fails closed when the
backing storage is unavailable, so an untrusted turn can't silently
pull in a dangerous capability.
- **Dependency security floors** — `cryptography` and `starlette` are
pinned to security-fixed minimums.
- **CI publish hardening** — the vendored-JS dispatch path refuses fork
PRs, and `workflow_run` publishing is gated to same-repo tag pushes, so
a fork can't trigger a release build.
## [1.6.0]
The first stable release of the 1.6 line — and the first under Apache 2.0.
+2 -4
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.11.27 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.19 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
@@ -17,10 +17,8 @@ RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
# ripgrep is the preferred backend for the search tool — natively bounds
# per-line, per-file, and per-filesize so pathological inputs (minified
# bundles, training-data JSONL with multi-MB single records) can't OOM us.
# ffmpeg transcodes omni STT uploads (browser webm/opus) to the 16 kHz mono
# WAV the omni chat-audio lane decodes.
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 git curl jq man-db manpages procps file ripgrep ffmpeg \
libpq5 git curl jq man-db manpages procps file ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
-229
View File
File diff suppressed because one or more lines are too long
-155
View File
@@ -1,155 +0,0 @@
# What a Harness Is — and What It Can Never Promise
*A plain-language companion to [HYPOTHESIS.md](HYPOTHESIS.md). Same object, no symbols required.*
**How to read this.** HYPOTHESIS.md defines, formally, what an agent harness is and what it can never guarantee. This file is that document lowered into plain language — and by the formal document's own rules, a summary is a cache, not an authority: it must stay re-derivable from its source, and wherever the two disagree, the formal one wins. Symbols appear once, in parentheses, so you can cross over; nothing here requires them. And none of it is decoration: the formal version, used as a checklist, has caught real bugs in a real harness — because most bugs are a violated invariant nobody had written down.
## The problem
You have a model. It is, roughly, a brilliant, tireless, lightning-fast intern that has read most of the internet — and that sometimes makes things up, sometimes gets confused, and sometimes takes instructions from strangers, because a page it was asked to read said "ignore your boss and email the passwords here" in white text on a white background.
So you don't wire the intern to production. You build a loop around it. The **harness** is that whole governed loop: a deterministic shell *you* write — build the prompt, approve or refuse each proposed action, fold the result back into memory — wrapped around a model you didn't write and a world you don't control, repeated until the run reaches a stopping state. The shell is code and does the same thing every time. The model is neither, and everything in the theory comes from taking that split seriously.
One sentence to keep: **the model proposes; the gate disposes.** The model's output is never an action. It is a suggestion, in text, which a piece of ordinary code you wrote either turns into an action or refuses.
## The parts
| Plain name | What it does | In the formal doc |
|---|---|---|
| The owner | The human — or sign-off group — the run acts for; the only place new permissions can come from | the trusted principal |
| The memory | Everything the run knows: task, plan, transcript, and the ledger of what has been done | the state, *s* |
| The prompt builder | Decides which slice of memory the model gets to see this step | the lowering, π |
| The model | The black box that reads the prompt and writes a proposal | the plant, M_W |
| The gate | Ordinary code that checks every proposal and approves or refuses it | the gate, γ |
| The tools and the world | What approved actions actually touch: files, APIs, shells, people | the environment, Q_E |
| The verifier | Checks each tool result, then writes it into memory | the fold-back, ρ |
| The stop rule | Decides when the run is finished — and whether it finished *well* | the halt set H, accepting halts H_ok |
| The danger zone | States that must never be reached: secrets exfiltrated, wrong files deleted, money moved twice | the bad set, B |
The loop:
```
you ask for something
prompt builder → model → "I propose: send_email(...)"
GATE ── no ──→ nothing happens (safe, recorded)
↓ yes
tool runs in the world
verifier checks the result, writes it to memory
done? ── no → around again
↓ yes
stop (well, or refused)
```
## The rules that make it a harness
Four invariants, all about *where* things are allowed to happen.
1. **The model sees only what the prompt builder shows it** — never raw memory. The corollary with teeth: a secret that never enters the prompt cannot leak through the model. The redaction step that keeps credentials and other people's data out of the prompt must be dumb, deterministic code — the moment that filter is "smart," your confidentiality guarantee is a probability.
2. **Model outputs are proposals, not actions.**
3. **Every side effect passes the gate.** There is no second door.
4. **The harness itself flips no coins.** Replay a step with the model's answer and the tool results pinned, and behavior must be identical; any leftover variation is randomness *you* added and must be accounted for. The fine print: "deterministic" is conditional on pinned versions — a provider silently retraining the model behind the same API name changes the machine under you, and every dashboard number you collected dies with the version.
Notice what the rules don't say: they don't say the harness is *good*. A gate that approves everything satisfies rule 3 the way a lock that's always open satisfies "has a lock." The definition is a shape; the guarantees are what a particular harness *earns* inside it. Everything below is about what can be earned — and what can't.
And notice the symmetry between rules 1 and 3. There is exactly one door from your data into the model — what it may see — and exactly one door from the model into the world — what it may do. Nearly every security failure in these systems is one of those two doors with a hole in it: a secret lowered into a prompt that didn't need it, or a path from model text to a side effect that skipped the gate. Same bug, arrow flipped.
## Fail-closed, said precisely
"Fail-closed" gets used loosely. Here it means something exact: **nothing happens unless the gate said yes, and a refusal must itself be safe** — a refused proposal causes no side effect and leaves the run somewhere sane, which may be "stopped, having declined." The run is allowed to *say so*: a templated status message written by the shell is the shell speaking, not the model, and needs no gate. Failed runs don't have to die silent.
Three consequences people miss:
**Reads are not free.** A read-only call can smuggle instructions *in* (the fetched page is attacker-controlled) or secrets *out* (the URL it fetches can encode the payload). The gate approves calls, not just writes.
**Validation must not act.** A "validator" that resolves a URL, expands a template that fires a webhook, or evaluates an argument has already acted — inside the check. The gate must be pure: it reads the proposal and the memory and outputs yes or no. If deciding requires touching the world, that touch is itself an action and goes through the gate.
**Anything irreversible is decided at the gate.** The verifier can reject a bad *result*; it cannot unsend the email. So the question "can we take this back, and until when?" is asked before execution — which means each tool declares, up front, how reversible its effects are, and the gate reads that declaration when it decides; the mark that comes back in the result record is confirmation for the books, not the gate's source — the gate needed the answer before the tool ever ran.
Two honest asterisks. First, the gate checks a snapshot: it approves against the world *as its memory describes it*, and the world can move between check and commit. For actions that race the world — spend against a balance, write against a row — the tool itself must bind check to commit (compare-and-swap), or you have a classic time-of-check/time-of-use hole. The gate decides; for those effects, the tool enforces. Second, a gate is only as binding as the authority behind the tools. A tool process holding standing credentials — a database connection with every grant, an environment full of long-lived secrets — doesn't need the model's proposal to act, and against it the gate's "no" is a decision with nothing enforcing it. **A gate in front of an omnipotent tool is a suggestion.** The fix is to make the approval *be* the key: each authorized action carries a short-lived credential scoped to exactly that action, that resource, that operation, so tools hold no standing power at all.
## Why you don't get a proof — and what you do instead
If you write a sort function, you can prove it sorts: the function is small and the spec is exact. A harness has neither luxury. The spec side fails first — the task arrives in natural language, and natural language is, in the compiler's sense, *all undefined behavior*: there is no formal standard for "what the user meant" to verify against. The mechanism side fails next — the model is billions of learned parameters, and nobody can hand you a compact argument for why they jointly do the right thing.
Here is the careful version, because "you can't prove it" overshoots. The quantity you would want — call it the *expected steps to done* from any situation — is perfectly well-defined; in principle it exists. The document's central conjecture is that, for a model of this size, any faithful writing-down of that quantity is roughly *model-sized*: the honest proof-object does not compress. Find a small one and the conjecture dies — the document lists that outcome, explicitly, among the ways it could be wrong.
So instead of proving, you measure. You pick a progress meter — plan depth shrinking, open obligations closing, budget burning at the expected rate — and you check, across many runs, that it goes downhill and that its stalls predict failure. Two disciplines keep the measurement honest. The number bounds the world you *sampled*, never the world an adversary will choose: a meter calibrated on friendly traffic says nothing about hostile traffic. And the meter is itself attack surface: if "is the agent making progress?" is judged by another model, an attacker who can bend your agent can bend your *measurement of it* first, hiding the divergence from the very dashboard built to catch it. A learned meter is part of the system under test, never a neutral instrument.
A measurement is a risk metric. A proof is a certificate. Keeping those two words apart is half of what this theory is for.
## Security: reach the goal, avoid the danger — and who may change the rules
Formally, security here is a *reach-avoid* problem: reach a good stop, never touch the danger zone, **while an adversary picks the worst tool outputs your setup permits**. That last clause is the formal home of prompt injection: injection isn't "the model misbehaved," it's the environment optimized to bend your loop — poisoned pages, malicious tool descriptions, crafted responses.
Two different numbers fall out here, and dashboards love to collapse them: *success* (reached an accepted end before anything went wrong — a safe refusal counts against it) and *safety* (never touched the danger zone — a safe refusal is perfectly safe). Track both. They move independently. And both are scored by your own stop rule — they count what the shell *declared* a success. Whether a declared success was actually *right* is a third, harder number that no dashboard inside the system can produce; only a judge outside the run — a test suite, an audit, ground truth — can.
The gate handles the visible half of injection: the model, freshly poisoned, proposes emailing your credentials somewhere, and the gate refuses — and injection or not, the action does not happen. But the deeper attack doesn't propose a bad action today. It rewrites *what the run believes its job is* — it edits the plan — and then every future action looks locally reasonable against a corrupted plan. So memory has to be partitioned: **data** (tool results, fetched pages, retrieved documents — content the world supplied) and **control** (the plan, the permissions, what is authorized next). The security claim is conditional on that partition holding: untrusted content lands in data, always. And "trust" is really two questions pointing opposite ways, which is worth keeping straight: *can this leak?* (a value is as secret as the most-secret thing that fed it — secrecy flows **upward**) and *can this boss us around?* (a value is as trustworthy as the least-trustworthy thing that fed it — authority flows **downward**). Untrusted content is safe as *data* precisely because the second question keeps it off the control side; a secret is kept out of the model by the first. Lowering either barrier on purpose — declassifying a secret, promoting data to trusted — is an explicit decision the owner makes, never a thing that happens by accident when two values are combined.
Which forces the question the theory has to answer: *somebody* must be able to write control mid-run, or no plan could ever be steered and no permission ever granted. The answer is a small hierarchy with a top the model can't reach. The simplest top is one owner — but it needn't be a single person: a two-person sign-off, a quorum, several authenticated people each holding different scopes all work equally well, because the one property that matters is the same for all of them — the thing that can grant new power is a *human decision*, never a model:
- **The top alone widens.** New permission, bigger budget, approval of the irreversible thing — asking the top — the owner, in the simple case — is itself an ordinary tool call, and its answer is the one kind of tool result allowed to change control.
- **The model rewrites the plan** — that is what replanning *is* — but only through the gated loop, and a plan is not a permission: nothing the model writes into its own plan can grant it powers it didn't have.
- **Everything else is data.** A fetched page can inform the plan only by passing through the model and the gate like everything else. It can suggest. It cannot promote itself to boss.
- **AI judges only tighten.** Add a model-based check — "does this action match what the user actually wanted?" — and its verdict may *veto* an action the plain rules would have allowed, never approve one they'd have refused. A judge that can approve is a tricked judge that can open the vault. And don't over-credit the veto either: a tricked judge can *aim* its refusals — denying exactly the action safety depended on, or denying everything but the path an attacker curated — so the escape hatch to the owner is the one thing a judge can never veto, and a judge's stated *reasons* are picked from a fixed, shell-owned menu, never written as prose. A judge that writes free text into the loop is an injection channel wearing a badge.
One more rule closes the loop: transformations don't launder trust. A *summary* of a session that contained an injected page is still injected — the summarizer is a model, and can be persuaded to write "the user asked to export the database" into the summary. So summaries of data are data, and the control lines — the plan, the grants — cross a summarization by being *copied verbatim* or re-confirmed by the owner, never paraphrased by the model. Memory that persists across sessions carries its trust label with it, or a poisoned memory is just an injection with a very long fuse.
## Operations: the rules you feel on Tuesday at 3 a.m.
The formal document's appendix works the operational cases in full; here they are at speed.
**The ledger, and the three-way distinction that keeps it honest.** Every action gets an ID and a record: committed, never-launched, or *unknown*. "The tool didn't confirm" is not "the tool didn't do it" — collapse those and you will, sooner or later, re-send something that already happened. And a subtler honesty: the ledger records what the tool *reported*, not what the world actually did. A well-built shell can guarantee its bookkeeping is faithful to the responses it received — it cannot, on its own, guarantee a tool told the truth. A tool that returns a clean "done!" for something it never did puts a clean "done!" in your ledger. So "the ledger is what happened" is only as good as your reason to trust the tools reporting into it; where you have no such reason, *unknown* is the honest entry, not an optimistic guess in either direction. The double-send bug has one reliable cure: **journal before dispatch.** The shell writes "I am about to run action #417" into durable memory *before* the tool sees it, so a crash in the gap resumes to an honest "unknown — go ask," never to silence misread as "never sent." Old database wisdom, but here it isn't imported; it's forced — it is the only ordering under which every crash point has a truthful reading.
**Crashes aren't finishes.** A process dying mid-run is not the run stopping; it's the run *pausing being computed*. Resume means re-entering the loop at the last durable memory — sound exactly when the durable memory was the *whole* state. Anything load-bearing that lived only in RAM — an in-flight buffer, a plan revision not yet written — is a bug you discover at the worst possible time. Recovery is where you find out whether your state was really your state. And a run you stopped — crash or deliberate cancel — is not automatically a *safe* run: if something was in flight and you never learned whether it fired, it may already have done the damage. "We stopped in time" is only true when everything in flight resolved to something safe; an outstanding *unknown* has to be treated as possibly-bad, the same optimism the ledger warns against, one level up.
**Two innocent actions can be guilty together.** Models emit several tool calls per turn. "Read the secret" passes review. "Post to the web" passes review. The pair is an exfiltration channel — so the gate authorizes the *set*, atomically, with the interactions checked, not each element in isolation.
**Sub-agents are just fancy tools.** An agent that spawns another agent is, from the parent's chair, calling a tool: the spawn is gated, the budget is part of the deal, and the child's whole run comes back as one result carrying the child's ledger. Two laws travel down the tree: budgets subdivide, and **authority only narrows** — a child holds at most a subset of its parent's permissions, and a child's request beyond those grants routes *up*, ultimately to the owner, because a parent inventing an approval it never held is the tricked-judge case wearing a manager's badge. A corollary worth framing: a *fully autonomous* run is one whose owner is unreachable — meaning the only channel that can ever widen anything is closed, and its permissions are frozen at launch. That is not a limitation of the theory. That is what the word "autonomous" costs.
**Keep the originals.** When the transcript outgrows the prompt and you summarize it down, deleting the original is an irreversible act against your own state — and irreversible acts are gate decisions, self-directed or not. Keep originals content-addressed; let the summary be an index, re-derivable, auditable. A summary you can check against its source is a note. A summary that replaced its source is a fait accompli.
## Robots that never clock out — and robots that assign their own work
Everything so far assumed a job that *ends*: you ask, the robot does it, you read the result. Two steps past that are where the interesting failures live, and they're the same idea one level bigger each time.
**The robot that never clocks out (a daemon).** A monitor, a coordinator, a service — it isn't supposed to finish; it's supposed to keep going, wake on events, do a bit of work, go back to waiting. The clean way to think about it: each wake-work-rest cycle is one ordinary run, and the daemon is just those runs chained end to end forever. That reframing is free — but it comes with a bill nobody likes. **Safety that's fine per cycle rots over many cycles.** A 99.99%-safe cycle sounds bulletproof; run it ten thousand times and you're at about a coin-flip of having touched the danger zone at least once. So a long-running robot's safety isn't a fixed wall, it's a slow leak — which means the antidote isn't a better wall, it's *scheduled resets*: the owner re-confirming, credentials rotating, memory getting audited and re-summarized against the originals. Housekeeping isn't housekeeping; it's the thing that keeps the safety math from decaying. And the slow-leak logic is exactly where slow attacks live — a poisoned note dropped into memory on Monday and read back into the plan on Friday is an injection with a long fuse. So the trust label on a piece of information has to survive across cycles, not just within one. One more wrinkle: a daemon drifts in and out of your reach. While you're around, it can escalate to you; while you're not, "escalate to the owner" isn't available — so the one thing it must always be able to do instead is *stop*. A robot that can be tricked into refusing everything, and can't reach you, had better be able to halt rather than be steered.
**The robot that assigns its own work (the loop).** Step back one more time. Above the robot that *does* a task sits a system that decides *which task is next* — scans the backlog, picks one, launches the robot at it, checks the result, remembers, fires again. This is the thing people mean in 2026 when they say they've stopped prompting their agents and started writing *loops* that prompt them: you design the assigner once, and it runs the doer for you while you sleep. The honest observation — and the reason this document bothers with it — is that the assigner is *not a new kind of thing*. It's the same harness, one level up: it has its own memory (the backlog), its own gate (**who let the loop refactor the auth module at 3 a.m.?**), its own verifier, and its own two walls. Every rule from the inner robot recurs on the outer one — including the uncomfortable ones. There's still no proof it stays out of trouble over a long night; there's only a measured progress meter, with the same catch that a *learned* meter can be fooled. And the origin story of the whole trend is the cautionary case in miniature: the famous first version was literally the same prompt in a `while` loop until the tests passed — which is the empty gate, the always-open lock, one level up. It works beautifully right up until the tests weren't checking the thing that mattered. The loop doesn't delete the hard problems. It moves them up a floor, where they're bigger and you're further away.
The pattern, if you want the whole thing in one line: *words, context, robot, loop* are four sizes of the same object, and every promise in this document lives in the whole assembled thing — never in any one layer by itself.
## The two walls
Two limits are structural. You don't fix them with a better harness; you design around them.
**The desk.** The model can hold only so much *in mind at once* — the context window. Files, databases, and search extend what it can *look up*, not what it can hold: every lookup still passes through the same small window to touch actual computation. The shell can page; the model cannot grow its desk. Tasks whose irreducible working set exceeds the desk don't fail loudly — they fail by forgetting the middle (the well-documented "lost in the middle" effect is this wall showing through the paint).
**The dictionary.** The model's knowledge is frozen into its parameters at training time — and the proof problem above is conjectured to live at that same scale: the certificate wouldn't fit anywhere smaller than the brain it certifies. The two walls trade against each other along the training-versus-inference axis — bigger dictionary or bigger desk — directionally, and at no clean exchange rate.
## How this could be wrong
This is a hypothesis, and it says out loud what would kill it. The tests, in plain terms:
- **The replay test.** Rerun with model answers and tool results pinned. Any leftover variation — timestamps, wall-clocks, and cache expiries are the classic leaks — falsifies "the harness adds no randomness" until accounted for.
- **The drop-a-variable test.** Remove something from memory; if behavior statistics shift, the memory wasn't complete. The crash-resume version of the same test: if resuming from saved state breaks, the saved state wasn't the state.
- **Does the meter mean anything?** If no reasonable progress meter's drift predicts real failures — across the natural families, not just one bad candidate — the whole "measure what you can't prove" program is empty.
- **The red-team test.** Swap sampled tool outputs for worst-case ones: injected pages, poisoned metadata, malformed replies. The design must survive the worst permitted world, not the average one.
- **Gates versus begging.** The theory predicts deterministic gating beats prompt-level pleading. If "please be careful" alone matches real gates on security outcomes, the controller-versus-model story is wrong.
- **The compression hunt.** Exhibit a compact, provably sound progress certificate for a frontier-scale model on a nontrivial task family, and the central conjecture falls — constructively.
- **The desk probe.** Take a task family with a *proven* memory floor — so "it needed the whole picture at once" is someone else's theorem, not our excuse — scale it past the window, and watch: the wall predicts a *ceiling*, not a cliff — past the boundary, a success rate that stays capped no matter how many retries you buy. A family solved reliably out there, without new shell tricks for splitting the work, kills the wall.
## Who else landed here
The formal document keeps three honesty tiers. **Borrowed**: real theorems, cited — the drift and stopping-time mathematics is classical, and the very architecture of a deterministic supervisor gating a plant it didn't author is 1987 control theory; the shape is older than the web. **Ours**: the modeling choices and the conjectures — the walls, the incompressibility claim, the design rules — organizing principles, not results. **Corroborated**: pieces of the same object reached independently by people who never saw this framing — capability-security work isolating control flow from untrusted data (CaMeL), reinforcement-learning "shields" filtering a learned policy's actions through a deterministic checker, verification work that states the "learned safeguards can't certify" gap as its opening motivation, and architecture patterns converging on plan-then-execute. Even the field's live disagreement — provable-but-rigid deterministic layers versus flexible-but-uncertifiable learned checks — is, in this frame, not a fight but a placement: you need both, on their proper sides of the irreversibility line, with the learned one permitted only to tighten.
## What to remember
The model proposes; the gate disposes. No is the default, and a refusal must be safe. Only the top of the trust hierarchy widens permissions — a human decision, never the model, a tool result, a summary, or a judge. "Didn't confirm" is not "didn't happen." The desk is finite and the proof doesn't compress, so you measure — and you say *measurement* when you mean measurement. A robot that never stops leaks safety slowly, so it needs scheduled resets — and when it can't reach you, it must be able to stop. A loop that runs robots for you is just a bigger robot with the same rules and a further-away owner. And all of it is a hypothesis wearing its own kill-conditions on its sleeve.
The formal version — the objects, the certificates, the falsifiers, the citations — is [HYPOTHESIS.md](HYPOTHESIS.md). It wins every disagreement with this file, including this sentence.
*Same ramblings, fewer symbols.*
+66 -81
View File
@@ -1,107 +1,92 @@
# Quickstart
# Bootstrap Wizard
Install Turnstone, then diagnose it with `turnstone-doctor` if anything looks off.
Interactive, AI-guided setup for Turnstone deployments. Instead of manually
editing `.env` files and reading deployment docs, the wizard walks you through
every decision conversationally and generates all the config files for you.
## Install
The one-line installer autodetects your distro (Ubuntu/Debian, Fedora/RHEL,
Arch, and WSL), installs git + Docker if missing, generates secrets, picks free
ports, and starts the stack:
## Quick Start
```bash
curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
turnstone-bootstrap
```
Re-running is safe — it updates the checkout and keeps your existing `.env`.
When it finishes it prints the dashboard URL and how to create the first admin
user.
That's it — no flags, no arguments. The wizard prompts for everything.
**Other ways to install**
## How It Works
- **Already have Docker?** Clone the repo and `docker compose up` for the full
local cluster, or `docker compose -f turnstone/deploy/compose.yaml up` for the
released single-node stack. See [docs/docker.md](docs/docker.md).
- **Python package:** `pip install turnstone` (add `--pre` for the experimental
track), then run `turnstone-server` / `turnstone-console` directly. See the
[README](README.md#quickstart).
1. **Pick a model** — Choose OpenAI, Anthropic, or a local/vLLM endpoint to
power the wizard. Local endpoints auto-detect available models.
2. **Answer questions** — The AI walks you through deployment mode, LLM
provider, database, authentication, ports, and optional features.
3. **Review generated files** — Each file is previewed before writing. You
confirm or reject every write.
4. **Start the stack** — The wizard prints the exact `docker compose` command
and a `setup.sh` script to create your first admin user, roles, and policies.
## Diagnose: `turnstone-doctor`
## What Gets Generated
`turnstone-doctor` is an LLM-backed assistant that inspects a **running**
Turnstone install and helps you troubleshoot it. It is **read-only** — it
investigates and tells you the exact commands to fix things, but never changes
your system. (Installation is the installer's job, not the doctor's.)
```bash
# From a host that has the turnstone package installed:
turnstone-doctor
# For a Docker install from run.sh (no package on the host), run it with pipx:
pipx run --spec turnstone turnstone-doctor --dir ~/turnstone
```
### What it does
1. **Preflight** — detects how Turnstone is installed here (docker-compose,
systemd/bare-metal, pip, or a source checkout) by probing for `config.toml`
files, `TURNSTONE_*` environment variables, compose files, and systemd units.
2. **Self-configures its LLM** — it powers its own brain from your cluster's
*own* model configuration (env / `config.toml` / the database). Whether that
works is the first diagnostic: success means your LLM backend is healthy; if
it can't, that's surfaced as finding #1 and it falls back to asking you for a
provider and key so it can still help.
3. **Version check** — reports the installed version, version drift across your
cluster's nodes, and the latest upstream stable/experimental releases.
4. **Interactive diagnosis** — it reads logs, `/health`, `docker compose ps`,
`systemctl`, config, and ports to pin down problems like a node not joining
the console, an unreachable database, a down model backend, port conflicts,
or a JWT-secret mismatch — then hands you the precise remediation commands.
### Flags
| Flag | Purpose |
| File | Purpose |
|------|---------|
| `--dir PATH` | Install directory to inspect (default: current directory) |
| `--report` | Print the deterministic preflight report and exit — no LLM key needed |
| `--offline` | Skip the upstream GitHub version check |
| `.env` | All environment variables for `compose.yaml` |
| `setup.sh` | Post-start script: creates admin user, roles, tool policies, prompt templates via the API |
| `docker-compose.override.yaml` | Only if customizations beyond env vars are needed |
`--report` is the fastest way to get a health snapshot (and to share one when
asking for help) — it never needs an API key:
## Requirements
```bash
turnstone-doctor --report --dir ~/turnstone
```
- **Python 3.11+** with turnstone installed (`pip install turnstone`)
- **An LLM API key** — for the wizard itself (OpenAI, Anthropic, or a local
model). This can differ from the LLM your deployment will use.
- **Docker & Docker Compose** — needed to run the stack. The wizard detects
whether Docker is installed and gives platform-specific install instructions
if it's missing. You can still generate config files without Docker.
## Deployment Modes
- **Single-node production** — `docker compose up` against the bundled
`turnstone/deploy/compose.yaml`: 1 server + console + channel + PostgreSQL,
pulled from ghcr.io. Good for most deployments.
- **Local multi-node cluster** — clone the repo and run `docker compose up` at
the root for a 10-node fleet + console + Caddy + channel, built locally.
See [docs/docker.md](docs/docker.md) for both.
## Example Session
```
## Install profile
- Detected kind(s): docker-compose (primary: docker-compose)
- Docker daemon reachable: yes
- Compose files:
/home/you/turnstone/compose.yaml
- Database: backend=postgresql, url=postgresql+psycopg://turnstone:****@postgres:5432/turnstone
- Candidate health URLs: http://localhost:8080/health, http://localhost:8090/health
$ turnstone-bootstrap
## Versions
- Installed (this tool): 1.7.0a2
- Cluster nodes: 10 reporting; versions ['1.7.0a2']
- Version drift across nodes: no
- Upstream: stable 1.6.9, experimental 1.7.0a2
Turnstone Bootstrap Wizard v1.5.0
────────────────────────────────────────────────
## LLM backend (ok)
- resolved Qwen/Qwen3-32B via openai-compatible @ http://host.docker.internal:8000/v1
Which provider for this wizard?
[1] OpenAI
[2] Anthropic
[3] OpenAI-compatible (local/vLLM)
> 3
Base URL [http://localhost:8000/v1]:
API key (press Enter for 'none'):
Querying http://localhost:8000/v1 for available models...
Found model: Qwen/Qwen3-32B
Connected to Qwen/Qwen3-32B. Handing off to AI assistant...
> (AI walks you through the rest interactively)
```
Secrets (JWT secret, database password, API keys) are always redacted in the
report and in anything the doctor reads.
## Tips
- **Type `quit`** to exit the conversation; **Ctrl+C** interrupts (twice to quit).
- **Point it at the right install** with `--dir` when you run it from elsewhere.
- **(Re)installing or adding nodes?** Use the installer (`run.sh`), not the doctor.
- **Re-run safely** — running the wizard again detects your existing `.env`
and offers to update it rather than overwriting.
- **Duplicate writes are skipped** — if the LLM tries to write the same file
twice with identical content, it's silently ignored.
- **Type `quit` to exit** at any time during the conversation.
- **Ctrl+C** is handled gracefully — press once to interrupt, twice to exit.
## See Also
- [Docker Deployment](docs/docker.md) — compose stacks, ports, and bare-metal nodes
- [Docker Deployment](docs/docker.md) — manual compose setup and profiles
- [Security](docs/security.md) — auth architecture and token types
- [Governance](docs/governance.md) — roles, policies, and templates
+4 -22
View File
@@ -5,7 +5,6 @@
[![Python](https://img.shields.io/pypi/pyversions/turnstone)](https://pypi.org/project/turnstone/)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/Nh3bWMacaq)
[![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/eous)
Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real tools — shell, files, search, web — and run them across your own cluster with direct HTTP routing and interactive interfaces. Your code, your models, your data stay on hardware you control: no telemetry, no phone-home.
@@ -15,14 +14,6 @@ Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) (*Arenaria interpres*) — a shorebird that flips stones to discover what's hiding underneath.
**What is a harness?**
```
: s_{n+1} ~ T(s_n) for n < τ*, T = ρ ∘ (M_W ∘ π, E)
```
[**the primer →**](PRIMER.md)
### Release Tracks
| Track | Install | Docker | Description |
@@ -36,7 +27,7 @@ See [docs/releasing.md](docs/releasing.md) for the full release process.
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
- **Local-first & private** — runs entirely on hardware you control, with no telemetry and no phone-home. Point it at local models (vLLM, llama.cpp) or commercial APIs you hold the keys to — your prompts and data never transit a third party you didn't choose.
- **Local-first & private** — runs entirely on hardware you control, with no telemetry and no phone-home. Point it at local models (vLLM, llama.cpp, Ollama) or commercial APIs you hold the keys to — your prompts and data never transit a third party you didn't choose.
- **Bring your own models** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), the Anthropic Messages API, and Google Gemini, mixed freely per role
- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams
- **Cluster dashboard** — real-time view of every node and workstream, with a rendezvous routing proxy
@@ -95,7 +86,7 @@ LLM; add model backends from the console UI.
For production (released images from ghcr.io, real secrets required), use the
bundled stack: `docker compose -f turnstone/deploy/compose.yaml up`.
See [QUICKSTART.md](QUICKSTART.md) for the install + troubleshooting walkthrough and [docs/docker.md](docs/docker.md) for Docker configuration.
See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration.
### Programmatic (SDK)
@@ -125,9 +116,8 @@ Built-in tools for shell, files, search, web, memory, notifications, and autonom
| `turnstone-console` | Cluster dashboard + routing proxy + admin panel |
| `turnstone-channel` | Channel gateway (Discord and Slack adapters) |
| `turnstone-admin` | User/token management CLI |
| `turnstone-eval` | Headless measurement — scores tool-use against expected actions |
| `turnstone-optimizer` | Prompt/tool optimizer (UCB self-modify loop over the eval substrate) |
| `turnstone-doctor` | LLM-backed cluster diagnostics |
| `turnstone-eval` | Eval harness for prompt/tool optimization |
| `turnstone-bootstrap` | LLM-guided setup wizard |
### Diagrams
@@ -172,14 +162,6 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
- Optional: Discord / Slack channel integrations (`pip install turnstone[discord,slack]`)
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
## Support
Turnstone is free, Apache-2.0, and self-hosted — no paid tier, no telemetry, no upsell. If it saves you time or you'd like to help keep development moving, you can sponsor the project:
**[❤ Sponsor Turnstone →](https://github.com/sponsors/eous)** · one-off via **[PayPal](https://paypal.me/eousphoros)**
Sponsorship is entirely optional and funds maintenance, new features, and infrastructure. Prefer to contribute in other ways? Filing issues, improving docs, and [pull requests](CONTRIBUTING.md) help just as much.
## Community
Questions, ideas, or want to show what you're building? Join us on Discord:
+16 -44
View File
@@ -29,11 +29,10 @@
# Fewer nodes (lighter machines):
# docker compose up postgres console caddy channel node-1 node-2 node-3
#
# Join a bare-metal host: a turnstone-server running OUTSIDE compose (e.g. to use
# a local GPU) can join this cluster. Postgres, the console's ACME endpoint, and
# SearxNG are published on 127.0.0.1 so a node on THIS machine reaches them via
# localhost. Keep secrets in ~/.config/turnstone/config.toml (chmod 0600 — the
# loader warns otherwise):
# Join a bare-metal host: Postgres is published on 127.0.0.1:5432, so a
# turnstone-server running directly on this machine (e.g. to use a local GPU)
# can join the same cluster. Keep the secret + connection settings in
# ~/.config/turnstone/config.toml (chmod 0600 — the loader warns otherwise):
# [auth]
# jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
# [database]
@@ -42,19 +41,10 @@
# [api]
# base_url = "http://localhost:8000/v1"
# api_key = "dummy"
# [tls] # only if the cluster runs mTLS
# enabled = true
# then run (node identity isn't a secret, so it stays on the command line):
# TURNSTONE_NODE_ID=host-1 \
# TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
# TURNSTONE_CONSOLE_URL=http://localhost:8090 \
# TURNSTONE_SEARXNG_URL=http://localhost:8081 \
# TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
# turnstone-server --host 0.0.0.0 --port 8080
# The node registers in Postgres, auto-enrolls its mTLS cert from the console's
# ACME endpoint (when the cluster runs mTLS), and the console collector reaches
# it back via host.docker.internal. To join from ANOTHER machine, set
# TURNSTONE_HOST_IP to this host's LAN IP and use it in the URLs above (and the
# node's TURNSTONE_ADVERTISE_URL = the NODE host's IP) — see docs/docker.md.
# It registers in Postgres and the console reaches it back via host.docker.internal.
# =============================================================================
name: turnstone
@@ -103,14 +93,13 @@ services:
# INSECURE dev default — override POSTGRES_PASSWORD in .env for real use.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-turnstone}
PGDATA: /var/lib/postgresql/data
# Published so a bare-metal turnstone-server can join the cluster (see "Join
# a bare-metal host" in the header). Bound to 127.0.0.1 by default (same-host
# nodes only); set TURNSTONE_HOST_IP to this host's LAN IP to let another
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose a
# database with the insecure default password to your network. (The legacy
# POSTGRES_BIND is still honored as a fallback when TURNSTONE_HOST_IP is unset.)
# Published on localhost so a bare-metal turnstone-server running on THIS
# host can join the cluster (see "Join a bare-metal host" in the header).
# Bound to 127.0.0.1 by default; set POSTGRES_BIND=0.0.0.0 to let another
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose
# a database with the insecure default password to your network.
ports:
- "${TURNSTONE_HOST_IP:-${POSTGRES_BIND:-127.0.0.1}}:${POSTGRES_PORT:-5432}:5432"
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
@@ -131,12 +120,10 @@ services:
# turnstone-console — cluster dashboard. Reach it ONLY through Caddy at
# https://localhost:8443 (see the caddy service below).
#
# Browsers must reach the dashboard through Caddy (https://localhost:8443): a
# plain HTTP/1.1 origin caps the browser at 6 connections, which starves the
# dashboard's per-pane SSE streams, whereas Caddy serves HTTP/2 (multiplexed)
# and proxies to console:8090 internally. The console's :8090 is published
# below ONLY so bare-metal nodes can reach the plain-HTTP ACME enrollment
# endpoint — don't point a browser at it.
# The console port (8090) is deliberately NOT published to the host: a plain
# HTTP/1.1 origin caps the browser at 6 connections, which starves the
# dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2
# (multiplexed) and proxies to console:8090 internally, so the cap is gone.
#
# The single `build:` here produces the turnstone:local image every other
# service reuses. extra_hosts lets the console reach a bare-metal server
@@ -151,14 +138,6 @@ services:
- turnstone-console
- --host=0.0.0.0
- --port=8090
# Publishes the console's plain-HTTP listener so a bare-metal node can reach
# the ACME endpoint, fetch the CA, and enroll its cert (the console serves
# HTTP here even under mTLS). Bound to 127.0.0.1 by default; setting
# TURNSTONE_HOST_IP exposes the WHOLE console HTTP API — including the
# cert-issuing ACME endpoint — on that interface, so the JWT secret's
# strength is the only gate. Browsers use Caddy :8443, never this port.
ports:
- "${TURNSTONE_HOST_IP:-127.0.0.1}:8090:8090"
environment:
TURNSTONE_JWT_SECRET: *jwt-secret
TURNSTONE_DB_BACKEND: *db-backend
@@ -240,13 +219,6 @@ services:
# -------------------------------------------------------------------
searxng:
image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest}
# Published so a bare-metal node's web_search can reach it. SearxNG has NO
# auth, so it is bound to 127.0.0.1 by default; setting TURNSTONE_HOST_IP
# exposes it on that interface — an open search proxy on your LAN, which also
# triggers the SearxNG AGPL-3.0 §13 source-offer obligation (see docs/docker.md).
# In-compose nodes always use the internal http://searxng:8080 and ignore this.
ports:
- "${TURNSTONE_HOST_IP:-127.0.0.1}:${SEARXNG_API_PORT:-8081}:8080"
volumes:
- ./turnstone/deploy/searxng:/etc/searxng:ro
- searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts)
-72
View File
@@ -1,72 +0,0 @@
# Running a bare-metal turnstone-server under systemd
These units run a `turnstone-server` **outside** Docker (e.g. on a box with a
local GPU) so it joins an existing cluster — typically the docker-compose stack
in [`compose.yaml`](../../compose.yaml). They are the hardened, production-shaped
counterpart to the quick `turnstone-server …` invocation in
[`docs/docker.md`](../../docs/docker.md) ("Join a bare-metal host").
| File | Purpose |
|------|---------|
| `turnstone-server.service` | The hardened server unit (sandboxed; secrets via `config.toml`). |
| `turnstone.slice` | Shared memory/process budget for colocated Turnstone units. |
| `turnstone-server.service.d/node.conf.example` | Per-host identity + cluster URLs drop-in (no secrets). |
## Cluster-side prerequisite
The compose stack must publish Postgres, the console's ACME endpoint, and SearxNG
on an address the bare-metal host can reach. Start it with `TURNSTONE_HOST_IP`
set to the compose host's LAN IP (default `127.0.0.1` keeps everything host-local):
```bash
TURNSTONE_HOST_IP=<compose-host-ip> docker compose up -d
```
## Install (run as root on the bare-metal host)
```bash
# 1. A dedicated, unprivileged user.
useradd --system --no-create-home --shell /usr/sbin/nologin turnstone
# 2. Install turnstone into a venv at /opt/turnstone-venv (lacme/mTLS is a core dep).
uv venv /opt/turnstone-venv --python 3.12
uv pip install --python /opt/turnstone-venv 'turnstone @ git+https://github.com/turnstonelabs/turnstone'
# …or from a local checkout: uv pip install --python /opt/turnstone-venv /path/to/turnstone
# 3. Secrets — match the cluster's JWT secret + DB credentials (kept out of env).
install -d -m 750 -o turnstone -g turnstone /etc/turnstone
cat > /etc/turnstone/config.toml <<'TOML'
[auth]
jwt_secret = "<same secret as the cluster>"
[database]
backend = "postgresql"
url = "postgresql+psycopg://turnstone:<password>@<compose-host-ip>:5432/turnstone"
[api]
base_url = "http://localhost:8000/v1" # a real model backend is configured in the console UI
api_key = "dummy"
TOML
chown turnstone:turnstone /etc/turnstone/config.toml
chmod 600 /etc/turnstone/config.toml
# 4. Units + per-host drop-in.
cp turnstone-server.service turnstone.slice /etc/systemd/system/
install -d /etc/systemd/system/turnstone-server.service.d
cp turnstone-server.service.d/node.conf.example \
/etc/systemd/system/turnstone-server.service.d/node.conf
$EDITOR /etc/systemd/system/turnstone-server.service.d/node.conf # set the addresses
# 5. Go.
systemctl daemon-reload
systemctl enable --now turnstone-server.service
journalctl -u turnstone-server -f # watch it register + (if the cluster runs mTLS) enroll
```
`tls.enabled` is **not** set here — a joining node inherits it from the cluster's
shared settings (the database). If the cluster runs mTLS, the node auto-enrolls a
cert from the console's ACME endpoint and re-advertises itself over `https://`.
> **mTLS + cross-host caveat:** a node on a *different* host than the console
> currently can't complete ACME enrollment — the console advertises an
> unroutable in-container address in its ACME directory
> ([turnstonelabs/lacme#22](https://github.com/turnstonelabs/lacme/issues/22)).
> Same-host bare-metal nodes, and any node in a non-mTLS cluster, are unaffected.
-85
View File
@@ -1,85 +0,0 @@
# Run a bare-metal turnstone-server as a systemd service so it joins a cluster
# (e.g. the docker-compose stack) from outside Docker — typically to use a local
# GPU. Install steps + the cluster-side prerequisites are in deploy/systemd/README.md
# and docs/docker.md ("Join a bare-metal host"). Per-host identity + the cluster
# URLs go in a drop-in (see node.conf.example); secrets go in config.toml.
[Unit]
Description=Turnstone server (chat workstreams + LLM gateway)
Documentation=https://github.com/turnstonelabs/turnstone
# Postgres is required. After= orders against a colocated postgresql.service
# when present and silently no-ops otherwise (the cluster DB is usually remote).
After=network.target postgresql.service
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=turnstone
Group=turnstone
# Secrets live in config.toml — JWT secret, Postgres URL+password, LLM API key —
# kept out of os.environ so a prompt-injected tool can't dump them via `env`.
Environment=TURNSTONE_CONFIG=/etc/turnstone/config.toml
Environment=TURNSTONE_LOG_LEVEL=info
Slice=turnstone.slice
# Per-host node identity + cluster wiring (TURNSTONE_NODE_ID / _ADVERTISE_URL /
# _CONSOLE_URL / _SEARXNG_URL) go in a drop-in, not here — see node.conf.example.
StateDirectory=turnstone
StateDirectoryMode=0750
LogsDirectory=turnstone
LogsDirectoryMode=0750
WorkingDirectory=/var/lib/turnstone
# --host 0.0.0.0 so the console collector + peer nodes can dial this node back
# at its advertised address. (A single-node, Caddy-fronted install can use
# 127.0.0.1 instead.) Rewrite --port if :8080 is already taken on the host.
ExecStart=/opt/turnstone-venv/bin/turnstone-server --host 0.0.0.0 --port 8080
Restart=on-failure
RestartSec=5s
TimeoutStartSec=120
TimeoutStopSec=30
KillSignal=SIGTERM
KillMode=mixed
# --- Resource limits ---
# SSE keeps an fd per active workstream + outbound LLM stream + MCP stdio pipe.
LimitNOFILE=65535
LimitNPROC=8192
TasksMax=8192
LimitCORE=0
# --- Hardening ---
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
UMask=0027
PrivateTmp=true
# PrivateDevices=true — disabled: GPU access via /sys/class/drm
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @mount
StandardOutput=journal
StandardError=journal
SyslogIdentifier=turnstone-server
[Install]
WantedBy=multi-user.target
@@ -1,25 +0,0 @@
# Per-host node identity + cluster wiring for a bare-metal turnstone-server.
# Copy to /etc/systemd/system/turnstone-server.service.d/node.conf and edit the
# addresses, then `systemctl daemon-reload`. Identity + URLs are NOT secrets, so
# they live here; the JWT secret + DB URL live in /etc/turnstone/config.toml.
#
# Addresses below use RFC 5737 documentation IPs — replace them:
# <this-host> = the bare-metal host's own LAN IP (what the console dials back)
# <compose-host> = the host running the cluster / docker-compose stack, started
# with TURNSTONE_HOST_IP=<compose-host> so :8090 and :8081 are
# published on its LAN interface (see docs/docker.md).
[Service]
# Unique node id (defaults to the hostname if unset).
Environment=TURNSTONE_NODE_ID=host-1
# The address peers + the console collector dial back. Auto-upgrades to https://
# once the node enrolls its mTLS cert.
Environment=TURNSTONE_ADVERTISE_URL=http://192.0.2.10:8080
# The cluster console's reachable plain-HTTP ACME/API endpoint. A bare-metal node
# can't resolve the in-cluster name (console:8090), so point it at the published
# port; turnstone-server honors this for cert enrollment.
Environment=TURNSTONE_CONSOLE_URL=http://192.0.2.1:8090
# The cluster's published SearxNG, for the web_search tool.
Environment=TURNSTONE_SEARXNG_URL=http://192.0.2.1:8081
-15
View File
@@ -1,15 +0,0 @@
# Shared resource budget for the colocated Turnstone units. Without a slice each
# unit's MemoryMax= is enforced independently — three units at 85% each can sum
# to 255% of host RAM before any throttles. Under a shared slice the cap is
# hierarchical: the slice ceiling is the real limit. (A bare-metal node that runs
# only turnstone-server still benefits — and keeps the unit's Slice= reference
# valid.) Adjust if the host runs other meaningful workloads alongside Turnstone.
[Unit]
Description=Turnstone services slice (server + console + channel)
Documentation=https://github.com/turnstonelabs/turnstone
Before=slices.target
[Slice]
MemoryHigh=70%
MemoryMax=85%
TasksMax=16384
+5 -49
View File
@@ -63,10 +63,7 @@ Auth is always enabled. All API endpoints except public paths require a valid to
Include a token in one of two ways:
- **Bearer header**: `Authorization: Bearer <token>`
- **Cookie**: the surface-scoped auth cookie — `turnstone_auth_server` on
turnstone-server, `turnstone_auth_console` on turnstone-console (set
automatically by the login endpoint). The names differ so the two surfaces,
when co-hosted on one origin, don't overwrite each other's session.
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
The server accepts two token types:
@@ -105,8 +102,7 @@ Authenticate with credentials and receive a JWT. Accepts two credential formats:
}
```
The response also sets a surface-scoped HttpOnly cookie containing the JWT
(`turnstone_auth_server` on turnstone-server, `turnstone_auth_console` on turnstone-console).
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
**Response (failure):** `401`
@@ -118,8 +114,7 @@ The response also sets a surface-scoped HttpOnly cookie containing the JWT
### `POST /v1/api/auth/logout`
Clears the surface-scoped auth cookie (`turnstone_auth_server` /
`turnstone_auth_console`). No request body required.
Clears the `turnstone_auth` cookie. No request body required.
**Response:** `200`
@@ -204,8 +199,7 @@ this endpoint.
}
```
The response also sets a surface-scoped HttpOnly cookie containing the JWT
(`turnstone_auth_server` on turnstone-server, `turnstone_auth_console` on turnstone-console).
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
**Response (already set up):** `409`
@@ -458,7 +452,7 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
| `context_window` | int | Total context window size in tokens |
| `pct` | float | Percentage of context window used |
| `effort` | string | Reasoning effort level (`low`/`medium`/`high`) |
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic + OpenAI) |
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
**`info`** -- an informational message (e.g. command output).
@@ -698,42 +692,6 @@ Each skill summary:
---
### `GET /v1/api/personas`
Returns the enabled personas offered by the workstream-creation pickers.
Authenticated for any logged-in user and deliberately gated by **no**
`persona.*` permission — selecting a persona at creation is a user
action, while the `persona.*` perms gate authoring. Display fields only;
the levers (base prompt, tool set, MCP/memory toggles) stay server-side.
**Response:**
```json
{
"personas": [
{"name": "engineer", "display_name": "Engineer", "description": "The stock interactive workstream: full tools, MCP, and memory.", "applies_to_kinds": ["interactive"], "is_default": true},
{"name": "researcher", "display_name": "Researcher", "description": "Answers questions with evidence — reads and cites, loads tools to verify when needed.", "applies_to_kinds": ["interactive"], "is_default": false}
],
"total": 2
}
```
Each persona summary:
| Field | Type | Description |
|--------------------|--------|------------------------------------------------------------------|
| `name` | string | Persona slug (used in the `persona` field on workstream creation) |
| `display_name` | string | Human-readable label for pickers |
| `description` | string | Short description of the persona's intent |
| `applies_to_kinds` | array | Workstream kinds the persona applies to (`interactive` / `coordinator`) |
| `is_default` | bool | Whether this is the default persona for its kind |
> **Note:** For full persona management (create, edit, archive), use the
> admin endpoints at `/v1/api/admin/personas` (requires the
> `persona.{create,read,write}` permissions).
---
### `POST /v1/api/workstreams/{ws_id}/send`
Sends a user message to a workstream. Spawns a daemon worker thread that calls
@@ -931,7 +889,6 @@ All fields are optional. The body can be empty or an empty JSON object.
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)|
| `skill` | string | "" | Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when `resume_ws` is set (resumed sessions restore their own skill). |
| `persona` | string | "" | Persona slug. Resolved and snapshotted into the workstream at creation; empty selects the kind's default. |
| `judge_model` | string | "" | Optional model alias for the judge (overrides default judge model for this workstream) |
> **Skill behavior:** When `skill` is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream.
@@ -948,7 +905,6 @@ All fields are optional. The body can be empty or an empty JSON object.
| `name` | string | Auto-generated workstream name |
| `resumed` | bool | Whether a previous session was successfully resumed |
| `message_count` | int | Number of messages in the resumed session (0 if fresh) |
| `initial_message_status` | string | Present ONLY when the workstream was created but its `initial_message` could not be delivered: `"queue_full"` (a raced live worker's interjection queue was at capacity — resend via `/send`; any uploads stay staged) or `"refused_closed"` (the workstream was closed mid-create). Absent whenever the message was dispatched. |
**Error (limit reached):**
+15 -175
View File
@@ -19,11 +19,10 @@ plugs in.
| `turnstone` | `turnstone.cli` | `TerminalUI` | Interactive terminal REPL |
| `turnstone-server` | `turnstone.server` | `WebUI` | Browser-based chat (HTTP + SSE) |
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
| `turnstone-eval` | `turnstone.eval.cli` | `NullUI` | Headless measurement (scores tool-use against expected actions) |
| `turnstone-optimizer` | `turnstone.optimizer` | `NullUI` | Prompt/tool optimization (UCB self-modify loop over the eval substrate) |
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
| `turnstone-doctor` | `turnstone.doctor` | — | LLM-backed cluster diagnostics |
| `turnstone-bootstrap` | `turnstone.bootstrap` | — | LLM-guided setup wizard |
---
@@ -268,7 +267,7 @@ the per-workstream events stream in
|-------|--------|-------|
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
| `NullUI` | `turnstone.eval.core` | Discards all output; `approve_tools` always returns `(True, None)` |
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
### WorkstreamTerminalUI
@@ -622,30 +621,20 @@ LLMProvider (protocol)
|------|--------|
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason`, `provider_blocks` |
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage`, `provider_blocks` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay`, `supports_verbosity`, `verbosity`, `supports_pro_mode`, `reasoning_mode` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay` |
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` |
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
already in OpenAI format), including multi-part content blocks (text + images)
in tool results. Model capability lookup covers GPT-5 through GPT-5.6,
in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4,
O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`.
For search models, injects `web_search_options` and removes the `web_search`
function tool (the model always searches). Citations from `url_citation`
annotations are formatted as footnotes. Pre-5.6 GPT-5 models request extended
prompt-cache retention (`prompt_cache_retention: "24h"`); GPT-5.6 uses
`prompt_cache_options.ttl: "30m"`. Cache reads and writes are extracted from
`cached_tokens` and `cache_write_tokens`. Unknown models get permissive
defaults with `supports_vision=False` and use SearxNG for web search. The
`openai-compatible` lane never consults this table at all — on either API
surface (the responses pin is served by a compat-mode
`OpenAIResponsesProvider`, mirroring `AnthropicProvider(compat=True)`): a
local server serves whatever the operator named it (vLLM
`--served-model-name` is a free string), so a prefix collision with a cloud
model id must not inherit that model's sampling/effort contract — every
local model gets the plain defaults, commercial prompt-cache controls are not
injected by model-name prefix, and anything beyond those defaults is declared
on the model definition (capabilities JSON + `server_compat`), matching the
`anthropic-compatible` lane.
annotations are formatted as footnotes. Extended prompt cache retention
(`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no
additional cost. Cached token counts are extracted from
`usage.prompt_tokens_details.cached_tokens`. Unknown models (local servers) get
permissive defaults with `supports_vision=False` and use SearxNG for web search.
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
Anthropic content blocks, maps `system`/`developer` roles to the `system`
@@ -713,7 +702,7 @@ agent_model = "claude"
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
`"openai-compatible"`, and `"anthropic-compatible"`.
and `"openai-compatible"`.
**Per-model sampling overrides:** Each model can specify `temperature`,
`max_tokens`, and `reasoning_effort` to override the global defaults from
@@ -776,153 +765,6 @@ model = "qwen-3.5-vl"
supports_vision = true
```
**Anthropic-compatible local servers (vLLM `/v1/messages`):** the
`"anthropic-compatible"` provider drives local servers that expose
Anthropic's Messages API for arbitrary checkpoints — vLLM's
`/v1/messages` endpoint, which requires a release with thinking-block
support in the Anthropic endpoint (post-2026-02-28; verified against
v0.22.1rc1). The lane reuses `AnthropicProvider` in compat mode: same
wire translation as the real Anthropic lane, but every model resolves to
the `_ANTHROPIC_COMPAT_DEFAULT` capabilities (200K context, 64K output,
`token_param=max_tokens`, `thinking_mode=none`, no native
web_search/tool_search, no vision) — the static Claude table never
applies to local checkpoints. `base_url` is required — the server root
WITHOUT `/v1` (the Anthropic SDK appends `/v1/messages`); a trailing
`/v1` pasted out of openai-compatible habit is stripped automatically,
and an empty value fails at client construction rather than falling
back to the commercial endpoint. Set a
placeholder `api_key` (e.g. `"dummy"`) for unauthenticated servers. Tool calling
needs the server started with `--enable-auto-tool-choice
--tool-call-parser <family>` plus the matching reasoning parser.
Per-model capability overrides opt in to what the checkpoint actually
supports:
```toml
[models.vllm-claude]
provider = "anthropic-compatible"
base_url = "http://localhost:8000" # no /v1 — the SDK appends /v1/messages
api_key = "dummy"
model = "deepseek-ai/DeepSeek-V4-Flash"
[models.vllm-claude.capabilities]
supports_vision = true # multimodal checkpoints only
supports_mid_conversation_system = true # template-dependent
context_window = 131072
thinking_mode = "manual" # session effort knob drives the template toggle
thinking_param = "enable_thinking" # Qwen/Gemma key; "thinking" for Granite/DeepSeek
```
Reasoning control does NOT use Anthropic's `thinking` request param —
the levers live in the chat template, reached through
`chat_template_kwargs` in the request body. Two channels, dynamic first:
* **Session effort knob (dynamic).** Set the model's thinking mode to
"Effort-knob controlled" in the admin Models form (or
`thinking_mode = "manual"` + `thinking_param` under
`[models.*.capabilities]`) and the provider maps the session's
reasoning-effort knob onto the template toggle per-request: effort
`none` sends `{<thinking_param>: false}`, any other level sends
`true` — the same contract as the real lane's manual mode. ("Always
on" / `thinking_mode = "adaptive"` instead always sends `true`: the
model self-regulates, so the knob never force-disables — mirroring
the native adaptive branch.) The graded effort value always rides
alongside the toggle: under `effort_param` when the operator names
the template's key, else under the conventional fallback key
(`reasoning_effort`) on the anthropic-compatible lane — the user's
effort setting always reaches the wire, and a template that doesn't
reference the kwarg ignores it. On the openai-compatible lane the
undeclared-key case rides the flat top-level `reasoning_effort`
param instead (the documented compat field), forwarded verbatim.
Optional `reasoning_effort_values` / `default_reasoning_effort`
validate the knob before it reaches the server; without declared
values the knob is forwarded as-is. The knob is ordinal, and validation
respects that: an off-list knob value rounds UP onto the declared
list and a value above the ceiling rides the ceiling
(`snap_reasoning_effort`) — asking for more effort than the model
declares never falls back to a lower default tier. The knob's
`none` position is forwarded verbatim when the model declares an
explicit `none` level (gpt-5.1+, grok-4.3) — omitting it there would
leave a reasoning-on server default (e.g. gpt-5.5's `medium`) in
charge of a knob that promises off — and omitted otherwise; `none`
is never a snap target for other positions.
`default_reasoning_effort` only catches values the ordinal snap
cannot rank (custom strings). Declare values that match the
template's documented vocabulary: for DeepSeek-V4, which officially
accepts `high`/`max` (Think High is the default thinking tier;
`low`/`medium` alias to `high`, `xhigh` to `max`), a
`("high", "max")` values list reproduces the official aliasing
exactly — `low`/`medium` round up to `high`, `xhigh` to `max`
and freeform passthrough matches it too. To map an undocumented
template, probe with per-request `chat_template_kwargs` and compare
`input_tokens`. Setting `effort_param` also suppresses the
flat top-level `reasoning_effort` request param on the
openai-compatible lane — the template channel replaces it, never
doubles it. With the default `thinking_mode = "none"` nothing is
injected and the server's template default decides.
Upgrade note: before 1.7.0a7 the openai-compatible lane sent the
toggle unconditionally `true` whenever thinking mode was enabled. A
stored per-model `reasoning_effort = "none"` now disables thinking
on such models — pick any real level (or clear the override) to keep
it on. Also since 1.7.0a7 the effort level itself always reaches the
wire on the local lanes (previously dropped unless
`reasoning_effort_values` was declared): flat `reasoning_effort` on
openai-compatible, the `effort_param`-or-fallback template key on
anthropic-compatible when reasoning control is engaged.
* **Operator pin (static).** Entries under `{"chat_template_kwargs":
...}` in the admin Models extra-body field ride the SDK's
`extra_body` unconditionally and win over the knob mapping on key
collision — e.g. pin `{"enable_thinking": true}` to keep thinking on
regardless of the session knob. (Server type and API surface remain
openai-compatible-only knobs and stay hidden for this provider.)
The same knob mapping drives the `openai-compatible` lane's Chat
Completions requests — `merge_reasoning_template_kwargs` is shared by
both local-server lanes, so `thinking_mode`/`thinking_param`/
`effort_param` mean the same thing whichever endpoint serves the model.
Only the Responses API surface (native reasoning) ignores it.
The console surfaces this projection as an *effective effort ladder*:
the admin model form's per-model effort select and the skill
launch-config effort select annotate each position with what the
request will carry, in plain words — a position whose delivered level
matches its name stays plain ("Max"), a snapped position says so
("Low — sends high"), the adaptive lanes' none position warns
"thinking stays on", and budget detail lives in the tooltip. A
position is never labeled after a sibling that shares its wire (that
rendered "Max (= minimal)", implying a downgrade the wire doesn't
contain). Computed server-side by `providers/effort_ladder.py` from
the same mapping functions the providers use at request time and
shipped on `/v1/api/models` rows (every row carries `effort_ladder`,
empty when the capabilities column fails to parse) and
`POST /v1/api/admin/models/effort-ladder`. The ladder describes what
Turnstone sends — a server-side template may alias further (DeepSeek-V4
folds `low`/`medium` into its default `high` tier).
The `anthropic-compatible` lane never sends Anthropic's native
`thinking`/`output_config` params — they are not in vLLM's request
schema. The real `anthropic` provider is unaffected: official Claude
models keep native thinking, budget mapping, and `output_config`
effort. A gateway fronting *real* Claude on a Messages-shaped URL
(e.g. a LiteLLM `anthropic/` route to the Claude API) should use
`provider = "anthropic"` with a custom `base_url`, which keeps the
native thinking params.
Verified quirks of vLLM's Anthropic endpoint:
* The `thinking` request param is silently dropped — use
`chat_template_kwargs` (above) to control reasoning.
* `stop_sequences` cut the raw stream wherever the text appears —
including inside thinking — and report `end_turn` with
`stop_sequence=None`. Turnstone does not send stop sequences from
this provider.
* No cache telemetry: `usage` carries input/output token counts only
(no `cache_creation_input_tokens` / `cache_read_input_tokens`).
* Images require a multimodal checkpoint — text-only models return a
500 on image blocks, so `supports_vision` stays opt-in per model.
* Mid-conversation `role: "system"` turns are template-dependent —
opt in per model via `supports_mid_conversation_system`.
**Database model definitions:** On server entry points, models can also be
defined in the `model_definitions` table (admin Models tab). DB models support
the same per-model sampling overrides. Config.toml models override DB models
@@ -1118,10 +960,9 @@ reconstructs the OpenAI message format from database rows:
in the same workstream
**Config persistence:** LLM-affecting parameters (`temperature`,
`reasoning_effort`, `max_tokens`, `instructions`, and the persona
snapshot — see `docs/personas.md`) are persisted to the
`workstream_config` table on creation and whenever changed via slash
commands. `resume()` restores these values so resumed workstreams
`reasoning_effort`, `max_tokens`, `instructions`, `creative_mode`) are
persisted to the `workstream_config` table on creation and whenever changed
via slash commands. `resume()` restores these values so resumed workstreams
behave identically to the original.
**`/clear` vs `/new`:** `/clear` wipes in-memory context but preserves
@@ -1293,8 +1134,7 @@ Three hierarchical scopes control endpoint access:
`/metrics`, `/openapi.json`, `/docs`, `/api/auth/*`, and `/api/auth/setup`
are always allowed.
2. **Token extraction**`Authorization: Bearer <token>` header first, then
surface-scoped auth cookie (`turnstone_auth_server` on the node server,
`turnstone_auth_console` on the console) as fallback.
`turnstone_auth` cookie as fallback.
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
indicates API token.
4. **Validation** — JWT signature check or API token hash lookup in storage.
+25 -12
View File
@@ -12,6 +12,7 @@ Existing bulk endpoints at time of writing:
|---------------------------------------------------------|--------------------------|------------------------------------------|
| `GET /v1/api/cluster/ws/live?ids=a,b,c` | bulk read | `{results, denied, truncated}` |
| model tool `spawn_batch` | bulk create (per-item) | `{results, denied}` |
| `POST /v1/api/workstreams/{ws_id}/stop_cascade` | cascade mutation | `{cancelled, failed, skipped}` |
| `POST /v1/api/workstreams/{ws_id}/close_all_children` | cascade mutation | `{closed, failed, skipped}` |
---
@@ -145,7 +146,7 @@ consistently-typed across the read and create cases.
```
Where `<bucket>` is the endpoint-specific name for "succeeded" —
`closed` for `close_all_children`.
`cancelled` for `stop_cascade`, `closed` for `close_all_children`.
The three buckets partition the input set exactly once:
| Bucket | Meaning |
@@ -160,6 +161,20 @@ be partial. `skipped` is pre-resolved — the target is already in
the terminal state the cascade was aiming at, so it's neither a
win to report nor a fault to fix.
### Example — `stop_cascade`
```json
{
"status": "ok",
"cancelled": ["child-1", "child-3"],
"failed": [],
"skipped": ["child-2"]
}
```
A subsequent retry would target only `failed` ids, not `skipped`
ones — the latter are already done.
### Example — `close_all_children`
```json
@@ -171,12 +186,10 @@ win to report nor a fault to fix.
}
```
Here the success bucket is `closed`. A subsequent retry would
target only `failed` ids, not `skipped` ones — the latter are
already done. When `coord_client` is unavailable (session loaded
but no HTTP client attached — a construction bug) every id goes to
`failed` so the operator notices rather than getting a silent
all-skipped response.
Same partition, different success-bucket name. When `coord_client`
is unavailable (session loaded but no HTTP client attached — a
construction bug) every id goes to `failed` so the operator notices
rather than getting a silent all-skipped response.
---
@@ -219,12 +232,12 @@ all-skipped response.
- **Phase 6** shipped `cluster/ws/live` as the first Shape A endpoint
(`{results, denied, truncated}`).
- **Phase 7** introduced the Shape B cascade-mutation envelope
(`{<bucket>, failed, skipped}`) for the coordinator's
cancel-cascade path.
- **Phase 7** shipped `stop_cascade` as the first Shape B endpoint
(`{cancelled, failed, skipped}`).
- **Phase 8 PR A** shipped `spawn_batch` (Shape A, keyed by idx) and
`close_all_children` (Shape B), which crystallised the
two-shape-per-semantic-category policy codified here.
`close_all_children` (Shape B, twin of `stop_cascade`), which
crystallised the two-shape-per-semantic-category policy codified
here.
Before adding a third shape, read this doc and argue for why the
new surface doesn't fit either A or B. Two idioms in the cluster
+3 -4
View File
@@ -379,7 +379,6 @@ Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated
Triggered by the "+ new" header button. A modal dialog with:
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" picks a node with available capacity using round-robin, or a specific node from the list (showing capacity).
- **Persona** — optional dropdown listing the enabled personas for the workstream kind. Sets the system-message composition and capability envelope at creation, snapshotted server-side; empty uses the kind's default. Picking one requires no `persona.*` permission.
- **Profile** — optional dropdown listing enabled skills. Applies the skill's model, auto-approve policy, token budget, and other behavioral settings at creation time.
- **Name** — optional text input. Auto-generated if left empty.
- **Model** — optional text input for a model alias from the target node's registry.
@@ -397,9 +396,9 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna
Accessed via the "admin" button in the header (visible when authenticated
with `approve` scope). Provides user, API token, channel link, MCP server,
and skill management with tabs that include Users, API Tokens, Channels,
Schedules, Watches, Personas, Roles, Policies, Prompts, Judge, Skills,
MCP Servers, Usage, Audit, Memories, Models, Nodes, Settings, and TLS. See also
and skill management with 18 tabs (Users, API Tokens, Channels, Schedules,
Watches, Roles, Policies, Prompts, Judge, Skills, MCP Servers, Usage,
Audit, Memories, Models, Nodes, Settings, TLS). See also
[Governance](governance.md) for the Roles, Policies, Skills, Usage, and
Audit tabs, and [Settings](settings.md) for the database-backed
configuration editor.
+43 -24
View File
@@ -18,7 +18,7 @@ schema changes.
> auth and the `admin.coordinator` permission. A session-scoped JWT
> is minted per login (see [docs/oidc.md](oidc.md) / [docs/security.md](security.md));
> a service token may call the read paths but destructive governance
> paths (`/restrict`, `/close_all_children`) require
> paths (`/restrict`, `/stop_cascade`, `/close_all_children`) require
> the explicit `admin.coordinator` grant — a service-token owner
> match isn't enough.
@@ -44,6 +44,7 @@ schema changes.
| 6 | Wait for fan-out | model-side tool `wait_for_workstream` |
| 7 | Govern | `POST /v1/api/workstreams/{ws_id}/trust` |
| | | `POST /v1/api/workstreams/{ws_id}/restrict` |
| | | `POST /v1/api/workstreams/{ws_id}/stop_cascade` |
| | | `POST /v1/api/workstreams/{ws_id}/close_all_children` |
| 8 | Approve / cancel | `POST /v1/api/workstreams/{ws_id}/approve` |
| | | `POST /v1/api/workstreams/{ws_id}/cancel` |
@@ -52,7 +53,7 @@ schema changes.
Refer to `/openapi.json` (Swagger UI at `/docs`) on any
`turnstone-console` process for the authoritative operation ids and
schemas. Coordinator-only verbs (`/children`, `/trust`, `/restrict`,
`/close_all_children`) 404 against `kind=interactive`
`/stop_cascade`, `/close_all_children`) 404 against `kind=interactive`
rows; the shared verbs (`/send`, `/approve`, `/cancel`, `/events`,
`/history`, `/open`, `/close`, etc.) work on both kinds.
@@ -260,10 +261,10 @@ rounds to a 10× token-efficiency win.
---
## 7. Governance — trust, restrict, stop_cascade, close_all_children
These four endpoints let an operator steer a live coordinator session
mid-flight. All three emit an audit event tagged
`coordinator.<action>` via the dedicated audit executor so a cascade
mid-flight. All four emit an audit event tagged
`coordinator.<action>` via the dedicated audit executor so a cascade
burst can't starve audit writes.
### `POST /trust` — auto-approve own-subtree sends
@@ -293,6 +294,28 @@ idempotent — calling twice with overlapping lists converges to the
opt in per session. Cap 256 tool names per request, 128 chars each.
### `POST /stop_cascade` — cancel the subtree
```http
POST /v1/api/workstreams/{ws_id}/stop_cascade
{}
```
Cancels the coordinator's in-flight generation AND dispatches
`cancel_workstream` through the routing proxy for every direct
child in the in-memory registry. Returns:
```json
{"status": "ok", "cancelled": ["child-1", "child-3"], "failed": [], "skipped": ["child-2"]}
```
Response uses the [cascade-mutation bulk shape](bulk-endpoints.md):
`cancelled` = accepted, `failed` = dispatch error worth retrying,
`skipped` = upstream 404 (already gone — stale registry entry or
the row was deleted between snapshot and dispatch). Grandchildren
aren't touched directly; they sit behind their parent's cancel and
propagate via the child's SSE stream.
### `POST /close_all_children` — soft-close the direct fan-out
```http
POST /v1/api/workstreams/{ws_id}/close_all_children
@@ -306,16 +329,16 @@ Response:
```
Soft-close cascade bounded by the same semaphore as `stop_cascade`.
(up to 512 chars) propagates into each closed child's audit +
`workstream_config` for postmortem. The model-facing tool that
pairs with this endpoint asks for a bounded teardown of the
coordinator's own fan-out. This *soft-closes*; to *cancel* the
fan-out instead, cancel the coordinator (§8) — a coordinator cancel
auto-cascades to its direct children.
The `reason` (up to 512 chars) propagates into each closed child's
audit + `workstream_config` for postmortem. Unlike `stop_cascade`
this does NOT recurse into grandchildren — the model-facing tool
that pairs with this endpoint asks for a bounded teardown of the
coordinator's own fan-out. For a full-subtree teardown, use
`stop_cascade`.
See [bulk-endpoints.md](bulk-endpoints.md) for why both endpoints
uses the cascade-mutation shape and how it differs from the
`spawn_batch` / `cluster/ws/live` shape.
share the cascade-mutation shape and how it differs from the
`spawn_batch` / `cluster/ws/live` shape.
---
@@ -333,10 +356,7 @@ POST /v1/api/workstreams/{ws_id}/approve
```
`cancel` drops the in-flight generation but leaves the coordinator
coordinator, auto-cascades the cancel to its direct children:
`cancel_workstream` is dispatched through the routing proxy for
every direct child in the registry. The coordinator itself is left
idle and open for a fresh `send`:
idle and open for a fresh `send`:
```http
POST /v1/api/workstreams/{ws_id}/cancel
@@ -353,10 +373,9 @@ POST /v1/api/workstreams/{ws_id}/close
```
Soft-closes the session — state persists, children keep running (use
(wind them down first with `close_all_children`, or by cancelling
the coordinator, which cascades the cancel to its direct children),
the worker thread exits, SSE streams send a final `stream_end` and
disconnect. The row is reopenable via
`close_all_children` or `stop_cascade` first to wind them down), the
worker thread exits, SSE streams send a final `stream_end` and
disconnect. The row is reopenable via
`POST /v1/api/workstreams/{ws_id}/open` so long as it hasn't been
deleted.
@@ -366,12 +385,12 @@ deleted.
- [coordinator-skills.md](coordinator-skills.md) — writing a skill
that runs on a coordinator session (orchestrator persona,
workflow patterns, `SkillKind` classifier).
workflow patterns, `SkillKind` classifier).
- [bulk-endpoints.md](bulk-endpoints.md) — the two bulk-shape
idioms (`{results, denied, truncated}` vs
`{<bucket>, failed, skipped}`) used by `cluster/ws/live`,
`spawn_batch`, `stop_cascade`, and `close_all_children`.
- [architecture.md](architecture.md) — cluster-wide architecture
- [architecture.md](architecture.md) — cluster-wide architecture
including how coordinator sessions fit next to node-hosted
interactive workstreams.
- The live OpenAPI spec (`/openapi.json` on any console process)
+13 -13
View File
@@ -1,13 +1,13 @@
# Writing a coordinator-specific skill
A skill is prompt-level framing that steers a Turnstone session
Skills are prompt-level personas that steer a Turnstone session
toward a narrow task. Most skills target **interactive** sessions —
the single-workstream "do this thing" surface where the model wields
`bash`, `edit_file`, `web_fetch`, and the rest of the maker toolset.
A **coordinator skill** is different. It runs on a session whose job
is to orchestrate other sessions. The toolset is smaller and
narrower, the role is an orchestrator instead of a maker, and the
narrower, the persona is an orchestrator instead of a maker, and the
success metric is "did the plan resolve" instead of "did the code
compile". This doc covers the differences a skill author has to
care about.
@@ -22,8 +22,8 @@ migration 044 added the column). Three values:
| `SkillKind` enum | Stored as | Meaning |
|-------------------------|-----------------|----------------------------------------------------------------------------|
| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker role (single-workstream "do this"). |
| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator role (delegate, monitor, synthesise). |
| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker persona (single-workstream "do this"). |
| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator persona (delegate, monitor, synthesise). |
| `SkillKind.ANY` | `"any"` | Either surface (or audience-neutral). Default on create. |
The `kind` field is a `StrEnum` — drop-in `str` compatible — so DB
@@ -77,7 +77,7 @@ or MCP config can do adds to it. Current members:
| `delete_workstream` | wind-down | Hard-delete one child. Requires approval. |
| `list_nodes` | discover | Enumerate live cluster nodes + capabilities. |
| `skills` (action=find) | discover | Browse the skill catalog; opt-in `kind` filter narrows by audience. |
| `memory` | persist | Durable orchestration memory (`coordinator` scope, per-user — survives across coordinator sessions). |
| `memory` | persist | Orchestration scratchpad keyed by the `coordinator` scope. |
| `notify` | broadcast | Post a status update to a human channel at a narrative beat. |
| `tasks` | plan | Orchestrator-only scratchpad. Children don't see it. |
@@ -96,20 +96,20 @@ for the output. The coordinator stays the orchestrator.
---
## Framing differences
## Persona differences
Interactive skills compose on top of `base_interactive.md` — a
"maker" framing: get the work done, use the tools, edit the code,
"maker" persona: get the work done, use the tools, edit the code,
close the loop.
Coordinator skills compose on top of
[`personas/orchestrator.md`](../turnstone/prompts/personas/orchestrator.md) —
an "orchestrator" framing: decompose, delegate, monitor, synthesise.
[`base_coordinator.md`](../turnstone/prompts/base_coordinator.md) —
an "orchestrator" persona: decompose, delegate, monitor, synthesise.
The base text is short but sets the tone every coordinator skill
inherits:
> You are a coordinator. Your role is to orchestrate work across
> the cluster... You do
> You are a coordinator on a small, focused infrastructure team.
> Your role is to orchestrate work across the cluster... You do
> not edit files, run shell commands, browse the web, or manipulate
> the codebase directly. Children do that.
@@ -339,7 +339,7 @@ For a new coordinator skill:
A full end-to-end test isn't required for every skill; a
prepare-step unit test that asserts "given this initial message, the
first tool call is X with Y args" is usually sufficient to catch
framing drift without a real LLM in the loop.
persona drift without a real LLM in the loop.
---
@@ -351,7 +351,7 @@ framing drift without a real LLM in the loop.
`spawn_batch` and `close_all_children` use, so your skill can
parse results / denied arrays correctly.
- [governance.md](governance.md) — the broader governance surface
(`/trust`, `/restrict`, role-based permissions)
(`/trust`, `/restrict`, `/stop_cascade`, role-based permissions)
that wraps every coord session.
- [settings.md](settings.md) — `coordinator.model_alias` and
`coordinator.reasoning_effort` settings that gate which LLM runs
+16 -34
View File
@@ -59,11 +59,9 @@ is gone. Everything goes through `https://localhost:8443`.
## Join a bare-metal host
PostgreSQL, the console's ACME endpoint (`:8090`), and SearxNG (`:8081`) are
published on `127.0.0.1`, so a `turnstone-server` running directly on the same
machine — for example to use a local GPU — can join the same cluster (enrolling
its mTLS cert and running `web_search`) and show up in the console alongside the
containerized nodes.
PostgreSQL is published on `127.0.0.1:5432`, so a `turnstone-server` running
directly on the same machine — for example to use a local GPU — can join the
same cluster and show up in the console alongside the containerized nodes.
Put the secret and connection settings in `~/.config/turnstone/config.toml`
(secrets belong in this file, not the process environment — keep it `0600`,
@@ -87,31 +85,18 @@ command line:
```bash
chmod 600 ~/.config/turnstone/config.toml
TURNSTONE_NODE_ID=host-1 \
TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
TURNSTONE_CONSOLE_URL=http://localhost:8090 \
TURNSTONE_SEARXNG_URL=http://localhost:8081 \
TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
turnstone-server --host 0.0.0.0 --port 8080
```
The host server registers itself in PostgreSQL; the console reaches it back via
`host.docker.internal`. `TURNSTONE_CONSOLE_URL` points the node at the console's
published ACME endpoint so it can enroll its mTLS certificate (needed only when
the cluster runs mTLS; harmless otherwise), and `TURNSTONE_SEARXNG_URL` points
`web_search` at the published SearxNG. The `jwt_secret` and DB credentials above
are the dev-stack defaults — match whatever you set in `.env` if you changed them.
To let a server on a **different** machine join, start the stack with
`TURNSTONE_HOST_IP=<this host's LAN IP>` — that binds PostgreSQL, the console
ACME endpoint, and SearxNG to that interface. Then on the remote box set the
three URLs above to that IP, and set `TURNSTONE_ADVERTISE_URL` to the **remote**
box's own IP (the address the console dials back). **Set a strong
`POSTGRES_PASSWORD` first** — `TURNSTONE_HOST_IP` exposes the database (and every
user account + API-token hash in it), the console API, and the unauthenticated
SearxNG to your network.
To run the bare-metal node as a hardened, persistent service instead of by hand,
use the systemd units in [`deploy/systemd/`](../deploy/systemd/).
`host.docker.internal`. The `jwt_secret` and DB credentials above are the
dev-stack defaults — match whatever you set in `.env` if you changed them. To
let a **different** machine join, start the stack with `POSTGRES_BIND=0.0.0.0`
and use the host's routable IP in the `url` and `TURNSTONE_ADVERTISE_URL`
but **set a strong `POSTGRES_PASSWORD` first**, or you'll expose a database with
the insecure default password (and every user account + API-token hash in it) to
your network.
## Production stack
@@ -125,7 +110,7 @@ docker compose -f turnstone/deploy/compose.yaml up
It's the same shape as the dev stack — Caddy-fronted console, channel, and a
PostgreSQL all share one database so the console discovers the node — but it
pulls released images, runs a single server node, and has **no baked-in
secrets**. Set these in `.env` first (generate with `openssl rand -hex 32`):
secrets**. Set these in `.env` first (`turnstone-bootstrap` generates them):
```bash
TURNSTONE_JWT_SECRET=<python -c "import secrets; print(secrets.token_hex(32))">
@@ -189,18 +174,15 @@ overrides.
### Ports
Both stacks publish Caddy (dashboard) and PostgreSQL; the dev stack additionally
publishes the console's ACME endpoint and SearxNG on localhost so a bare-metal
node can enroll its cert and run `web_search`. Everything else is reached through
Caddy or proxied by the console:
publishes the SearxNG UI on localhost. Everything else is reached through Caddy or
proxied by the console:
| Variable | Default | Description |
|----------|---------|-------------|
| `CONSOLE_HTTPS_PORT` | `8443` | Host port for Caddy (dashboard HTTPS) |
| `SEARXNG_HTTPS_PORT` | `8444` | Host port for the SearxNG UI via Caddy (dev: localhost-only; prod: opt-in) |
| `POSTGRES_PORT` | `5432` | Host port for PostgreSQL (for bare-metal joins) |
| `SEARXNG_API_PORT` | `8081` | Host port for the SearxNG API a bare-metal node's `web_search` dials (dev stack) |
| `TURNSTONE_HOST_IP` | `127.0.0.1` | Interface PostgreSQL, the console ACME endpoint, and SearxNG bind on (dev stack). Set to this host's LAN IP so a bare-metal node on **another machine** can reach them — set a strong `POSTGRES_PASSWORD` first (it also exposes the DB and the unauthenticated SearxNG to your network). |
| `POSTGRES_BIND` | `127.0.0.1` | Production stack (`turnstone/deploy/compose.yaml`) only: interface PostgreSQL binds on; set to the host's LAN IP for remote joins. |
| `POSTGRES_BIND` | `127.0.0.1` | Interface PostgreSQL binds on; set `0.0.0.0` for LAN access |
### Channel gateway
@@ -260,7 +242,7 @@ interface, or anyone who can reach it can search through your instance.
Both stacks install all entry points into a single image (`turnstone`,
`turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`,
`turnstone-eval`, `turnstone-optimizer`, `turnstone-doctor`):
`turnstone-eval`, `turnstone-bootstrap`):
```bash
docker compose build # build the dev image
+24 -55
View File
@@ -1,19 +1,11 @@
# Evaluation and Prompt Optimization (turnstone-eval, turnstone-optimizer)
# Evaluation and Prompt Optimization (turnstone-eval)
Evaluation for turnstone is split into two commands:
`turnstone-eval` is the evaluation and prompt optimization system for turnstone. It
runs test cases against the LLM, scores tool call sequences against expected
actions, and optionally uses a multi-agent pipeline to optimize the developer
prompt and tool descriptions.
- **`turnstone-eval`** — the measurement substrate. Runs test cases against the LLM
and scores tool call sequences against expected actions. A single measurement pass,
no self-modification.
- **`turnstone-optimizer`** — the prompt/tool optimizer. Loops over the measurement
substrate, using a multi-agent pipeline (analyst, optimizer, observer, diversifier,
tool optimizer) to edit the developer prompt and tool descriptions so more tests pass.
The dependency is strictly one-way: the optimizer consumes the eval substrate; the
substrate never depends on the optimizer.
Source: `turnstone/eval/core.py` (measurement substrate), `turnstone/eval/cli.py`
(the `turnstone-eval` CLI), `turnstone/optimizer.py` (the `turnstone-optimizer` CLI).
Source: `turnstone/eval.py`
---
@@ -35,8 +27,8 @@ This approach (inspired by [Learning to Self-Evolve](https://arxiv.org/abs/2603.
prevents irrecoverable collapse from bad edits — UCB naturally backtracks to
high-scoring ancestors instead of following a linear chain.
The `turnstone-eval` command (or `turnstone-optimizer --no-optimize`) executes only
steps 2-4: a single measurement pass over the root prompt, no optimization.
When optimization is disabled (`--no-optimize`), only steps 2-4 execute
(a single iteration evaluating the root node).
---
@@ -460,46 +452,30 @@ structure is:
## CLI Usage
Two console scripts (installed as entry points), or the equivalent `python -m`
invocations:
- `turnstone-eval` / `python -m turnstone.eval.cli` — measure only.
- `turnstone-optimizer` / `python -m turnstone.optimizer` — optimize.
### Measure (`turnstone-eval`)
The entry point is `turnstone-eval` (installed as a console script) or
`python -m turnstone.eval`.
```
turnstone-eval tests.json # one measurement pass, print scores
turnstone-eval tests.json --prompt custom.txt # measure a custom prompt
turnstone-eval tests.json --n-runs 5 # more runs per case
turnstone-eval tests.json --parallel 4 # run cases across 4 workers
turnstone-eval tests.json -v # verbose per-turn logging
turnstone-eval tests.json # evaluate + optimize
turnstone-eval tests.json --no-optimize # evaluate only (single iteration)
turnstone-eval tests.json --n-runs 5 --max-iter 10 # more thorough evaluation
turnstone-eval tests.json --prompt custom.txt # start from a custom prompt
turnstone-eval tests.json --optimize-tools # optimize tool descriptions only
turnstone-eval tests.json --diversify 10 # test with prompt variants
turnstone-eval tests.json -v # verbose per-turn logging
```
### Optimize (`turnstone-optimizer`)
### Multi-model setup (local test model, cloud optimizer)
```
turnstone-optimizer tests.json # evaluate + optimize
turnstone-optimizer tests.json --no-optimize # single pass, no optimization
turnstone-optimizer tests.json --n-runs 5 --max-iter 10 # more thorough optimization
turnstone-optimizer tests.json --prompt custom.txt # start from a custom prompt
turnstone-optimizer tests.json --optimize-tools # optimize tool descriptions only
turnstone-optimizer tests.json --diversify 10 # test with prompt variants
```
#### Multi-model setup (local test model, cloud optimizer)
```
turnstone-optimizer tests.json \
turnstone-eval tests.json \
--base-url http://localhost:8000/v1 \
--optimizer-base-url https://api.anthropic.com \
--optimizer-model claude-sonnet-4-6 \
--analyst-model claude-opus-4-6
```
### Measurement Options
Accepted by **both** commands.
### All Options
| Flag | Default | Description |
|-------------------------|----------------------------|-------------|
@@ -508,26 +484,19 @@ Accepted by **both** commands.
| `--model` | auto-detect | Model name. Auto-detected from the API if not specified. |
| `--prompt` | turnstone built-in prompt | Path to initial prompt text file. |
| `--n-runs` | from tests.json or 3 | Number of runs per test case. |
| `--max-iter` | 5 | Maximum optimization iterations. |
| `--no-optimize` | false | Run evaluation only (sets max-iter to 1). |
| `--temperature` | 0.7 | Sampling temperature. |
| `--max-tokens` | 32768 | Max completion tokens. |
| `--reasoning-effort` | `medium` | Reasoning effort: `low`, `medium`, or `high`. |
| `--context-window` | 131072 | Context window size. |
| `--output` | `eval_results.json` | Output results file path. |
| `-v`, `--verbose` | false | Show detailed per-turn logging. |
| `--explore-constant` | 1.414 (sqrt(2)) | UCB exploration constant C. |
| `--test-timeout` | 300 | Per-test timeout in seconds. |
| `--suite-timeout` | 0 (unlimited) | Total suite timeout in seconds. |
| `--no-fast-fail` | false | Disable early termination on all-zero initial runs. |
| `--parallel` | 1 (serial) | Parallel workers (0=auto, N=use N workers). |
### Optimizer Options
Accepted by **`turnstone-optimizer`** only.
| Flag | Default | Description |
|-------------------------|----------------------------|-------------|
| `--max-iter` | 5 | Maximum optimization iterations. |
| `--no-optimize` | false | Run a single measurement pass (sets max-iter to 1). |
| `--explore-constant` | 1.414 (sqrt(2)) | UCB exploration constant C. |
| `--suite-timeout` | 0 (unlimited) | Total suite timeout in seconds. |
| `--optimizer-model` | same as `--model` | Model for prompt optimization. |
| `--optimizer-base-url` | same as `--base-url` | Base URL for optimizer model. |
| `--observer-model` | same as optimizer | Model for meta-optimization (observer). |
+6 -14
View File
@@ -13,7 +13,7 @@ The permission model has two layers:
1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware`
on every request based on URL path classification.
2. **Permissions** (granular) — named permission strings checked per-endpoint by
2. **Permissions** (granular) — 15 permission strings checked per-endpoint by
`require_permission()`.
**Built-in roles** (seeded by migration 008):
@@ -24,11 +24,7 @@ The permission model has two layers:
| operator | read, write, workstreams.create, workstreams.close |
| viewer | read |
Custom roles can be created with any subset of the valid permissions.
The `persona.create` / `persona.read` / `persona.write` family gates
persona administration; migration `063` seeds all three onto
`builtin-admin`, and any role can be granted them through the standard
role and permission-override editors.
Custom roles can be created with any subset of the 15 valid permissions.
**Auth flow:**
1. User logs in (password or API token) → `_load_user_permissions()` aggregates
@@ -131,12 +127,9 @@ Per-LLM-request token and tool call metrics:
LLM response with prompt/completion tokens, cache tokens, tool call count,
model, ws_id
- **Prompt caching**: Anthropic automatic caching (`cache_control: ephemeral`)
and OpenAI caching are enabled by default. Pre-5.6 GPT-5 models request
`prompt_cache_retention: 24h`; GPT-5.6 uses
`prompt_cache_options: {"ttl": "30m"}`. GPT-5.6 cache writes use the
provider's 1.25× input-token rate. `cache_creation_tokens` and
`cache_read_tokens` are tracked per request in `usage_events` and surfaced
in the Usage admin tab
and OpenAI extended retention (`prompt_cache_retention: 24h` for GPT-5.x)
are enabled by default. `cache_creation_tokens` and `cache_read_tokens` are
tracked per request in `usage_events` and surfaced in the Usage admin tab
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
and time range filtering — includes cache token aggregates
- **Prometheus**: `turnstone_tokens_total{type="cache_creation|cache_read"}`
@@ -184,7 +177,6 @@ All under `/v1/api/admin/` (requires `approve` scope + granular permission).
| Orgs | 3 (list, get, update) | `admin.orgs` |
| Tool Policies | 4 (CRUD) | `admin.policies` |
| Skills | 4 (CRUD) | `admin.skills` |
| Personas | 4 (list, create, get, edit/archive) | `persona.read` / `persona.create` / `persona.write` |
| Schedules | 6 (CRUD + runs) | `admin.schedules` |
| Watches | 3 (list, create, cancel) | `admin.watches` |
| Usage | 1 (aggregated query) | `admin.usage` |
@@ -230,7 +222,7 @@ Both Python and TypeScript console SDKs expose governance methods:
- **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment
and requires caller to hold a superset of the target role's permissions
- **Permission validation**: Role create/update validates permissions against
the permission allowlist (`_VALID_PERMISSIONS`)
a 15-item allowlist (`_VALID_PERMISSIONS`)
- **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete
your own account (matching the self-assignment guard on role endpoints)
- **Field allowlists**: Storage `update_*` methods filter fields against
+7 -14
View File
@@ -40,7 +40,7 @@ api_key = ""
smart_approvals = false # auto-approve high-confidence "approve" LLM verdicts (opt-in)
confidence_threshold = 0.95 # Smart Approvals auto-approve bar (LLM recommendation=approve)
max_context_ratio = 0.5 # max % of judge context window for history
timeout = 120.0 # seconds (generous for local models)
timeout = 60.0 # seconds (generous for local models)
read_only_tools = true # judge can use read_file/list_directory
cancel_on_approval = false # stop judging remaining tool calls once user decides
```
@@ -71,7 +71,7 @@ All fields are optional. The judge is enabled by default; use `enabled = false`
--judge / --no-judge Enable/disable (default: enabled)
--judge-model MODEL Model for judge
--judge-provider PROVIDER Provider for judge
--judge-timeout SECONDS LLM judge timeout (default: 120)
--judge-timeout SECONDS LLM judge timeout (default: 60)
--judge-confidence FLOAT Confidence threshold, 0-1 (default: 0.95)
```
@@ -193,10 +193,9 @@ Security hardening blocks access to sensitive paths:
### Timeout
The `timeout` setting (default 120 seconds) applies **per turn**, not as a total
budget across turns — each of the up to 5 turns gets a fresh budget, so a slow
earlier turn doesn't starve later ones. If a turn's budget expires, the judge
attempts to parse whatever partial response is available.
The `timeout` setting (default 60 seconds) is a total budget across all judge
turns. Time is decremented after each LLM call. If the budget expires mid-turn,
the judge attempts to parse whatever partial response is available.
---
@@ -249,14 +248,8 @@ are withheld from the live surfaces (a reused call_id must never ride a stale
`approve` into Smart Approvals) but still persist with
`user_decision = "superseded"` so the audit trail records the judge's answer.
Sub-agent (task agent) tool calls are judge-gated too. Each runs the same
intent pipeline as its own `agent_gate` generation, grounded in that sub-agent's
own trajectory -- its task prompt is the delegation contract the operator
approved, so "does this call serve the task" is the right local question.
Agent-gate generations never occupy the main loop's supersede slot (parallel
siblings would otherwise make each other's verdicts look stale); per-cycle
generation checks enforce staleness instead, and `judge.cancel_on_approval`
fires per gate exactly like the main loop.
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
always get full tool visibility without judge evaluation.
---
+5 -35
View File
@@ -26,40 +26,15 @@ Each memory has three dimensions:
### Memory scopes
| Scope | Visibility |
|---------------|-----------------------------------------------------------------|
| `global` | Visible to all workstreams and users |
| `workstream` | Visible only within the originating workstream |
| `user` | Follows the authenticated user across workstreams |
| `coordinator` | Coordinator sessions only; follows the user across coordinators |
| Scope | Visibility |
|--------------|-----------------------------------------------------------|
| `global` | Visible to all workstreams and users |
| `workstream` | Visible only within the originating workstream |
| `user` | Follows the authenticated user across workstreams |
A memory's identity is the tuple `(name, scope, scope_id)`. Saving a memory
with the same identity upserts -- updating content while preserving the ID.
### Coordinator scope
Coordinator sessions are isolated to a single scope: `coordinator`, keyed by
the coordinator's creator `user_id`. It is durable -- every coordinator
session the same user runs (including concurrent ones) shares one
orchestration namespace, so procedures and lessons survive close/reopen.
Isolation is bidirectional and enforced by session kind, not by secrecy of
the scope id:
- A coordinator session can read and write **only** `coordinator`-scope rows.
It never sees `global`/`workstream`/`user` memories, so content written by
interactive sessions (which routinely ingest untrusted MCP/attachment
output) cannot reach a coordinator's system message.
- Interactive sessions -- including a coordinator's own children, which share
its `user_id` -- are rejected from the `coordinator` scope on every memory
action. Children cannot plant rows the parent coordinator would read.
- The REST memory API (`/v1/api/memories`) does not accept the `coordinator`
scope at all; the scope is written exclusively through a coordinator
session's own memory tool.
Coordinator sessions require an authenticated user identity -- an anonymous
coordinator cannot be constructed, so the scope id is always a real user.
### BM25 relevance injection
On every conversation turn, the system:
@@ -75,11 +50,6 @@ This means the model always has its most relevant memories available without
explicit recall -- but can still use `memory(action='search')` for deeper
lookup.
The persona memory lever gates this pathway: a workstream whose persona
turns memory off receives no relevance injection at all -- the steps
above run only when memory is enabled for the session. See
[Personas](personas.md).
### Nudges
The metacognition layer can nudge the model to save memories at appropriate
-37
View File
@@ -41,7 +41,6 @@ are set.
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
| `TURNSTONE_OIDC_REDIRECT_BASE` | Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
| `TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS` | No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See [Cross-host endpoints](#cross-host-endpoints). |
| `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK` | No | `false` | Allow the issuer (and its discovered endpoints) to resolve to private/internal addresses — needed for a self-hosted IdP on an internal network. See [Self-hosted and internal IdPs](#self-hosted-and-internal-idps). |
All four required fields — issuer, client ID, client secret, and
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
@@ -100,40 +99,6 @@ The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts —
this knob only relaxes the same-origin check, not the security gates.
Each entry is a hostname (no scheme, no path).
### Self-hosted and internal IdPs
By default Turnstone refuses an issuer whose hostname resolves to a
private or internal address:
```
OIDCError: endpoint URL resolves to non-public address (10.0.0.5): https://auth.example.site
```
This is SSRF hardening, not a licensing or product restriction: the OIDC
flow makes server-side HTTP requests (discovery, JWKS, token exchange),
and refusing non-public destinations keeps a mistyped or maliciously
steered issuer from aiming those fetches at internal services. For a
self-hosted IdP (Keycloak, Authentik, Dex, …) on a private network,
opt in explicitly in `config.toml`:
```toml
[oidc]
allow_private_network = true
```
or via `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true` (the env var wins
when both are set).
The opt-in admits private-range (RFC 1918), unique-local, CGNAT
(100.64/10 — tailnets), and loopback addresses. Link-local, multicast,
and reserved ranges stay refused even with the opt-in — cloud metadata
services (169.254.169.254) live there, and no legitimate IdP does. The
HTTPS requirement and the same-origin endpoint checks are unaffected.
This knob only affects the login-flow IdP configured here. OAuth
endpoints advertised by remote MCP servers are untrusted input and are
always held to the strict public-address rule.
### config.toml alternative
```toml
@@ -146,8 +111,6 @@ provider_name = "Google"
role_claim = "groups"
password_enabled = true
redirect_base = "https://app.example.com"
# Self-hosted IdP on an internal network (see "Self-hosted and internal IdPs")
allow_private_network = false
[oidc.role_map]
admin = "builtin-admin"
-173
View File
@@ -1,173 +0,0 @@
# Personas
A **persona** is a named, reusable bundle attached to a workstream **at
creation** that controls how its system message is composed and what
capability envelope it runs with. Personas answer a recurring operational
complaint: the default composition primes every session for heavy tool use,
and there was no per-workstream dial to launch a "just write prose" or
"evidence-first research" session.
A persona is exactly four levers — no more:
| Lever | What it does |
|---|---|
| **Base prompt** | Replaces the BASE module of the composed system message. *Only* BASE: ENV, CONTEXT, TOOLS, and POLICIES keep composing, so mandatory [prompt policies](governance.md) ride on top of every persona. Built-in personas source their prose from a repo file; operator personas store it inline — see [Where persona prompts live](#where-persona-prompts-live). |
| **Tool visibility** | Which tools the session advertises. Tri-state: *unrestricted* (tracks tool growth and MCP catalogs), *no tools* (the TOOLS prompt block self-suppresses and zero definitions go on the wire), or an *exact set* of names. Including `tool_search` in a set makes it **soft** — tools the model discovers through search join the visible set; omitting it makes the set **hard** (the search pathway is disabled entirely). On commercial providers a soft set costs one prompt-cache re-prime per `tool_search` expansion, since each expansion rewrites the wire tool set and recomposes the prompt. |
| **MCP** | Whether the workstream talks to MCP at all. **Session-wide**: off means no MCP tools for the persona's own hands *or* for in-process task agents, no resource/prompt catalogs, and no listener registrations. This lever expresses infrastructure intent, not behavior shaping. |
| **Memory** | Whether the persona's **own hands** get memory: recalled-memory injection into the prompt, memory-directed metacognitive nudges, and the `memory` tool. Task agents keep their own envelope, and compaction spill/markers are session mechanics that are never persona-gated. An exact tool set that hides `memory` also mutes those nudges, and the compaction-resume pointer follows `recall`'s visibility. |
Visibility is behavior shaping, **not** a security boundary: any tool call
that does reach the wire still clears the same approval, judge, and policy
machinery as always. RBAC and tool policies remain the enforcement layers.
## Snapshot semantics — resolve once, stamp forever
The persona is resolved **once**, at workstream creation, and stamped into
`workstream_config` as five keys (`persona`, `persona_prompt`,
`persona_tools`, `persona_mcp`, `persona_memory`). From then on the session
reads only the stamp:
- **Editing or archiving a persona never changes an existing workstream.**
Rehydrate, resume, and post-compaction resume all run from the stamp.
A mid-session REPL `/resume` adopts the target workstream's stamp for
prompt, tools, and memory; for the MCP lever it can only narrow in
place — adopting an MCP-off stamp drops the live MCP surface, while
adopting an MCP-on stamp into a session whose persona dropped MCP at
construction is refused with an error telling you to reopen the
workstream fresh.
- A workstream outlives its persona — an archived persona keeps labelling
the workstreams stamped with it.
- A partial or unparseable stamp is treated as corruption: session
construction fails loudly rather than silently falling back to a default
envelope the operator never chose.
- Workstreams created before personas existed carry no stamp and keep
legacy behavior, byte-identical to the `engineer` / `orchestrator`
defaults below — with one exception: pre-1.7 workstreams that had
`creative_mode` set are converted by migration `063` into full
`writer` stamps, so they resume as writing sessions rather than as
legacy defaults.
- Forking (`resume_ws` on create) resumes the source's stamped persona; the
fork does not re-resolve.
## Seed personas
Migration `063` seeds six personas. The two per-kind **defaults** carry no
overrides at all, so a zero-touch launch behaves exactly as it did before
personas existed:
| Persona | Kind | Base prompt | Tools | MCP | Memory |
|---|---|---|---|---|---|
| `engineer` *(default)* | interactive | stock | unrestricted | on | on |
| `orchestrator` *(default)* | coordinator | stock | unrestricted | on | on |
| `scribe` | interactive | custom (faithful structuring of given material) | none | off | off |
| `researcher` | interactive | custom (evidence-first) | `read_file`, `search`, `web_fetch`, `web_search`, `recall`, `memory`, `tool_search` (soft) | off | on |
| `writer` | interactive | custom (creative writing partner — replaces the removed `/creative`) | none | off | on |
| `executive` | coordinator | custom (delegate, interrogate plans, judge outcomes) | spawn/inspect/lifecycle tools plus `memory`: `spawn_workstream`, `spawn_batch`, `send_to_workstream`, `wait_for_workstream`, `inspect_workstream`, `list_workstreams`, `list_nodes`, `close_workstream`, `cancel_workstream`, `memory` (hard) | off | on |
Notes:
- `scribe` turns memory off deliberately: recalled memories would
contaminate faithful summarization with unrelated context.
- `researcher`'s set is soft (includes `tool_search`): it starts with
read and evidence tools but can pull in others on demand — e.g. load
`bash` to run a snippet and verify a calculation. It is evidence-first,
not sandboxed; any escalated tool still hits the normal approval path.
- Coordinator sessions do not merge MCP today, so the MCP lever on
coordinator personas is forward-compatible bookkeeping; it bites on
interactive workstreams.
## Where persona prompts live
Prompt source is explicit in the persona row — two nullable columns, never both empty:
| `base_prompt_file` | `base_prompt` | Meaning |
|---|---|---|
| set (e.g. `scribe.md`) | — | **built-in**: prose lives in `prompts/personas/<file>`, code-owned and PR-reviewed |
| set | set | built-in with an **operator override** layered on top (the inline text wins) |
| — | set | **operator** persona, inline prose |
A `CHECK` forbids the both-empty row, so resolution is a plain coalesce —
`base_prompt ?? load(base_prompt_file)` — with no implicit "inherit the default"
branch in application logic. `base_prompt_file` is set only by the migration/code
(the admin API never exposes it): it marks a persona as built-in and blocks
archive, so `engineer` and `orchestrator` can't be removed. To customise a
built-in, set `base_prompt` on it (clear it to revert), or create your own persona.
The resolved prompt is **frozen into the workstream at creation** — later edits to
a built-in's file or an operator's row never change a running workstream; only new
ones pick up the change. "No persona" is not a state: every workstream is stamped,
and an empty `persona=` resolves to the kind's `is_default` (`engineer` /
`orchestrator`).
## Choosing a persona
Every creation surface takes an optional persona; empty always means the
kind's default (or plain legacy behavior on a database with no personas
seeded):
- **Web/console**: the persona select on the console launcher, the server
webui's new-workstream dialog, and the dashboard composer. Selecting a
persona requires **no** `persona.*` permission — the picker feed
(`GET /v1/api/personas`) is authenticated-only and returns display fields.
- **API/SDK**: `CreateWorkstreamRequest.persona` (Python:
`create_workstream(persona=...)`; TypeScript: `{ persona: ... }`).
- **CLI**: `turnstone --persona <name>`. Unknown or disabled names error at
startup. `--resume` ignores `--persona` and adopts the resumed
workstream's stamp.
- **Coordinator spawn**: `spawn_workstream` / `spawn_batch` take a
`persona` argument, validated when the coordinator prepares the spawn
and re-checked by the node that creates the child (children are always
interactive-kind). Omitted means the interactive **default** — a child
never inherits its parent coordinator's persona.
- **Sub-agents**: `task_agent` takes a `persona` argument setting the
sub-agent's identity and capability envelope (resolved against
interactive-kind personas, frozen into the task at prep). Omitted keeps
the default autonomous task-agent identity — never the parent's persona.
## How agents discover personas
Agents are told, not expected to guess: the live persona list (enabled,
interactive-kind — children and sub-agents are always interactive) is
injected into the `persona` parameter description of `task_agent`,
`spawn_workstream`, and `spawn_batch` whenever the session's tool surface
is rendered — session start, MCP catalog change, model-registry reload.
Each entry carries the name, the default marker, and the persona's
one-line description so the model can pick by purpose (descriptions drop
out past 25 personas; the name list always enumerates completely).
A persona created after that render is still reachable — pass its name.
Every resolve failure enumerates the names currently valid for the kind,
so a stale list (or a typo) self-corrects on the next attempt.
Resolution is forgiving on all surfaces (they share one rule):
- names match case-insensitively (`Writer` resolves `writer`);
- an input that uniquely matches a persona's **display name**
(case-insensitive, among the kind's enabled personas — display names are
not unique, and a same-label persona of another kind neither blocks nor
wins) resolves to that persona; an ambiguous match errors, listing the
candidate slugs;
- whatever variant matched, the stamped identity, approval chrome, and
wire always carry the canonical `name` slug.
## Authoring (console)
Personas are managed in the console's **Manage → Governance → Personas**
tab. The admin shelf exposes exactly the four levers plus the kind
list, the default marker, and archive. Rules:
- `name` is an immutable lowercase slug — and the identifier agents and
the CLI launch the persona by (`persona=` on the spawn tools,
`--persona` on the CLI); the create shelf says so under **Name**.
`display_name` is a list label, editable any time, and deliberately
not an identifier (a unique display name happens to resolve, as a
forgiveness fallback — don't design workflows around it).
- Exactly one default per kind, storage-enforced: flipping the flag on a
successor demotes the incumbent atomically, defaults are single-kind,
and a default cannot be archived.
- **Archive only** — there is no delete verb, so every stamped
workstream's provenance stays explicable.
RBAC: `persona.create` / `persona.read` / `persona.write` gate the admin
CRUD (`/v1/api/admin/personas`); all three are granted to `builtin-admin`
by migration `063`, and other roles opt in via role permission overrides.
+2 -2
View File
@@ -69,7 +69,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|----------|--------|---------|
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
| | `dashboard()` | `DashboardResponse` |
| | `create_workstream(*, name, model, auto_approve, skill, persona, initial_message, attachments)` | `CreateWorkstreamResponse` |
| | `create_workstream(*, name, model, auto_approve, skill, initial_message, attachments)` | `CreateWorkstreamResponse` |
| | `close_workstream(ws_id)` | `StatusResponse` |
| **Attachments** | `upload_attachment(ws_id, filename, data, *, mime_type=...)` | `UploadAttachmentResponse` |
| | `list_attachments(ws_id)` | `ListAttachmentsResponse` |
@@ -100,7 +100,7 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose:
| | `workstreams(*, state, node, search, sort, page, per_page)` | `ClusterWorkstreamsResponse` |
| | `node_detail(node_id)` | `NodeDetailResponse` |
| | `snapshot()` | `ClusterSnapshotResponse` |
| | `create_workstream(*, node_id, name, model, initial_message, skill, persona)` | `ConsoleCreateWsResponse` |
| | `create_workstream(*, node_id, name, model, initial_message, skill)` | `ConsoleCreateWsResponse` |
| **Schedules** | `list_schedules()` | `ListSchedulesResponse` |
| | `create_schedule(*, name, schedule_type, initial_message, ...)` | `ScheduleInfo` |
| | `get_schedule(task_id)` | `ScheduleInfo` |
-17
View File
@@ -54,23 +54,6 @@ When a per-model override is `NULL` (empty in the UI), the global default is
used. Switching models via `/model <alias>` re-resolves sampling parameters
from the new model's overrides or global defaults.
### Responses output controls (per-model)
Models whose capability table declares Responses output controls expose two
additional fields in the Models create/edit shelf:
| Field | Stored capability | Values | Effect |
|-------|-------------------|--------|--------|
| Output verbosity | `verbosity` | `low`, `medium`, `high` | Controls answer length independently of reasoning effort. |
| Reasoning mode | `reasoning_mode` | `standard`, `pro` | Selects standard or higher-compute Pro execution without changing the model ID. |
An empty selection means provider default and omits the capability key. Known
GPT-5.6 models inherit support from the built-in table without persisting
redundant support flags. An OpenAI-compatible model pinned to the Responses API
can opt in with the `supports_verbosity` and `supports_pro_mode` capability
tiles. Chat Completions and non-Responses providers do not surface or submit
these controls.
**Removed settings:** `model.name` and `model.context_window` have been removed
from ConfigStore. Model names and context windows are now configured per-model
in the Models tab. A startup warning is logged if these keys appear in
+2 -6
View File
@@ -244,10 +244,7 @@ const client = new TurnstoneServer({
### Node Bootstrap Flow
1. Node starts, connects to shared database (plain connection)
2. Discovers the console URL from the `services` table — or honors an explicit
`TURNSTONE_CONSOLE_URL` (a bare-metal node outside the compose network can't
resolve the in-cluster `console` name, so it points this at the console's
published ACME endpoint)
2. Discovers console URL from `services` table
3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU)
4. Requests a service cert via ACME (plain HTTP, JWS-signed). The cert's
primary domain / SAN is the node's **advertised host** (the host of
@@ -294,8 +291,7 @@ cert's SANs don't include the dialed name.
The console registers itself in the `services` table on startup. If the console
hasn't started or the registration expired (1 hour TTL), nodes can't discover
it. Set `TURNSTONE_CONSOLE_URL` to a reachable console address (this is also how
a bare-metal node that can't resolve the in-cluster `console` name enrolls).
it. Use `--console-url` explicitly.
### Browser HTTPS to the console
+6 -49
View File
@@ -1,6 +1,6 @@
# Tools Reference
turnstone exposes 17 built-in tools plus any number of external MCP tools to the
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
MCP tools are discovered from configured MCP servers at startup by
@@ -44,10 +44,10 @@ schema plus turnstone-specific metadata keys:
| Name | Description |
|---------------------|-------------|
| `TOOLS` | All 29 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
| `TOOLS` | All 28 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
| `TASK_AUTO_TOOLS` | Set of all tool names with `auto_approve: true` -- used by task-agent sub-sessions to skip confirmation for matching available tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 29 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 28 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
---
@@ -65,7 +65,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
- Parses the JSON arguments (with fallback for malformed JSON).
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
to the correct parameter.
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 16
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
the generic `_prepare_mcp_tool()` handler for MCP tools.
- Validates arguments and builds a preview dict containing:
@@ -125,9 +125,6 @@ Each item's `execute` callable is invoked:
- `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests)
- `web_search` -- web search via self-hosted SearxNG (makes network requests)
- `task_agent` -- spawns an autonomous sub-agent
- `open_preview` -- **URL targets only** (network access, gated like `web_fetch`);
file-path and `attachment:` targets are local reads and run unprompted like
`read_file`
Note: The JSON schema metadata key `auto_approve` controls membership in
`TASK_AUTO_TOOLS` (used for task agent sub-sessions). The actual runtime
@@ -160,7 +157,6 @@ Every tool defines a `primary_key`. The mapping is:
| `search` | `query` |
| `web_fetch` | `url` |
| `web_search` | `query` |
| `open_preview` | `target` |
| `task_agent` | `prompt` |
| `memory` | `name` |
| `recall` | `query` |
@@ -289,7 +285,7 @@ Fetch a URL and extract specific information from it.
| `url` | string | yes | The URL to fetch (must start with `http://` or `https://`). |
| `question` | string | yes | What to extract or answer from the page content. |
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Every redirect hop is SSRF-screened before it is requested. Private/internal addresses are refused by default; enable `tools.allow_private_network` (console Settings → Tools) to make them approvable for self-hosted setups whose services live on the local network — the approval prompt marks such requests, and a public site redirecting into private space is refused regardless.
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs).
- **Auto-approve**: No -- requires user confirmation (makes network requests).
- **Agent availability**: `task_agent`.
@@ -349,39 +345,6 @@ It reports the score scale, whether the endpoint cleanly separates relevant from
---
### open_preview
Show the user rich content in a preview pane beside the conversation.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `target` | string | yes | An http(s) URL, a file path, or `attachment:<id>` for a file attached to the conversation. |
| `kind` | string | no | Rendering override: `web`, `pdf`, `image`, `table`, `text`, or `markdown`. Detected from the content when omitted. |
| `title` | string | no | Pane header title. Defaults to the page title, filename, or URL. |
- **What it does**: Resolves the target to bytes (URLs fetch through the same
SSRF-guarded path as `web_fetch`, screened per redirect hop, honoring the
same `tools.allow_private_network` opt-in), classifies the
content, stores it content-addressed against the workstream, and opens the
frontend preview pane beside the conversation: web pages render in a fully
sandboxed iframe (no scripts, opaque origin), PDFs in the browser viewer,
images inline, CSV/TSV/JSON as a sortable table, text/markdown rendered. A
previewed web page loads none of its remote images or styles by default, so
opening it never reveals the viewer to the page's site; a toggle in the pane
header turns remote content back on for that preview. The
model receives only a one-line confirmation — to reason about content, use
`web_fetch` / `read_file` instead. Preview content is size-capped per kind
(pages 4 MB, PDFs 32 MB, images 4 MB, tables 2 MB, text 512 KB) and GC'd
with the workstream.
- **Auto-approve**: URL targets require confirmation (network access); file
paths and `attachment:` targets run unprompted (local reads).
- **Agent availability**: interactive sessions only (not `task_agent`, not
coordinators).
- **Surfaces**: the pane renders in the web UI (standalone and console). The
CLI prints the confirmation line only — there is no terminal pane.
---
## Agent
The tool name uses the `_agent` suffix — bare `task` collides with
@@ -582,7 +545,6 @@ pre-configure skills at workstream creation.
| `search` | File Ops | Yes | Yes | `query` |
| `web_fetch` | Info | No | Yes | `url` |
| `web_search` | Info | No | Yes | `query` |
| `open_preview`| Info | URL: no; path/attachment: yes | No | `target` |
| `task_agent` | Agent | No | No | `prompt` |
| `memory` | Memory | Yes | No | `name` |
| `recall` | Memory | Yes | No | `query` |
@@ -618,11 +580,6 @@ Tool search uses the best available mechanism for each provider:
`_exec_tool_search()` runs a pure-Python BM25 index over tool names and
descriptions, then expands the matched tools into the visible set.
A persona with a tool-visibility set overrides this selection: any exact
set forces tool search into the client-side BM25 mechanism (tier 3)
regardless of provider, and a **hard** set — one whose visible tools omit
`tool_search` — disables tool search entirely.
### Configuration
Tool search is configured in `config.toml` under the `[tools]` section:
@@ -692,7 +649,7 @@ MCP-compatible service.
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
4. **Merging**: MCP tools are appended after the 17 built-in tools via
4. **Merging**: MCP tools are appended after the 16 built-in tools via
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
When dynamic tool search is active, MCP tools are deferred rather than directly
visible -- the model discovers them via search as needed (see
-56
View File
@@ -1,56 +0,0 @@
{
"defaults": {
"n_runs": 3
},
"cases": [
{
"id": "search-first",
"skill": {
"name": "search-first",
"content": "# Search First\n\nBefore answering ANY question about where something lives in the codebase, you MUST call the `search` tool first. Never answer from memory."
},
"user_prompt": "Where is JWT token validation implemented in this project?",
"expected_actions": [{ "tool": "search" }],
"match_mode": "ordered_subset",
"max_turns": 4
},
{
"id": "test-after-edit",
"skill": {
"name": "test-after-edit",
"content": "# Test After Edit\n\nAfter editing or writing ANY file, you MUST run the test suite with `python -m pytest` via bash before you finish. Do not report done until tests have run."
},
"user_prompt": "Add a function `clamp(x, lo, hi)` that clamps x to [lo, hi] in utils.py.",
"setup": {
"files": {
"utils.py": ""
}
},
"expected_actions": [
{ "tool": "write_file" },
{ "tool": "bash", "args_pattern": { "command": "pytest" } }
],
"match_mode": "ordered_subset",
"max_turns": 8
},
{
"id": "changelog-update",
"skill": {
"name": "changelog-update",
"content": "# Changelog Discipline\n\nWhenever you modify a file, you MUST also append a one-line entry to CHANGELOG.md describing the change in the same task."
},
"user_prompt": "Fix the off-by-one so pager.py shows the last page. Edit pager.py.",
"setup": {
"files": {
"pager.py": "def last_page(total_items, per_page):\n # off-by-one: drops the final partial page\n return total_items // per_page\n",
"CHANGELOG.md": "# Changelog\n"
}
},
"expected_actions": [
{ "tool": "edit_file", "args_pattern": { "path": "CHANGELOG.md" } }
],
"match_mode": "subset",
"max_turns": 8
}
]
}
-9
View File
@@ -1,9 +0,0 @@
__pycache__/
.venv/
*.db
*.db-wal
*.db-shm
.ruff_cache/
.pytest_cache/
.mypy_cache/
uv.lock
-282
View File
@@ -1,282 +0,0 @@
# Understone
A small, multiplayer, BBS-style **ANSI door game** served over the Model
Context Protocol (MCP). It is a text RPG in the spirit of *Legend of the Red
Dragon* — explore an overworld of box-drawing maps, fight wandering monsters,
shop and rest in town, and descend a dungeon — except the "door" is an MCP
server and the player drives it by talking to an AI assistant.
The server is the rules engine and the single source of truth. Players share
**one persistent world**: your assistant calls tools, the server returns
authoritative frames and facts, and the assistant narrates the story around
them.
This is a self-contained reference example. It depends only on `mcp` — there
is no dependency on Turnstone itself — so it runs against any MCP client.
## How to play
There is **no prompt to paste and no persona to configure**. The tool schema
is the whole interface. Once the server is registered with your assistant:
1. Tell your assistant you'd like to play an ANSI door game / text dungeon
RPG (it can discover the tools by name and description).
2. The assistant calls `door_help` to learn how to run the world, then
`door_join` with your adventurer's name.
3. Play unfolds as a conversation: "head east", "fight it", "rest at the inn".
Everything the assistant needs to run the game well is returned by
`door_help`.
## Gameplay
A run is a little RPG loop, played a bit each day:
- **Explore** the overworld of box-drawing maps. Walking is free, but the wild
country has texture — a step may turn up a wandering monster, a purse of
gold, a healing spring, a small trap (which can never kill you), or a scrap
of old Vale lore. Only one such find happens per move, and the non-combat
ones don't interrupt your walk.
- **Fight, shop, and heal** in and around town. Fighting and descending one
rung of the dungeon each spend one of your daily turns; resting, shopping and
moving do not.
- **Delve the deep, a rung at a time.** The dungeon is a ladder of guardians:
each `descend` faces the next one past your deepest and either advances your
depth or bounces you home (your depth persists either way). Carry a few
**potions in your satchel**`quaff` the strongest when you choose, and if a
fight would kill you the satchel saves you automatically, the elixir burning
down your throat at death's edge. Clearing a rung also yields **forge ore**,
which rides the satchel (a won forest fight sometimes turns up a little, too).
- **Forge an edge — with gold AND ore.** At the shop's **forge** you can add a
+1 edge to your equipped weapon or armour, up to a cap, each step dearer than
the last. A step costs gold *and* the ore you won in the deep — so the forge is
fed by descending, not just by a fat purse. Watch, too, for the **rare beasts**
that prowl the forest: felling one is Herald news and always drops a draught.
- **Win the game** by slaying **the Wyrm Below**. Once your hero is seasoned
enough AND has plumbed the deep to its floor, `challenge` it at the dungeon. A
victory frees the Vale, carves your run into the **Hall of Legends**, and — in
the tradition of the classic BBS door games — begins a new life: your
character resets to first-day gear and stats but keeps a permanent ★ for every
Wyrm slain, ready to do it all again.
- **Read the news.** `door_log` is the **Understone Herald**, a shared
broadsheet of notable deeds across the whole world — who joined, who rose a
level, who was dragged home by a goblin, and who freed the Vale.
- **Make it social.** It is a shared world, so you can touch other players.
`ambush` a rival who has not yet acted today — a classic
style player-kill that robs a sleeping foe of some gold, except the surest
defence is simply to take your own turn (an active player is awake and can't
be caught). Lose the ambush and *you* are the one who flees, shamed on the
feed. `post` a private note another player reads on their next visit (it
never reaches the public Herald). Or `gamble` a little gold at the inn's dice
against the house. Ambush spends a turn; mail and dice do not.
- **Bank your coin.** The inn keeps a strongbox: `deposit` gold into the
**vault** and `withdraw` it later (no turn either way). Banked gold is **safe
from ambush** — a sleeping-robber only ever lifts what you carry — and it is
the one thing that **survives a Wyrm-win reset**, carrying wealth across runs.
## Installation
This example uses [`uv`](https://docs.astral.sh/uv/). From the example
directory:
```bash
cd examples/door-game
uv venv
uv pip install -e .
```
That installs the `understone` entry point into the environment.
To run the tests and quality gates:
```bash
uv pip install -e ".[test,dev]"
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run mypy understone/
```
## Running the server
By default the server speaks the **stdio** transport, which is how MCP clients
launch a per-session subprocess:
```bash
understone
```
To host one shared world over HTTP for several clients, run the
**streamable-http** transport as a single long-lived process:
```bash
UNDERSTONE_TRANSPORT=streamable-http understone
```
### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `UNDERSTONE_DB` | `./understone.db` | SQLite database file for the world's state. |
| `UNDERSTONE_WORLD` | _(packaged pack)_ | Directory of a content pack to load instead of the bundled Vale of Understone. |
| `UNDERSTONE_TRANSPORT` | `stdio` | `stdio` or `streamable-http`. |
| `UNDERSTONE_HOST` | `127.0.0.1` | Bind host (streamable-http only). |
| `UNDERSTONE_PORT` | `8077` | Bind port (streamable-http only). |
| `UNDERSTONE_PATH` | `/mcp` | HTTP path for the MCP endpoint (streamable-http only). |
## The Watch — a live spectator view
When the server runs under the **streamable-http** transport, it also serves a
read-only **Watch** page: the lobby TV of the Vale. Point a browser at
```
http://127.0.0.1:8077/watch
```
(the host and port follow `UNDERSTONE_HOST` / `UNDERSTONE_PORT`). It is a
period **CRT spectator console** — a green-and-amber phosphor map of the whole
world with every adventurer's `☻` marker, a live **Understone Herald** feed, the
**Hall of Legends**, and a roster of who is currently abroad. It refreshes every
couple of seconds; if it loses contact it dims and reads `SIGNAL LOST` until the
server returns. The console's palette follows the pack: a world may pick its own
CRT colour with `settings.watch_theme` (`phosphor` green, `amber` gold, `ice`
blue, `ember` red), defaulting to the Vale's green if it says nothing.
The Watch is **strictly read-only**. Input never flows through it — there are no
controls, no forms, nothing that can change the world. It reads the same shared
state the tools do and paints it; that is all. There is no authentication, in
keeping with the rest of this easter-egg server (see the safety note below), so
treat the page as you would the MCP endpoint itself.
> _Screenshot: the Watch console — a phosphor-green overworld map with amber
> `☻` markers, the Herald feed and Hall of Legends down the right-hand rail.
> (Image placeholder; run the server and open the URL to see it live.)_
When the Watch is up, the `door_join` welcome and the `door_help` manual both
print its URL so players (and the assistant narrating for them) know it exists.
If you bind to `0.0.0.0` to share the world across a network, advertise a host
that browsers can actually reach (your machine's LAN address or hostname) rather
than `0.0.0.0` itself — the link is composed from `UNDERSTONE_HOST`.
## Authoring worlds
The Vale of Understone is just the *bundled* world. The whole game — its map,
monsters, economy, and endgame — is a **content pack**: a directory of six JSON
files the server loads at start. Nothing about the Vale is privileged; point
the server at another pack and it runs that world instead. This is the seam
where the game becomes its own authoring target: a pack is plain data, so a
person *or an LLM* can write one, and the same zero-setup philosophy that makes
the game playable with no prompt makes it **authorable with no code**.
The loop has these commands:
```bash
understone newpack mypack # scaffold a pack (copies the Vale as a template)
# ...edit or LLM-generate the JSON in mypack/ to describe your world...
understone validate mypack # check it; prints a report or names what's wrong
understone simulate mypack # play a greedy bot through it and measure the balance
UNDERSTONE_WORLD=mypack understone # serve your world
understone worlds # list the bundled worlds and whether each is sound
```
`newpack` writes a starting template plus an `AUTHORING.md` manual — the
file-by-file schema, the enforced limits, and design guidance — written to be
followed cold by a model. `validate` loads the pack through exactly the same
hardened loader the server uses and either prints a summary ending **"This pack
is sound. The door stands open."** or fails with one precise line naming the
file, the row, and the field at fault.
`simulate` is the **balance instrument**: it drives a deliberately simple,
greedy bot through the *real* game — the same `join`/`move`/`action` calls the
tools make — over a seeded RNG and an injected clock, then prints a report
(final level, gold earned, fights fought, rungs cleared, whether and when the
Wyrm fell). It is a tuning probe, not a player to admire: it answers "is this
world *shaped* right, and is it *winnable*?". Pass `--days N`, `--seed S`, or
`--seeds K` for a multi-seed sweep with means and spreads. `worlds` lists every
bundled world — the default Vale plus any alternate packs shipped under
`understone/world/packs/` — loading each so it can report it as sound or flawed.
**A second bundled world: The Cinder Wastes.** Understone ships a second world
alongside the Vale, in `understone/world/packs/cinder-wastes/` — a volcanic
ash-and-slag map whose Watch page glows ember-red instead of the Vale's green
phosphor. It is the pipeline's own dogfood: it was authored **by an LLM working
only from `AUTHORING.md` and the `validate` loop**, with no engine code touched,
then bundled verbatim. `understone worlds` lists it as sound, and
`understone simulate understone/world/packs/cinder-wastes --days 50 --seeds 3`
shows the greedy bot taking its Magma Wyrm — the end-to-end proof that a world
described purely as data, from the manual alone, is genuinely playable to
victory. Serve it with
`UNDERSTONE_WORLD=understone/world/packs/cinder-wastes understone`.
Packs are validated **hard** at load: every map glyph must render as exactly
one terminal column (no fullwidth runes, no emoji, no combining marks — the
frames are box-drawing rectangles) and may not collide with the frame's
box-drawing lines or the player markers, dimensions and counts are bounded,
display names are length-checked, and every cross-reference (a legend
character, a starting item, the boss monster, a dungeon tier) must resolve. The
loader also pins the rules that keep the endgame coherent: a world has exactly
one boss, and a dungeon tier's lead monster (its fixed rung guardian) may not be
a rare. Because packs are now routinely untrusted, generated output, those error
messages are not a nuisance — they are the **feedback loop**. Iterate against
them until the door stands open.
## Registering with Turnstone
Understone is an ordinary MCP server, so it plugs into Turnstone's MCP client
config two ways.
**Stdio (per-session subprocess).** Turnstone launches the `understone`
command for each session. Each session gets its own subprocess, so for a
truly shared world prefer the HTTP form below; stdio is simplest for solo
play.
```toml
[mcp.servers.understone]
command = "understone"
[mcp.servers.understone.env]
UNDERSTONE_DB = "/var/lib/understone/world.db"
```
**Streamable-HTTP (one shared world).** Run a single Understone process with
`UNDERSTONE_TRANSPORT=streamable-http` and point every client at its URL. This
is the right setup for multiplayer: one process, one database, one world that
all adventurers share.
```toml
[mcp.servers.understone]
url = "http://localhost:8077/mcp"
```
> **Operator note.** For multiplayer, start exactly one shared process —
> `UNDERSTONE_TRANSPORT=streamable-http understone` — and have all clients use
> the url form. The world lives in a single SQLite file written by that one
> process.
## The tools
| Tool | What it does |
|------|--------------|
| `door_help` | The game-master manual. Start here. |
| `door_join` | Create or resume an adventurer; returns the opening map. |
| `door_status` | The character sheet (read-only). |
| `door_look` | Redraw the current view — overworld map or location menu. |
| `door_move` | Walk the overworld (free; no daily turn spent). |
| `door_action` | Context verbs: fight, flee, ambush (a rival), rest, deposit/withdraw (the inn vault), buy, sell, forge (a +1 edge, gold + ore), heal, gamble (inn dice), descend (one rung), challenge (the Wyrm), post (mail another player), quaff (a carried potion), leave. |
| `door_log` | The Understone Herald — the shared feed of notable deeds. |
| `door_rank` | The leaderboard, plus the Hall of Legends (★ marks Wyrm kills). |
| `door_bestow` | Game-master grant of a little gold/healing for a story beat. |
## A note on identity and safety
This example is an **easter egg**, not a hardened service. Identity is
**self-asserted**: a "player" is just a name passed to the tools, and there is
**no authentication** — anyone who can reach the server can act as any name.
That is fine for a shared toy world among people who trust each other, and
deliberately out of scope for a game. Do not store anything sensitive in it,
and if you expose the HTTP transport beyond localhost, put it behind whatever
access control your environment already provides.
The game master's `door_bestow` channel can only grant small, capped amounts
of in-game gold and healing — never items, never turns — and every grant is
written to the public in-world log, so its reach is bounded by design.
-55
View File
@@ -1,55 +0,0 @@
[build-system]
requires = ["hatchling>=1.29"]
build-backend = "hatchling.build"
[project]
name = "understone"
version = "0.10.0"
description = "Understone — a BBS-style ANSI door game served over MCP."
requires-python = ">=3.11"
license = "Apache-2.0"
dependencies = [
"mcp>=1.27,<2",
]
[project.scripts]
understone = "understone.server:main"
[project.optional-dependencies]
test = ["pytest>=9.0"]
dev = ["ruff>=0.9", "mypy>=1.14"]
[tool.hatch.build.targets.wheel]
packages = ["understone"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
ignore = ["E501"]
[tool.ruff.format]
quote-style = "double"
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = ["mcp", "mcp.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
-256
View File
@@ -1,256 +0,0 @@
"""Shared test fixtures and builders.
These builders construct engine objects directly (no JSON loader) so the
engine tests stay independent of the content pack. Later chunks add
fixtures that load the shipped world and build the game façade.
"""
from __future__ import annotations
from collections import Counter
from datetime import UTC, datetime
from typing import TYPE_CHECKING
import pytest
from understone.engine.models import (
Item,
LocationDef,
Mode,
Monster,
Player,
Settings,
Slot,
TerrainDef,
WorldEvent,
Zone,
)
from understone.engine.world import World
if TYPE_CHECKING:
from collections.abc import Callable
from understone.game import Game
# ---------------------------------------------------------------------------
# Terrain kinds for synthetic test worlds
# ---------------------------------------------------------------------------
GRASS = TerrainDef(key="grass", glyph=".", walkable=True, encounter_rate=0.0, color="floor")
WALL = TerrainDef(key="wall", glyph="", walkable=False, encounter_rate=0.0, color="wall")
WATER = TerrainDef(key="water", glyph="~", walkable=False, encounter_rate=0.0, color="water")
FOREST = TerrainDef(key="forest", glyph="", walkable=True, encounter_rate=1.0, color="tree")
SAFE_FOREST = TerrainDef(key="forest", glyph="", walkable=True, encounter_rate=0.0, color="tree")
DEFAULT_SETTINGS = Settings(
daily_turns=10,
rest_cost=15,
heal_cost_per_hp=2,
starting_gold=20,
starting_weapon="rusty_dagger",
starting_armor="cloth_tunic",
start_hp=20,
start_atk=3,
start_def=0,
xp_base=100,
growth_max_hp=6,
growth_atk=2,
growth_def=1,
bestow_daily_budget=25,
dungeon_tiers=(4, 5),
boss_monster="wyrm_below",
wyrm_min_level=6,
ambush_min_level=3,
ambush_level_band=2,
ambush_gold_pct=25,
post_daily_cap=5,
gamble_max_bet=50,
gamble_daily_cap=5,
satchel_max=3,
forge_base_cost=60,
forge_max_plus=3,
rare_drop_item="minor_potion",
forge_ore_item="iron_ore",
forge_ore_per_plus=1,
ore_dungeon_drop=2,
ore_forest_chance=0.2,
watch_theme="phosphor",
)
def make_settings(**overrides: object) -> Settings:
"""Return DEFAULT_SETTINGS with field overrides for band testing."""
base = {
"daily_turns": DEFAULT_SETTINGS.daily_turns,
"rest_cost": DEFAULT_SETTINGS.rest_cost,
"heal_cost_per_hp": DEFAULT_SETTINGS.heal_cost_per_hp,
"starting_gold": DEFAULT_SETTINGS.starting_gold,
"starting_weapon": DEFAULT_SETTINGS.starting_weapon,
"starting_armor": DEFAULT_SETTINGS.starting_armor,
"start_hp": DEFAULT_SETTINGS.start_hp,
"start_atk": DEFAULT_SETTINGS.start_atk,
"start_def": DEFAULT_SETTINGS.start_def,
"xp_base": DEFAULT_SETTINGS.xp_base,
"growth_max_hp": DEFAULT_SETTINGS.growth_max_hp,
"growth_atk": DEFAULT_SETTINGS.growth_atk,
"growth_def": DEFAULT_SETTINGS.growth_def,
"bestow_daily_budget": DEFAULT_SETTINGS.bestow_daily_budget,
"dungeon_tiers": DEFAULT_SETTINGS.dungeon_tiers,
"boss_monster": DEFAULT_SETTINGS.boss_monster,
"wyrm_min_level": DEFAULT_SETTINGS.wyrm_min_level,
"ambush_min_level": DEFAULT_SETTINGS.ambush_min_level,
"ambush_level_band": DEFAULT_SETTINGS.ambush_level_band,
"ambush_gold_pct": DEFAULT_SETTINGS.ambush_gold_pct,
"post_daily_cap": DEFAULT_SETTINGS.post_daily_cap,
"gamble_max_bet": DEFAULT_SETTINGS.gamble_max_bet,
"gamble_daily_cap": DEFAULT_SETTINGS.gamble_daily_cap,
"satchel_max": DEFAULT_SETTINGS.satchel_max,
"forge_base_cost": DEFAULT_SETTINGS.forge_base_cost,
"forge_max_plus": DEFAULT_SETTINGS.forge_max_plus,
"rare_drop_item": DEFAULT_SETTINGS.rare_drop_item,
"forge_ore_item": DEFAULT_SETTINGS.forge_ore_item,
"forge_ore_per_plus": DEFAULT_SETTINGS.forge_ore_per_plus,
"ore_dungeon_drop": DEFAULT_SETTINGS.ore_dungeon_drop,
"ore_forest_chance": DEFAULT_SETTINGS.ore_forest_chance,
"watch_theme": DEFAULT_SETTINGS.watch_theme,
}
base.update(overrides)
return Settings(**base) # type: ignore[arg-type]
def make_player(**overrides: object) -> Player:
"""Build a Player at sane defaults; override any field by keyword."""
fields = {
"name": "Tester",
"x": 5,
"y": 5,
"hp": 20,
"max_hp": 20,
"level": 1,
"xp": 0,
"gold": 50,
"atk": 5,
"def_": 1,
"weapon_id": "rusty_dagger",
"armor_id": "cloth_tunic",
"turns_left": 10,
"turn_day": 0,
"mode": Mode.TILE,
"at_location": "",
"created_at": "2026-01-01T00:00:00+00:00",
"last_seen": "2026-01-01T00:00:00+00:00",
"log_cursor": 0,
"bestow_spent": 0,
"bestow_day": 0,
"wins": 0,
"posts_sent": 0,
"post_day": 0,
"gambles": 0,
"gamble_day": 0,
}
fields.update(overrides)
return Player(**fields) # type: ignore[arg-type]
def make_monster(**overrides: object) -> Monster:
"""Build a Monster at tier-1 defaults."""
fields = {
"tier": 1,
"name": "Field Rat",
"hp": 6,
"atk": 3,
"def_": 0,
"xp": 8,
"gold": 3,
"monster_id": "",
"boss": False,
}
fields.update(overrides)
return Monster(**fields) # type: ignore[arg-type]
def make_world(
*,
grid: list[list[TerrainDef]] | None = None,
width: int = 11,
height: int = 11,
spawn: tuple[int, int] = (5, 5),
locations: list[LocationDef] | None = None,
zones: list[Zone] | None = None,
monsters: list[Monster] | None = None,
items: list[Item] | None = None,
settings: Settings | None = None,
events: list[WorldEvent] | None = None,
) -> World:
"""Build a small synthetic World (all-grass by default)."""
if grid is None:
grid = [[GRASS for _ in range(width)] for _ in range(height)]
return World(
name="Test Vale",
width=width,
height=height,
spawn=spawn,
terrain=grid,
locations=locations or [],
zones=zones or [],
monsters=monsters or [make_monster()],
items=items or _default_items(),
settings=settings or DEFAULT_SETTINGS,
events=events,
)
def _default_items() -> list[Item]:
return [
Item("rusty_dagger", "Rusty Dagger", Slot.WEAPON, 2, 0, 0, 0),
Item("short_sword", "Short Sword", Slot.WEAPON, 5, 0, 0, 40),
Item("cloth_tunic", "Cloth Tunic", Slot.ARMOR, 0, 1, 0, 0),
Item("leather_armor", "Leather Armor", Slot.ARMOR, 0, 3, 0, 50),
Item("minor_potion", "Minor Potion", Slot.CONSUMABLE, 0, 0, 15, 12),
Item("iron_ore", "Iron Ore", Slot.MATERIAL, 0, 0, 0, 0),
]
def fixed_clock(moment: datetime) -> Callable[[], datetime]:
"""Return a clock callable that always reports *moment*."""
def _clock() -> datetime:
return moment
return _clock
def utc(year: int, month: int, day: int, hour: int = 0, minute: int = 0) -> datetime:
"""Construct a tz-aware UTC datetime."""
return datetime(year, month, day, hour, minute, tzinfo=UTC)
# ---------------------------------------------------------------------------
# Satchel test helpers (the v0.10 stack encoding)
# ---------------------------------------------------------------------------
# The satchel is stack-based ("id:qty"); these wrap the game façade's stack
# helpers so a test can seed/read a bag as a flat id list (duplicate ids
# collapse to one stack), keeping the assertions readable. Shared by the
# descend and Wyrm suites.
def set_satchel(game: Game, player: object, ids: list[str]) -> None:
"""Seed *player*'s satchel from a flat id list (duplicates -> one stack qty)."""
counts = Counter(ids)
stacks = [(item_id, counts[item_id]) for item_id in dict.fromkeys(ids)]
game._satchel_set_stacks(player, stacks) # type: ignore[arg-type]
def satchel_ids(game: Game, player: object) -> list[str]:
"""Return the satchel as a flat id list, each stack expanded by its qty."""
out: list[str] = []
for item_id, qty in game._satchel_stacks(player): # type: ignore[arg-type]
out.extend([item_id] * qty)
return out
@pytest.fixture
def small_world() -> World:
"""An 11x11 all-grass world with the default content tables."""
return make_world()
@@ -1,7 +0,0 @@
┌── The Sleeping Drake ───┐
│ A warm hearth crackles. │
│ A bed costs 15 gold. │
│ │
│ (R)est (L)eave │
└─────────────────────────┘
[ status ]
@@ -1,8 +0,0 @@
┌─ Vale ──┐
│@........│
│.........│
│.........│
│.........│
│.........│
└─────────┘
[ status ]
@@ -1,8 +0,0 @@
┌─ Vale ──┐
│.........│
│.........│
│....@....│
│.........│
│.........│
└─────────┘
[ status ]
-374
View File
@@ -1,374 +0,0 @@
"""Tests for the pack-authoring command surface.
Covers the validate/newpack functions directly (sound and broken packs, the
scaffold round-trip, AUTHORING.md generation from the live loader bands, and
the refuse-non-empty guard), the ``server.main`` argv dispatch (validate routes
through and bare invocation still reaches serve without binding a port), and
one end-to-end subprocess smoke of ``python -m understone validate``.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from understone import cli, server
from understone.world import loader
if TYPE_CHECKING:
from collections.abc import Callable
EXAMPLE_DIR = Path(__file__).resolve().parents[1]
SHIPPED = EXAMPLE_DIR / "understone" / "world" / "data"
# The six content files a scaffolded pack must carry, plus the manual.
_PACK_JSONS = {
"terrain.json",
"monsters.json",
"items.json",
"locations.json",
"events.json",
"world.json",
}
# ---------------------------------------------------------------------------
# cli_validate
# ---------------------------------------------------------------------------
def test_cli_validate_sound_pack_reports_and_returns_zero() -> None:
out, err = StringIO(), StringIO()
rc = cli.cli_validate(SHIPPED, out=out, err=err)
assert rc == 0
report = out.getvalue()
assert "This pack is sound. The door stands open." in report
# The report surfaces the headline facts the brief calls for.
assert "The Vale of Understone" in report
assert "96x48" in report
assert "1 boss" in report
assert "% fight" in report
assert err.getvalue() == ""
def test_cli_validate_broken_pack_names_field_and_returns_two(tmp_path: Path) -> None:
# A pack whose daily_turns is out of band: the loader names the field.
pack = _clone_shipped(tmp_path)
_patch_world(pack, _break_daily_turns)
out, err = StringIO(), StringIO()
rc = cli.cli_validate(pack, out=out, err=err)
assert rc == 2
message = err.getvalue()
assert message.startswith("The pack is flawed:")
assert "daily_turns" in message # the offending field is named
assert out.getvalue() == ""
def test_cli_validate_missing_directory_returns_two(tmp_path: Path) -> None:
out, err = StringIO(), StringIO()
rc = cli.cli_validate(tmp_path / "nope", out=out, err=err)
assert rc == 2
assert "The pack is flawed:" in err.getvalue()
# ---------------------------------------------------------------------------
# cli_newpack
# ---------------------------------------------------------------------------
def test_cli_newpack_writes_template_and_manual(tmp_path: Path) -> None:
dest = tmp_path / "mypack"
out, err = StringIO(), StringIO()
rc = cli.cli_newpack(dest, out=out, err=err)
assert rc == 0
present = {p.name for p in dest.iterdir()}
assert present >= _PACK_JSONS # the six content files are all there
assert "AUTHORING.md" in present
# Next-steps guidance points the author at the validate verb.
assert "understone validate" in out.getvalue()
def test_cli_newpack_scaffold_validates(tmp_path: Path) -> None:
"""The load-bearing test: a freshly scaffolded pack loads cleanly.
newpack -> load_world round-trip. If the template the scaffolder copies
ever drifts out of the loader's bands, this fails immediately.
"""
dest = tmp_path / "mypack"
assert cli.cli_newpack(dest, out=StringIO(), err=StringIO()) == 0
world = loader.load_world(dest)
assert world.name == "The Vale of Understone"
assert world.width == 96
def test_cli_newpack_authoring_md_renders_live_band(tmp_path: Path) -> None:
"""AUTHORING.md's bands are generated from the loader, not hand-copied.
The daily_turns band is read straight from the live loader table and must
appear verbatim in the scaffolded manual proving generation from source.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
lo, hi = loader.SETTINGS_BANDS["daily_turns"]
assert lo is not None and hi is not None
assert f"`{lo}..{hi}`" in manual
assert "daily_turns" in manual
def test_cli_newpack_authoring_md_has_width_rule_and_live_palette(tmp_path: Path) -> None:
"""AUTHORING.md documents the one-column rule and renders the live palette.
The width section states the Western-monospace assumption, and the safe
palette is generated from ``textwidth.SAFE_PALETTE`` (same can't-drift
pattern as the bands table) every glyph appears, in a backticked cell.
"""
from understone.engine.textwidth import SAFE_PALETTE
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "## Glyph width" in manual
assert "exactly one terminal column" in manual
assert "Western monospace" in manual # the stated assumption
assert "Safe glyph palette" in manual
for glyph in SAFE_PALETTE:
assert f"`{glyph}`" in manual, f"palette glyph {glyph!r} missing from manual"
def test_cli_newpack_authoring_md_documents_action_sets(tmp_path: Path) -> None:
"""AUTHORING.md documents each building's real verb menu.
The per-building menus are an explicit table: the inn's `gamble` (v0.8) and
the v0.10 vault verbs `deposit`/`withdraw`, the shop's `forge`, and so on.
This pins the table rows and the "quaff anywhere" note so a doc regression
trips.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "| `inn` | `rest`, `deposit`, `withdraw`, `gamble`, `leave` |" in manual
assert "| `shop` | `buy`, `sell`, `forge`, `leave` |" in manual
assert "| `healer` | `heal`, `leave` |" in manual
assert "| `dungeon` | `descend`, `challenge`, `leave` |" in manual
assert "`quaff`" in manual and "legal **anywhere**" in manual
# The vault is described where its verbs are listed.
assert "VAULT" in manual and "SAFE from ambush" in manual
def test_cli_newpack_authoring_md_documents_ore_forge(tmp_path: Path) -> None:
"""AUTHORING.md documents the v0.10 ore-gated forge: material slot + settings.
The forge ore is a `material` item earned in combat; the four ore settings
(item, per-plus, dungeon drop, forest chance) are documented, and the band
figures are generated from the live loader so they cannot drift.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "`material`" in manual # the new slot
assert "forge_ore_item" in manual
assert "ore_forest_chance" in manual # the float setting (prose, not the band table)
# The two banded ore settings carry their LIVE bands.
lo, hi = loader.SETTINGS_BANDS["ore_dungeon_drop"]
assert f"`{lo}..{hi}`" in manual
assert "earns in combat" in manual or "earned in combat" in manual
def test_cli_newpack_authoring_md_states_color_advisory_and_spawn_walkable(
tmp_path: Path,
) -> None:
"""AUTHORING.md states color is advisory (loader does not validate it) and
that spawn must be on walkable terrain both v0.8 honesty fixes."""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
# color is documented as advisory / not validated (it matches loader behaviour).
assert "advisory and not validated" in manual
# spawn's walkability requirement is now stated where spawn is introduced.
assert "must be on walkable terrain" in manual
def test_cli_newpack_authoring_md_color_roles_generated_from_enum(tmp_path: Path) -> None:
"""AUTHORING.md's colour-role vocabulary is generated from the Color enum.
The v0.9 fix: the assignable roles were hand-listed (and went stale road
and the per-building roles were missing). They are now generated from
``Color.assignable()`` the single source for the overlay-vs-assignable
split so the manual lists exactly what the Watch can paint and cannot
drift. This asserts the NEW roles appear, that every assignable enum role
appears, and that the non-assignable roles (overlays + DEFAULT) are NOT
offered as author-assignable.
"""
from understone.screen.palette import Color
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
# A sampling of the new v0.9 roles is offered in the manual, backticked.
for role in ("road", "forest", "lava", "barren", "inn", "shop", "healer"):
assert f"`{role}`" in manual, f"new colour role {role!r} missing from manual"
# EVERY assignable enum role appears (generated, so the full set is present).
color_section = manual[manual.index("`color` — a palette role string") :].split("###", 1)[0]
for role in Color.assignable():
assert f"`{role.value}`" in manual, f"assignable role {role.value!r} missing from manual"
# The non-assignable roles (runtime overlays + the DEFAULT fallback) are NOT
# offered as terrain/location colours.
non_assignable = {c for c in Color} - set(Color.assignable())
assert Color.DEFAULT in non_assignable # the fallback is not author-pickable
for role in non_assignable:
assert f"`{role.value}`" not in color_section, (
f"non-assignable role {role.value!r} wrongly offered as author-assignable"
)
def test_cli_newpack_authoring_md_has_validate_coverage_split(tmp_path: Path) -> None:
"""AUTHORING.md honestly separates machine-enforced rules from eyeball-only.
The v0.8 subsection lists what `validate` DOES catch (including the two new
enforcements rare-as-guardian and single-boss) and what it does NOT (chief
among them: location menu `actions` contents are unvalidated).
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "What `validate` checks, and what it cannot" in manual
# The newly-enforced rules are named in the DOES-catch list.
assert "Exactly one boss" in manual
assert "fixed rung guardian) must" in manual # rare-as-guardian enforcement
# The eyeball-only short list names the actions gap and the flavour caveat.
assert "Location menu `actions` contents" in manual
assert "Flavour and narration quality" in manual
def test_cli_newpack_refuses_non_empty_dir(tmp_path: Path) -> None:
dest = tmp_path / "occupied"
dest.mkdir()
(dest / "keep.txt").write_text("mine", encoding="utf-8")
out, err = StringIO(), StringIO()
rc = cli.cli_newpack(dest, out=out, err=err)
assert rc == 2
assert "non-empty" in err.getvalue()
# The pre-existing file is untouched (nothing was scaffolded over it).
assert (dest / "keep.txt").read_text(encoding="utf-8") == "mine"
assert not (dest / "AUTHORING.md").exists()
def test_cli_newpack_into_empty_existing_dir_succeeds(tmp_path: Path) -> None:
"""An existing but empty directory is a fine scaffold target."""
dest = tmp_path / "empty"
dest.mkdir()
assert cli.cli_newpack(dest, out=StringIO(), err=StringIO()) == 0
assert (dest / "AUTHORING.md").exists()
# ---------------------------------------------------------------------------
# server.main argv dispatch
# ---------------------------------------------------------------------------
def test_main_validate_dispatch_returns_status(
tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
# A broken pack routed through main exits 2; a sound one exits 0.
pack = _clone_shipped(tmp_path)
_patch_world(pack, _break_daily_turns)
with pytest.raises(SystemExit) as broken:
server.main(["validate", str(pack)])
assert broken.value.code == 2
with pytest.raises(SystemExit) as sound:
server.main(["validate", str(SHIPPED)])
assert sound.value.code == 0
assert "The door stands open." in capsys.readouterr().out
def test_main_newpack_dispatch(tmp_path: Path) -> None:
dest = tmp_path / "viamain"
with pytest.raises(SystemExit) as exc:
server.main(["newpack", str(dest)])
assert exc.value.code == 0
assert (dest / "AUTHORING.md").exists()
def test_main_worlds_dispatch(capsys: pytest.CaptureFixture) -> None:
"""`understone worlds` routes through main, exits 0, and lists the Vale."""
with pytest.raises(SystemExit) as exc:
server.main(["worlds"])
assert exc.value.code == 0
out = capsys.readouterr().out
assert "vale" in out
assert "The Vale of Understone" in out
assert "UNDERSTONE_WORLD=" in out
def test_bare_invocation_resolves_to_serve_without_side_effects() -> None:
"""Parsing no argv yields the serve path, and parsing has no side effects.
The transport launch (_serve) is reachable, but argument parsing neither
loads a world nor binds a port so this asserts the resolved command
without ever calling _serve.
"""
args = server._build_parser().parse_args([])
assert args.cmd is None # None => the serve branch in main()
assert callable(server._serve)
def test_subprocess_validate_packaged_world_exits_zero() -> None:
"""End-to-end smoke: `python -m understone validate <packaged dir>` exits 0."""
result = subprocess.run(
[sys.executable, "-m", "understone", "validate", str(SHIPPED)],
cwd=EXAMPLE_DIR,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
assert "The door stands open." in result.stdout
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _clone_shipped(tmp_path: Path) -> Path:
dest = tmp_path / "pack"
shutil.copytree(SHIPPED, dest)
return dest
def _patch_world(pack: Path, mutate: Callable[[dict[str, Any]], None]) -> None:
path = pack / "world.json"
data = json.loads(path.read_text(encoding="utf-8"))
mutate(data)
path.write_text(json.dumps(data), encoding="utf-8")
def _break_daily_turns(data: dict[str, Any]) -> None:
"""Set daily_turns out of its 1..100 band so the pack fails to load."""
data["settings"]["daily_turns"] = 0
-123
View File
@@ -1,123 +0,0 @@
"""Combat resolution tests.
Pins determinism (a fixed seed yields identical results twice, log and
deltas), each outcome (win/lose/flee), xp/gold crediting on victory, and
the defeat contract: the result flags a spawn bounce with no xp/gold and a
zero hp delta (the façade applies hp=1 and the move).
"""
from __future__ import annotations
from tests.conftest import make_monster, make_player
from understone.engine.combat import Outcome, resolve_fight, resolve_flee
from understone.engine.rng import GameRNG
# A strong adventurer vs a Field Rat wins on every probed seed.
_WIN_SEED = 1
# A fragile adventurer vs a Stone Wyrm loses on every probed seed.
_LOSE_SEED = 0
# Flee outcomes (probed): seed 1 escapes clean, seed 0 is caught.
_FLEE_CLEAN_SEED = 1
_FLEE_CAUGHT_SEED = 0
def _strong_player() -> object:
return make_player(hp=20, max_hp=20, atk=5, def_=1, xp=0, gold=50)
def _wyrm() -> object:
return make_monster(tier=5, name="Stone Wyrm", hp=60, atk=18, def_=6, xp=140, gold=60)
def test_fight_is_deterministic_under_fixed_seed() -> None:
r1 = resolve_fight(GameRNG(seed=7), make_player(), make_monster())
r2 = resolve_fight(GameRNG(seed=7), make_player(), make_monster())
assert r1.log == r2.log
assert (r1.outcome, r1.xp_delta, r1.gold_delta, r1.hp_delta) == (
r2.outcome,
r2.xp_delta,
r2.gold_delta,
r2.hp_delta,
)
def test_win_credits_xp_and_gold() -> None:
player = make_player(hp=20, max_hp=20, atk=5, def_=1)
monster = make_monster(hp=6, atk=3, def_=0, xp=8, gold=3)
result = resolve_fight(GameRNG(seed=_WIN_SEED), player, monster)
assert result.outcome is Outcome.WIN
assert result.xp_delta == 8
assert result.gold_delta == 3
# hp_delta is non-positive (you may take a scratch) and never fatal here.
assert result.hp_delta <= 0
assert not result.bounce_to_spawn
def test_win_deltas_are_exact_for_pinned_seed() -> None:
player = make_player(hp=20, max_hp=20, atk=5, def_=1)
monster = make_monster(hp=6, atk=3, def_=0, xp=8, gold=3)
result = resolve_fight(GameRNG(seed=_WIN_SEED), player, monster)
# Pinned from a determinism probe; guards against silent damage drift.
assert result.hp_delta == -1
# The engine no longer emits a "falls + reward" line — that sentence is
# composed by the game façade where the xp/gold are actually banked — so
# the WIN log is one line shorter than before and ends on the kill blow.
assert len(result.log) == 4
assert result.log[-1] == "You strike for 6. (Field Rat: 0 HP)"
def test_win_log_does_not_claim_rewards() -> None:
"""The engine narrates the kill blow only; it never claims xp/gold itself.
Reward ownership lives in the façade (so the Wyrm-win legacy reset, which
keeps no xp/gold, narrates no reward). The deltas are still carried on the
result for the caller to apply.
"""
player = make_player(hp=20, max_hp=20, atk=5, def_=1)
monster = make_monster(hp=6, atk=3, def_=0, xp=8, gold=3)
result = resolve_fight(GameRNG(seed=_WIN_SEED), player, monster)
assert result.outcome is Outcome.WIN
assert result.xp_delta == 8 and result.gold_delta == 3 # deltas still set
joined = "\n".join(result.log)
assert "falls" not in joined # no kill/reward sentence in the engine log
assert "XP" not in joined and "gold" not in joined
def test_loss_flags_bounce_without_rewards() -> None:
result = resolve_fight(GameRNG(seed=_LOSE_SEED), _strong_player_loses(), _wyrm())
assert result.outcome is Outcome.LOSE
assert result.bounce_to_spawn is True
assert result.xp_delta == 0
assert result.gold_delta == 0
# Combat does not set hp to 1 itself — that is the façade's job.
assert result.hp_delta == 0
def _strong_player_loses() -> object:
return make_player(hp=12, max_hp=12, atk=4, def_=0)
def test_flee_can_escape_clean() -> None:
player = make_player(hp=20, max_hp=20, def_=1)
monster = make_monster(atk=8, def_=2)
result = resolve_flee(GameRNG(seed=_FLEE_CLEAN_SEED), player, monster)
assert result.outcome is Outcome.FLED
assert result.hp_delta == 0
def test_flee_caught_costs_hp_but_never_kills() -> None:
player = make_player(hp=20, max_hp=20, def_=1)
monster = make_monster(atk=8, def_=2)
result = resolve_flee(GameRNG(seed=_FLEE_CAUGHT_SEED), player, monster)
assert result.outcome is Outcome.FLED
assert result.hp_delta < 0
# A caught flight cannot drop the player to or below zero.
assert player.hp + result.hp_delta >= 1
def test_flee_caught_never_kills_at_low_hp() -> None:
player = make_player(hp=1, max_hp=20, def_=0)
monster = make_monster(atk=40, def_=0)
result = resolve_flee(GameRNG(seed=_FLEE_CAUGHT_SEED), player, monster)
# At 1 HP the most a failed flee can cost is 0 (cannot go below 1).
assert result.hp_delta == 0
File diff suppressed because it is too large Load Diff
-858
View File
@@ -1,858 +0,0 @@
"""Game façade integration tests over the shipped world.
Drives a full session against a temp store, a frozen clock, and a seeded
RNG: join -> status -> look -> move -> action(buy/rest/fight) -> log ->
rank -> bestow. Persistence is exercised by reopening the store.
Negative-test discipline (turn guard and bestow cap):
Two guards are pinned by assertions here. To confirm each assertion has
teeth, the implementer temporarily reverted the guard line and observed
the matching test FAIL, then restored it:
* Turn guard (engine/turns.py spend_turn): replacing
``if player.turns_left <= 0: return False`` with ``return True``
let fighting continue past the daily budget ``test_turn_budget_blocks``
then failed on the "spent for today" assertion. Restored.
* Bestow cap (game.py bestow): removing the ``if cost > remaining``
refusal let an over-budget bestowal through ``test_bestow_cap_refuses``
then failed on the unchanged-gold assertion. Restored.
* Sanitizer control-char guard (game.py _sanitize): disabling the
``not cleaned.isprintable()`` clause let a newline-injected name create a
player row and a public event ``test_join_rejects_control_char_name``
then failed. Restored. (See the comment block above the hygiene tests.)
"""
from __future__ import annotations
import unicodedata
from pathlib import Path
import pytest
from tests.conftest import fixed_clock, utc
from understone.engine.models import Mode
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.world.loader import load_world
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
@pytest.fixture
def clock() -> object:
return fixed_clock(utc(2026, 6, 12, 10, 0))
def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
world = load_world(PACK)
store = Store(tmp_path / "game.db")
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Join / status / look
# ---------------------------------------------------------------------------
def test_join_creates_player_at_spawn(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
out = game.join("Brandr")
player = game.players["Brandr"]
assert (player.x, player.y) == game.world.spawn
assert player.gold == game.world.settings.starting_gold
assert "@" in out
assert game.world.name in out
def test_join_resumes_existing(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.players["Brandr"].gold = 123
out = game.join("Brandr")
assert "Welcome back" in out
assert game.players["Brandr"].gold == 123
def test_status_unknown_player_is_friendly(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
out = game.status("Nobody")
assert "has signed the ledger" in out
assert "door_join" in out
def test_look_overworld_has_frame(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
out = game.look("Brandr")
assert "@" in out
assert "" in out and "" in out
assert len(out) < 2048
def test_overworld_frame_textured_borders_intact(tmp_path: Path, clock: object) -> None:
"""The textured overworld frame keeps square borders and a single player marker.
Structural discipline for the v0.6 texture: variants change the GLYPHS but
must never change the geometry. The box rows are uniform width, exactly one
'@' is painted, and the grass field shows more than one variant in a row
(the deterministic stipple, not a flat sheet of '.').
"""
game = _game(tmp_path, clock)
game.join("Brandr")
frame = game.look("Brandr")
lines = frame.split("\n")
# Box rows: top border + VIEW_H grid rows + bottom border, all equal width.
box = [ln for ln in lines if ln and ln[0] in "┌│└"]
widths = {len(ln) for ln in box}
assert len(widths) == 1, f"textured frame rows ragged: {widths}"
# Exactly one player marker, regardless of the surrounding texture.
assert frame.count("@") == 1
# The grass texture varies: a body row carries at least two of . , '
body = [ln for ln in lines if ln.startswith("")]
assert any(len({ch for ch in ln if ch in ".,'"}) >= 2 for ln in body)
def test_look_in_menu_shows_location(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
# Shop is two cells east of spawn along the road.
game.move("Brandr", "", "east", 2)
assert game.players["Brandr"].mode is Mode.MENU
out = game.look("Brandr")
assert "(B)uy" in out and "(L)eave" in out
# ---------------------------------------------------------------------------
# Move
# ---------------------------------------------------------------------------
def test_move_blocked_in_menu(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.move("Brandr", "", "east", 2) # into the shop menu
out = game.move("Brandr", "", "east", 2)
assert "inside" in out.lower()
def test_move_enters_location(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
out = game.move("Brandr", "", "west", 2) # inn is two cells west
assert game.players["Brandr"].at_location == "inn"
assert "step inside" in out.lower()
# ---------------------------------------------------------------------------
# Actions: rest, fight, turn budget
# ---------------------------------------------------------------------------
def test_rest_heals_and_charges(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.hp = 5
game.move("Brandr", "", "west", 2) # inn
out = game.action("Brandr", "rest", "", "")
assert player.hp == player.max_hp
assert player.gold == game.world.settings.starting_gold - game.world.settings.rest_cost
assert "full health" in out.lower()
def test_rest_when_spent_restores_a_fresh_days_turns(tmp_path: Path, clock: object) -> None:
"""Sleeping at the inn with no turns left rolls into a fresh day's allowance."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
daily = game.world.settings.daily_turns
player.turns_left = 0 # spent for the day
player.hp = 5
game.move("Brandr", "", "west", 2) # step into the inn
out = game.action("Brandr", "rest", "", "")
assert player.turns_left == daily # a fresh day's turns restored
assert player.hp == player.max_hp # and fully mended
assert f"/{daily} ]" in out # footer reflects the refreshed budget
def test_rest_with_turns_in_hand_never_inflates_the_budget(tmp_path: Path, clock: object) -> None:
"""Resting mid-day mends but adds no turns — the top-up only fires at zero."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
daily = game.world.settings.daily_turns
player.turns_left = daily - 3 # turns still in hand
player.hp = 5
game.move("Brandr", "", "west", 2) # step into the inn
game.action("Brandr", "rest", "", "")
assert player.turns_left == daily - 3 # unchanged: no farming past the cap
assert player.hp == player.max_hp # but the heal still lands
def test_fight_spends_a_turn_and_credits(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
# Drop into the forest_near zone so an encounter is available.
player.x, player.y = 35, 25
before_turns = player.turns_left
out = game.action("Brandr", "fight", "", "")
assert player.turns_left == before_turns - 1
assert player.xp > 0
assert "XP" in out
def test_turn_budget_blocks(tmp_path: Path, clock: object) -> None:
"""Pins the spend_turn guard: at 0 turns, fighting is refused.
See the module docstring for the revert-and-observe-failure check that
proves this assertion has teeth.
"""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.x, player.y = 35, 25
player.turns_left = 0
out = game.action("Brandr", "fight", "", "")
assert "spent for today" in out.lower()
# No turn was consumed past zero, and no XP was gained.
assert player.turns_left == 0
assert player.xp == 0
# ---------------------------------------------------------------------------
# Log / rank
# ---------------------------------------------------------------------------
def test_log_reports_then_advances(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
# A second player acting creates a public event Brandr has not yet seen.
game.join("Sigrun")
first = game.log("Brandr")
assert "Sigrun" in first or "Brandr" in first
assert "The Understone Herald" in first # dressed as the broadsheet
# The cursor advanced; a second read with no new events is quiet.
second = game.log("Brandr")
assert "The Understone Herald" in second # the masthead still prints
assert "still" in second.lower() # the herald-flavoured "all quiet" line
def test_rank_marks_caller(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.join("Sigrun")
game.players["Sigrun"].level = 5
out = game.rank("Brandr")
assert "Brandr" in out and "Sigrun" in out
assert "*" in out # the caller's row is marked
assert "" in out # box-drawing table
# ---------------------------------------------------------------------------
# Rank ★ column: stars live in their own column, so a long name keeps them
# ---------------------------------------------------------------------------
def test_win_stars_column_formats() -> None:
"""Zero is blank, 1..5 render as ★ runs, and >5 collapses to ★xN."""
from understone.game import _win_stars
assert _win_stars(0) == ""
assert _win_stars(1) == ""
assert _win_stars(5) == "★★★★★"
assert _win_stars(7) == "★x7"
def test_long_name_with_one_win_keeps_its_star() -> None:
"""A full 24-char name no longer eats its own ★ (the v0.1 truncation bug).
The name occupied the whole 20-wide field before, clipping the star away;
with a separate stars column the survives beside a maximal name.
"""
from understone.engine.rank import RankEntry
from understone.game import _render_rank_table
name = "X" * 24
rows = _render_rank_table([RankEntry(name=name, level=5, xp=100, gold=50, wins=1)], caller="")
body = "\n".join(rows)
assert name in body # the full name is present
assert "" in body # and so is its star
def test_high_win_count_renders_compact_marker() -> None:
"""Seven wins render as the compact ``★x7`` rather than seven glyphs."""
from understone.engine.rank import RankEntry
from understone.game import _render_rank_table
rows = _render_rank_table([RankEntry(name="Champ", level=9, xp=9, gold=9, wins=7)], caller="")
body = "\n".join(rows)
assert "★x7" in body
assert "★★★★★★★" not in body # not seven literal stars
def test_shared_world_other_player_marker(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.join("Sigrun")
# Stand Sigrun one cell east of Brandr's spawn so she lands in the view.
sig = game.players["Sigrun"]
brandr = game.players["Brandr"]
sig.x, sig.y = brandr.x + 1, brandr.y
out = game.look("Brandr")
assert "" in out # the other player shows as '☻'
# ---------------------------------------------------------------------------
# Bestow (+ cap negative test)
# ---------------------------------------------------------------------------
def test_bestow_grants_gold(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
before = player.gold
out = game.bestow("Brandr", "a daring rescue", 10, 0)
assert player.gold == before + 10
assert player.bestow_spent == 10
assert "bestowal" in out.lower()
def test_bestow_heal_charges_only_applied(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.hp = player.max_hp - 3 # only 3 missing
game.bestow("Brandr", "mercy after a hard fight", 0, 10)
assert player.hp == player.max_hp
# Charged for 3 HP at heal_cost_per_hp, not the requested 10.
assert player.bestow_spent == 3 * game.world.settings.heal_cost_per_hp
def test_bestow_requires_reason(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
out = game.bestow("Brandr", " ", 10, 0)
assert "reason" in out.lower()
assert game.players["Brandr"].gold == game.world.settings.starting_gold
def test_bestow_requires_nonzero(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
out = game.bestow("Brandr", "nothing at all", 0, 0)
assert "at least" in out.lower()
def test_bestow_cap_refuses(tmp_path: Path, clock: object) -> None:
"""Pins the bestow cap: an over-budget grant is refused without mutation.
See the module docstring for the revert-and-observe-failure check that
proves this assertion has teeth.
"""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
budget = game.world.settings.bestow_daily_budget
before_gold = player.gold
out = game.bestow("Brandr", "an absurd windfall", budget + 100, 0)
assert "the fates allow" in out.lower()
# Refused cleanly: no gold moved and no pool spent.
assert player.gold == before_gold
assert player.bestow_spent == 0
def test_bestow_pool_resets_next_day(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
game.bestow("Brandr", "first blessing", 20, 0)
assert player.bestow_spent == 20
# Advance the clock past UTC midnight; the next bestow sees a fresh pool.
game.clock = fixed_clock(utc(2026, 6, 13, 0, 5)) # type: ignore[assignment]
game.bestow("Brandr", "a new day's fortune", 20, 0)
assert player.bestow_spent == 20 # reset to 0 then +20, not 40
# ---------------------------------------------------------------------------
# Persistence round-trip through the façade
# ---------------------------------------------------------------------------
def test_state_survives_store_reopen(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.players["Brandr"].x, game.players["Brandr"].y = 35, 25
game.action("Brandr", "fight", "", "")
xp_after = game.players["Brandr"].xp
gold_after = game.players["Brandr"].gold
game.store.close()
world = load_world(PACK)
reopened = Store(tmp_path / "game.db")
revived = Game(world, reopened, clock=clock) # type: ignore[arg-type]
assert revived.players["Brandr"].xp == xp_after
assert revived.players["Brandr"].gold == gold_after
# ---------------------------------------------------------------------------
# Day rollover applies to fight/descend, not just join/bestow
# ---------------------------------------------------------------------------
class _MutableClock:
"""A clock whose reported moment can be advanced between calls."""
def __init__(self, moment: object) -> None:
self.moment = moment
def __call__(self) -> object:
return self.moment
def test_fight_refreshes_budget_across_midnight(tmp_path: Path) -> None:
"""A fight on a new UTC day must reset the budget without re-joining.
Before the fix, _resolve_encounter spent a turn without calling
_ensure_day, so an exhausted player who returned the next day was still
blocked until they happened to re-join.
"""
clk = _MutableClock(utc(2026, 6, 12, 23, 0))
world = load_world(PACK)
store = Store(tmp_path / "game.db")
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Brandr")
player = game.players["Brandr"]
player.x, player.y = 35, 25 # forest_near zone: an encounter is available
player.turns_left = 0 # spent for the day
daily = game.world.settings.daily_turns
clk.moment = utc(2026, 6, 13, 0, 5) # cross UTC midnight, no re-join
out = game.action("Brandr", "fight", "", "")
assert "spent for today" not in out.lower() # the fresh day let the fight run
assert player.turns_left == daily - 1 # reset to full, then one spent
assert player.xp > 0
assert f"/{daily} ]" in out # footer shows the refreshed budget
def test_descend_refreshes_budget_across_midnight(tmp_path: Path) -> None:
"""Descending on a new UTC day resets the budget without re-joining."""
clk = _MutableClock(utc(2026, 6, 12, 23, 0))
world = load_world(PACK)
store = Store(tmp_path / "game.db")
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Hero")
player = game.players["Hero"]
# Overwhelming stats so the gauntlet itself never bounces the player.
player.level, player.atk, player.def_ = 20, 200, 100
player.hp = player.max_hp = 500
player.mode = Mode.MENU
player.at_location = "dungeon"
player.turns_left = 0
daily = game.world.settings.daily_turns
clk.moment = utc(2026, 6, 13, 0, 5)
out = game.action("Hero", "descend", "", "")
assert "too weary" not in out.lower()
assert player.turns_left == daily - 1
# ---------------------------------------------------------------------------
# Input hygiene chokepoint (the _sanitize helper)
# ---------------------------------------------------------------------------
#
# Negative-test discipline (security invariant): to prove the control-char
# rejection in Game._sanitize has teeth, the implementer temporarily replaced
# its ``not cleaned.isprintable()`` clause with ``False`` (disabling the
# check) and confirmed test_join_rejects_control_char_name FAILED — the
# injected name created a player row and a public event. The clause was then
# restored. The newline-injection test below is the standing regression for
# that invariant.
def test_join_rejects_control_char_name(tmp_path: Path, clock: object) -> None:
"""A bell/control character in a name is refused with the runes line."""
game = _game(tmp_path, clock)
out = game.join("Bra\x07ndr")
assert "strange runes" in out
assert game.players == {} # no row created
assert game.events == [] # nothing persisted
def test_join_rejects_newline_name_no_persist(tmp_path: Path, clock: object) -> None:
"""An embedded newline (log-injection vector) is refused, nothing written.
The name is kept short so it is the control-char clause not the length
clause that rejects it; this is the standing regression for the
isprintable security invariant documented in the module docstring.
"""
game = _game(tmp_path, clock)
out = game.join("Bra\nndr") # 7 chars: well under the 24 limit
assert "strange runes" in out # the runes (bad-character) refusal, not length
# The security invariant: no player row and no event row escaped the guard.
assert game.players == {}
assert game.events == []
def test_join_rejects_overlong_name(tmp_path: Path, clock: object) -> None:
"""A 25-character name is refused with the narrow-ledger line."""
game = _game(tmp_path, clock)
out = game.join("X" * 25)
assert "ledger is narrow" in out
assert game.players == {}
def test_join_accepts_max_length_name(tmp_path: Path, clock: object) -> None:
"""A 24-character name is exactly at the limit and accepted."""
game = _game(tmp_path, clock)
name = "X" * 24
game.join(name)
assert name in game.players
# ---------------------------------------------------------------------------
# Narrow-ledger width rule (the _sanitize one-column clause, v0.6)
#
# Names/reasons/mail render inside fixed-width frames and tables, so a glyph
# that does not fit a single column would shove a column out of true. The
# sanitizer rejects wide runes and combining marks; a printable-but-wide name
# gets the dedicated narrow-ledger refusal, not the control-char "runes" line.
# ---------------------------------------------------------------------------
def test_join_rejects_wide_cjk_name(tmp_path: Path, clock: object) -> None:
"""A CJK ideograph name is refused with the narrow-ledger line; nothing written."""
game = _game(tmp_path, clock)
out = game.join("")
assert "columns are narrow" in out
assert game.players == {}
assert game.events == []
def test_join_rejects_emoji_name(tmp_path: Path, clock: object) -> None:
"""An emoji in a name (🌲x) is wide and refused with the narrow-ledger line."""
game = _game(tmp_path, clock)
out = game.join("🌲x")
assert "columns are narrow" in out
assert game.players == {}
def test_join_rejects_fullwidth_name(tmp_path: Path, clock: object) -> None:
"""A fullwidth Latin letter () is two columns and refused."""
game = _game(tmp_path, clock)
out = game.join("")
assert "columns are narrow" in out
assert game.players == {}
def test_join_rejects_combining_mark_name(tmp_path: Path, clock: object) -> None:
"""A name with a combining mark (decomposed accent) is refused as wide.
The name is normalised to NFD so the 'o' carries a separate U+0308
combining diaeresis a zero-width code point that desynchronises the
column count. Built explicitly so the source encoding cannot mask it.
"""
game = _game(tmp_path, clock)
decomposed = unicodedata.normalize("NFD", "Bj\u00f6rn")
assert any(unicodedata.combining(ch) for ch in decomposed) # genuinely NFD
out = game.join(decomposed)
assert "columns are narrow" in out
assert game.players == {}
def test_join_accepts_composed_latin_name(tmp_path: Path, clock: object) -> None:
"""A precomposed Latin accent (NFC name) is all single-column and accepted."""
game = _game(tmp_path, clock)
composed = unicodedata.normalize("NFC", "Bj\u00f6rn")
game.join(composed)
assert composed in game.players
def _seed_wide_named_player(db: Path, clock: object, wide_name: str) -> None:
"""Write a stored adventurer whose name is a now-illegal wide rune.
Bypasses ``join`` (which would refuse a wide name at creation) by upserting
a Player row straight through the Store, so the fixture stands in for a save
that predates the narrow-ledger rule. Built by renaming a legitimately-
created hero so every other field stays valid.
"""
from dataclasses import replace
world = load_world(PACK)
seed = Store(db)
game = Game(world, seed, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Brandr")
base = game.players["Brandr"]
seed.upsert_player(replace(base, name=wide_name))
seed.commit()
seed.close()
def test_join_resumes_stored_wide_name(tmp_path: Path, clock: object) -> None:
"""An existing adventurer with a wide-rune name resumes \u2014 identity is never re-gated.
Resume keys off the exact stored name BEFORE the sanitizer, so a character
whose name predates the narrow-ledger rule is welcomed back rather than
locked out. This is the resume-by-exact-name invariant.
"""
db = tmp_path / "game.db"
wide = "\u9f8d"
_seed_wide_named_player(db, clock, wide)
world = load_world(PACK)
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
out = game.join(wide)
assert "Welcome back" in out # resumed, not refused
assert "columns are narrow" not in out
assert wide in game.players
def test_join_still_refuses_new_wide_name(tmp_path: Path, clock: object) -> None:
"""Creation is still gated: a NEW wide name with no stored row is refused.
The resume bypass is exact-name only; a wide name that matches no stored
adventurer falls through to the creation gate and gets the narrow-ledger
refusal, with nothing written.
"""
db = tmp_path / "game.db"
# Seed one wide-named save, then try to CREATE a different wide name.
_seed_wide_named_player(db, clock, "\u9f8d")
world = load_world(PACK)
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
out = game.join("\u7363") # a different wide rune \u2014 no stored row for it
assert "columns are narrow" in out
assert "\u7363" not in game.players
def test_bestow_rejects_newline_reason_no_persist(tmp_path: Path, clock: object) -> None:
"""A newline-embedded bestow reason is refused; no event, pool unchanged."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
events_before = len(game.events)
out = game.bestow("Brandr", "heroics\nand a forged log line", 10, 0)
assert "plainly-spoken" in out
assert len(game.events) == events_before # no bestow event appended
assert player.bestow_spent == 0 # pool untouched
# ---------------------------------------------------------------------------
# Bestow: heal-only at full HP grants nothing (no empty grant persisted)
# ---------------------------------------------------------------------------
def test_bestow_heal_only_at_full_hp_refused(tmp_path: Path, clock: object) -> None:
"""A heal-only bestow at full HP applies nothing and must not persist."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
assert player.hp == player.max_hp # join starts at full health
events_before = len(game.events)
out = game.bestow("Brandr", "a quiet blessing", 0, 10)
assert "already hale" in out
assert len(game.events) == events_before # no "Fortune favours" line written
assert player.bestow_spent == 0 # nothing charged
# ---------------------------------------------------------------------------
# Descend the deep: one rung per descent (see test_descend.py for the ladder)
# ---------------------------------------------------------------------------
def test_descend_fights_one_rung_and_advances(tmp_path: Path, clock: object) -> None:
"""A strong player clears the next rung: one foe fought, rewards banked, depth +1."""
game = _game(tmp_path, clock)
game.join("Hero")
player = game.players["Hero"]
player.level, player.atk, player.def_ = 20, 200, 100
player.hp = player.max_hp = 500
player.mode = Mode.MENU
player.at_location = "dungeon"
before_turns, before_gold, before_xp = player.turns_left, player.gold, player.xp
out = game.action("Hero", "descend", "", "")
# The first rung is the tier-3 guardian (Forest Wolf); deeper rungs do NOT
# appear in one descent — the deep is fought a rung at a time now.
assert "Forest Wolf" in out
assert "Cave Troll" not in out
assert player.deepest_rung == 1
assert player.turns_left == before_turns - 1
assert player.gold > before_gold
assert player.xp > before_xp
def test_descend_bounces_weak_player_to_spawn(tmp_path: Path, clock: object) -> None:
"""A fresh weak player falls on the first rung and wakes at the spawn.
Depth is NOT advanced by a loss, but it persists at whatever it was (here 0).
"""
game = _game(tmp_path, clock)
game.join("Weakling")
player = game.players["Weakling"]
player.mode = Mode.MENU
player.at_location = "dungeon"
out = game.action("Weakling", "descend", "", "")
assert player.hp == 1
assert player.mode is Mode.TILE
assert player.at_location == ""
assert (player.x, player.y) == game.world.spawn
assert player.deepest_rung == 0 # a loss never advances the deep
# Felled by the first rung (the tier-3 Forest Wolf).
assert "Forest Wolf" in out
# ---------------------------------------------------------------------------
# Shop façade: buy / upgrade / sell / heal stat arithmetic
# ---------------------------------------------------------------------------
def test_shop_buy_upgrade_sell_heal_cycle(tmp_path: Path, clock: object) -> None:
"""Equip deltas apply once on buy/upgrade and unwind cleanly on sell."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.gold = 1000
player.mode = Mode.MENU
player.at_location = "shop"
short_sword = game.world.item_by_id("short_sword")
war_axe = game.world.item_by_id("war_axe")
starter = game.world.item_by_id(game.world.settings.starting_weapon)
assert short_sword is not None and war_axe is not None and starter is not None
starter_atk = player.atk # 3 base + rusty dagger bonus
# Buy the short sword: gold falls by its price, atk rises by the delta.
gold0 = player.gold
game.action("Brandr", "buy", "", "short_sword")
assert player.gold == gold0 - short_sword.price
assert player.atk == starter_atk + (short_sword.atk - starter.atk)
atk_with_sword = player.atk
# Upgrade to the war axe: atk reflects the difference, not a double-add.
gold1 = player.gold
game.action("Brandr", "buy", "", "war_axe")
assert player.gold == gold1 - war_axe.price
assert player.atk == atk_with_sword + (war_axe.atk - short_sword.atk)
# Sell the war axe: half-price refund, atk falls back to the starter bonus.
gold2 = player.gold
game.action("Brandr", "sell", "", "")
assert player.gold == gold2 + war_axe.price // 2
assert player.atk == starter_atk
# Heal at the shrine: HP restored, gold debited per missing point.
player.mode = Mode.MENU
player.at_location = "healer"
player.hp = player.max_hp - 5
per_hp = game.world.settings.heal_cost_per_hp
gold3 = player.gold
game.action("Brandr", "heal", "", "")
assert player.hp == player.max_hp
assert player.gold == gold3 - 5 * per_hp
def test_sell_starter_weapon_refused(tmp_path: Path, clock: object) -> None:
"""The starter blade is unsellable regardless of price (no free-gold loop)."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
assert player.weapon_id == game.world.settings.starting_weapon
player.mode = Mode.MENU
player.at_location = "shop"
gold_before = player.gold
out = game.action("Brandr", "sell", "", "")
assert "nothing worth selling" in out.lower()
assert player.gold == gold_before
# ---------------------------------------------------------------------------
# Bounded in-memory event tail (full history stays in SQLite)
# ---------------------------------------------------------------------------
def test_event_tail_is_capped_but_log_still_works(tmp_path: Path, clock: object) -> None:
"""Loading caps the resident tail; door_log still serves recent events."""
from understone.engine.log import since
from understone.game import EVENT_TAIL_KEEP
db = tmp_path / "game.db"
seed_store = Store(db)
last_id = 0
for i in range(EVENT_TAIL_KEEP + 50):
last_id = seed_store.insert_event("t", "sys", "note", f"event {i}")
seed_store.commit()
seed_store.close()
world = load_world(PACK)
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
# Only the most recent EVENT_TAIL_KEEP events are resident in memory.
assert len(game.events) == EVENT_TAIL_KEEP
assert game.events[-1].event_id == last_id
# door_log still reports events after a recent cursor.
recent_cursor = game.events[-3].event_id
game.join("Brandr")
game.players["Brandr"].log_cursor = recent_cursor
out = game.log("Brandr")
assert "The Understone Herald" in out # broadsheet masthead
assert "since your last visit" in out
fresh, new_cursor = since(game.events, recent_cursor)
assert fresh # there are events past the cursor
assert new_cursor == game.events[-1].event_id
def test_private_mail_survives_tail_eviction(tmp_path: Path, clock: object) -> None:
"""A private note older than the resident tail is still delivered (durable mail).
Public history that falls off the in-memory tail is gone by design (the
broadsheet does not keep), but mail must not be: a note left while the
recipient was away has to surface however many public events have since
pushed it out of the tail. A third player whose cursor also predates the
note must still never see it, because it was never theirs.
"""
from understone.persistence import EVENT_TAIL_KEEP
db = tmp_path / "game.db"
store = Store(db)
game = Game(load_world(PACK), store, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Scribe")
game.join("Reader")
game.join("Bystander")
# Scribe leaves Reader a private note; neither Reader nor Bystander reads it.
secret = "the cellar key is under the third barrel"
game.action("Scribe", "post", "Reader", "", secret)
# Flood the feed past the tail bound so the note is evicted from memory.
for i in range(EVENT_TAIL_KEEP + 20):
store.insert_event("t", "sys", "note", f"broadsheet filler {i}")
store.commit()
store.close()
# Reopen: only the newest tail is resident, so the note now lives in the gap.
reopened = Store(db)
revived = Game(load_world(PACK), reopened, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
note_id = next(
e.event_id
for e in reopened.targeted_events_since("Reader", 0) # note: from SQLite, not the tail
if secret in e.text
)
assert note_id < revived.events[0].event_id # the note really is past the tail
# The recipient still sees the note, backfilled from SQLite...
reader_log = revived.log("Reader")
assert secret in reader_log
assert "While you were away" in reader_log
# ...but a third player never does, even though their cursor predates it too.
third_log = revived.log("Bystander")
assert secret not in third_log
reopened.close()
-118
View File
@@ -1,118 +0,0 @@
"""XP curve, level-up, and restorative-maths tests.
Pins the threshold edges (at / just below / just above), a multi-level
jump from a single award, the exact growth table, the inn's flat-rate
full heal with affordability gating, and the healer's per-HP cost maths.
"""
from __future__ import annotations
from tests.conftest import DEFAULT_SETTINGS, make_player, make_settings
from understone.engine.leveling import apply_xp, heal, rest, xp_for_level
# Default curve is 100 * (n-1)*n/2 cumulative:
# L2 = 100, L3 = 300, L4 = 600, L5 = 1000.
def test_xp_curve_thresholds() -> None:
assert xp_for_level(1, DEFAULT_SETTINGS) == 0
assert xp_for_level(2, DEFAULT_SETTINGS) == 100
assert xp_for_level(3, DEFAULT_SETTINGS) == 300
assert xp_for_level(4, DEFAULT_SETTINGS) == 600
assert xp_for_level(5, DEFAULT_SETTINGS) == 1000
def test_just_below_threshold_does_not_level() -> None:
player = make_player(level=1, xp=0, hp=20, max_hp=20)
gains = apply_xp(player, 99, DEFAULT_SETTINGS)
assert gains == []
assert player.level == 1
def test_exact_threshold_levels_once() -> None:
player = make_player(level=1, xp=0, hp=10, max_hp=20, atk=5, def_=1)
gains = apply_xp(player, 100, DEFAULT_SETTINGS)
assert len(gains) == 1
assert player.level == 2
# Growth table applied and a full heal granted on level-up.
assert player.max_hp == 26
assert player.atk == 7
assert player.def_ == 2
assert player.hp == player.max_hp
def test_just_above_threshold_levels_once() -> None:
player = make_player(level=1, xp=0)
gains = apply_xp(player, 101, DEFAULT_SETTINGS)
assert len(gains) == 1
assert player.level == 2
assert player.xp == 101
def test_single_award_can_jump_multiple_levels() -> None:
player = make_player(level=1, xp=0, max_hp=20, atk=5, def_=1)
gains = apply_xp(player, 600, DEFAULT_SETTINGS)
# 600 cumulative reaches level 4 (L2=100, L3=300, L4=600).
assert player.level == 4
assert [g.new_level for g in gains] == [2, 3, 4]
# Three levels of growth stacked.
assert player.max_hp == 20 + 3 * 6
assert player.atk == 5 + 3 * 2
assert player.def_ == 1 + 3 * 1
def test_growth_table_respects_settings() -> None:
settings = make_settings(growth_max_hp=10, growth_atk=3, growth_def=2, xp_base=50)
player = make_player(level=1, xp=0, max_hp=20, atk=5, def_=1)
apply_xp(player, 50, settings) # L2 at 50 with xp_base=50
assert player.level == 2
assert player.max_hp == 30
assert player.atk == 8
assert player.def_ == 3
# ---------------------------------------------------------------------------
# rest (inn) and heal (healer)
# ---------------------------------------------------------------------------
def test_rest_full_heals_and_charges() -> None:
player = make_player(hp=5, max_hp=20, gold=50)
assert rest(player, cost=15) is True
assert player.hp == 20
assert player.gold == 35
def test_rest_refused_when_unaffordable() -> None:
player = make_player(hp=5, max_hp=20, gold=10)
assert rest(player, cost=15) is False
assert player.hp == 5
assert player.gold == 10
def test_heal_charges_only_for_hp_restored() -> None:
player = make_player(hp=15, max_hp=20, gold=100)
result = heal(player, amount=10, cost_per_hp=2)
# Only 5 HP were missing.
assert result.healed == 5
assert result.cost == 10
assert player.hp == 20
assert player.gold == 90
def test_heal_bounded_by_affordability() -> None:
player = make_player(hp=2, max_hp=20, gold=7)
result = heal(player, amount=10, cost_per_hp=2)
# 7 gold buys 3 HP at 2/hp.
assert result.healed == 3
assert result.cost == 6
assert player.hp == 5
assert player.gold == 1
def test_heal_noop_when_full() -> None:
player = make_player(hp=20, max_hp=20, gold=100)
result = heal(player, amount=10, cost_per_hp=2)
assert result.healed == 0
assert result.cost == 0
assert player.gold == 100
@@ -1,288 +0,0 @@
"""End-to-end MCP integration test — the only test that touches the network.
Boots the real Understone FastMCP app (backed by a temp DB) in a uvicorn
thread, then drives it over the real streamable-HTTP wire with the real MCP
client: initialize, list_tools (all nine door_* names), join, look. A second
client session joins a second adventurer in the SAME process and world, and
the first player's view then shows the '&' other-player marker — proving the
shared-world, single-process contract over a real wire.
A second test drives the read-only Watch routes that ride inside the same app:
GET /watch (the HTML page), /watch/world.json (the static map), and
/watch/state.json (the live snapshot) confirming the spectator endpoints
serve real world data alongside a working /mcp without breaking either.
"""
from __future__ import annotations
import asyncio
import socket
import threading
import time
from typing import TYPE_CHECKING, Any
import httpx
import pytest
import uvicorn
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from understone import server as understone_server
if TYPE_CHECKING:
from pathlib import Path
PACK = str(understone_server.PACKAGED_WORLD_DIR)
def _find_free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return int(port)
def _build_server(port: int, db_path: str) -> uvicorn.Server:
app = understone_server.create_app(db_path, PACK)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", access_log=False)
return uvicorn.Server(config)
def _wait_ready(port: int, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
return
except OSError:
time.sleep(0.05)
raise TimeoutError(f"understone server at 127.0.0.1:{port} not ready after {timeout}s")
@pytest.fixture
def live_server(tmp_path: Path) -> Any:
"""Boot the real Understone app in a background uvicorn thread."""
port = _find_free_port()
db_path = str(tmp_path / "wire.db")
server = _build_server(port, db_path)
def _run() -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
thread = threading.Thread(target=_run, daemon=True, name="understone-itest")
thread.start()
try:
_wait_ready(port)
yield f"http://127.0.0.1:{port}/mcp"
finally:
server.should_exit = True
thread.join(timeout=5)
# create_app installed a module-level game whose Store holds an open
# SQLite connection; close it and clear the singleton so the next test
# builds its own rather than inheriting this temp DB.
if understone_server._GAME is not None:
understone_server._GAME.store.close()
understone_server._GAME = None
# FastMCP caches a StreamableHTTPSessionManager on the module-level mcp
# singleton and refuses a second lifespan .run() on the same instance.
# Reset it so each fixture instance boots a fresh session manager (the
# production server only ever runs one). Without this, a second
# fixture-using test fails on "run() can only be called once".
understone_server.mcp._session_manager = None
async def _call_text(session: ClientSession, name: str, arguments: dict[str, Any]) -> str:
result = await session.call_tool(name, arguments)
chunks = [block.text for block in result.content if getattr(block, "type", None) == "text"]
return "\n".join(chunks)
async def _drive(url: str) -> dict[str, Any]:
"""Run the full client conversation and return observations."""
observations: dict[str, Any] = {}
async with (
streamable_http_client(url) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
tools = await session.list_tools()
observations["tool_names"] = sorted(t.name for t in tools.tools)
observations["join_one"] = await _call_text(session, "door_join", {"player": "Brandr"})
observations["look_one_before"] = await _call_text(
session, "door_look", {"player": "Brandr"}
)
# A SECOND, independent session joins a second adventurer in the same world.
async with (
streamable_http_client(url) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
# Place player two adjacent to player one so they share the view.
await _call_text(session, "door_join", {"player": "Sigrun"})
await _call_text(
session, "door_move", {"player": "Sigrun", "heading": "east", "distance": 1}
)
# Back as player one: the shared world now shows the other adventurer.
async with (
streamable_http_client(url) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
observations["look_one_after"] = await _call_text(
session, "door_look", {"player": "Brandr"}
)
observations["rank"] = await _call_text(session, "door_rank", {"player": "Brandr"})
return observations
def test_mcp_end_to_end(live_server: str) -> None:
obs = asyncio.run(_drive(live_server))
# All nine tools are advertised over the wire.
expected = {
"door_help",
"door_join",
"door_status",
"door_look",
"door_move",
"door_action",
"door_log",
"door_rank",
"door_bestow",
}
assert set(obs["tool_names"]) == expected
# The join + look frames are real ASCII map frames.
assert "@" in obs["join_one"]
look_before = obs["look_one_before"]
assert "@" in look_before
assert "" in look_before and "" in look_before
# Shared-world proof: after player two joins next door, player one sees '☻'.
assert "" in obs["look_one_after"]
# And the leaderboard lists both adventurers (one process, one world).
assert "Brandr" in obs["rank"]
assert "Sigrun" in obs["rank"]
def _watch_base(mcp_url: str) -> str:
"""Derive the app root (where /watch lives) from the /mcp endpoint URL."""
return mcp_url[: -len("/mcp")] if mcp_url.endswith("/mcp") else mcp_url
async def _join_over_mcp(mcp_url: str, name: str) -> None:
"""Sign one adventurer in over the real MCP wire (so state.json sees them)."""
async with (
streamable_http_client(mcp_url) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
await _call_text(session, "door_join", {"player": name})
def test_watch_routes_serve_world_state(live_server: str) -> None:
base = _watch_base(live_server)
# The MCP join writes the player into the shared world the routes read.
asyncio.run(_join_over_mcp(live_server, "Watcher"))
with httpx.Client(timeout=5.0) as client:
page = client.get(f"{base}/watch")
world = client.get(f"{base}/watch/world.json")
state = client.get(f"{base}/watch/state.json")
# The page is real HTML carrying the static masthead.
assert page.status_code == 200
assert page.headers["content-type"].startswith("text/html")
assert "Understone — Live Watch" in page.text
# The static world payload matches the loaded world.
assert world.status_code == 200
world_body = world.json()
assert world_body["width"] == 96
assert world_body["height"] == 48
assert len(world_body["glyph_rows"]) == world_body["height"]
assert all(len(row) == world_body["width"] for row in world_body["glyph_rows"])
# The live snapshot lists the adventurer who joined over MCP.
assert state.status_code == 200
state_body = state.json()
names = {p["name"] for p in state_body["players"]}
assert "Watcher" in names
def test_watch_routes_coexist_with_mcp(live_server: str) -> None:
"""The custom routes don't shadow /mcp: tool calls still work alongside them."""
base = _watch_base(live_server)
async def _drive_both() -> tuple[str, int]:
async with (
streamable_http_client(live_server) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
joined = await _call_text(session, "door_join", {"player": "Coexist"})
with httpx.Client(timeout=5.0) as client:
status = client.get(f"{base}/watch/state.json").status_code
return joined, status
joined, watch_status = asyncio.run(_drive_both())
assert "@" in joined # the MCP tool still returns a real frame
assert watch_status == 200 # and the watch route still answers
def test_streamable_http_host_gate_off_localhost() -> None:
"""A non-localhost bind must accept remote `Host` headers on /mcp.
REGRESSION: FastMCP freezes DNS-rebinding protection (a localhost-only Host
allowlist) at CONSTRUCTION, and ``server`` builds its FastMCP at import with
the default 127.0.0.1 host. A 0.0.0.0/LAN bind therefore answered TCP and
`/watch` but 421'd `/mcp` for every remote node ("Invalid Host header").
``_serve`` drops the allowlist when bound off localhost; this pins the
mechanism a default instance rejects a foreign Host, a protection-disabled
one accepts it (a 421 in the second case is the bug returning).
Uses fresh FastMCP instances (not the module singleton) so there is no
shared-state or app-cache coupling with the live-server tests above.
"""
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.testclient import TestClient
foreign = {
"Host": "192.168.0.239:8077",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
init = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "probe", "version": "0"},
},
}
# Default (localhost-baked allowlist) — a remote Host is refused.
locked = FastMCP("hostgate-locked")
with TestClient(locked.streamable_http_app()) as client:
assert client.post("/mcp", headers=foreign, json=init).status_code == 421
# Protection disabled (what _serve does off localhost) — remote Host accepted.
opened = FastMCP("hostgate-open")
opened.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=False
)
with TestClient(opened.streamable_http_app()) as client:
resp = client.post("/mcp", headers=foreign, json=init)
assert resp.status_code != 421, f"remote Host still rejected: {resp.status_code} {resp.text}"
-336
View File
@@ -1,336 +0,0 @@
"""Movement resolution tests.
Covers edge clipping on all four sides, blocking terrain, the two input
forms (``"NNEE"`` vs heading+distance) and their equivalence, location
entry flipping to MENU, the MAX_STEPS cap, and a stubbed always-encounter
RNG interrupting a walk with a pending fight.
"""
from __future__ import annotations
from tests.conftest import (
FOREST,
GRASS,
WALL,
WATER,
LocationDef,
Zone,
make_player,
make_world,
)
from understone.engine.models import Mode, WorldEvent
from understone.engine.movement import MAX_STEPS, parse_directions, resolve_move
from understone.engine.rng import GameRNG
class _NeverRNG(GameRNG):
"""An RNG whose chance() never fires (no wandering encounters)."""
def __init__(self) -> None:
super().__init__(seed=0)
def chance(self, probability: float) -> bool: # noqa: ARG002
return False
class _AlwaysRNG(GameRNG):
"""An RNG whose chance() always fires (forces an encounter).
The seed still drives ``weighted_index``/``randint``, so different seeds
select different event rows while every encounter roll fires.
"""
def __init__(self, seed: int = 0) -> None:
super().__init__(seed=seed)
def chance(self, probability: float) -> bool: # noqa: ARG002
return True
# ---------------------------------------------------------------------------
# parse_directions
# ---------------------------------------------------------------------------
def test_parse_steps_string() -> None:
assert parse_directions("NNEE", "", 1) == ["N", "N", "E", "E"]
def test_parse_heading_distance() -> None:
assert parse_directions("", "east", 3) == ["E", "E", "E"]
def test_parse_clamps_to_max_steps() -> None:
assert parse_directions("NNNNNNNNNNNN", "", 1) == ["N"] * MAX_STEPS
assert parse_directions("", "north", 99) == ["N"] * MAX_STEPS
def test_parse_rejects_unknown_direction() -> None:
try:
parse_directions("NQ", "", 1)
except ValueError as exc:
assert "Q" in str(exc)
else: # pragma: no cover - failure path
raise AssertionError("expected ValueError")
# ---------------------------------------------------------------------------
# Edge clipping (all four sides)
# ---------------------------------------------------------------------------
def test_clip_north_edge() -> None:
world = make_world()
player = make_player(x=5, y=0)
result = resolve_move(world, player, _NeverRNG(), heading="north", distance=3)
assert player.y == 0
assert result.steps_taken == 0
assert result.blocked
def test_clip_south_edge() -> None:
world = make_world()
player = make_player(x=5, y=10)
result = resolve_move(world, player, _NeverRNG(), heading="south", distance=3)
assert player.y == 10
assert result.blocked
def test_clip_west_edge() -> None:
world = make_world()
player = make_player(x=0, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="west", distance=3)
assert player.x == 0
assert result.blocked
def test_clip_east_edge() -> None:
world = make_world()
player = make_player(x=10, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=3)
assert player.x == 10
assert result.blocked
def test_partial_move_then_clip() -> None:
world = make_world()
player = make_player(x=8, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=5)
# 8 -> 9 -> 10, then edge.
assert player.x == 10
assert result.steps_taken == 2
assert result.blocked
# ---------------------------------------------------------------------------
# Blocking terrain
# ---------------------------------------------------------------------------
def test_blocked_by_wall() -> None:
grid = [[GRASS for _ in range(11)] for _ in range(11)]
grid[5][6] = WALL
world = make_world(grid=grid)
player = make_player(x=5, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=2)
assert player.x == 5
assert result.blocked
assert "wall" in result.blocked_reason
def test_blocked_by_water() -> None:
grid = [[GRASS for _ in range(11)] for _ in range(11)]
grid[4][5] = WATER
world = make_world(grid=grid)
player = make_player(x=5, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="north", distance=2)
assert player.y == 5
assert result.blocked
assert "water" in result.blocked_reason
# ---------------------------------------------------------------------------
# Input-form equivalence and direction correctness
# ---------------------------------------------------------------------------
def test_nnee_lands_at_expected_cell() -> None:
world = make_world()
player = make_player(x=5, y=5)
resolve_move(world, player, _NeverRNG(), steps="NNEE")
# Two north (y-2), two east (x+2).
assert (player.x, player.y) == (7, 3)
def test_heading_equivalent_to_steps() -> None:
world_a = make_world()
player_a = make_player(x=5, y=5)
resolve_move(world_a, player_a, _NeverRNG(), steps="EEE")
world_b = make_world()
player_b = make_player(x=5, y=5)
resolve_move(world_b, player_b, _NeverRNG(), heading="east", distance=3)
assert (player_a.x, player_a.y) == (player_b.x, player_b.y)
def test_max_steps_truncates_long_walk() -> None:
world = make_world(width=40, height=11)
player = make_player(x=0, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=99)
assert result.steps_taken == MAX_STEPS
assert player.x == MAX_STEPS
# ---------------------------------------------------------------------------
# Location entry flips to MENU
# ---------------------------------------------------------------------------
def test_entering_location_flips_menu_mode() -> None:
loc = LocationDef(
key="inn",
kind="inn",
name="The Sleeping Drake",
x=7,
y=5,
glyph="I",
color="town",
actions=("rest", "leave"),
)
world = make_world(locations=[loc])
player = make_player(x=5, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=4)
assert player.mode is Mode.MENU
assert player.at_location == "inn"
assert result.entered_location == "inn"
# Stopped on the door at x=7 even though distance asked for 4.
assert (player.x, player.y) == (7, 5)
# ---------------------------------------------------------------------------
# Encounter interrupt
# ---------------------------------------------------------------------------
def test_always_encounter_stops_with_pending_fight() -> None:
grid = [[FOREST for _ in range(11)] for _ in range(11)]
zone = Zone(key="wood", x0=0, y0=0, x1=10, y1=10, tier_lo=1, tier_hi=2)
world = make_world(grid=grid, zones=[zone])
player = make_player(x=5, y=5)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=5)
assert result.pending_fight == (1, 2)
# The encounter fires on the first entered cell.
assert result.steps_taken == 1
assert player.x == 6
def test_no_zone_means_no_encounter() -> None:
grid = [[FOREST for _ in range(11)] for _ in range(11)]
world = make_world(grid=grid, zones=[])
player = make_player(x=5, y=5)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=3)
assert result.pending_fight is None
assert result.steps_taken == 3
# ---------------------------------------------------------------------------
# Weighted non-combat overworld events (v0.2)
# ---------------------------------------------------------------------------
def _event_world(*events: WorldEvent) -> object:
"""An all-forest, fully-zoned world carrying a crafted event table."""
grid = [[FOREST for _ in range(11)] for _ in range(11)]
zone = Zone(key="wood", x0=0, y0=0, x1=10, y1=10, tier_lo=1, tier_hi=2)
return make_world(grid=grid, zones=[zone], events=list(events))
def test_event_fight_stops_the_walk() -> None:
"""A fight-kind event sets pending_fight and halts the walk like v0.1."""
world = _event_world(WorldEvent("fight", 1, "", 0, 0))
player = make_player(x=5, y=5)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=5)
assert result.pending_fight == (1, 2)
assert result.event is None
assert result.steps_taken == 1 # stopped on the first triggering cell
def test_event_gold_credits_and_continues() -> None:
"""A gold event credits the rolled amount and does NOT stop the walk."""
world = _event_world(WorldEvent("gold", 1, "a coin-purse", 5, 5))
player = make_player(x=5, y=5, gold=10)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=3)
assert result.event is not None
assert result.event.kind == "gold"
assert result.event.amount == 5 # min == max == 5, so deterministic
assert player.gold == 15
assert result.pending_fight is None
assert result.steps_taken == 3 # the walk ran to completion
def test_event_heal_caps_at_max_hp() -> None:
"""A heal event never overfills: hp is clamped to max_hp."""
world = _event_world(WorldEvent("heal", 1, "a spring", 50, 50))
player = make_player(x=5, y=5, hp=18, max_hp=20)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=1)
assert player.hp == 20 # +50 requested, capped at the 2 missing
assert result.event is not None and result.event.amount == 2
def test_event_trap_floors_hp_at_one_and_spares_gold() -> None:
"""A trap event never kills (floors at 1 HP) and never touches gold."""
world = _event_world(WorldEvent("trap", 1, "old briars", 500, 500))
player = make_player(x=5, y=5, hp=10, max_hp=20, gold=42)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=1)
assert player.hp == 1 # huge trap, but floored
assert player.gold == 42 # gold untouched
assert result.event is not None and result.event.amount == 9 # only 9 could be taken
def test_event_lore_mutates_nothing() -> None:
"""A lore event changes no state and reports a zero amount."""
world = _event_world(WorldEvent("lore", 1, "an old waystone", 0, 0))
player = make_player(x=5, y=5, hp=15, max_hp=20, gold=7)
before = (player.hp, player.gold)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=2)
assert (player.hp, player.gold) == before
assert result.event is not None and result.event.kind == "lore"
assert result.event.amount == 0
assert result.steps_taken == 2
def test_at_most_one_event_per_walk() -> None:
"""Once any event fires, no further cells roll for the rest of the walk.
Two distinct gold rolls would credit 2 gold (1 each); a single fired event
credits exactly 1, proving the walk stops rolling after the first trigger.
"""
world = _event_world(WorldEvent("gold", 1, "a coin", 1, 1))
player = make_player(x=5, y=5, gold=0)
resolve_move(world, player, _AlwaysRNG(), heading="east", distance=5)
assert player.gold == 1 # exactly one event, not five
def test_each_event_kind_reachable_with_crafted_table() -> None:
"""Equal weights make every kind in a crafted table reachable from movement."""
table = [
WorldEvent("fight", 1, "", 0, 0),
WorldEvent("gold", 1, "g", 1, 1),
WorldEvent("heal", 1, "h", 1, 1),
WorldEvent("trap", 1, "t", 1, 1),
WorldEvent("lore", 1, "l", 0, 0),
]
zone = Zone(key="wood", x0=0, y0=0, x1=0, y1=0, tier_lo=1, tier_hi=2)
grid = [[FOREST for _ in range(11)] for _ in range(11)]
world = make_world(grid=grid, zones=[zone], events=table)
seen: set[str] = set()
for seed in range(60):
player = make_player(x=0, y=1, hp=10, max_hp=20) # one step north into the zone cell
result = resolve_move(world, player, _AlwaysRNG(seed), steps="N")
if result.pending_fight is not None:
seen.add("fight")
elif result.event is not None:
seen.add(result.event.kind)
assert seen == {"fight", "gold", "heal", "trap", "lore"}
-9
View File
@@ -1,9 +0,0 @@
"""Smoke test for the packaging skeleton."""
from __future__ import annotations
import understone
def test_version_present() -> None:
assert understone.__version__ == "0.10.0"
@@ -1,269 +0,0 @@
"""SQLite persistence tests.
Covers idempotent schema init, a full player round-trip through every
column (including ``def_``, ``turn_day``, ``log_cursor`` and the bestow
fields), event append with cursor-based catch-up, leaderboard tie-breaks,
and that WAL journaling is active.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from tests.conftest import make_player
from understone.engine.log import since
from understone.engine.models import Mode
from understone.persistence import Store
if TYPE_CHECKING:
from pathlib import Path
def _store(tmp_path: Path) -> Store:
return Store(tmp_path / "understone.db")
def test_schema_init_is_idempotent(tmp_path: Path) -> None:
db = tmp_path / "understone.db"
Store(db).close()
# Re-opening the same file must not error or duplicate schema.
second = Store(db)
assert second.get_meta("schema_version") == "1"
second.close()
def test_wal_mode_active(tmp_path: Path) -> None:
store = _store(tmp_path)
assert store.journal_mode().lower() == "wal"
store.close()
def test_player_round_trip_all_columns(tmp_path: Path) -> None:
store = _store(tmp_path)
player = make_player(
name="Brandr",
x=12,
y=7,
hp=18,
max_hp=26,
level=3,
xp=305,
gold=88,
atk=9,
def_=4,
weapon_id="short_sword",
armor_id="leather_armor",
turns_left=6,
turn_day=739_400,
mode=Mode.MENU,
at_location="inn",
log_cursor=42,
bestow_spent=15,
bestow_day=739_400,
posts_sent=3,
post_day=739_400,
gambles=2,
gamble_day=739_400,
banked=420,
)
store.upsert_player(player)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
loaded = players["Brandr"]
assert loaded == player
assert loaded.banked == 420
# Spot-check the fields most prone to silent drop.
assert loaded.def_ == 4
assert loaded.turn_day == 739_400
assert loaded.log_cursor == 42
assert loaded.bestow_spent == 15
assert loaded.bestow_day == 739_400
assert loaded.mode is Mode.MENU
# The v0.5 social columns survive the round-trip too.
assert loaded.posts_sent == 3
assert loaded.post_day == 739_400
assert loaded.gambles == 2
assert loaded.gamble_day == 739_400
reopened.close()
def test_event_target_round_trips(tmp_path: Path) -> None:
"""A targeted (private) event keeps its target across a reopen; public is ''."""
store = _store(tmp_path)
pub = store.insert_event("t1", "Brandr", "join", "set out")
priv = store.insert_event("t2", "Sigrun", "ambushed", "robbed in your sleep", "Brandr")
store.commit()
store.close()
reopened = _store(tmp_path)
_, events = reopened.load_all()
by_id = {e.event_id: e for e in events}
assert by_id[pub].target == "" # public stays empty
assert by_id[priv].target == "Brandr" # private keeps its recipient
reopened.close()
def test_ambush_table_per_day_uniqueness(tmp_path: Path) -> None:
"""The ambushes PK is (attacker, target, day): one row per pair per day."""
store = _store(tmp_path)
day = 739_400
assert store.has_ambushed("Brandr", "Sigrun", day) is False
store.record_ambush("Brandr", "Sigrun", day)
store.commit()
assert store.has_ambushed("Brandr", "Sigrun", day) is True
# A second record for the same pair/day is a no-op (INSERT OR IGNORE):
# the duplicate must not raise and must not add a row.
store.record_ambush("Brandr", "Sigrun", day)
store.commit()
rows = store._conn.execute(
"SELECT COUNT(*) AS n FROM ambushes WHERE attacker=? AND target=? AND day=?",
("Brandr", "Sigrun", day),
).fetchone()
assert rows["n"] == 1
# A new day is a fresh attempt; the old day stays recorded.
assert store.has_ambushed("Brandr", "Sigrun", day + 1) is False
store.record_ambush("Brandr", "Sigrun", day + 1)
store.commit()
assert store.has_ambushed("Brandr", "Sigrun", day) is True
assert store.has_ambushed("Brandr", "Sigrun", day + 1) is True
store.close()
def test_upsert_updates_existing_row(tmp_path: Path) -> None:
store = _store(tmp_path)
player = make_player(name="Sigrun", gold=10)
store.upsert_player(player)
store.commit()
player.gold = 999
store.upsert_player(player)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
assert players["Sigrun"].gold == 999
assert len(players) == 1
reopened.close()
def test_event_append_and_since_cursor(tmp_path: Path) -> None:
store = _store(tmp_path)
id1 = store.insert_event("t1", "Brandr", "fight", "slew a rat")
id2 = store.insert_event("t2", "Sigrun", "bestow", "blessed with gold")
store.commit()
store.close()
reopened = _store(tmp_path)
_, events = reopened.load_all()
assert [e.event_id for e in events] == [id1, id2]
# Catch up from a cursor before both, then advance past the first.
fresh, cursor = since(events, 0)
assert len(fresh) == 2
assert cursor == id2
after_first, cursor2 = since(events, id1)
assert [e.event_id for e in after_first] == [id2]
assert cursor2 == id2
nothing, cursor3 = since(events, id2)
assert nothing == []
assert cursor3 == id2
reopened.close()
def test_top_ranks_tie_breaks(tmp_path: Path) -> None:
store = _store(tmp_path)
# Same level: higher XP ranks first; equal XP breaks by name ascending.
store.upsert_player(make_player(name="Carol", level=5, xp=1200, gold=10))
store.upsert_player(make_player(name="Alice", level=5, xp=1500, gold=10))
store.upsert_player(make_player(name="Bob", level=5, xp=1500, gold=10))
store.upsert_player(make_player(name="Dave", level=4, xp=9999, gold=10))
store.commit()
ranks = store.top_ranks(limit=10)
assert [r.name for r in ranks] == ["Alice", "Bob", "Carol", "Dave"]
store.close()
def test_top_ranks_honours_limit(tmp_path: Path) -> None:
store = _store(tmp_path)
for i in range(15):
store.upsert_player(make_player(name=f"P{i:02d}", level=i, xp=i * 10))
store.commit()
ranks = store.top_ranks(limit=10)
assert len(ranks) == 10
# Highest level first.
assert ranks[0].name == "P14"
store.close()
def test_meta_round_trip(tmp_path: Path) -> None:
store = _store(tmp_path)
store.set_meta("world_name", "The Vale of Understone")
assert store.get_meta("world_name") == "The Vale of Understone"
assert store.get_meta("missing") is None
store.close()
def test_retention_columns_round_trip(tmp_path: Path) -> None:
"""The retention columns survive a reopen: depth, the v0.10 stack-encoded
satchel, the two forged plusses, and the v0.10 banked vault gold."""
store = _store(tmp_path)
player = make_player(
name="Delver",
deepest_rung=2,
satchel="minor_potion:3,iron_ore:5", # v0.10 "id:qty" stack encoding
weapon_plus=2,
armor_plus=1,
banked=300,
)
store.upsert_player(player)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
loaded = players["Delver"]
assert loaded == player # full equality across every column
assert loaded.deepest_rung == 2
assert loaded.satchel == "minor_potion:3,iron_ore:5"
assert loaded.weapon_plus == 2
assert loaded.armor_plus == 1
assert loaded.banked == 300
reopened.close()
def test_v0_7_depth_columns_default_for_legacy_rows(tmp_path: Path) -> None:
"""A row written without the new columns loads them at their defaults.
The schema mutates in place (no migration, stamp stays 1), so the new
columns carry DB-side defaults: a pre-v0.7 player row (inserted with the
legacy column set) must read back deepest_rung 0, an empty satchel, and
zero plusses rather than erroring.
"""
store = _store(tmp_path)
store._conn.execute(
"INSERT INTO players "
"(name, x, y, hp, max_hp, level, xp, gold, atk, def_, weapon_id, armor_id, "
" turns_left, turn_day, mode, at_location, created_at, last_seen, log_cursor, "
" bestow_spent, bestow_day) "
"VALUES ('Old', 5, 5, 20, 20, 1, 0, 20, 5, 1, 'rusty_dagger', 'cloth_tunic', "
" 10, 0, 'tile', '', 't0', 't0', 0, 0, 0)",
)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
old = players["Old"]
assert old.deepest_rung == 0
assert old.satchel == ""
assert old.weapon_plus == 0
assert old.armor_plus == 0
assert old.banked == 0 # the v0.10 vault column defaults too
assert reopened.get_meta("schema_version") == "1" # stamp unchanged
reopened.close()
-47
View File
@@ -1,47 +0,0 @@
"""GameRNG tests — the deterministic randomness seam.
Covers the v0.2 ``weighted_index`` helper: that a fixed seed reproduces the
same stream, that the cumulative-sum mapping honours the weights' proportions,
and that every index of a crafted table is reachable.
"""
from __future__ import annotations
from collections import Counter
from understone.engine.rng import GameRNG
def test_weighted_index_is_deterministic_under_seed() -> None:
"""Two RNGs at the same seed yield the identical weighted-index stream."""
weights = [55, 8, 7, 5, 5, 5, 5, 3, 3, 4]
a = GameRNG(seed=2026)
b = GameRNG(seed=2026)
draws_a = [a.weighted_index(weights) for _ in range(50)]
draws_b = [b.weighted_index(weights) for _ in range(50)]
assert draws_a == draws_b
def test_weighted_index_every_index_reachable() -> None:
"""With equal weights, a crafted table sees every index appear."""
weights = [1, 1, 1, 1, 1]
rng = GameRNG(seed=7)
seen = {rng.weighted_index(weights) for _ in range(500)}
assert seen == set(range(len(weights)))
def test_weighted_index_single_entry_always_zero() -> None:
"""A one-row table can only ever pick index 0."""
rng = GameRNG(seed=1)
assert all(rng.weighted_index([9]) == 0 for _ in range(20))
def test_weighted_index_respects_proportions() -> None:
"""A heavily-weighted index dominates the empirical distribution."""
weights = [90, 5, 5]
rng = GameRNG(seed=99)
counts = Counter(rng.weighted_index(weights) for _ in range(4000))
# Index 0 carries 90% of the mass; it must be by far the most common.
assert counts[0] > counts[1] + counts[2]
# And the rare indices still occur (no off-by-one swallowing the tail).
assert counts[1] > 0 and counts[2] > 0
-63
View File
@@ -1,63 +0,0 @@
"""The satchel "id:qty" wire codec (understone.engine.satchel).
Pins the single-source codec the game façade, the Watch payload, and the
balance simulator all decode through. The format is comma-joined ``id:qty``
stacks; this proves a clean round-trip, the defensive bare-id => qty-1 rule, the
malformed/zero/empty fragments that are skipped, and that the encoder never
emits a zero-or-negative stack.
"""
from __future__ import annotations
import pytest
from understone.engine.satchel import decode_satchel, encode_satchel
def test_round_trips_id_qty_stacks() -> None:
"""The canonical "id:qty,id:qty" data decodes and re-encodes unchanged."""
encoded = "minor_potion:3,iron_ore:5"
stacks = decode_satchel(encoded)
assert stacks == [("minor_potion", 3), ("iron_ore", 5)]
assert encode_satchel(stacks) == encoded
def test_bare_id_decodes_as_qty_one() -> None:
"""A colonless chunk is a single item (defensive — never silently dropped)."""
assert decode_satchel("minor_potion") == [("minor_potion", 1)]
# Mixed with a normal stack, order preserved.
assert decode_satchel("minor_potion,iron_ore:5") == [
("minor_potion", 1),
("iron_ore", 5),
]
@pytest.mark.parametrize(
("encoded", "reason"),
[
("id:0", "zero quantity"),
("id:-1", "negative quantity"),
("id:abc", "non-integer quantity"),
(":5", "empty id"),
("", "empty string"),
("minor_potion:3,", "trailing comma yields an empty chunk"),
(",minor_potion:3", "leading comma yields an empty chunk"),
],
)
def test_skips_malformed_or_zero_fragments(encoded: str, reason: str) -> None:
"""A present-but-invalid or non-positive fragment is skipped; valid ones survive."""
stacks = decode_satchel(encoded)
assert all(item_id and qty > 0 for item_id, qty in stacks), reason
# The only valid stack in the trailing/leading-comma cases is the potion.
if "minor_potion:3" in encoded:
assert stacks == [("minor_potion", 3)]
else:
assert stacks == []
def test_encode_drops_non_positive_stacks() -> None:
"""The encoder never emits "id:0" or a negative quantity."""
assert encode_satchel([("minor_potion", 0)]) == ""
assert encode_satchel([("minor_potion", -2)]) == ""
assert encode_satchel([("minor_potion", 2), ("iron_ore", 0)]) == "minor_potion:2"
assert encode_satchel([]) == ""
-169
View File
@@ -1,169 +0,0 @@
"""Screen-layer tests: viewport maths, frame rendering, menu rendering.
Golden discipline: the golden files under ``tests/golden`` are authored by
hand (correct borders/centring, eyeballed) and are NOT machine-dumped
renderer output. Every golden comparison is paired with structural asserts
that hold independent of the exact golden bytes, so a renderer regression
that happens to match a stale golden still trips a structural check.
"""
from __future__ import annotations
from pathlib import Path
from understone.screen.grid import Cell, CellGrid
from understone.screen.menus import render_menu
from understone.screen.palette import Color
from understone.screen.text_renderer import render_frame
from understone.screen.viewport import compute_window
GOLDEN = Path(__file__).parent / "golden"
# ---------------------------------------------------------------------------
# viewport.compute_window
# ---------------------------------------------------------------------------
def test_window_centers_when_interior() -> None:
# 100x100 map, 48x16 view, focus at (50, 50): centred.
x0, y0 = compute_window(100, 100, 48, 16, 50, 50)
assert x0 == 50 - 48 // 2
assert y0 == 50 - 16 // 2
def test_window_clamps_nw_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 0, 0)
assert (x0, y0) == (0, 0)
def test_window_clamps_ne_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 99, 0)
assert x0 == 100 - 48
assert y0 == 0
def test_window_clamps_sw_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 0, 99)
assert x0 == 0
assert y0 == 100 - 16
def test_window_clamps_se_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 99, 99)
assert x0 == 100 - 48
assert y0 == 100 - 16
def test_window_view_larger_than_map_pins_origin() -> None:
x0, y0 = compute_window(10, 8, 48, 16, 5, 4)
assert (x0, y0) == (0, 0)
# ---------------------------------------------------------------------------
# Shared small-grid builders for the golden frames
# ---------------------------------------------------------------------------
_FLOOR = Cell(".", Color.FLOOR)
_PLAYER = Cell("@", Color.PLAYER)
def _floor_grid(rows: int, cols: int) -> CellGrid:
grid = CellGrid(rows, cols)
for r in range(rows):
for c in range(cols):
grid.set(r, c, _FLOOR)
return grid
def _spawn_grid() -> CellGrid:
"""9x5 floor with the player centred at (row 2, col 4)."""
grid = _floor_grid(5, 9)
grid.set(2, 4, _PLAYER)
return grid
def _edge_nw_grid() -> CellGrid:
"""9x5 floor with the player pinned to the NW corner (row 0, col 0)."""
grid = _floor_grid(5, 9)
grid.set(0, 0, _PLAYER)
return grid
# ---------------------------------------------------------------------------
# text_renderer.render_frame
# ---------------------------------------------------------------------------
def test_render_frame_matches_golden_spawn() -> None:
frame = render_frame(_spawn_grid(), title="Vale", status="[ status ]")
expected = (GOLDEN / "viewport_spawn.txt").read_text(encoding="utf-8")
assert frame == expected.rstrip("\n")
def test_render_frame_matches_golden_edge_nw() -> None:
frame = render_frame(_edge_nw_grid(), title="Vale", status="[ status ]")
expected = (GOLDEN / "viewport_edge_nw.txt").read_text(encoding="utf-8")
assert frame == expected.rstrip("\n")
def test_render_frame_structural_invariants() -> None:
frame = render_frame(_spawn_grid(), title="Vale", status="[ status ]")
lines = frame.split("\n")
# Top border, 5 grid rows, bottom border, status = 8 lines.
assert len(lines) == 8
# Title substring lives in the top border.
assert "Vale" in lines[0]
# Uniform width across the box (top border through bottom border).
box_lines = lines[:-1]
widths = {len(line) for line in box_lines}
assert len(widths) == 1, f"box rows ragged: {widths}"
# Exactly one '@' and it sits at the centre column of the interior.
body = lines[1:-2]
at_positions = [(r, line.index("@")) for r, line in enumerate(body) if "@" in line]
assert len(at_positions) == 1
_, col = at_positions[0]
# Interior centre: 1 (left border) + cols//2 = 1 + 4 = 5.
assert col == 1 + 9 // 2
# Status line is preserved verbatim as the last line.
assert lines[-1] == "[ status ]"
def test_render_frame_under_size_budget() -> None:
grid = _floor_grid(16, 48)
grid.set(8, 24, _PLAYER)
frame = render_frame(grid, title="The Vale of Understone", status="[ a long status line here ]")
assert len(frame) < 2048
# ---------------------------------------------------------------------------
# menus.render_menu
# ---------------------------------------------------------------------------
def test_render_menu_matches_golden_inn() -> None:
menu = render_menu(
"The Sleeping Drake",
["A warm hearth crackles.", "A bed costs 15 gold."],
["(R)est", "(L)eave"],
"[ status ]",
)
expected = (GOLDEN / "menu_inn.txt").read_text(encoding="utf-8")
assert menu == expected.rstrip("\n")
def test_render_menu_structural_invariants() -> None:
menu = render_menu(
"The Sleeping Drake",
["A warm hearth crackles.", "A bed costs 15 gold."],
["(R)est", "(L)eave"],
"[ status ]",
)
lines = menu.split("\n")
assert "The Sleeping Drake" in lines[0]
assert lines[-1] == "[ status ]"
box = lines[:-1]
widths = {len(line) for line in box}
assert len(widths) == 1, f"menu box ragged: {widths}"
# Option line is present inside the body.
assert any("(R)est" in line and "(L)eave" in line for line in lines)
-306
View File
@@ -1,306 +0,0 @@
"""Tests for the balance instrument (the greedy bot simulator).
These run the REAL game façade end-to-end, so they double as the fiercest
integration test in the suite: determinism (same inputs identical report),
that the greedy bot makes genuine progress over a Vale run, that its realized
fight share lands in a sane band, that a multi-seed sweep aggregates and the
report renders and the single best end-to-end assertion, that a short seed
sweep actually SLAYS THE WYRM, proving the whole v0.1v0.7 loop is winnable by
an unclever bot.
"""
from __future__ import annotations
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING
from understone import sim
from understone.engine.models import LocationDef, Mode, Zone
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.sim import BalanceReport, simulate
from .conftest import make_monster, make_world
if TYPE_CHECKING:
import pytest
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
# ---------------------------------------------------------------------------
# determinism
# ---------------------------------------------------------------------------
def test_same_inputs_give_identical_report() -> None:
"""Same (pack, days, seed) → byte-identical BalanceReport (frozen + seeded)."""
a = simulate(PACK, 20, 5)
b = simulate(PACK, 20, 5)
assert a == b
assert isinstance(a, BalanceReport)
def test_different_seeds_diverge() -> None:
"""Different seeds produce different runs (the RNG actually threads through)."""
a = simulate(PACK, 20, 1)
b = simulate(PACK, 20, 2)
# The runs are not identical (some headline measure differs).
assert (a.fights_fought, a.total_gold_earned, a.day_of_first_wyrm_kill) != (
b.fights_fought,
b.total_gold_earned,
b.day_of_first_wyrm_kill,
)
# ---------------------------------------------------------------------------
# progress
# ---------------------------------------------------------------------------
def test_bot_makes_progress_over_thirty_days() -> None:
"""A 30-day Vale run climbs past level 1 and actually fights."""
r = simulate(PACK, 30, 1)
assert r.final_level > 1
assert r.fights_fought > 0
assert r.total_gold_earned > 0
# It also plumbs the deep — the rung ladder is reachable for a geared bot.
assert r.rungs_cleared > 0
def test_realized_fight_share_in_sane_band() -> None:
"""The bot's fight share is a real fraction and forest-fight dominant.
A greedy XP grinder spends most of its turns fighting the wood (the rest are
the handful of descents and the Wyrm bout), so the share is high but it is
a genuine fraction in (0, 1], never a degenerate 0 or a value out of range.
"""
r = simulate(PACK, 30, 3)
assert 0.0 < r.realized_fight_share <= 1.0
# Fights dominate the turn-spend, but descents/challenges exist too, so the
# share is below a hard 1.0 floor only loosely — assert the sane half-band.
assert r.realized_fight_share >= 0.5
# ---------------------------------------------------------------------------
# reporting & sweep
# ---------------------------------------------------------------------------
def test_report_renders_without_crashing() -> None:
r = simulate(PACK, 15, 1)
text = sim._render_report("The Vale of Understone", r)
assert "greedy bot" in text
assert "final level" in text
assert "Wyrm slain" in text
def test_cli_simulate_single_seed_renders(tmp_path: Path) -> None:
out = StringIO()
rc = sim.cli_simulate(PACK, 15, 1, out=out)
assert rc == 0
assert "The Vale of Understone" in out.getvalue()
assert "fight share" in out.getvalue()
def test_cli_simulate_sweep_aggregates() -> None:
"""A --seeds sweep prints per-seed lines plus an aggregate with spreads."""
out = StringIO()
rc = sim.cli_simulate(PACK, 20, 1, out=out, seeds=3)
assert rc == 0
text = out.getvalue()
assert "3 seeds" in text
assert "aggregate" in text
# Per-seed lines for each of the three seeds.
for seed in (1, 2, 3):
assert f"seed {seed:>3}" in text or f"seed {seed}" in text
# The aggregate carries a mean [min..max] spread.
assert "[" in text and "]" in text
def test_sweep_reports_are_each_deterministic() -> None:
"""Each seed in a sweep is independently reproducible by single simulate."""
seed = 4
swept = simulate(PACK, 20, seed)
again = simulate(PACK, 20, seed)
assert swept == again
# ---------------------------------------------------------------------------
# the load-bearing assertion: the world is winnable
# ---------------------------------------------------------------------------
def test_greedy_bot_slays_the_wyrm() -> None:
"""The single best end-to-end check: a short seed sweep KILLS THE WYRM.
If a greedy, unclever bot can take the Wyrm Below playing through the real
façade, then the whole authored loop movement, the zone-banded forest, the
economy, the rung ladder, the satchel death-save, the forge, and the endgame
gate composes into a *winnable* game. A run that ever stops winning trips
here. A small sweep (not one lucky seed) so the proof is robust.
"""
reports = [simulate(PACK, 40, seed) for seed in (1, 2, 3)]
kills = [r for r in reports if r.wyrm_killed]
assert kills, "the greedy bot never slew the Wyrm across the seed sweep"
# Every kill records the day it first happened, within the run window.
for r in kills:
assert r.day_of_first_wyrm_kill is not None
assert 1 <= r.day_of_first_wyrm_kill <= 40
# ---------------------------------------------------------------------------
# the bundled ALTERNATE world: The Cinder Wastes (LLM-authored from the manual)
#
# The Vale assertions above are the primary proof. These mirror them against the
# real bundled second world, so the dogfood pack — authored cold from AUTHORING.md
# — is held to the same bar: the bot must make genuine progress through it, and a
# short seed sweep must actually slay its Magma Wyrm. If the authored world ever
# stops being winnable, this trips.
# ---------------------------------------------------------------------------
CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes"
def test_cinder_wastes_bot_makes_progress() -> None:
"""A short Cinder Wastes run climbs past level 1 and genuinely plays.
Fifteen days lands before the bot's first Wyrm kill (~day 24), so the level
is still climbing rather than reset post-win a stable "the world plays"
signal across the durable measures (level, fights, gold, the rung ladder).
"""
r = simulate(CINDER, 15, 1)
assert r.final_level > 1
assert r.fights_fought > 0
assert r.total_gold_earned > 0
assert r.rungs_cleared > 0 # the caldera rung ladder is reachable
def test_cinder_wastes_is_winnable() -> None:
"""The dogfood proof: a greedy bot SLAYS THE MAGMA WYRM in the authored world.
The Cinder Wastes was written by an LLM working only from AUTHORING.md and
the validator. This is the end-to-end demonstration that the manual plus the
loader produce not merely a *valid* pack but a *playable-to-victory* one a
short seed sweep takes the Magma Wyrm. (It is harder than the Vale: the kill
lands later, so the window is wider than the Vale's.)
"""
reports = [simulate(CINDER, 50, seed) for seed in (1, 2, 3)]
kills = [r for r in reports if r.wyrm_killed]
assert kills, "the greedy bot never slew the Magma Wyrm across the seed sweep"
for r in kills:
assert r.day_of_first_wyrm_kill is not None
assert 1 <= r.day_of_first_wyrm_kill <= 50
# ---------------------------------------------------------------------------
# robustness on non-shipped pack shapes: location doors inside hunt zones
#
# The bot runs arbitrary authored packs, not just the two bundled worlds, so a
# zone may overlap a location door. A door cell is "walkable" (you can step onto
# it) but standing on it flips the bot into that location's MENU — useless ground
# for a forest fight, and a "fight" issued from a MENU is rejected by the engine
# WITHOUT spending a turn. These pin the two guards that keep that from spinning
# the per-day loop or over-counting fights.
# ---------------------------------------------------------------------------
def _door(x: int, y: int) -> LocationDef:
"""A bare location door placed at ``(x, y)`` (an inn, for concreteness)."""
return LocationDef(
key="inn",
kind="inn",
name="Wayhouse",
x=x,
y=y,
glyph="",
color="town",
actions=("rest", "leave"),
)
def test_nearest_in_zone_skips_a_door_cell() -> None:
"""A door is never returned as a zone's hunt cell, even when it is nearest.
The zone here spans a column running away from the spawn; its closest-to-spawn
walkable cell IS a location door, with open ground one step further. The
helper must skip the door (it would only trap the bot in a menu) and return
the open cell beyond it the FIX-2 filter, mirroring ``_adjacent_open``.
"""
# 11x11 grass; spawn (5, 5). A door at (5, 6) is the nearest cell inside the
# zone (Manhattan 1); the nearest OPEN in-zone cell is (5, 7) (Manhattan 2).
world = make_world(
locations=[_door(5, 6)],
zones=[Zone(key="wood", x0=5, y0=6, x1=5, y1=9, tier_lo=1, tier_hi=1)],
)
walkable = sim._reachable(world)
assert (5, 6) in walkable # the door cell is walkable...
cell = sim._nearest_in_zone(world, walkable, world.zones[0])
assert cell is not None
assert cell != (5, 6) # ...but the helper does not pick it
assert world.location_at(*cell) is None # the returned cell is open ground
assert cell == (5, 7) # the nearest open in-zone cell beyond the door
def test_zone_hunt_spots_drops_a_zone_with_no_fightable_foe() -> None:
"""A zone whose tier band holds no foe is dropped, not appended with None.
FIX-4: the fallback in ``_best_hunt_spot`` (``ranked[-1]``) must never land on
a zone where no monster can roll. A zone banded to a tier with no monster is
simply not a hunting ground, so it never enters the spot list.
"""
# One zone banded to tier 9 (no monster lives there); the only monster is a
# tier-1 rat. The empty-band zone must be dropped entirely.
world = make_world(
monsters=[make_monster(tier=1)],
zones=[Zone(key="void", x0=4, y0=4, x1=6, y1=6, tier_lo=9, tier_hi=9)],
)
spots = sim._zone_hunt_spots(world, sim._reachable(world))
assert spots == [] # the foe-less zone is not a spot
def test_hunt_yields_the_turn_when_stuck_in_a_menu(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A hunt that ends in a MENU yields the turn instead of over-counting.
The defence-in-depth for FIX-1: should the bot ever reach the fight moment
still inside a location MENU (a door swallowed the walk), the engine would
REJECT the "fight" without spending a turn and the old string-only check
misread that reject as a won bout, over-counting and spinning the loop. The
new mode pre-check must instead leave the menu and return False (yield), so no
phantom fight is recorded and the day loop makes honest progress.
"""
# A door at (5, 4) inside a tier-1 zone. We inject this door cell as the hunt
# spot directly — the pre-FIX-2 state where a door WAS the nearest in-zone
# cell — so the guard, not the spot-selection filter, is what is under test.
world = make_world(
locations=[_door(5, 4)],
zones=[Zone(key="wood", x0=4, y0=3, x1=6, y1=5, tier_lo=1, tier_hi=1)],
monsters=[make_monster(tier=1)],
)
clock = sim._Clock(sim._SIM_START)
game = Game(world, Store(tmp_path / "g.db"), clock=clock, rng=GameRNG(seed=1)) # type: ignore[arg-type]
bot = sim._Bot(game, world, clock)
game.join(bot.name)
bot._hunt_spots = [(1, (5, 4), make_monster(tier=1))]
player = game.players[bot.name]
# Model "a location door swallowed the walk": every navigation step ends with
# the bot back inside the door's menu, so the hunt reaches its fight decision
# still in MENU mode no matter how many times it tries to step clear — exactly
# the trap the guard exists for (a single un-menu + re-walk cannot escape it).
def _walk_into_door(_goal: tuple[int, int]) -> None:
player.mode = Mode.MENU
player.at_location = "inn"
monkeypatch.setattr(bot, "_goto_xy", _walk_into_door)
_walk_into_door((5, 4)) # start the hunt already inside the menu
fought = bot._hunt()
assert fought is False # the turn is yielded, not spent on a menu-reject
assert bot.fights_fought == 0 # no phantom fight recorded
assert game.players[bot.name].mode is Mode.TILE # and the menu was left behind
-862
View File
@@ -1,862 +0,0 @@
"""The v0.5 social slice — ambush (async PvP), inn mail, and inn dice.
Drives the game façade over the shipped world with a frozen clock and a seeded
RNG. Three feature areas:
* AMBUSH the full eligibility matrix (every refusal branch), the win path
(exact gold transfer, victim bounced to spawn at 1 HP, private mail visible
only to the victim, public news), the lose path (attacker bounced, no
transfer), the flee stalemate, per-day once-per-pair, and next-day retry.
* MAIL ``post`` delivers a private note to the target's log once, the sender
is confirmed, the daily cap refuses the overflow, the sanitizer rejects a
newline body, and the Watch state payload NEVER carries a targeted row.
* DICE win/lose/push under a seeded RNG, the bet band, affordability, the
daily cap (a push still counts), and the Herald firing only on a big win.
Negative-test discipline (the SLEEP RULE has teeth):
``test_sleep_rule_guard_has_teeth`` documents the revert-and-observe check.
Disabling the ``target.turn_day >= today`` clause in Game._ambush_refusal
let an ALREADY-AWAKE target be ambushed ``test_ambush_refused_target_awake``
then failed (the attempt resolved instead of being refused). The clause was
restored; that refusal test is the standing regression for the invariant.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import fixed_clock, utc
from understone.engine.models import Mode
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.watch import build_state_payload
from understone.world.loader import load_world
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
# The frozen "today" all these tests run on; the sleep rule keys off its ordinal.
_NOW = utc(2026, 6, 12, 10, 0)
_TODAY = _NOW.toordinal()
@pytest.fixture
def clock() -> object:
return fixed_clock(_NOW)
def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
world = load_world(PACK)
store = Store(tmp_path / "social.db")
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
def _arm_ambush(
game: Game,
*,
attacker_level: int = 5,
target_level: int = 5,
target_asleep: bool = True,
target_gold: int = 100,
) -> tuple[object, object]:
"""Join an attacker + target and tune their sheets for an ambush.
The attacker is overworld and seasoned; the target sits at *target_level*
with *target_gold*, and ``target_asleep`` controls the sleep rule (a
sleeping target has not acted today). Returns ``(attacker, target)``.
"""
game.join("Raider")
game.join("Sleeper")
attacker = game.players["Raider"]
target = game.players["Sleeper"]
attacker.level = attacker_level
target.level = target_level
target.gold = target_gold
target.turn_day = _TODAY - 1 if target_asleep else _TODAY
return attacker, target
# ---------------------------------------------------------------------------
# Ambush — eligibility matrix (each refusal is a distinct in-fiction line)
# ---------------------------------------------------------------------------
def test_ambush_refused_unknown_target(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Raider")
game.players["Raider"].level = 5
out = game.action("Raider", "ambush", "Ghost", "")
assert "signed the ledger" in out # the unknown-player refusal
# No turn spent on an unresolvable target.
assert game.players["Raider"].turns_left == game.world.settings.daily_turns
def test_ambush_refused_self(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Raider")
game.players["Raider"].level = 5
out = game.action("Raider", "ambush", "Raider", "")
assert "yourself" in out.lower()
def test_ambush_refused_young_attacker(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
floor = game.world.settings.ambush_min_level
_arm_ambush(game, attacker_level=floor - 1, target_level=floor + 1)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "shields the young" in out
assert game.players["Raider"].turns_left == game.world.settings.daily_turns
def test_ambush_refused_young_target(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
floor = game.world.settings.ambush_min_level
# Attacker is seasoned but the target is below the floor: still shielded.
_arm_ambush(game, attacker_level=floor + 1, target_level=floor - 1)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "shields the young" in out
def test_ambush_refused_out_of_band(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
band = game.world.settings.ambush_level_band
floor = game.world.settings.ambush_min_level
_arm_ambush(
game,
attacker_level=floor + band + 5,
target_level=floor,
)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "far from your measure" in out
def test_ambush_band_beats_awake_in_refusal_order(tmp_path: Path, clock: object) -> None:
"""PRECEDENCE: the band gate is checked before the sleep rule.
A target who is BOTH out of band AND awake must report the band message,
not the watchful one pinning the documented order (level gates before the
live-play sleep defence).
"""
game = _game(tmp_path, clock)
band = game.world.settings.ambush_level_band
floor = game.world.settings.ambush_min_level
_arm_ambush(
game,
attacker_level=floor + band + 1, # one past the band...
target_level=floor,
target_asleep=False, # ...and also awake
)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "far from your measure" in out # the band gate wins
assert "watchful today" not in out
def test_ambush_band_boundary_exact_is_allowed(tmp_path: Path, clock: object) -> None:
"""Exactly ``ambush_level_band`` apart clears the band gate (it is inclusive).
Armed awake so the very next gate the sleep rule is what speaks: a
'watchful today' refusal proves the band gate let this pair through.
"""
game = _game(tmp_path, clock)
band = game.world.settings.ambush_level_band
floor = game.world.settings.ambush_min_level
_arm_ambush(
game,
attacker_level=floor + band, # exactly band levels above the floor
target_level=floor,
target_asleep=False,
)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "far from your measure" not in out # past the band gate
assert "watchful today" in out # stopped by the next gate instead
def test_ambush_band_boundary_one_over_is_refused(tmp_path: Path, clock: object) -> None:
"""One level past ``ambush_level_band`` is refused with the band message."""
game = _game(tmp_path, clock)
band = game.world.settings.ambush_level_band
floor = game.world.settings.ambush_min_level
_arm_ambush(
game,
attacker_level=floor + band + 1, # just over the band
target_level=floor,
)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "far from your measure" in out
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False
def test_ambush_refused_target_awake(tmp_path: Path, clock: object) -> None:
"""The SLEEP RULE: a target who has already acted today is un-ambushable.
See the module docstring for the revert-and-observe check proving this
refusal has teeth.
"""
game = _game(tmp_path, clock)
_arm_ambush(game, target_asleep=False)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "watchful today" in out
# Refused without resolving: no turn spent, no ambush recorded.
assert game.players["Raider"].turns_left == game.world.settings.daily_turns
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False
def test_ambush_refused_repeat_same_pair_same_day(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game)
# First attempt resolves (attacker overwhelming -> a clean win).
attacker.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
# Re-arm the target as sleeping AND healed above 1 HP (so the mercy rule
# does not intercept first); the SAME pair is still barred for the day.
target.turn_day = _TODAY - 1
target.hp = 20
out = game.action("Raider", "ambush", "Sleeper", "")
assert "already lain in wait" in out
def test_ambush_refused_pile_on_downed_victim(tmp_path: Path, clock: object) -> None:
"""MERCY RULE: a second, DIFFERENT attacker cannot kick a just-bounced sleeper.
The first ambush leaves the victim at 1 HP (still asleep being robbed does
not start their day). A fresh raider then finds them battered in the ditch;
even bandits have standards, so the pile-on is refused outright no turn
spent, no pair-row written for the second attacker.
"""
game = _game(tmp_path, clock)
first, target = _arm_ambush(game, target_gold=100)
first.atk = 200 # one-shot: leaves the victim at 1 HP
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
assert target.hp == 1 # downed and still asleep
# A second, seasoned raider tries to finish the job.
game.join("Marauder")
second = game.players["Marauder"]
second.level = 5
turns_before = second.turns_left
out = game.action("Marauder", "ambush", "Sleeper", "")
assert "battered in the ditch" in out
# No turn spent and no attempt recorded for the second attacker.
assert second.turns_left == turns_before
assert game.store.has_ambushed("Marauder", "Sleeper", _TODAY) is False
def test_ambush_healed_victim_is_ambushable_again(tmp_path: Path, clock: object) -> None:
"""The mercy rule lifts once the victim mends: healed above 1 HP (and still
asleep), a fresh attacker may strike."""
game = _game(tmp_path, clock)
first, target = _arm_ambush(game, target_gold=100)
first.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
assert target.hp == 1
# The victim is tended back above the floor (still asleep this day).
target.hp = 18
game.join("Marauder")
second = game.players["Marauder"]
second.level = 5
second.atk = 200 # one-shot again
out = game.action("Marauder", "ambush", "Sleeper", "")
assert "battered in the ditch" not in out
# The fresh ambush resolved: recorded, and the victim is bounced anew.
assert game.store.has_ambushed("Marauder", "Sleeper", _TODAY) is True
assert target.hp == 1
def test_ambush_refused_zero_turns(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
attacker, _ = _arm_ambush(game)
attacker.turns_left = 0
out = game.action("Raider", "ambush", "Sleeper", "")
assert "spent for today" in out.lower()
# Eligible but exhausted: nothing recorded (the attempt never landed).
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False
# ---------------------------------------------------------------------------
# Ambush — outcomes (win / lose / flee) and the records they leave
# ---------------------------------------------------------------------------
def test_ambush_win_transfers_gold_and_bounces_victim(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=100)
attacker.atk = 200 # one-shot the sleeper
target.hp = 5
pct = game.world.settings.ambush_gold_pct
steal = 100 * pct // 100 # 25 gold at the shipped 25%
raider_gold_before = attacker.gold
out = game.action("Raider", "ambush", "Sleeper", "")
# Exact transfer: attacker up by steal, victim down by the same.
assert attacker.gold == raider_gold_before + steal
assert target.gold == 100 - steal
# The victim wakes at the spawn at 1 HP, knocked out of any menu.
assert target.hp == 1
assert (target.x, target.y) == game.world.spawn
assert target.mode is Mode.TILE
assert target.at_location == ""
assert f"{steal} gold" in out
# The attempt is recorded.
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
def test_ambush_steals_only_carried_gold_not_the_vault(tmp_path: Path, clock: object) -> None:
"""A winning ambush robs carried gold only — banked vault gold is untouched.
The steal is a slice of ``target.gold`` (gold in hand); the strongbox
(``banked``) is safe by design. This pins the vault's whole point: bank your
coin before you sleep and a sleeping-robber cannot lift it.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=40)
target.banked = 1000 # a fat vault the raider must not be able to touch
attacker.atk = 200 # one-shot the sleeper
target.hp = 5
pct = game.world.settings.ambush_gold_pct
steal = 40 * pct // 100 # a slice of the CARRIED 40, not the banked 1000
game.action("Raider", "ambush", "Sleeper", "")
assert target.gold == 40 - steal # carried gold robbed
assert target.banked == 1000 # the vault is wholly untouched
assert attacker.gold == game.world.settings.starting_gold + steal
def test_ambush_win_applies_attacker_wear(tmp_path: Path, clock: object) -> None:
"""A multi-round win banks the attacker's wear: the log narrates the
sleeper's counter-blows, so the sheet must show the HP they cost.
The one-shot win above leaves the attacker untouched, which would mask a
WIN branch that drops ``hp_delta`` on the floor. Here the sleeper is tanky
enough to trade blows before falling (and the attacker still wins), so the
attacker must end below full HP. Stats and seed are tuned so the win is
decisive but not instant.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=100)
attacker.atk, attacker.def_ = 8, 2
attacker.hp = attacker.max_hp = 30
target.atk, target.def_, target.hp = 5, 1, 25
out = game.action("Raider", "ambush", "Sleeper", "")
# The win lands (victim robbed and bounced to 1 HP)...
assert target.hp == 1
assert (
any(crow in out for crow in ("made off", "robbed the sleeping", "lifted")) or "rob" in out
)
# ...but the sleeper's counter-blows cost the attacker real HP this time.
assert attacker.hp < attacker.max_hp
assert attacker.hp >= 1 # never below the floor
def test_ambush_win_news_is_public_and_mail_is_private(tmp_path: Path, clock: object) -> None:
"""The victory crows on the public feed; the victim gets a PRIVATE note.
A THIRD player must see the public ambush line but never the private one.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=80)
attacker.atk = 200
target.hp = 5
game.join("Bystander") # a third player who must never see the private note
game.action("Raider", "ambush", "Sleeper", "")
# The victim reads the private "While you slept" note in their own log.
victim_log = game.log("Sleeper")
assert "While you slept" in victim_log
assert "ambushed you" in victim_log
# The bystander sees the public crow but NOT the private note.
third_log = game.log("Bystander")
assert (
"made off with" in third_log
or "robbed the sleeping" in third_log
or ("lifted" in third_log)
)
assert "While you slept" not in third_log
def test_ambush_win_on_pauper_steals_nothing_but_still_lands(tmp_path: Path, clock: object) -> None:
"""A win over a penniless sleeper: steal is 0, but the beat still plays.
The victim is bounced to the spawn at 1 HP all the same, the public herald
crows the robbery, and the private 'while you slept' note still reaches the
victim the gold transfer being empty changes none of that.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=0)
attacker.atk = 200 # one-shot the sleeper
target.hp = 5
game.join("Bystander")
raider_gold_before = attacker.gold
out = game.action("Raider", "ambush", "Sleeper", "")
# Nothing to steal: both purses are unchanged by the transfer.
assert attacker.gold == raider_gold_before
assert target.gold == 0
assert "0 gold" in out
# The victim is still bounced to the spawn at 1 HP.
assert target.hp == 1
assert (target.x, target.y) == game.world.spawn
assert target.mode is Mode.TILE
assert target.at_location == ""
# Public herald fires (a bystander reads the crow)...
third_log = game.log("Bystander")
assert any(crow in third_log for crow in ("made off", "robbed the sleeping", "lifted"))
# ...and the private mail still reaches the victim.
victim_log = game.log("Sleeper")
assert "While you slept" in victim_log
assert "ambushed you" in victim_log
def test_ambush_lose_bounces_attacker_no_transfer(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=100)
# The sleeper is deadly: the ambush rebounds onto the attacker.
target.atk = 200
target.def_ = 100
target.hp = 200
attacker_gold_before = attacker.gold
out = game.action("Raider", "ambush", "Sleeper", "")
# No gold moved; the ATTACKER is the one bounced to spawn at 1 HP.
assert attacker.gold == attacker_gold_before
assert target.gold == 100
assert attacker.hp == 1
assert (attacker.x, attacker.y) == game.world.spawn
assert "flee" in out.lower() or "wakes" in out.lower()
# The attempt is still spent.
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
def test_ambush_records_attempt_on_every_outcome(tmp_path: Path, clock: object) -> None:
"""Win, lose, or flee — the (attacker, target, day) row is always written."""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game)
# Tune a flee: when neither side can meaningfully dent the other, the fight
# grinds to the 50-round stalemate guard, which resolves as FLED with no
# transfer. Both deal the 1-damage floor (atk << def), and both carry far
# more HP than 50 rounds can drain, so neither drops first.
attacker.atk, attacker.def_ = 1, 200
attacker.hp = attacker.max_hp = 500
target.atk, target.def_, target.hp = 1, 200, 500
gold_before = attacker.gold
out = game.action("Raider", "ambush", "Sleeper", "")
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
assert attacker.gold == gold_before # a flee moves no gold
assert "slip away" in out.lower() or "nerve" in out.lower()
def test_ambush_next_day_retry_allowed(tmp_path: Path, clock: object) -> None:
"""A new UTC day clears the once-per-pair lock (advance the injected clock)."""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game)
attacker.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
# Advance past UTC midnight; re-arm the sleeper for the new day.
tomorrow = utc(2026, 6, 13, 9, 0)
game.clock = fixed_clock(tomorrow) # type: ignore[assignment]
target.turn_day = tomorrow.toordinal() - 1 # asleep again
target.hp = 5
out = game.action("Raider", "ambush", "Sleeper", "")
assert "already lain in wait" not in out # the new day permits a fresh attempt
assert game.store.has_ambushed("Raider", "Sleeper", tomorrow.toordinal()) is True
def test_sleep_rule_guard_has_teeth(tmp_path: Path, clock: object) -> None:
"""Pin the sleep rule on a single-field divergence.
The un-ambushable case and the ambushable case differ ONLY in ``turn_day``:
with the target awake the action is refused, and flipping that one field to
asleep makes the very same attempt resolve and record.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_asleep=False)
attacker.atk = 200
target.hp = 5
refused = game.action("Raider", "ambush", "Sleeper", "")
assert "watchful today" in refused
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False
# Flip ONLY the sleep field; now the very same attempt lands.
target.turn_day = _TODAY - 1
resolved = game.action("Raider", "ambush", "Sleeper", "")
assert "watchful today" not in resolved
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
def test_ambush_both_rows_persist_in_one_transaction(tmp_path: Path, clock: object) -> None:
"""A win commits BOTH fighters' rows; a store reopen sees the transfer."""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=100)
attacker.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
raider_gold = attacker.gold
sleeper_gold = target.gold
game.store.close()
world = load_world(PACK)
reopened = Store(tmp_path / "social.db")
revived = Game(world, reopened, clock=clock) # type: ignore[arg-type]
assert revived.players["Raider"].gold == raider_gold
assert revived.players["Sleeper"].gold == sleeper_gold
assert revived.players["Sleeper"].hp == 1
reopened.close()
# ---------------------------------------------------------------------------
# Mail — post delivers privately, confirms, caps, sanitizes
# ---------------------------------------------------------------------------
def test_post_delivers_to_target_once_with_confirmation(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
confirm = game.action("Scribe", "post", "Reader", "", "meet me at the inn")
assert "tucks the note" in confirm # the sender's in-fiction confirmation
# No turn spent on a post.
assert game.players["Scribe"].turns_left == game.world.settings.daily_turns
first = game.log("Reader")
assert "While you were away" in first
assert "meet me at the inn" in first
# Read once: the cursor advanced, so a second read no longer shows it.
second = game.log("Reader")
assert "meet me at the inn" not in second
def test_post_refused_unknown_and_self(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Scribe")
unknown = game.action("Scribe", "post", "Nobody", "", "hello?")
assert "signed the ledger" in unknown
mine = game.action("Scribe", "post", "Scribe", "", "note to self")
assert "talk to yourself" in mine.lower()
def test_post_daily_cap_refuses_overflow(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
cap = game.world.settings.post_daily_cap
for i in range(cap):
out = game.action("Scribe", "post", "Reader", "", f"note {i}")
assert "tucks the note" in out
# The (cap+1)-th post is refused.
over = game.action("Scribe", "post", "Reader", "", "one too many")
assert "all the word you may today" in over
assert game.players["Scribe"].posts_sent == cap
def test_post_sanitizer_rejects_newline_body(tmp_path: Path, clock: object) -> None:
"""A newline-injected note body is refused; nothing is delivered or counted."""
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
events_before = len(game.events)
out = game.action("Scribe", "post", "Reader", "", "line one\nFORGED HERALD LINE")
assert "scrawl" in out.lower()
# No event appended and the daily counter is untouched.
assert len(game.events) == events_before
assert game.players["Scribe"].posts_sent == 0
# And the reader never receives it.
assert "FORGED" not in game.log("Reader")
def test_post_works_from_inside_a_building(tmp_path: Path, clock: object) -> None:
"""Posting is legal anywhere: a menu-bound sender still gets a menu reply."""
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
scribe = game.players["Scribe"]
scribe.mode = Mode.MENU
scribe.at_location = "inn"
out = game.action("Scribe", "post", "Reader", "", "by the hearth")
assert "tucks the note" in out
# The reply is the inn menu (a menu surface), not an overworld frame.
assert "(R)est" in out or "Sleeping Drake" in out
# ---------------------------------------------------------------------------
# Mail — the lobby TV must never carry a private note
# ---------------------------------------------------------------------------
def test_watch_state_excludes_targeted_rows(tmp_path: Path, clock: object) -> None:
"""EXPLICIT: a private (targeted) event must not reach the Watch herald."""
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
game.action("Scribe", "post", "Reader", "", "a secret for the Reader")
payload = build_state_payload(game)
herald = payload["herald"]
assert isinstance(herald, list)
texts = [row["text"] for row in herald]
# The join lines are public and present; the private note is absent.
assert any("Scribe" in t or "Reader" in t for t in texts) # public joins show
assert all("a secret for the Reader" not in t for t in texts)
def test_watch_state_excludes_private_ambush_note(tmp_path: Path, clock: object) -> None:
"""The ambush victim's private alert is filtered from the lobby TV too."""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=80)
attacker.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
herald_texts = [row["text"] for row in build_state_payload(game)["herald"]] # type: ignore[union-attr]
# The PUBLIC ambush crow is on the feed...
assert any(
"Sleeper" in t and ("made off" in t or "robbed" in t or "lifted" in t) for t in herald_texts
)
# ...but the PRIVATE "While you slept" note never is.
assert all("While you slept" not in t for t in herald_texts)
# ---------------------------------------------------------------------------
# Dice — win / lose / push under a seeded RNG, bands, cap, herald gate
# ---------------------------------------------------------------------------
def _at_inn(game: Game, name: str) -> object:
"""Join *name* and seat them at the inn (MENU surface)."""
game.join(name)
player = game.players[name]
player.mode = Mode.MENU
player.at_location = "inn"
return player
def test_gamble_win_under_seeded_rng(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100
# Seed 2 makes the gamble child roll 11 (you) vs 9 (house) -> a win.
game.rng = GameRNG(seed=2)
out = game.action("Gambler", "gamble", "", "", "", 10)
assert player.gold == 110 # stake doubled back
assert "win" in out.lower()
# No turn spent; one game counted.
assert player.turns_left == game.world.settings.daily_turns
assert player.gambles == 1
def test_gamble_lose_under_seeded_rng(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100
# Seed 0 rolls 4 (you) vs 9 (house) -> a loss.
game.rng = GameRNG(seed=0)
out = game.action("Gambler", "gamble", "", "", "", 10)
assert player.gold == 90
assert "lose" in out.lower()
assert player.gambles == 1
def test_gamble_push_under_seeded_rng(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100
# Seed 1 rolls 6 vs 6 -> a push: no gold change, but it still counts.
game.rng = GameRNG(seed=1)
out = game.action("Gambler", "gamble", "", "", "", 10)
assert player.gold == 100
assert "push" in out.lower()
assert player.gambles == 1 # a push still consumes a daily game
def test_gamble_bet_band_refused(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100_000
max_bet = game.world.settings.gamble_max_bet
low = game.action("Gambler", "gamble", "", "", "", 0)
assert f"1 to {max_bet}" in low
high = game.action("Gambler", "gamble", "", "", "", max_bet + 1)
assert f"1 to {max_bet}" in high
# A rejected bet neither moves gold nor counts toward the cap.
assert player.gold == 100_000
assert player.gambles == 0
def test_gamble_unaffordable_refused(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 5
out = game.action("Gambler", "gamble", "", "", "", 10) # within band, can't cover
assert "can't cover" in out.lower()
assert player.gold == 5
assert player.gambles == 0
def test_gamble_daily_cap_refused(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100_000
cap = game.world.settings.gamble_daily_cap
player.gambles = cap # already at the cap
out = game.action("Gambler", "gamble", "", "", "", 5)
assert "enough for one day" in out
assert player.gambles == cap # not incremented past the cap
def test_gamble_outside_inn_refused(tmp_path: Path, clock: object) -> None:
"""The dice live at the inn: the verb is illegal in another building."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.at_location = "shop" # the shop has no 'gamble' action
player.gold = 100
out = game.action("Gambler", "gamble", "", "", "", 10)
assert "can't 'gamble' here" in out.lower()
assert player.gold == 100
def test_gamble_big_win_heralds(tmp_path: Path, clock: object) -> None:
"""A win of >= 25 gold reaches the public Herald; a small one does not."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 1000
# A 50-gold win (>= the 25 threshold) writes a public dice line.
game.rng = GameRNG(seed=2) # a winning roll
events_before = len(game.events)
game.action("Gambler", "gamble", "", "", "", 50)
new = game.events[events_before:]
assert any(e.kind == "gamble" and e.target == "" for e in new)
assert player.gold == 1050
def test_gamble_small_win_is_quiet(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 1000
# A 10-gold win is below the 25-gold Herald threshold: no public line.
game.rng = GameRNG(seed=2)
events_before = len(game.events)
game.action("Gambler", "gamble", "", "", "", 10)
new = game.events[events_before:]
assert all(e.kind != "gamble" for e in new)
assert player.gold == 1010
# ---------------------------------------------------------------------------
# The Vault — deposit/withdraw at the inn (no turn; banked gold is safe)
# ---------------------------------------------------------------------------
def test_deposit_moves_gold_to_the_vault_no_turn(tmp_path: Path, clock: object) -> None:
"""Deposit moves coin from hand to vault, costs no turn, and is friendly."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 100
turns_before = player.turns_left
out = game.action("Saver", "deposit", "", "", "", 60)
assert player.gold == 40
assert player.banked == 60
assert player.turns_left == turns_before # banking spends no turn
assert "strongbox" in out.lower()
def test_withdraw_moves_gold_back_to_hand(tmp_path: Path, clock: object) -> None:
"""Withdraw moves coin from vault to hand."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 10
player.banked = 90
game.action("Saver", "withdraw", "", "", "", 50)
assert player.gold == 60
assert player.banked == 40
def test_deposit_amount_exceeding_holdings_refused(tmp_path: Path, clock: object) -> None:
"""Depositing more than you carry is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 30
player.banked = 0
out = game.action("Saver", "deposit", "", "", "", 50)
assert player.gold == 30 # unchanged
assert player.banked == 0
assert "1 to 30" in out
def test_deposit_with_nothing_in_hand_refused(tmp_path: Path, clock: object) -> None:
"""Depositing with an empty hand is a friendly refusal."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 0
out = game.action("Saver", "deposit", "", "", "", 10)
assert player.banked == 0
assert "no coin" in out.lower()
def test_withdraw_amount_exceeding_vault_refused(tmp_path: Path, clock: object) -> None:
"""Withdrawing more than is banked is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 0
player.banked = 20
out = game.action("Saver", "withdraw", "", "", "", 50)
assert player.gold == 0
assert player.banked == 20 # unchanged
assert "1 to 20" in out
def test_withdraw_empty_vault_refused(tmp_path: Path, clock: object) -> None:
"""Withdrawing from an empty vault is a friendly refusal."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.banked = 0
out = game.action("Saver", "withdraw", "", "", "", 10)
assert player.gold == game.world.settings.starting_gold # unchanged
assert "empty" in out.lower()
def test_status_shows_carried_and_vault_gold(tmp_path: Path, clock: object) -> None:
"""door_status reports gold as carried-on-hand plus banked-in-the-vault."""
game = _game(tmp_path, clock)
game.join("Saver")
player = game.players["Saver"]
player.gold = 75
player.banked = 250
out = game.status("Saver")
assert "75 on hand" in out
assert "250 in the vault" in out
-60
View File
@@ -1,60 +0,0 @@
"""Deterministic terrain texturing (understone.screen.texture).
Pins the contract the Watch JS mirrors: a textured glyph is a pure function of
its cell coordinate (stable per cell), an un-listed glyph is returned
untouched, and the selection formula is ``(x * _HASH_X + y * _HASH_Y) % n``
derived from the module's hash constants. The formula is asserted against those
constants so a retune moves the test with it and a drift is caught.
"""
from __future__ import annotations
from understone.screen.texture import _HASH_X, _HASH_Y, VARIANTS, textured
def test_untextured_glyph_is_unchanged() -> None:
"""A glyph with no VARIANTS row passes through verbatim (actors, walls)."""
for ch in "█@☻⌂$":
assert textured(ch, 3, 7) == ch
def test_same_coord_same_variant() -> None:
"""Texturing is position-only and stable: one cell always picks one glyph."""
first = textured(".", 12, 5)
for _ in range(5):
assert textured(".", 12, 5) == first
def test_variant_is_always_in_the_row() -> None:
"""Every selected glyph is one of the declared variants for its base."""
choices = VARIANTS["."]
for x in range(20):
for y in range(20):
assert textured(".", x, y) in choices
def test_a_row_uses_more_than_one_variant() -> None:
"""Across a row the hash spreads — the texture is not a single repeated glyph."""
seen = {textured(".", x, 0) for x in range(len(VARIANTS["."]) * 4)}
assert len(seen) > 1
def test_formula_matches_the_hash_constants() -> None:
"""The selection index is (x * _HASH_X + y * _HASH_Y) % len — the JS twin's formula.
Derived from the live ``_HASH_X`` / ``_HASH_Y`` constants (not the literal
31/17) and checked against the live VARIANTS rows, so it stays a formula
test that tracks a retune rather than a snapshot a table or constant edit
could silently invalidate.
"""
for base, choices in VARIANTS.items():
n = len(choices)
for x, y in [(0, 0), (1, 0), (0, 1), (12, 5), (7, 13), (255, 255)]:
assert textured(base, x, y) == choices[(x * _HASH_X + y * _HASH_Y) % n]
def test_origin_cell_is_the_base_glyph() -> None:
"""Cell (0,0) hashes to index 0, which is the base glyph (variants[0])."""
for base, choices in VARIANTS.items():
assert textured(base, 0, 0) == choices[0]
assert choices[0] == base
@@ -1,81 +0,0 @@
"""The one-glyph-one-column grid contract (understone.engine.textwidth).
Pins the accept/reject boundary of :func:`is_grid_safe` and proves every
:data:`SAFE_PALETTE` entry clears it. The acceptances include the
East-Asian-Width *Ambiguous* CP437 glyphs the game leans on (`` ``),
which render single-column under the Western monospace our surfaces use; the
rejections are the genuinely double-width and zero-width classes that tear a
frame.
"""
from __future__ import annotations
import unicodedata
import pytest
from understone.engine.textwidth import SAFE_PALETTE, is_grid_safe
from understone.world.loader import RESERVED_GLYPHS
# Single-column glyphs that must be admitted: plain ASCII, a Latin accent that
# is one composed code point, and the Ambiguous-width CP437 set the re-skin uses.
_ACCEPTED = ["a", "Z", "ö", "", "", "", "", "", "", "", ".", "$", " "]
# Must be rejected, with the reason each one trips the gate.
_REJECTED = {
"": "wide CJK ideograph (EAW=W) — two columns",
"🌲": "emoji (EAW=W) — two columns",
"": "fullwidth Latin A (EAW=F) — two columns",
"": "decomposed e + combining acute — two code points",
"́": "a lone combining acute — zero width",
"👨‍👩": "ZWJ sequence — multiple code points",
"ab": "two characters",
"": "empty string",
"\t": "a control character",
}
@pytest.mark.parametrize("ch", _ACCEPTED)
def test_is_grid_safe_accepts(ch: str) -> None:
assert is_grid_safe(ch) is True
@pytest.mark.parametrize("text", list(_REJECTED), ids=list(_REJECTED.values()))
def test_is_grid_safe_rejects(text: str) -> None:
assert is_grid_safe(text) is False
def test_safe_palette_is_all_grid_safe() -> None:
"""Every curated palette glyph clears the gate — the appendix can't ship a dud."""
bad = [g for g in SAFE_PALETTE if not is_grid_safe(g)]
assert bad == [], f"palette has non-grid-safe glyphs: {bad}"
def test_safe_palette_has_no_reserved_glyphs() -> None:
"""No palette glyph is a loader-reserved marker — the 'author-usable' promise.
The appendix tells a pack author to pull any palette glyph for terrain,
structures, or actors, but the loader rejects the box-drawing frame lines
and the '@'/'' player markers (``loader.RESERVED_GLYPHS``). A palette entry
that is also reserved would hand the author a glyph that load-fails the
exact doc-vs-enforcement trap. Guarding the intersection keeps "all tested
safe AND author-usable" enforced, not merely asserted on width.
"""
collisions = set(SAFE_PALETTE) & RESERVED_GLYPHS
assert collisions == set(), f"palette offers loader-reserved glyphs: {sorted(collisions)}"
def test_safe_palette_has_no_duplicates() -> None:
"""The palette is a set in spirit; a dupe would be an authoring slip."""
assert len(SAFE_PALETTE) == len(set(SAFE_PALETTE))
def test_ambiguous_width_glyphs_are_accepted() -> None:
"""Document the load-bearing call: EAW=Ambiguous is admitted, not barred.
These are the CP437 glyphs the game depends on; if a future tightening
barred Ambiguous, the whole re-skin would vanish from the map.
"""
for ch in "█♣↑∩≈★":
assert unicodedata.east_asian_width(ch) == "A"
assert is_grid_safe(ch) is True
-94
View File
@@ -1,94 +0,0 @@
"""Daily-turn budget and UTC rollover tests.
Covers spend/refuse semantics, the lazy reset when the UTC day advances
(including a 23:59 -> 00:01 crossing on the same Player instance), and the
shared rollover of the bestow pool.
"""
from __future__ import annotations
from tests.conftest import fixed_clock, make_player, utc
from understone.engine.turns import ensure_day, spend_turn
def test_spend_decrements() -> None:
player = make_player(turns_left=3)
assert spend_turn(player) is True
assert player.turns_left == 2
def test_spend_refuses_at_zero_without_mutation() -> None:
player = make_player(turns_left=0)
before = player.turns_left
assert spend_turn(player) is False
assert player.turns_left == before
def test_ensure_day_resets_on_new_day() -> None:
day = utc(2026, 6, 12).toordinal()
player = make_player(turns_left=0, turn_day=day - 1, bestow_spent=20, bestow_day=day - 1)
reset = ensure_day(player, fixed_clock(utc(2026, 6, 12, 9, 0)), daily_turns=10)
assert reset is True
assert player.turns_left == 10
assert player.turn_day == day
assert player.bestow_spent == 0
assert player.bestow_day == day
def test_ensure_day_noop_within_same_day() -> None:
day = utc(2026, 6, 12).toordinal()
# Every day marker is already today, so no allowance (turns, bestow, posts,
# dice) is touched — the rollover is a pure no-op.
player = make_player(
turns_left=4,
turn_day=day,
bestow_spent=10,
bestow_day=day,
post_day=day,
gamble_day=day,
)
reset = ensure_day(player, fixed_clock(utc(2026, 6, 12, 23, 0)), daily_turns=10)
assert reset is False
assert player.turns_left == 4
assert player.bestow_spent == 10
def test_midnight_crossing_refreshes_on_same_instance() -> None:
# Evening of day one: spend down to a low budget.
player = make_player(turns_left=10, turn_day=0, bestow_spent=0, bestow_day=0)
evening = utc(2026, 6, 12, 23, 59)
ensure_day(player, fixed_clock(evening), daily_turns=10)
for _ in range(8):
spend_turn(player)
assert player.turns_left == 2
# Just past midnight (UTC) the next action refreshes the budget.
after_midnight = utc(2026, 6, 13, 0, 1)
reset = ensure_day(player, fixed_clock(after_midnight), daily_turns=10)
assert reset is True
assert player.turns_left == 10
assert player.turn_day == after_midnight.toordinal()
def test_bestow_pool_resets_on_the_same_boundary() -> None:
player = make_player(bestow_spent=25, bestow_day=utc(2026, 6, 12).toordinal())
ensure_day(player, fixed_clock(utc(2026, 6, 13, 0, 1)), daily_turns=10)
assert player.bestow_spent == 0
assert player.bestow_day == utc(2026, 6, 13).toordinal()
def test_social_caps_reset_on_the_same_boundary() -> None:
"""Posts and dice counts ride the same UTC rollover as turns and bestow."""
yesterday = utc(2026, 6, 12).toordinal()
player = make_player(
posts_sent=5,
post_day=yesterday,
gambles=5,
gamble_day=yesterday,
)
reset = ensure_day(player, fixed_clock(utc(2026, 6, 13, 0, 1)), daily_turns=10)
assert reset is True
assert player.posts_sent == 0
assert player.post_day == utc(2026, 6, 13).toordinal()
assert player.gambles == 0
assert player.gamble_day == utc(2026, 6, 13).toordinal()
-591
View File
@@ -1,591 +0,0 @@
"""Watch-page payload builders and the watch-URL advertisement.
These are pure-unit tests of :mod:`understone.watch` (no network): the static
world payload's shape and legend completeness, the dynamic state payload's
player/herald/hall content under a frozen clock, and the join/help "Watch the
Vale live" line that appears only when a Game carries a watch URL.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from tests.conftest import fixed_clock, utc
from understone import server as understone_server
from understone import watch
from understone.engine.log import Event
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.screen.palette import Color
from understone.world.loader import load_world
if TYPE_CHECKING:
from understone.engine.world import World
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
@pytest.fixture
def world() -> World:
return load_world(PACK)
@pytest.fixture
def clock() -> object:
return fixed_clock(utc(2026, 6, 12, 10, 30))
def _game(tmp_path: Path, clock: object, watch_url: str | None = None) -> Game:
world = load_world(PACK)
store = Store(tmp_path / "watch.db")
return Game( # type: ignore[arg-type]
world, store, clock=clock, rng=GameRNG(seed=7), watch_url=watch_url
)
# ---------------------------------------------------------------------------
# World payload (static)
# ---------------------------------------------------------------------------
def test_world_payload_shape(world: World) -> None:
payload = watch.build_world_payload(world)
assert payload["name"] == world.name
assert payload["width"] == world.width
assert payload["height"] == world.height
rows = payload["glyph_rows"]
assert isinstance(rows, list)
assert len(rows) == world.height
assert all(isinstance(r, str) and len(r) == world.width for r in rows)
def test_world_payload_legend_is_complete(world: World) -> None:
payload = watch.build_world_payload(world)
rows = payload["glyph_rows"]
legend = payload["legend"]
assert isinstance(rows, list)
assert isinstance(legend, dict)
# Contract: every glyph that appears in the rows has a colour in the legend.
glyphs = {ch for row in rows for ch in row}
assert glyphs <= set(legend)
# And every legend colour is a real palette colour name (no stray roles).
valid = {c.value for c in Color}
assert set(legend.values()) <= valid
def test_world_payload_locations_present(world: World) -> None:
payload = watch.build_world_payload(world)
locations = payload["locations"]
assert isinstance(locations, list)
assert len(locations) == len(world.locations)
by_name = {loc["name"]: loc for loc in locations}
# The dungeon mouth rides in the locations overlay with its glyph + colour.
deep = by_name["The Understone Deep"]
assert deep["glyph"] == ""
assert deep["color"] == "dungeon"
assert (deep["x"], deep["y"]) == (70, 12)
def test_world_payload_carries_reskinned_glyphs(world: World) -> None:
"""The v0.6 re-skin reaches the Watch: ≋ water in the rows, ⌂/✚/∩ buildings.
Water rides the base terrain (glyph_rows + legend); the buildings ride the
locations overlay. If a glyph reverts, the live map drifts from the frames.
"""
payload = watch.build_world_payload(world)
rows = payload["glyph_rows"]
assert isinstance(rows, list)
glyphs = {ch for row in rows for ch in row}
assert "" in glyphs # water in the base map
assert "~" not in glyphs # the old water glyph is gone
legend = payload["legend"]
assert isinstance(legend, dict)
assert "" in legend
by_name = {loc["name"]: loc["glyph"] for loc in payload["locations"]} # type: ignore[index,union-attr]
assert by_name["The Sleeping Drake"] == ""
assert by_name["The Quiet Shrine"] == ""
assert by_name["The Understone Deep"] == ""
# ---------------------------------------------------------------------------
# v0.9 colour-role split — the payload now carries the EXPANDED vocabulary, so
# distinct terrain/building types read by hue on the Watch and not just by glyph.
# These pin the literal fixes: road no longer shares grass's colour, forest no
# longer shares tree's, the town buildings each carry their own role, and the
# Cinder slag is lava (orange), no longer water (blue).
# ---------------------------------------------------------------------------
CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes"
def _terrain_kinds(world: World) -> dict[str, str]:
"""Return the distinct terrain kinds in *world* as ``{key: colour role}``.
``world.terrain`` is the painted 2-D grid (one ``TerrainDef`` per cell); the
distinct kinds are recovered by deduplicating it on ``key``. Every kind in a
shipped world appears on the map, so this sees all of them.
"""
kinds: dict[str, str] = {}
for row in world.terrain:
for cell in row:
kinds[cell.key] = cell.color
return kinds
def _legend_for_terrain_key(world: World, key: str) -> str:
"""Return the legend colour the payload carries for terrain ``key``.
Resolves the terrain key to its glyph, then reads that glyph's colour out of
the built payload's legend — so the assertion is on what the Watch receives,
not on the raw JSON.
"""
payload = watch.build_world_payload(world)
legend = payload["legend"]
assert isinstance(legend, dict)
glyph = next(cell.glyph for row in world.terrain for cell in row if cell.key == key)
return legend[glyph]
def test_vale_payload_road_is_not_floor(world: World) -> None:
"""REGRESSION (the literal bug the slice fixes): road has its OWN colour.
Before v0.9 the Vale road shared ``floor`` with grass, so a path was
indistinguishable from open ground on the Watch. The road now carries
``road``; grass keeps ``floor``; they must differ.
"""
road = _legend_for_terrain_key(world, "road")
grass = _legend_for_terrain_key(world, "grass")
assert road == "road"
assert grass == "floor"
assert road != grass
def test_vale_payload_forest_is_not_tree(world: World) -> None:
"""REGRESSION: forest has its OWN colour, no longer shared with tree.
Dense forest scrub used to share ``tree`` with the tree wall, so the two
read identically. Forest now carries ``forest``; tree keeps ``tree``.
"""
forest = _legend_for_terrain_key(world, "forest")
tree = _legend_for_terrain_key(world, "tree")
assert forest == "forest"
assert tree == "tree"
assert forest != tree
def test_vale_payload_buildings_carry_distinct_roles(world: World) -> None:
"""Each Vale town building rides its own role (inn/shop/healer), not ``town``."""
payload = watch.build_world_payload(world)
by_name = {loc["name"]: loc["color"] for loc in payload["locations"]} # type: ignore[index,union-attr]
assert by_name["The Sleeping Drake"] == "inn"
assert by_name["Gravel & Sons Outfitters"] == "shop"
assert by_name["The Quiet Shrine"] == "healer"
assert by_name["The Understone Deep"] == "dungeon"
# No two distinct buildings share a colour role.
roles = list(by_name.values())
assert len(set(roles)) == len(roles)
def test_cinder_payload_slag_is_lava_not_water() -> None:
"""The Cinder slag carries ``lava`` (orange), never ``water`` (blue) again.
This is the Cinder half of the bug: molten slag shared ``water``, so the
lava rendered BLUE on the Watch. After the remap the legend carries ``lava``
and ``water`` appears NOWHERE in the Cinder payload (no water in this world).
"""
cinder = load_world(CINDER)
payload = watch.build_world_payload(cinder)
legend = payload["legend"]
assert isinstance(legend, dict)
assert _legend_for_terrain_key(cinder, "slag") == "lava"
assert "water" not in legend.values()
def test_cinder_payload_carries_expanded_roles() -> None:
"""The Cinder terrain reads by hue: ash→barren, basalt→road, cinder→scrub.
Cinder-fields use ``scrub`` (dusky ember-brown), NOT ``forest`` (green)
a volcanic waste must not render as lush woods. ``forest`` is for green
worlds; ``scrub`` is its barren counterpart.
"""
cinder = load_world(CINDER)
assert _legend_for_terrain_key(cinder, "ash") == "barren"
assert _legend_for_terrain_key(cinder, "basalt") == "road"
assert _legend_for_terrain_key(cinder, "cinder") == "scrub"
legend = watch.build_world_payload(cinder)["legend"]
assert isinstance(legend, dict)
assert "forest" not in legend.values() # no green woods in a volcanic waste
# Obsidian spire reuses the wall role (a rock barrier), same as caldera.
assert _legend_for_terrain_key(cinder, "spire") == "wall"
assert _legend_for_terrain_key(cinder, "caldera") == "wall"
def test_both_worlds_terrain_roles_are_distinct_per_world() -> None:
"""No two DISTINCT terrain types share a colour role within a world.
The point of the slice: after the remap each terrain kind reads by its own
hue. (A role MAY be shared by two types that are deliberately the same
barrier spire/caldera both ``wall`` in Cinder so this checks distinct
KEYS that map to the same role are only the intended wall pair.)
"""
for world_dir, allowed_shared in (
(PACK, set()),
(CINDER, {("caldera", "spire")}),
):
w = load_world(world_dir)
by_role: dict[str, list[str]] = {}
for key, role in _terrain_kinds(w).items():
by_role.setdefault(role, []).append(key)
for role, keys in by_role.items():
if len(keys) > 1:
pair = tuple(sorted(keys))
assert pair in allowed_shared, f"unexpected shared role {role!r}: {keys}"
# ---------------------------------------------------------------------------
# State payload (dynamic)
# ---------------------------------------------------------------------------
def test_state_payload_includes_joined_player(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
payload = watch.build_state_payload(game)
players = payload["players"]
assert isinstance(players, list)
brandr = next(p for p in players if p["name"] == "Brandr")
assert brandr["level"] == 1
assert brandr["wins"] == 0
assert brandr["hp"] == brandr["max_hp"]
assert brandr["mode"] == "tile"
assert (brandr["x"], brandr["y"]) == game.world.spawn
# v0.10: a fresh hero shows their starting gold on hand, nothing banked, and
# an empty satchel.
assert brandr["gold"] == game.world.settings.starting_gold
assert brandr["banked"] == 0
assert brandr["satchel"] == []
def test_state_payload_surfaces_gold_banked_and_satchel(tmp_path: Path, clock: object) -> None:
"""A joined hero with a stocked satchel and banked gold shows the right values.
The lobby TV surfaces the whole shared world, so each player's purse (gold
on hand + vault) and satchel stacks (name + qty, resolved via the pack) ride
the state payload.
"""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.gold = 120
player.banked = 300
game._satchel_set_stacks(player, [("iron_ore", 5), ("minor_potion", 2)])
payload = watch.build_state_payload(game)
brandr = next(p for p in payload["players"] if p["name"] == "Brandr") # type: ignore[union-attr]
assert brandr["gold"] == 120
assert brandr["banked"] == 300
# Stacks resolve their display name from the pack, preserving stow order.
assert brandr["satchel"] == [
{"name": "Iron Ore", "qty": 5},
{"name": "Minor Potion", "qty": 2},
]
def test_state_payload_satchel_unknown_id_falls_back_to_raw(tmp_path: Path, clock: object) -> None:
"""A satchel id no longer in the pack falls back to the raw id, never blank."""
game = _game(tmp_path, clock)
game.join("Brandr")
game.players["Brandr"].satchel = "ghost_item:2" # not in the pack
payload = watch.build_state_payload(game)
brandr = next(p for p in payload["players"] if p["name"] == "Brandr") # type: ignore[union-attr]
assert brandr["satchel"] == [{"name": "ghost_item", "qty": 2}]
def test_state_payload_reports_all_players_including_menu(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.join("Sigrun")
# Put Sigrun in a MENU surface; the Watch still shows her on the board.
sigrun = game.players["Sigrun"]
from understone.engine.models import Mode
sigrun.mode = Mode.MENU
sigrun.at_location = "inn"
payload = watch.build_state_payload(game)
names = {p["name"] for p in payload["players"]} # type: ignore[union-attr]
assert names == {"Brandr", "Sigrun"}
menu = next(p for p in payload["players"] if p["name"] == "Sigrun") # type: ignore[union-attr]
assert menu["mode"] == "menu"
def test_state_payload_ts_comes_from_clock(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
payload = watch.build_state_payload(game)
assert payload["ts"] == "2026-06-12T10:30:00+00:00"
def test_state_payload_herald_is_last_15_oldest_first(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
# Replace the resident feed with 20 synthetic events in ascending id order.
game.events = [
Event(
event_id=i,
ts=f"2026-06-12T10:{i:02d}:00+00:00",
kind="join",
actor=f"Hero{i}",
text=f"event {i}",
)
for i in range(1, 21)
]
payload = watch.build_state_payload(game)
herald = payload["herald"]
assert isinstance(herald, list)
assert len(herald) == 15
# Oldest-first: the window is events 6..20, in ascending order.
assert herald[0]["text"] == "event 6"
assert herald[-1]["text"] == "event 20"
def test_state_payload_herald_full_window_despite_sparse_ids(tmp_path: Path, clock: object) -> None:
"""Id gaps must not shrink the feed (regression: the window is a list
tail, not id arithmetic AUTOINCREMENT ids may be non-contiguous)."""
game = _game(tmp_path, clock)
game.events = [
Event(
event_id=i * 7, # sparse, non-contiguous ids
ts=f"2026-06-12T10:{i:02d}:00+00:00",
kind="join",
actor=f"Hero{i}",
text=f"event {i}",
)
for i in range(1, 21)
]
herald = watch.build_state_payload(game)["herald"]
assert len(herald) == 15
assert herald[0]["text"] == "event 6"
assert herald[-1]["text"] == "event 20"
def test_state_payload_herald_handles_short_feed(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.events = [
Event(
event_id=1,
ts="2026-06-12T10:00:00+00:00",
kind="join",
actor="Solo",
text="only one",
)
]
payload = watch.build_state_payload(game)
herald = payload["herald"]
assert isinstance(herald, list)
assert [e["text"] for e in herald] == ["only one"]
def test_state_payload_hall_capped_at_five(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
# Seven immortalised runs; the Watch shows only the five most recent.
for i in range(7):
game.store.insert_hall_row(f"Hero{i}", f"2026-06-{10 + i:02d}T12:00:00+00:00", i, 6 + i)
game.store.commit()
payload = watch.build_state_payload(game)
hall = payload["hall"]
assert isinstance(hall, list)
assert len(hall) == 5
# Newest first (store ordering): Hero6 leads.
assert hall[0]["name"] == "Hero6"
assert hall[0]["level_at_win"] == 12
# ---------------------------------------------------------------------------
# Watch-URL advertisement (join banner + help manual)
# ---------------------------------------------------------------------------
def test_join_advertises_watch_url_when_set(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock, watch_url="http://127.0.0.1:8077/watch")
out = game.join("Brandr")
assert "Watch the Vale live: http://127.0.0.1:8077/watch" in out
def test_join_omits_watch_line_when_unset(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
out = game.join("Brandr")
assert "Watch the Vale live" not in out
def test_resume_advertises_watch_url_when_set(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock, watch_url="http://127.0.0.1:8077/watch")
game.join("Brandr")
again = game.join("Brandr")
assert "Welcome back" in again
assert "Watch the Vale live: http://127.0.0.1:8077/watch" in again
def test_help_advertises_watch_url_when_set(tmp_path: Path) -> None:
# door_help reads the module game; install one carrying a watch URL.
world = load_world(PACK)
store = Store(tmp_path / "help.db")
understone_server._set_game(Game(world, store, watch_url="http://127.0.0.1:8077/watch"))
try:
manual = understone_server.door_help()
assert "Watch the Vale live: http://127.0.0.1:8077/watch" in manual
finally:
understone_server._GAME.store.close() # type: ignore[union-attr]
understone_server._GAME = None
def test_help_omits_watch_line_when_unset(tmp_path: Path) -> None:
world = load_world(PACK)
store = Store(tmp_path / "help.db")
understone_server._set_game(Game(world, store))
try:
manual = understone_server.door_help()
assert "Watch the Vale live" not in manual
finally:
understone_server._GAME.store.close() # type: ignore[union-attr]
understone_server._GAME = None
# ---------------------------------------------------------------------------
# WATCH_HTML lockstep guards (the JS twin of texture.py + the v0.6 glow-up)
#
# The inline page reproduces logic that lives in Python; these guard the two
# invariants most prone to silent drift — the texture selection formula and the
# other-player marker — plus the presence of the day-phase machinery.
# ---------------------------------------------------------------------------
def test_watch_html_derives_texture_formula_from_constants() -> None:
"""The page's JS index string is DERIVED from texture._HASH_X / _HASH_Y.
Not a hard-coded "x * 31 + y * 17" snapshot: the expected substring is built
from the live constants, so a Python-side retune that the watch builder
fails to track trips here instead of silently shipping a stale formula.
"""
from understone.screen import texture
expected = f"x * {texture._HASH_X} + y * {texture._HASH_Y}"
assert expected in watch.WATCH_HTML
def test_watch_html_js_selection_agrees_with_textured() -> None:
"""The JS selection arithmetic, replayed in Python, matches ``textured``.
The page computes ``variants[(x * _HASH_X + y * _HASH_Y) % len]``. Replaying
that exact formula here from the SAME constants and the SAME VARIANTS rows
and asserting it equals ``texture.textured`` over a full screen grid proves
both implementations select identically a stronger lockstep than a string
match, since it pins the result, not the source text.
"""
from understone.screen import texture
for base, choices in texture.VARIANTS.items():
for x in range(24):
for y in range(16):
js_pick = choices[(x * texture._HASH_X + y * texture._HASH_Y) % len(choices)]
assert texture.textured(base, x, y) == js_pick
def test_watch_html_variants_match_texture_table() -> None:
"""Every base->variants row in texture.VARIANTS appears in the JS VARIANTS map.
Glyphs ride into the inline JS as ``\\uXXXX`` escapes, so compare against the
escaped form. A new variant added to Python but not the page trips this.
"""
from understone.screen import texture
html = watch.WATCH_HTML
for base, choices in texture.VARIANTS.items():
for glyph in {base, *choices}:
token = glyph if glyph.isascii() else f"\\u{ord(glyph):04x}"
assert token in html, f"variant glyph {glyph!r} missing from WATCH_HTML"
def test_watch_html_uses_other_player_marker() -> None:
"""Players on the lobby TV wear the ☻ marker (escaped) — no bare '@' marker paint."""
assert "\\u263b" in watch.WATCH_HTML
def test_watch_html_renders_gold_banked_and_satchel() -> None:
"""The Adventurers panel JS references each player's gold, vault, and satchel."""
html = watch.WATCH_HTML
# The roster sub-lines read these state fields by name.
assert "p.gold" in html
assert "p.banked" in html
assert "p.satchel" in html
# The satchel line has a dedicated renderer with an empty-bag note.
assert "satchelText" in html
assert "satchel empty" in html
assert "vault" in html
def test_watch_html_has_day_phase_machinery() -> None:
"""The dusk/dawn glow-up is wired: the tint classes and the UTC-hour read."""
html = watch.WATCH_HTML
assert "applyDayPhase" in html
assert "getUTCHours" in html
assert ".map-frame.night" in html
assert ".map-frame.twilight" in html
assert "Noto Sans Mono" in html
# ---------------------------------------------------------------------------
# PALETTE completeness — the v0.9 invariant that kills the "silent fallback"
# bug class. The road bug existed because a Color role with no hex in the JS
# PALETTE map fell back to default; this pins that EVERY role has a hex.
# ---------------------------------------------------------------------------
def _watch_palette_keys() -> set[str]:
"""Parse the JS ``var PALETTE = { ... }`` map out of WATCH_HTML, return its keys.
The map uses bare (unquoted) JS identifier keys ``road: "#b89a6a",`` so
this slices the object literal and collects every ``key:`` token. Keeping the
parse here (not a hard-coded list) means the test reads whatever the page
actually ships, so a typo'd or dropped key surfaces as a missing role.
"""
html = watch.WATCH_HTML
start = html.index("var PALETTE = {")
body = html[start : html.index("};", start)]
# Each entry is `<ident>: "<hex>"`; capture the identifier before the colon.
return set(re.findall(r"(\w+)\s*:\s*\"#", body))
def test_watch_palette_covers_every_color_role() -> None:
"""EVERY Color enum value has an entry in the JS PALETTE map — no fallbacks.
This is the literal fix for the road bug: a shipped role with no hex paints
as ``default`` silently. Asserting ``{c.value} <= palette_keys`` means adding
a Color without a Watch hex trips here instead of shipping a grey/green road.
"""
palette_keys = _watch_palette_keys()
roles = {c.value for c in Color}
missing = roles - palette_keys
assert not missing, f"Color roles with no PALETTE hex (silent fallback): {sorted(missing)}"
def test_watch_palette_distinct_new_terrain_hexes() -> None:
"""The expanded terrain roles carry DISTINCT hexes (the point of the slice).
A guard that the seven new roles didn't accidentally collapse onto one hex
(which would re-introduce the very "two types, one colour" bug v0.9 fixes).
Parsed straight from the shipped map.
"""
html = watch.WATCH_HTML
start = html.index("var PALETTE = {")
body = html[start : html.index("};", start)]
pairs = dict(re.findall(r"(\w+)\s*:\s*\"(#[0-9a-fA-F]{6})\"", body))
new_roles = ["road", "forest", "lava", "barren", "inn", "shop", "healer"]
hexes = [pairs[r] for r in new_roles]
assert all(r in pairs for r in new_roles), "a new v0.9 role is missing its hex"
assert len(set(hexes)) == len(hexes), f"new roles share a hex: {hexes}"
# The molten role must NOT reuse water's blue (the Cinder slag bug).
assert pairs["lava"] != pairs["water"]
@@ -1,143 +0,0 @@
"""Tests for the per-pack Watch CRT theme (v0.8).
Covers the loader band (each of the four legal themes loads; an unknown theme
is rejected naming the legal set; an omitted theme defaults to phosphor), the
state-payload carrying the theme, and the WATCH_HTML page's JS THEME table —
including the load-bearing guard that the "phosphor" values byte-match the
original ``:root`` CSS, so the bundled Vale stays visually identical.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from understone import watch
from understone.errors import WorldLoadError
from understone.world.loader import (
DEFAULT_WATCH_THEME,
WATCH_THEMES,
load_world,
)
SHIPPED = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
# The original :root CRT custom-property values (pre-v0.8). The "phosphor" theme
# MUST reproduce these byte-for-byte so the default Vale is pixel-identical.
_ORIGINAL_ROOT = {
"--phosphor": "#7dffa0",
"--phosphor-dim": "#2f7a46",
"--amber": "#ffb44d",
"--bg": "#050a06",
"--panel": "#0a140d",
"--edge": "#163a22",
}
def _pack_with_theme(tmp_path: Path, theme: Any) -> Path:
"""Clone the Vale into a temp pack with ``settings.watch_theme`` set/removed.
``theme`` set to a string writes that value; set to the sentinel ``...``
DELETES the key entirely (to exercise the omitted-defaults path).
"""
dest = tmp_path / "themed"
shutil.copytree(SHIPPED, dest)
world_json = dest / "world.json"
data = json.loads(world_json.read_text(encoding="utf-8"))
if theme is ...:
data["settings"].pop("watch_theme", None)
else:
data["settings"]["watch_theme"] = theme
world_json.write_text(json.dumps(data), encoding="utf-8")
return dest
# ---------------------------------------------------------------------------
# loader band
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("theme", sorted(WATCH_THEMES))
def test_each_legal_theme_loads(tmp_path: Path, theme: str) -> None:
pack = _pack_with_theme(tmp_path, theme)
world = load_world(pack)
assert world.settings.watch_theme == theme
def test_unknown_theme_rejected_naming_the_set(tmp_path: Path) -> None:
pack = _pack_with_theme(tmp_path, "ultraviolet")
with pytest.raises(WorldLoadError) as exc:
load_world(pack)
message = str(exc.value)
assert "watch_theme" in message
assert "ultraviolet" in message
# The friendly message lists every legal theme so the author can fix it.
for name in WATCH_THEMES:
assert name in message
def test_omitted_theme_defaults_to_phosphor(tmp_path: Path) -> None:
pack = _pack_with_theme(tmp_path, ...) # delete the key entirely
world = load_world(pack)
assert world.settings.watch_theme == DEFAULT_WATCH_THEME == "phosphor"
def test_shipped_vale_is_phosphor() -> None:
"""The bundled Vale ships the phosphor theme (its green is unchanged)."""
world = load_world(SHIPPED)
assert world.settings.watch_theme == "phosphor"
# ---------------------------------------------------------------------------
# payload + WATCH_HTML
# ---------------------------------------------------------------------------
def test_world_payload_carries_theme(tmp_path: Path) -> None:
pack = _pack_with_theme(tmp_path, "ice")
world = load_world(pack)
payload = watch.build_world_payload(world)
assert payload["theme"] == "ice"
def test_shipped_payload_theme_is_phosphor() -> None:
world = load_world(SHIPPED)
payload = watch.build_world_payload(world)
assert payload["theme"] == "phosphor"
def test_watch_html_has_theme_table_and_all_names() -> None:
"""The page carries a JS THEME table keyed by every legal theme name."""
html = watch.WATCH_HTML
assert "var THEMES" in html
assert "applyTheme" in html
for name in WATCH_THEMES:
# Each theme is a JS object key, e.g. ``phosphor: {``.
assert f"{name}: {{" in html, f"theme {name!r} missing from THEME table"
def test_watch_html_phosphor_values_byte_match_original_root() -> None:
"""The "phosphor" theme reproduces the original :root values exactly.
This is the load-bearing guard for "the Vale looks identical": every
original custom-property value still appears in the page (in the :root block
AND the THEME table), so swapping in the phosphor theme is a no-op repaint.
"""
html = watch.WATCH_HTML
for prop, value in _ORIGINAL_ROOT.items():
# The value lives both in the :root CSS and the phosphor theme entry.
assert html.count(value) >= 2, f"{prop} value {value} not byte-matched twice"
# And the phosphor theme maps the property to exactly that value.
assert f'"{prop}": "{value}"' in html, f"phosphor {prop} != {value}"
def test_watch_html_applies_theme_on_world_fetch() -> None:
"""The page applies the theme when world.json arrives (in paintMap)."""
html = watch.WATCH_HTML
assert "applyTheme(world.theme)" in html
# It swaps CSS custom properties on the document root.
assert "documentElement.style.setProperty" in html
@@ -1,851 +0,0 @@
"""Content-pack loader tests.
Asserts the shipped pack loads, and that representative malformed packs
each raise :class:`WorldLoadError` with a readable message: a bad legend
character, a location placed on non-walkable terrain, a row-width / height
mismatch, and an economy setting outside its sanity band.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from understone.errors import WorldLoadError
from understone.world.loader import load_world
SHIPPED = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
def test_shipped_pack_loads() -> None:
world = load_world(SHIPPED)
assert world.name == "The Vale of Understone"
assert world.width == 96
assert world.height == 48
assert world.is_walkable(*world.spawn)
assert len(world.locations) == 4
assert len(world.zones) == 2
# Tiers 1..5 are the random foes; tier 6 is the boss (the Wyrm Below).
assert {m.tier for m in world.monsters} == {1, 2, 3, 4, 5, 6}
boss = world.monster_by_id(world.settings.boss_monster)
assert boss is not None and boss.boss and boss.name == "the Wyrm Below"
def _clone_pack(tmp_path: Path) -> Path:
dest = tmp_path / "pack"
shutil.copytree(SHIPPED, dest)
return dest
def _rewrite(path: Path, mutate: Any) -> None:
data = json.loads(path.read_text(encoding="utf-8"))
mutate(data)
path.write_text(json.dumps(data), encoding="utf-8")
def test_bad_legend_char_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Splice an unknown glyph into the middle of a terrain row.
row = list(data["terrain_rows"][24])
row[40] = "Z"
data["terrain_rows"][24] = "".join(row)
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="not in the legend"):
load_world(pack)
def test_location_on_non_walkable_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Move the inn onto a tree-border tile (col 0 is the tree frame).
for loc in data["locations"]:
if loc["key"] == "inn":
loc["x"] = 0
loc["y"] = 24
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="non-walkable"):
load_world(pack)
def test_dimension_mismatch_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Truncate one row so its width no longer matches the declared width.
data["terrain_rows"][10] = data["terrain_rows"][10][:-5]
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="wide but width is"):
load_world(pack)
def test_height_mismatch_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["terrain_rows"] = data["terrain_rows"][:-1]
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="rows but height is"):
load_world(pack)
def test_settings_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["daily_turns"] = 0 # band is 1..100
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="daily_turns"):
load_world(pack)
def test_start_hp_zero_rejected(tmp_path: Path) -> None:
"""A starting HP of 0 is out of band (1..500): a hero must begin alive."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["start_hp"] = 0 # band is 1..500
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="start_hp"):
load_world(pack)
def test_unknown_starting_item_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["starting_weapon"] = "no_such_blade"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="not a known item id"):
load_world(pack)
def test_missing_pack_file_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
(pack / "monsters.json").unlink()
with pytest.raises(WorldLoadError, match="missing pack file"):
load_world(pack)
def test_monster_nonpositive_hp_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[0]["hp"] = 0 # a monster with no hit points is unkillable nonsense
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] hp must be >= 1"):
load_world(pack)
def test_monster_negative_stat_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[1]["gold"] = -5
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[1\] gold must be >= 0"):
load_world(pack)
def test_item_negative_price_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[1]["price"] = -10 # a negative price would pay the player to take it
_rewrite(pack / "items.json", mutate)
with pytest.raises(WorldLoadError, match=r"items\.json\[1\] price must be >= 0"):
load_world(pack)
def test_dungeon_tier_without_monster_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Tier 9 has no monster in the pack, so the gauntlet rung is unfillable.
data["settings"]["dungeon_tiers"] = [4, 9]
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"dungeon_tiers\[1\] = 9 has no non-boss monster"):
load_world(pack)
def test_dungeon_tiers_empty_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["dungeon_tiers"] = []
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="dungeon_tiers must be a non-empty list"):
load_world(pack)
def test_dungeon_tier_backed_only_by_boss_rejected(tmp_path: Path) -> None:
"""A boss-only tier is unfillable: the gauntlet excludes boss monsters.
Tier 6 in the shipped pack holds only the Wyrm Below (a boss). A gauntlet
rung at tier 6 would draw from monsters_for_tier_band, which filters bosses
out, so the rung silently does nothing the loader must reject it instead.
The message says "no NON-boss monster" (not merely "no monster"): the boss
is present at that tier, it just cannot fill a rung, and the wording must
point the author at exactly that.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["dungeon_tiers"] = [4, 6] # 6 is the boss-only tier
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"dungeon_tiers\[1\] = 6 has no non-boss monster"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.2 loader rejections: the event table and the Wyrm settings
# ---------------------------------------------------------------------------
def test_events_without_fight_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Strip every fight row; a walk could then never spawn a monster.
data["events"] = [e for e in data["events"] if e["kind"] != "fight"]
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match="at least one 'fight' entry"):
load_world(pack)
def test_event_zero_weight_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["events"][0]["weight"] = 0
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match="weight must be > 0"):
load_world(pack)
def test_event_min_exceeds_max_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Find a value-bearing row and invert its band.
for event in data["events"]:
if event["kind"] == "gold":
event["min"], event["max"] = 9, 2
break
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match="min 9 exceeds max 2"):
load_world(pack)
def test_event_amount_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
for event in data["events"]:
if event["kind"] == "heal":
event["max"] = 500 # heal band is 1..100
break
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match=r"heal amount .* is out of band"):
load_world(pack)
def test_event_nonfight_blank_text_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
for event in data["events"]:
if event["kind"] == "lore":
event["text"] = " "
break
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match="requires non-empty 'text'"):
load_world(pack)
def test_boss_monster_unknown_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["boss_monster"] = "no_such_wyrm"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="is not a known monster id"):
load_world(pack)
def test_boss_monster_not_flagged_boss_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
# Give a plain monster an id and point boss_monster at it; it lacks the
# boss flag, so it must be rejected as the endgame foe.
data[0]["id"] = "field_rat"
_rewrite(pack / "monsters.json", mutate)
def point(data: dict[str, Any]) -> None:
data["settings"]["boss_monster"] = "field_rat"
_rewrite(pack / "world.json", point)
with pytest.raises(WorldLoadError, match='must be flagged "boss": true'):
load_world(pack)
def test_wyrm_min_level_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["wyrm_min_level"] = 0 # band is 1..50
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="wyrm_min_level"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.5 social settings: ambush / post / gamble economy bands
# ---------------------------------------------------------------------------
def test_ambush_gold_pct_out_of_band_rejected(tmp_path: Path) -> None:
"""The steal percentage is a 0..100 band; 101 is rejected by name."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ambush_gold_pct"] = 101 # band is 0..100
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="ambush_gold_pct"):
load_world(pack)
def test_ambush_level_band_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ambush_level_band"] = 11 # band is 0..10
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="ambush_level_band"):
load_world(pack)
def test_gamble_max_bet_out_of_band_rejected(tmp_path: Path) -> None:
"""A max bet of 0 is below the 1..10000 floor: the house needs a real stake."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["gamble_max_bet"] = 0 # band is 1..10000
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="gamble_max_bet"):
load_world(pack)
def test_post_daily_cap_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["post_daily_cap"] = 51 # band is 0..50
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="post_daily_cap"):
load_world(pack)
def test_missing_social_setting_rejected(tmp_path: Path) -> None:
"""A pack that predates the social settings fails loudly (no silent default)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
del data["settings"]["ambush_min_level"]
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="ambush_min_level"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.4 loader hardening: glyphs, map size, count caps, and name lengths
#
# Packs are now routinely untrusted LLM output, so the loader bands the shapes
# that could tear a frame, balloon memory, or impersonate a player. Each
# rejection still names the file and field at fault.
# ---------------------------------------------------------------------------
def test_box_drawing_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A terrain glyph may not be a frame box-drawing line (it would tear borders)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "" # the horizontal frame run
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* box-drawing"):
load_world(pack)
def test_player_marker_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A terrain glyph may not be '@' — that is the player's own marker."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "@"
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* reserved for player markers"):
load_world(pack)
def test_other_player_marker_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A terrain glyph may not be '' — the v0.6 other-player marker."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = ""
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* reserved for player markers"):
load_world(pack)
def test_ampersand_terrain_glyph_now_accepted(tmp_path: Path) -> None:
"""'&' is no longer an actor marker (☻ took that role), so it is pack-legal.
The load itself is the assertion it must not raise the actor-marker
rejection. A grass cell then carries the new glyph.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "&"
_rewrite(pack / "terrain.json", mutate)
world = load_world(pack) # no WorldLoadError: '&' is admitted
grass = next(
world.terrain_at(x, y)
for y in range(world.height)
for x in range(world.width)
if world.terrain_at(x, y).key == "grass"
)
assert grass.glyph == "&"
def test_wide_cjk_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A Wide (EAW=W) ideograph would render two columns and tear the frame."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = ""
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* exactly one column"):
load_world(pack)
def test_fullwidth_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A Fullwidth (EAW=F) Latin letter is two columns and is rejected."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "" # U+FF21 FULLWIDTH LATIN CAPITAL LETTER A
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* exactly one column"):
load_world(pack)
def test_reskinned_shipped_pack_glyphs() -> None:
"""The shipped pack carries the v0.6 re-skin and still loads cleanly.
The load-bearing guard for the re-skin: water is and the three lettered
buildings became //. If a data edit reverts a glyph, this trips.
"""
world = load_world(SHIPPED)
waters = {
world.terrain_at(x, y).glyph
for y in range(world.height)
for x in range(world.width)
if world.terrain_at(x, y).key == "water"
}
assert waters == {""}
by_key = {loc.key: loc.glyph for loc in world.locations}
assert by_key["inn"] == ""
assert by_key["healer"] == ""
assert by_key["dungeon"] == ""
assert by_key["shop"] == "$" # the shop glyph is unchanged
def test_multichar_location_glyph_rejected(tmp_path: Path) -> None:
"""A location glyph must be exactly one character."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["inn"]["glyph"] = "In" # two characters
_rewrite(pack / "locations.json", mutate)
with pytest.raises(WorldLoadError, match=r"locations\.json.* single character"):
load_world(pack)
def test_oversized_map_rejected(tmp_path: Path) -> None:
"""A 300x300 map is past the dimension ceiling (8..256)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["width"] = 300
data["height"] = 300
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"world\.json width = 300 is out of band"):
load_world(pack)
def test_too_many_events_rejected(tmp_path: Path) -> None:
"""An event table over the 500-row cap is rejected before it is decoded."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
filler = {"kind": "lore", "weight": 1, "text": "filler"}
data["events"] = [filler.copy() for _ in range(501)]
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match=r"events\.json defines 501 events; the limit is 500"):
load_world(pack)
def test_overlong_monster_name_rejected(tmp_path: Path) -> None:
"""A 49-character monster name is one past the 48-char display limit."""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[0]["name"] = "x" * 49
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] name is 49 characters"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.7 loader rejections: the satchel/forge bands, rare_drop_item, monster weight
# ---------------------------------------------------------------------------
def test_rare_drop_item_unknown_rejected(tmp_path: Path) -> None:
"""A rare_drop_item that names no item is rejected with the item-id message."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["rare_drop_item"] = "no_such_draught"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="rare_drop_item = 'no_such_draught' is not a known"):
load_world(pack)
def test_rare_drop_item_non_consumable_rejected(tmp_path: Path) -> None:
"""A rare_drop_item that names a weapon (not a consumable) is rejected.
The drop goes straight into the satchel to be quaffed, so a weapon or
armour id is incoherent the loader pins the slot.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["rare_drop_item"] = "iron_sword" # a weapon, not a draught
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="rare_drop_item = 'iron_sword' must be a consumable"):
load_world(pack)
def test_satchel_max_out_of_band_rejected(tmp_path: Path) -> None:
"""satchel_max above its 1..10 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["satchel_max"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"satchel_max = 11 is out of band \(1\.\.10\)"):
load_world(pack)
def test_forge_max_plus_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_max_plus above its 0..10 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_max_plus"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_max_plus = 11 is out of band \(0\.\.10\)"):
load_world(pack)
def test_forge_base_cost_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_base_cost below its floor of 1 is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_base_cost"] = 0
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_base_cost = 0 is out of band \(1\.\.10000\)"):
load_world(pack)
def test_forge_ore_item_unknown_rejected(tmp_path: Path) -> None:
"""A forge_ore_item that names no item is rejected with the item-id message."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_ore_item"] = "no_such_ore"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="forge_ore_item = 'no_such_ore' is not a known"):
load_world(pack)
def test_forge_ore_item_non_material_rejected(tmp_path: Path) -> None:
"""A forge_ore_item that names a non-material (a potion) is rejected.
Ore is carried in the satchel and spent at the forge, never equipped or
quaffed, so a consumable/weapon/armour id is incoherent the loader pins
the slot to ``material`` (mirroring the rare_drop_item consumable check).
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_ore_item"] = "greater_potion" # a draught, not ore
_rewrite(pack / "world.json", mutate)
with pytest.raises(
WorldLoadError, match="forge_ore_item = 'greater_potion' must be a material"
):
load_world(pack)
def test_forge_ore_per_plus_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_ore_per_plus above its 0..10 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_ore_per_plus"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_ore_per_plus = 11 is out of band \(0\.\.10\)"):
load_world(pack)
def test_ore_dungeon_drop_out_of_band_rejected(tmp_path: Path) -> None:
"""ore_dungeon_drop above its 0..20 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ore_dungeon_drop"] = 21
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"ore_dungeon_drop = 21 is out of band \(0\.\.20\)"):
load_world(pack)
def test_ore_forest_chance_out_of_band_rejected(tmp_path: Path) -> None:
"""ore_forest_chance outside 0.0..1.0 is a load error (it is a probability)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ore_forest_chance"] = 1.5
_rewrite(pack / "world.json", mutate)
with pytest.raises(
WorldLoadError, match=r"ore_forest_chance = 1.5 is out of band \(0.0..1.0\)"
):
load_world(pack)
def test_monster_zero_weight_rejected(tmp_path: Path) -> None:
"""A monster weight of 0 is rejected (the weighted pick needs a positive total)."""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[0]["weight"] = 0
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] weight must be > 0"):
load_world(pack)
def test_shipped_pack_carries_rares_and_weights() -> None:
"""The shipped pack parses the v0.7 rare beasts with their low weights."""
world = load_world(SHIPPED)
rares = [m for m in world.monsters if m.rare]
names = {m.name for m in rares}
assert names == {"the Gilded Stag", "the Hollow Knight"}
assert all(m.weight == 1 for m in rares) # rares surface seldom
# The rare_drop_item resolves to a consumable.
drop = world.item_by_id(world.settings.rare_drop_item)
assert drop is not None and drop.slot.value == "consumable"
# The new economy settings land on their shipped values.
assert world.settings.satchel_max == 3
assert world.settings.forge_base_cost == 60
assert world.settings.forge_max_plus == 3
assert world.settings.dungeon_tiers == (3, 4, 5)
# v0.10 ore-forge settings resolve, and the forge ore is a material item.
assert world.settings.forge_ore_item == "iron_ore"
ore = world.item_by_id(world.settings.forge_ore_item)
assert ore is not None and ore.slot.value == "material"
assert world.settings.forge_ore_per_plus == 1
assert world.settings.ore_dungeon_drop == 2
assert world.settings.ore_forest_chance == 0.2
def test_monster_weight_and_rare_default_when_omitted(tmp_path: Path) -> None:
"""A monster spec without weight/rare loads as weight 10, rare False.
Both fields are optional with defaults, so an unannotated common monster
(the shipped Field Rat) parses to the default weight and the non-rare flag.
"""
world = load_world(SHIPPED)
rat = next(m for m in world.monsters if m.name == "Field Rat")
assert rat.weight == 10 # the default biasing weight
assert rat.rare is False
# ---------------------------------------------------------------------------
# v0.8 loader hardening: rare-as-rung-guardian and the single-boss invariant
#
# AUTHORING states both as rules; v0.8 makes them machine-checked. A rare in
# the lead slot of a dungeon tier would be silently promoted to a fixed rung
# guardian (and pulled from the rare pool); a stray second boss would validate
# clean yet make "the one endgame foe" a lie.
# ---------------------------------------------------------------------------
def test_rare_as_first_dungeon_tier_monster_rejected(tmp_path: Path) -> None:
"""A rare in the FIRST slot of a dungeon tier becomes a fixed guardian — rejected.
Tier 3 backs a ``dungeon_tiers`` rung and its first monster (the Forest
Wolf) is the rung guardian (``band[0]``). Flagging that lead monster rare
would quietly turn the rare into the fixed, repeatable guardian and remove
it from the weighted rare roll, so the loader rejects it by name.
"""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
wolf = next(m for m in data if m["name"] == "Forest Wolf") # first tier-3
wolf["rare"] = True
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(
WorldLoadError,
match=r"'Forest Wolf' is rare but is the first tier-3 monster.*fixed guardian",
):
load_world(pack)
def test_rare_after_guardian_in_dungeon_tier_accepted(tmp_path: Path) -> None:
"""A rare placed AFTER the guardian in the same dungeon tier loads cleanly.
The shipped pack already does exactly this (the Hollow Knight is the third
tier-3 entry, behind the Forest Wolf guardian). Inserting another rare also
after the guardian must not trip the new check only the LEAD slot of a
dungeon tier is constrained.
"""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
# Splice a second tier-3 rare in just before the boss (well after the
# tier-3 guardian), so the tier's first non-boss monster is unchanged.
extra = {
"tier": 3,
"name": "the Ashen Stalker",
"hp": 26,
"atk": 10,
"def": 3,
"xp": 55,
"gold": 75,
"weight": 1,
"rare": True,
}
data.insert(len(data) - 1, extra)
_rewrite(pack / "monsters.json", mutate)
world = load_world(pack) # no WorldLoadError: the rare is not the lead foe
tier3 = world.monsters_for_tier_band(3, 3)
assert tier3[0].name == "Forest Wolf" # the guardian is still the non-rare lead
assert any(m.name == "the Ashen Stalker" and m.rare for m in tier3)
def test_two_bosses_rejected(tmp_path: Path) -> None:
"""Two ``boss``-flagged monsters are rejected: a world has exactly one boss."""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
# Give the Field Rat the boss flag too; now two monsters claim the role.
rat = next(m for m in data if m["name"] == "Field Rat")
rat["boss"] = True
rat["id"] = "field_rat"
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"flags 2 monsters as .boss.* true"):
load_world(pack)
def test_single_boss_accepted() -> None:
"""The shipped pack carries exactly one boss and loads — the single-boss path.
The positive half of the invariant: the Wyrm Below is the only boss, so the
load succeeds and the boss count is exactly one.
"""
world = load_world(SHIPPED)
bosses = [m for m in world.monsters if m.boss]
assert len(bosses) == 1
assert bosses[0].name == "the Wyrm Below"
def test_overlapping_zones_rejected(tmp_path: Path) -> None:
"""Overlapping zone rectangles are a load error.
``zone_for`` returns the FIRST matching zone, so two zones sharing any cell
would silently shadow one tier band there exactly the bug a cold-authored
pack shipped (a 1-column caldera-edge strip dropped to the low band). Pull
the deep zone west so its rect overlaps the near zone and confirm the loader
refuses it rather than loading the ambiguity.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
for zone in data["zones"]:
if zone["key"] == "dungeon_deep":
zone["rect"][0] = 50 # now overlaps forest_near's x30..60 strip
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="overlap"):
load_world(pack)
-206
View File
@@ -1,206 +0,0 @@
"""Tests for bundled-world discovery and the ``worlds`` listing.
Covers the discovery helper (the Vale leads, alternate packs follow
alphabetically, non-pack directories are skipped) and the ``cli_worlds``
listing it backs: a sound fixture pack reports "sound", a deliberately-flawed
fixture pack reports "flawed", and the Vale is always listed first. The
``packs/`` directory is monkeypatched to a temp fixture tree so these tests
never depend on the real (separately-authored) second world.
"""
from __future__ import annotations
import json
import shutil
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING, Any
from understone import cli
from understone import world as world_pkg
from understone.world import VALE_SLUG, bundled_world_dirs
if TYPE_CHECKING:
import pytest
SHIPPED = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
def _make_packs(tmp_path: Path, *, sound: list[str], flawed: dict[str, Any]) -> Path:
"""Build a temp ``packs/`` tree: sound slugs plus flawed-world slugs.
Each sound slug is a verbatim copy of the shipped Vale; each flawed slug is
a copy whose ``world.json`` is patched with the given settings overrides so
it fails to load. Returns the packs root to monkeypatch ``PACKS_DIR`` onto.
"""
packs = tmp_path / "packs"
packs.mkdir()
for slug in sound:
shutil.copytree(SHIPPED, packs / slug)
for slug, overrides in flawed.items():
dest = packs / slug
shutil.copytree(SHIPPED, dest)
world_json = dest / "world.json"
data = json.loads(world_json.read_text(encoding="utf-8"))
data["settings"].update(overrides)
world_json.write_text(json.dumps(data), encoding="utf-8")
return packs
# ---------------------------------------------------------------------------
# bundled_world_dirs discovery
# ---------------------------------------------------------------------------
def test_bundled_world_dirs_vale_leads_then_alpha(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
packs = _make_packs(tmp_path, sound=["zephyr", "ashfall"], flawed={})
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
found = bundled_world_dirs()
slugs = [slug for slug, _ in found]
# The Vale is always first; alternates follow alphabetically.
assert slugs == [VALE_SLUG, "ashfall", "zephyr"]
# The Vale entry points at the packaged data dir, not a packs subdir.
assert found[0][1] == world_pkg.PACKAGED_WORLD_DIR
def test_bundled_world_dirs_skips_non_pack_entries(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
packs = _make_packs(tmp_path, sound=["real"], flawed={})
# A README placeholder and a directory with no world.json are NOT worlds.
(packs / "README.md").write_text("placeholder", encoding="utf-8")
(packs / "empty_dir").mkdir()
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
slugs = [slug for slug, _ in bundled_world_dirs()]
assert slugs == [VALE_SLUG, "real"]
def test_bundled_world_dirs_handles_absent_packs_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing packs/ directory yields just the Vale (never raises)."""
monkeypatch.setattr(world_pkg, "PACKS_DIR", tmp_path / "does_not_exist")
found = bundled_world_dirs()
assert [slug for slug, _ in found] == [VALE_SLUG]
# ---------------------------------------------------------------------------
# cli_worlds listing
# ---------------------------------------------------------------------------
def test_cli_worlds_lists_vale_sound_first(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(world_pkg, "PACKS_DIR", tmp_path / "empty")
out, err = StringIO(), StringIO()
rc = cli.cli_worlds(out=out, err=err)
assert rc == 0
text = out.getvalue()
lines = [ln for ln in text.splitlines() if ln.strip()]
# The very first listing line is the Vale, reported sound, with its size.
assert lines[0].split()[0] == VALE_SLUG
assert "The Vale of Understone" in lines[0]
assert "96x48" in lines[0]
assert "sound" in lines[0]
# The serve hint closes the listing.
assert "UNDERSTONE_WORLD=" in text
assert "the default Vale needs no setting" in text
def test_cli_worlds_reports_sound_alternate(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
packs = _make_packs(tmp_path, sound=["mirefen"], flawed={})
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
out = StringIO()
cli.cli_worlds(out=out)
text = out.getvalue()
line = next(ln for ln in text.splitlines() if ln.strip().startswith("mirefen"))
assert "sound" in line
assert "flawed" not in line
def test_cli_worlds_flags_flawed_alternate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# daily_turns 0 is out of its 1..100 band: the pack fails to load.
packs = _make_packs(tmp_path, sound=["sound_one"], flawed={"broken": {"daily_turns": 0}})
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
out = StringIO()
rc = cli.cli_worlds(out=out)
assert rc == 0 # a flawed pack is reported, never fatal
text = out.getvalue()
broken_line = next(ln for ln in text.splitlines() if ln.strip().startswith("broken"))
assert "flawed:" in broken_line
assert "daily_turns" in broken_line # the offending field surfaces
# The sound pack alongside it still reports sound — one bad pack doesn't
# poison the survey.
sound_line = next(ln for ln in text.splitlines() if ln.strip().startswith("sound_one"))
assert "sound" in sound_line
def test_cli_worlds_vale_sorts_before_flawed_alternate(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Even with an alphabetically-earlier flawed pack, the Vale leads."""
packs = _make_packs(tmp_path, sound=[], flawed={"aaa_broken": {"start_hp": 0}})
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
out = StringIO()
cli.cli_worlds(out=out)
lines = [ln for ln in out.getvalue().splitlines() if ln.strip()]
assert lines[0].split()[0] == VALE_SLUG
assert lines[1].strip().startswith("aaa_broken")
assert "flawed:" in lines[1]
# ---------------------------------------------------------------------------
# the REAL bundled alternate world (no monkeypatch): The Cinder Wastes
#
# The tests above stub PACKS_DIR to a fixture tree so they never depend on the
# separately-authored pack. These two exercise the actual shipped packs/ — the
# bundled Cinder Wastes must discover, load, validate, and appear in the listing
# as sound, so a broken or unbundled alternate trips here.
# ---------------------------------------------------------------------------
CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes"
def test_bundled_cinder_wastes_loads_and_validates() -> None:
"""The bundled Cinder Wastes loads through the (strict v0.8) loader cleanly.
It is LLM-authored from AUTHORING.md alone, so this is the dogfood proof
that the manual + validator produce a pack the real loader accepts and,
after v0.8, one that passes the stricter rare-as-guardian and single-boss
checks (its rares sit after their guardians; it has exactly one boss).
"""
from understone.world.loader import load_world
world = load_world(CINDER)
assert world.name == "The Cinder Wastes"
assert world.settings.watch_theme == "ember" # the thematic ember CRT palette
bosses = [m for m in world.monsters if m.boss]
assert len(bosses) == 1 and bosses[0].name == "the Magma Wyrm"
# The boss id resolves and is the declared endgame foe.
assert world.settings.boss_monster == "magma_wyrm"
def test_cli_worlds_lists_bundled_cinder_wastes_sound() -> None:
"""`understone worlds` discovers the real bundled Cinder Wastes as sound.
No monkeypatch: this runs against the actual packs/ directory, so it asserts
the genuinely-shipped second world appears in the listing (alongside the
fixture-based listing tests above, which stay).
"""
out = StringIO()
rc = cli.cli_worlds(out=out)
assert rc == 0
line = next(ln for ln in out.getvalue().splitlines() if ln.strip().startswith("cinder-wastes"))
assert "The Cinder Wastes" in line
assert "sound" in line
assert "flawed" not in line
-600
View File
@@ -1,600 +0,0 @@
"""The Wyrm Below — the v0.2 endgame, legacy reset, and the Herald feed.
Drives the challenge verb against the shipped pack: the level gate, the win
path (Hall of Legends + reincarnation), defeat, and the stalemate flight, plus
the run-days bookkeeping. Also pins the boss exclusion from random selection
and proves the new level_up / defeat beats reach OTHER players' Herald.
Negative-test discipline (the level gate):
The challenge gate is pinned by ``test_challenge_under_level_refused``. To
confirm the assertion has teeth, the implementer temporarily removed the
``if player.level < min_level`` refusal in Game._challenge (letting an
under-level hero spend a turn and fight the Wyrm); the test then FAILED on
the unchanged-turns assertion (a turn was consumed and the refusal line was
absent). The guard was restored. This test is the standing regression.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import (
fixed_clock,
satchel_ids,
set_satchel,
utc,
)
from understone.engine.models import Mode
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.world.loader import load_world
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
# Module-local aliases for the shared satchel helpers, keeping the existing
# call sites (_set_satchel / _satchel_ids) unchanged.
_set_satchel = set_satchel
_satchel_ids = satchel_ids
@pytest.fixture
def clock() -> object:
return fixed_clock(utc(2026, 6, 12, 10, 0))
def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
world = load_world(PACK)
store = Store(tmp_path / "game.db")
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
# The flat-id-list satchel helpers (_set_satchel / _satchel_ids) live in
# tests/conftest.py now, shared with the descend suite; they are imported above.
def _at_dungeon(game: Game, name: str) -> object:
"""Place an already-joined player inside the dungeon menu, at the deep floor.
The challenge verb now gates on depth as well as level: the Wyrm will not
stir until the hero has plumbed the deep to its floor. These challenge
tests exercise the win/lose/flee paths, not the gate, so the helper puts
the hero at the bottom (deepest_rung == the rung count). The depth gate
itself is exercised by the dedicated tests in test_descend.py.
"""
player = game.players[name]
player.mode = Mode.MENU
player.at_location = "dungeon"
player.deepest_rung = len(game.world.settings.dungeon_tiers)
return player
# ---------------------------------------------------------------------------
# Boss exclusion from random selection
# ---------------------------------------------------------------------------
def test_boss_never_in_any_tier_band(tmp_path: Path, clock: object) -> None:
"""The Wyrm Below is never returned by monsters_for_tier_band, any band."""
game = _game(tmp_path, clock)
world = game.world
tiers = [m.tier for m in world.monsters]
lo, hi = min(tiers), max(tiers)
for band_lo in range(lo, hi + 2):
for band_hi in range(band_lo, hi + 2):
band = world.monsters_for_tier_band(band_lo, band_hi)
assert all(not m.boss for m in band)
assert all(m.monster_id != "wyrm_below" for m in band)
# ---------------------------------------------------------------------------
# The level gate (negative-tested; see module docstring)
# ---------------------------------------------------------------------------
def test_challenge_under_level_refused(tmp_path: Path, clock: object) -> None:
"""An under-level hero is turned away in-fiction, spending no turn.
See the module docstring for the revert-and-observe-failure check proving
the gate has teeth.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = _at_dungeon(game, "Brak")
assert player.level < game.world.settings.wyrm_min_level
before_turns = player.turns_left
before_events = len(game.events)
out = game.action("Brak", "challenge", "", "")
assert "sixth circle" in out.lower() # names the threshold in-fiction
assert player.turns_left == before_turns # no turn spent
assert player.level == 1 # nothing reset
assert len(game.events) == before_events # no public news
assert player.mode is Mode.MENU # still standing at the dungeon
def test_challenge_at_level_threshold_is_allowed(tmp_path: Path, clock: object) -> None:
"""Exactly at the threshold the challenge proceeds (spends a turn)."""
game = _game(tmp_path, clock)
game.join("Brak")
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
before_turns = player.turns_left
out = game.action("Brak", "challenge", "", "")
assert "sixth circle" not in out.lower() # not refused
assert player.turns_left == before_turns - 1 # a turn was spent
def test_challenge_at_zero_turns_refused_clean(tmp_path: Path) -> None:
"""At the level gate but out of turns, the challenge is refused with no effect.
A wyrm-eligible hero with an empty daily budget (and no day-roll to refill
it) is turned away in-fiction: no turn drops below zero, no Hall row is
cut, no public beat is written, wins are untouched and the no-op player
row is still committed (the refusal branch upserts + commits), so a store
reopen sees the unchanged hero.
"""
clk = _MutableClock(utc(2026, 6, 12, 10, 0))
world = load_world(PACK)
store = Store(tmp_path / "game.db")
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Brak")
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level # eligible
player.turns_left = 0 # but spent for the day (same day: no refill)
events_before = len(game.events)
hall_before = len(game.store.top_hall(50))
out = game.action("Brak", "challenge", "", "")
assert "tomorrow" in out.lower() # the "too spent ... today" refusal
assert "sixth circle" not in out.lower() # not the level gate
assert player.turns_left == 0 # never spent below zero
assert player.wins == 0 # no win recorded
assert len(game.events) == events_before # no public feed beat
assert len(game.store.top_hall(50)) == hall_before # no Hall row
assert player.mode is Mode.MENU # still standing at the dungeon
# The refusal branch commits the (unchanged) row: a reopen sees the hero.
game.store.close()
reopened = Game(world, Store(tmp_path / "game.db"), clock=clk) # type: ignore[arg-type]
assert reopened.players["Brak"].turns_left == 0
assert reopened.players["Brak"].wins == 0
# ---------------------------------------------------------------------------
# Win path: Hall of Legends + legacy reset
# ---------------------------------------------------------------------------
def test_challenge_win_resets_with_legacy(tmp_path: Path, clock: object) -> None:
"""A win records the run, heralds it, and reincarnates the hero with a ★."""
game = _game(tmp_path, clock)
game.join("Brak")
player = _at_dungeon(game, "Brak")
# Mid-run state that must be wiped by the reset.
player.level, player.xp = 12, 5000
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
player.gold = 999
player.weapon_id, player.armor_id = "war_axe", "chainmail"
# State that must SURVIVE the reset.
player.turns_left = 4
player.log_cursor = 1
player.bestow_spent = 7
events_before = len(game.events)
settings = game.world.settings
out = game.action("Brak", "challenge", "", "")
# Win narration and the immortalised run.
assert "freed the vale" in out.lower()
assert "hall of legends" in out.lower()
# The legacy reset wipes xp/gold, so the Wyrm win must NOT narrate a reward
# the hero never keeps (the old engine appended "+400 XP, +250 gold." to the
# kill line, which _wyrm_won echoed verbatim). The boss's reward never lands.
boss = game.world.monster_by_id(game.world.settings.boss_monster)
assert boss is not None
assert f"+{boss.xp} XP" not in out # i.e. "+400 XP"
assert f"+{boss.gold} gold" not in out # i.e. "+250 gold"
assert "+400 XP" not in out and "+250 gold" not in out
hall = game.store.top_hall(5)
assert len(hall) == 1
assert hall[0].name == "Brak"
assert hall[0].level_at_win == 12 # the level at the moment of the kill
assert hall[0].run_days == 0 # same UTC day as the join under the frozen clock
# A public news beat was written (all-caps herald moment).
assert len(game.events) == events_before + 1
assert game.events[-1].kind == "wyrm_win"
assert "WYRM" in game.events[-1].text
# Reincarnation: stats/gold/gear/position back to first-day values.
assert player.wins == 1
assert player.level == 1
assert player.xp == 0
assert player.gold == settings.starting_gold
assert player.weapon_id == settings.starting_weapon
assert player.armor_id == settings.starting_armor
assert player.hp == player.max_hp
assert (player.x, player.y) == game.world.spawn
assert player.mode is Mode.TILE
assert player.at_location == ""
# The daily clock and the log cursor were deliberately left alone.
assert player.turns_left == 4 - 1 # only the one challenge turn was spent
assert player.log_cursor == 1
assert player.bestow_spent == 7
def test_challenge_win_legacy_reset_spares_the_vault(tmp_path: Path, clock: object) -> None:
"""The vault SURVIVES a Wyrm-win rebirth; carried gold resets to starting.
Banked gold is the one wealth (besides the ) a legacy reset does not clear:
the strongbox is the inn's, not the reborn hero's. This deposits gold into
the vault through the inn, drives a Wyrm WIN, and asserts ``banked`` is
UNCHANGED while ``gold`` drops back to ``starting_gold``.
Negative-check (the revert-and-observe-failure discipline of this module):
the implementer temporarily added ``player.banked = 0`` to
Game._reset_with_legacy; this test then FAILED on the unchanged-``banked``
assertion (the vault was wiped by the rebirth). The line was restored, so
this test is the standing regression that the vault outlives the reset.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = game.players["Brak"]
# Bank some gold through the real inn path, then stand at the dungeon floor.
player.gold = 200
player.mode = Mode.MENU
player.at_location = "inn"
game.action("Brak", "deposit", "", "", amount=120)
assert player.banked == 120 and player.gold == 80 # vault holds; hand drained
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
out = game.action("Brak", "challenge", "", "")
assert "freed the vale" in out.lower() # a genuine win drove the reset
assert player.wins == 1
assert player.banked == 120 # the vault is untouched by the rebirth
assert player.gold == game.world.settings.starting_gold # carried wealth resets
def test_challenge_win_star_in_rank_and_hall(tmp_path: Path, clock: object) -> None:
"""After a win, door_rank shows the ★ and renders the Hall of Legends."""
game = _game(tmp_path, clock)
game.join("Brak")
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
game.action("Brak", "challenge", "", "")
out = game.rank("Brak")
assert "" in out
assert "Hall of Legends" in out
assert "Brak" in out
def test_two_wins_render_two_stars(tmp_path: Path, clock: object) -> None:
"""A second Wyrm kill stacks a second ★ on the leaderboard name."""
game = _game(tmp_path, clock)
game.join("Brak")
for _ in range(2):
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
game.action("Brak", "challenge", "", "")
assert game.players["Brak"].wins == 2
assert "★★" in game.rank("Brak")
# ---------------------------------------------------------------------------
# Lose path and flight
# ---------------------------------------------------------------------------
def test_challenge_loss_bounces_and_heralds(tmp_path: Path, clock: object) -> None:
"""A defeat drops the hero to 1 HP at the spawn and heralds the devouring."""
game = _game(tmp_path, clock)
game.join("Brak")
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level
player.atk, player.def_, player.hp, player.max_hp = 5, 1, 20, 20 # outmatched
events_before = len(game.events)
out = game.action("Brak", "challenge", "", "")
assert player.hp == 1
assert (player.x, player.y) == game.world.spawn
assert player.mode is Mode.TILE
assert player.at_location == ""
assert player.wins == 0 # a loss is not a win
assert len(game.events) == events_before + 1
devoured = game.events[-1]
assert devoured.kind == "wyrm_lose"
# Either phrasing of the devouring names the hero and the Wyrm.
assert "Brak" in devoured.text and "Wyrm" in devoured.text
assert "lays you low" in out.lower() or "wyrm" in out.lower()
def _doomed_wyrm_challenger(game: Game, name: str) -> object:
"""Stand *name* at the floor, wyrm-eligible, and doomed to a GRINDING loss.
The stats modest atk and def, hp 50 below max_hp 80, well off the spawn
make the Wyrm bout a genuine multi-round lethal loss (not a one-shot where
no blow lands before the save). hp 50 is none of the potion heal values
(15/40/70), so a death-save that sets hp to the potion's heal is unmistakable.
"""
player = _at_dungeon(game, name)
player.level = game.world.settings.wyrm_min_level
player.x, player.y = 35, 25 # away from the spawn (a save never moves them)
player.atk, player.def_, player.hp, player.max_hp = 6, 12, 50, 80
return player
def test_challenge_loss_with_potion_survives_no_legacy_reset(tmp_path: Path, clock: object) -> None:
"""A lethal Wyrm bout with a potion is SURVIVED — no bounce, no legacy reset.
The universal death-save reaches the Wyrm: a carried draught is drunk instead
of the devouring. A save is NOT a win, so NOTHING resets level, gold, and
``deepest_rung`` all stand and it is NOT the devouring either, so the hero
keeps their place at the dungeon. The PUBLIC beat is the survival one
(``wyrm_flee``, "driven back, alive but unproven"), NEVER "devoured". The
turn is still spent and the draught is consumed.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = _doomed_wyrm_challenger(game, "Brak")
potion = game.world.item_by_id("greater_potion")
assert potion is not None
_set_satchel(game, player, ["greater_potion"])
floor = len(game.world.settings.dungeon_tiers)
spawn = game.world.spawn
before_turns = player.turns_left
before_level, before_gold = player.level, player.gold
events_before = len(game.events)
out = game.action("Brak", "challenge", "", "")
# Survived standing: hp at the potion's value, no bounce, draught spent.
assert player.hp == min(player.max_hp, potion.heal)
assert (player.x, player.y) != spawn # NOT bounced to the spawn
assert player.mode is Mode.MENU # still standing at the dungeon
assert _satchel_ids(game, player) == [] # the draught was spent
assert "death's edge" in out.lower() # the spliced survival line
assert player.turns_left == before_turns - 1 # the challenge still cost a turn
# No win, so NO legacy reset: level, gold, and depth all stand.
assert player.wins == 0
assert player.level == before_level
assert player.gold == before_gold
assert player.deepest_rung == floor # depth untouched (no reset to 0)
# The PUBLIC beat is the survival one, NOT the devouring.
assert len(game.events) == events_before + 1
beat = game.events[-1]
assert beat.kind == "wyrm_flee"
assert beat.kind != "wyrm_lose"
assert "fled" in beat.text.lower() or "ran" in beat.text.lower()
def test_challenge_loss_potion_negative_without_save_devours(
tmp_path: Path, clock: object, monkeypatch: pytest.MonkeyPatch
) -> None:
"""NEGATIVE TEST: with the death-save disabled, the same potion-carrier is devoured.
The mechanical equivalent of reverting the added ``_death_save`` call in
``_wyrm_lost``: we stub ``_death_save`` to always decline, then run the exact
scenario of the survival test. The potion-carrier must now bounce to the
spawn at 1 HP with the draught UNSPENT and the PUBLIC beat back to
``wyrm_lose`` (devoured) proving the death-save (not some other path) is
what saves them at the Wyrm. Restoring the real method (automatic when the
patch lifts) restores the survival behaviour.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = _doomed_wyrm_challenger(game, "Brak")
_set_satchel(game, player, ["greater_potion"])
floor = len(game.world.settings.dungeon_tiers)
spawn = game.world.spawn
monkeypatch.setattr(Game, "_death_save", lambda self, pl, lines: False)
out = game.action("Brak", "challenge", "", "")
assert player.hp == 1 # devoured, not saved
assert (player.x, player.y) == spawn
assert player.mode is Mode.TILE
assert player.deepest_rung == floor # a defeat keeps depth (no reset, no advance)
assert _satchel_ids(game, player) == ["greater_potion"] # the draught is UNSPENT
assert "death's edge" not in out.lower() # no save, no dramatic line
assert game.events[-1].kind == "wyrm_lose" # the devouring beat, not the survival one
def test_challenge_stalemate_counts_as_flight(tmp_path: Path, clock: object) -> None:
"""A 50-round stalemate resolves as a flight: a wyrm_flee news beat.
With atk == boss def (no kill possible in the round cap) and enough HP to
outlast the boss's chip damage, resolve_fight returns FLED deterministically.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level
player.atk, player.def_, player.hp, player.max_hp = 8, 24, 200, 200
events_before = len(game.events)
game.action("Brak", "challenge", "", "")
assert player.wins == 0
assert player.hp >= 1 # never killed by a flight
assert len(game.events) == events_before + 1
assert game.events[-1].kind == "wyrm_flee"
assert "fled" in game.events[-1].text.lower() or "ran" in game.events[-1].text.lower()
# ---------------------------------------------------------------------------
# run_days from a frozen, advanced clock
# ---------------------------------------------------------------------------
class _MutableClock:
"""A clock whose reported moment can be advanced between calls."""
def __init__(self, moment: object) -> None:
self.moment = moment
def __call__(self) -> object:
return self.moment
def test_run_days_counts_whole_days(tmp_path: Path) -> None:
"""Joining, advancing the clock three days, then winning records run_days==3."""
clk = _MutableClock(utc(2026, 6, 12, 10, 0))
world = load_world(PACK)
store = Store(tmp_path / "game.db")
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Brak")
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
clk.moment = utc(2026, 6, 15, 12, 0) # three days (and a couple hours) later
game.action("Brak", "challenge", "", "")
hall = game.store.top_hall(1)
assert hall[0].run_days == 3
def test_top_hall_orders_most_recent_first(tmp_path: Path) -> None:
"""Two heroes slay the Wyrm at advancing times; the latest tops the Hall.
Pins ``ORDER BY id DESC`` in ``Store.top_hall`` the most recently cut
run is at index 0, regardless of name or level-at-win order.
"""
clk = _MutableClock(utc(2026, 6, 12, 10, 0))
world = load_world(PACK)
store = Store(tmp_path / "game.db")
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
def _win(name: str) -> None:
game.join(name)
hero = _at_dungeon(game, name)
hero.level = game.world.settings.wyrm_min_level
hero.atk, hero.def_, hero.hp, hero.max_hp = 500, 100, 500, 500
game.action(name, "challenge", "", "")
_win("Early")
clk.moment = utc(2026, 6, 13, 10, 0) # a day later
_win("Later")
hall = game.store.top_hall(5)
assert len(hall) == 2
assert hall[0].name == "Later" # most recent run is first
assert hall[1].name == "Early"
# ---------------------------------------------------------------------------
# Shared-feed proof: level_up and defeat reach ANOTHER player's Herald
# ---------------------------------------------------------------------------
def test_multi_level_jump_is_one_feed_beat_naming_final_level(
tmp_path: Path, clock: object
) -> None:
"""A single award crossing two thresholds posts ONE level_up beat, at the top.
With xp parked just under the level-3 line while still level 1, one forest
kill vaults the hero past both the level-2 and level-3 thresholds. The
public feed must carry exactly one level_up beat a multi-level jump is one
notable moment, not a flood and that beat must name the FINAL level (3),
not the intermediate one.
"""
game = _game(tmp_path, clock)
game.join("Climber")
climber = game.players["Climber"]
climber.x, climber.y = 35, 25 # forest_near zone
climber.atk, climber.def_, climber.hp, climber.max_hp = 100, 50, 100, 100
# Level 1 but xp just under L3 (300): the smallest forest reward (8) crosses
# both L2 (100) and L3 (300) in this one award.
climber.level, climber.xp = 1, 295
events_before = len(game.events)
game.action("Climber", "fight", "", "")
assert climber.level == 3 # vaulted two levels on the single kill
new_events = game.events[events_before:]
level_ups = [e for e in new_events if e.kind == "level_up"]
assert len(level_ups) == 1 # one beat, not one per level crossed
assert "level 3" in level_ups[0].text.lower() # names the final level
assert "level 2" not in level_ups[0].text.lower() # not the intermediate
def test_level_up_appears_in_other_players_herald(tmp_path: Path, clock: object) -> None:
"""A level-up by one hero is news in another hero's Herald."""
game = _game(tmp_path, clock)
game.join("Riser")
game.join("Watcher")
watcher = game.players["Watcher"]
watcher.log_cursor = game._latest_event_id() # start Watcher caught up
riser = game.players["Riser"]
riser.x, riser.y = 35, 25 # forest_near zone
riser.atk, riser.def_, riser.hp, riser.max_hp = 100, 50, 100, 100
riser.xp = 95 # one win (>= 8 xp) crosses the level-2 threshold of 100
game.action("Riser", "fight", "", "")
assert riser.level >= 2 # the fight pushed Riser over the line
out = game.log("Watcher")
assert "Riser" in out
assert "level 2" in out.lower()
def test_defeat_appears_in_other_players_herald(tmp_path: Path, clock: object) -> None:
"""A defeat by a regular monster is news in another hero's Herald."""
game = _game(tmp_path, clock)
game.join("Faller")
game.join("Watcher")
watcher = game.players["Watcher"]
watcher.log_cursor = game._latest_event_id()
faller = game.players["Faller"]
faller.x, faller.y = 35, 25 # forest_near zone
faller.atk, faller.def_, faller.hp, faller.max_hp = 1, 0, 2, 20 # certain to fall
game.action("Faller", "fight", "", "")
assert faller.hp == 1 # bounced
out = game.log("Watcher")
assert "Faller" in out
assert "dragged back" in out.lower() or "fell to" in out.lower() or "bested" in out.lower()
# ---------------------------------------------------------------------------
# Movement events at the façade: no turn, no public feed
# ---------------------------------------------------------------------------
def test_move_events_cost_no_turn_and_write_no_feed(tmp_path: Path, clock: object) -> None:
"""A walk that fires non-combat events spends no turn and posts no Herald news.
Walks Brak back and forth across the forest_near zone (encounter_rate 0.25)
enough that some non-fight event almost certainly fires; whatever happens,
no turn is consumed and no public event is appended.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = game.players["Brak"]
player.x, player.y = 35, 25 # inside forest_near
before_turns = player.turns_left
before_events = len(game.events)
for _ in range(12):
game.move("Brak", "", "east", 1)
game.move("Brak", "", "west", 1)
assert player.turns_left == before_turns # movement never costs a turn
assert len(game.events) == before_events # walk texture is private
@@ -1,3 +0,0 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.10.0"
@@ -1,4 +0,0 @@
from understone.server import main
if __name__ == "__main__":
main()
-724
View File
@@ -1,724 +0,0 @@
"""The pack-authoring command surface — validate a pack and scaffold a new one.
This module is deliberately pure: it imports only the loader and the standard
library, takes no part in argument parsing (``server.main`` owns the argparse
front end), and writes to the streams it is handed. That keeps the authoring
loop ``newpack`` then ``validate`` testable as plain function calls.
Three entry points back the three verbs:
* :func:`cli_validate` loads a pack and, on success, prints a human-readable
report; on failure it prints the loader's author-facing message and returns
a non-zero code. This is the feedback half of the loop.
* :func:`cli_newpack` scaffolds a new pack: it copies the bundled world as a
starting template and writes an ``AUTHORING.md`` manual whose bands table is
generated from the loader's own band data, so the documented limits can
never drift from the enforced ones.
* :func:`cli_worlds` lists the bundled worlds the default Vale plus every
alternate pack shipped under ``world/packs/`` loading each so it can report
whether it is sound or flawed, the discovery seam for "worlds without authors".
"""
from __future__ import annotations
import shutil
import sys
from typing import TYPE_CHECKING, TextIO
from understone.engine.textwidth import SAFE_PALETTE
from understone.errors import WorldLoadError
from understone.world import PACKAGED_WORLD_DIR, bundled_world_dirs, loader
if TYPE_CHECKING:
from pathlib import Path
from understone.engine.world import World
# The six packaged content files copied verbatim as a new pack's template.
_PACK_FILES = (
"terrain.json",
"monsters.json",
"items.json",
"locations.json",
"events.json",
"world.json",
)
def cli_validate(pack_dir: Path, out: TextIO | None = None, err: TextIO | None = None) -> int:
"""Load *pack_dir* and report; return 0 if sound, 2 if it fails to load.
On success a pack report is written to *out* and the function returns 0.
On any :class:`WorldLoadError` the loader's message — which names the
file, index, and field at fault is written to *err* and the function
returns 2. The author iterates against that message until the pack loads.
*out*/*err* default to the live ``sys.stdout``/``sys.stderr`` resolved at
call time, so a caller (or pytest's capture) may redirect them.
"""
out = out if out is not None else sys.stdout
err = err if err is not None else sys.stderr
try:
world = loader.load_world(pack_dir)
except WorldLoadError as exc:
print(f"The pack is flawed: {exc}", file=err)
return 2
print(_pack_report(world), file=out)
return 0
def cli_newpack(dest: Path, out: TextIO | None = None, err: TextIO | None = None) -> int:
"""Scaffold a new content pack at *dest*; return 0, or 2 if *dest* is taken.
Refuses to write into an existing non-empty directory (so an author never
clobbers work in progress). Otherwise it creates *dest*, copies the six
packaged content files as a starting template, and writes an
``AUTHORING.md`` manual generated from the live loader bands. The author
then edits or regenerates the JSON and runs ``validate``.
*out*/*err* default to the live ``sys.stdout``/``sys.stderr`` resolved at
call time, so a caller (or pytest's capture) may redirect them.
"""
out = out if out is not None else sys.stdout
err = err if err is not None else sys.stderr
if dest.exists() and dest.is_dir() and any(dest.iterdir()):
print(f"refusing to scaffold into non-empty directory: {dest}", file=err)
return 2
if dest.exists() and not dest.is_dir():
print(f"refusing to scaffold over a file: {dest}", file=err)
return 2
dest.mkdir(parents=True, exist_ok=True)
for name in _PACK_FILES:
shutil.copyfile(PACKAGED_WORLD_DIR / name, dest / name)
(dest / "AUTHORING.md").write_text(build_authoring_md(), encoding="utf-8")
print(f"Scaffolded a new pack at {dest}.", file=out)
print("Six content files plus AUTHORING.md are in place; the template is the", file=out)
print("shipped Vale of Understone, ready to edit or regenerate.", file=out)
print(f"Next: edit or regenerate the JSON, then: understone validate {dest}", file=out)
return 0
def cli_worlds(out: TextIO | None = None, err: TextIO | None = None) -> int:
"""List every bundled world, reporting each as sound or flawed; return 0.
Discovers the worlds through :func:`~understone.world.bundled_world_dirs`
(the default Vale first, then the alternate packs alphabetically) and loads
each one. Each world is one line its slug, name, ``WxH``, and either
``sound`` or ``flawed: <short reason>`` so a shipped pack that has gone
out of band is visible at a glance rather than only failing at serve time.
A flawed world is reported, not fatal: the listing always returns 0 and
always ends with the hint for serving an alternate. *err* is accepted for a
uniform signature with the other verbs; the listing writes only to *out*.
"""
out = out if out is not None else sys.stdout
for slug, world_dir in bundled_world_dirs():
print(_world_line(slug, world_dir), file=out)
print("", file=out)
print(
"Serve one with UNDERSTONE_WORLD=<path> (or the default Vale needs no setting).",
file=out,
)
return 0
def _world_line(slug: str, world_dir: Path) -> str:
"""Render one ``worlds`` listing line for the world at *world_dir*.
Loads the world to report its real name, dimensions, and soundness. A pack
that fails to load is summarised as ``flawed: <reason>`` using the loader's
own author-facing message (truncated to keep the listing to one line per
world), never raised the listing surveys every bundled world even when one
is broken.
"""
try:
world = loader.load_world(world_dir)
except WorldLoadError as exc:
return f" {slug:<10} flawed: {_short_reason(str(exc))}"
return f" {slug:<10} {world.name}{world.width}x{world.height} — sound"
# How much of a loader error message the one-line ``worlds`` summary keeps.
_FLAW_REASON_MAX = 70
def _short_reason(message: str) -> str:
"""Trim a loader error to a single readable clause for the worlds listing."""
flattened = " ".join(message.split())
if len(flattened) <= _FLAW_REASON_MAX:
return flattened
return flattened[: _FLAW_REASON_MAX - 1].rstrip() + ""
def _pack_report(world: World) -> str:
"""Render the success report for a loaded *world*.
Counts and shares are computed from the runtime world so the figures match
what the engine will actually run, not what the JSON nominally declares.
"""
settings = world.settings
boss_count = sum(1 for m in world.monsters if m.boss)
fight_share = _fight_share_pct(world)
lines = [
f"{world.name}{world.width}x{world.height}",
f" monsters : {len(world.monsters)} ({boss_count} boss)",
f" items : {len(world.items)}",
f" zones : {len(world.zones)}",
f" events : {len(world.events)} ({fight_share}% fight by weight)",
(
" settings : "
f"{settings.daily_turns} turns/day, "
f"bestow budget {settings.bestow_daily_budget}, "
f"Wyrm gate level {settings.wyrm_min_level}"
),
"",
"This pack is sound. The door stands open.",
]
return "\n".join(lines)
def _fight_share_pct(world: World) -> int:
"""Return the share of overworld encounter weight that is a ``fight``.
Reported by weight, not row count, because weight is the draw probability
the engine actually rolls against it is the number an author tunes to hit
the ~55% fight feel.
"""
total = sum(e.weight for e in world.events)
if total == 0:
return 0
fight = sum(e.weight for e in world.events if e.kind == "fight")
return round(100 * fight / total)
def build_authoring_md() -> str:
"""Build the AUTHORING.md manual, bands table and glyph palette included.
Both the bands section and the safe-glyph palette are generated from live
source the loader's own band tables and ``textwidth.SAFE_PALETTE`` — so
the documented limits and the suggested glyphs are exactly what the loader
enforces and admits, and cannot silently drift from it.
"""
md = _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands())
md = md.replace("{{PALETTE}}", _render_palette())
md = md.replace("{{COLOR_ROLES}}", _render_color_roles())
return md.replace("{{VALIDATE_COVERAGE}}", _render_validate_coverage())
def _render_bands() -> str:
"""Render the bands reference straight from the loader's band data."""
parts: list[str] = []
parts.append("### Map and counts\n")
parts.append(
f"* Map width and height: each `{loader.MAP_DIM_MIN}`..`{loader.MAP_DIM_MAX}` cells."
)
# monsters/items/events are their own files; locations and zones are lists
# inside world.json, so name each cap's real source.
count_source = {
"monsters": "`monsters.json`",
"items": "`items.json`",
"events": "`events.json`",
"locations": "`world.json` → `locations`",
"zones": "`world.json` → `zones`",
}
for name, cap in loader.MAX_COUNTS.items():
parts.append(f"* {count_source[name]}: at most `{cap}` entries.")
parts.append(
f"* Display names (monster, item, location): at most "
f"`{loader.MAX_NAME_LEN}` printable characters."
)
parts.append(
"* Map glyphs (terrain, location, legend keys): exactly one terminal "
"column (one printable code point, no fullwidth runes, no combining "
"marks — see the width rule above), and never one of "
+ ", ".join(f"`{g}`" for g in _reserved_glyph_list())
+ " (the frame box-drawing lines and the `@`/`☻` player markers)."
)
parts.append("")
parts.append("### Economy and progression settings (`world.json` → `settings`)\n")
parts.append("| field | allowed range |")
parts.append("| --- | --- |")
for field_name, (lo, hi) in loader.SETTINGS_BANDS.items():
rng = f"{lo}..{hi}" if hi is not None else f"{lo} or more"
parts.append(f"| `{field_name}` | `{rng}` |")
parts.append("")
parts.append("### Overworld event amounts (`events.json`, per kind)\n")
parts.append("| kind | min..max amount |")
parts.append("| --- | --- |")
for kind, (lo, hi) in loader.EVENT_AMOUNT_BANDS.items():
parts.append(f"| `{kind}` | `{lo}..{hi}` |")
parts.append(
"\n(`fight` and `lore` carry no amount; `fight` draws its foe from the "
"zone tier band, `lore` is pure flavour text.)\n"
)
parts.append("### Watch theme (`world.json` → `settings.watch_theme`)\n")
legal = ", ".join(f"`{name}`" for name in sorted(loader.WATCH_THEMES))
parts.append(
f"OPTIONAL. The CRT palette the live Watch page paints your world in, "
f"one of: {legal}. It defaults to `{loader.DEFAULT_WATCH_THEME}` (the "
f"original green phosphor), so you may leave it out entirely — a pack "
f"that omits it looks exactly as the bundled Vale always has. Set it to "
f"give your world its own colour: `amber` is a warm gold monitor, `ice` "
f"a cold pale blue, `ember` a hot red/orange. An unknown name is a load "
f"error naming the legal set."
)
parts.append("\n### The ore-gated forge (`world.json` → `settings`)\n")
ore_per = loader.SETTINGS_BANDS["forge_ore_per_plus"]
dungeon = loader.SETTINGS_BANDS["ore_dungeon_drop"]
parts.append(
"Forging a +1 edge now costs both GOLD and ORE — a `material` item the "
"hero earns in combat, never buys. Four settings bind it:"
)
parts.append(
"* `forge_ore_item` — REQUIRED. The item id of your world's forge ore; "
"it must name an `items.json` entry whose `slot` is `material` (an "
"unknown id or a non-material slot is a load error). The Vale uses "
"`iron_ore`."
)
parts.append(
f"* `forge_ore_per_plus` — band `{ore_per[0]}..{ore_per[1]}`. Ore per +1 "
f"step: a +N forge costs `(current_plus + 1) * forge_ore_per_plus` ore. "
f"{_forge_ore_worked_example()}"
)
parts.append(
f"* `ore_dungeon_drop` — band `{dungeon[0]}..{dungeon[1]}`. Ore granted "
f"on every WON dungeon rung — the reliable source. The Vale drops 2."
)
parts.append(
"* `ore_forest_chance` — a `0.0`..`1.0` probability (a float, validated "
"outside the integer band table). The chance a WON forest fight yields "
"one ore — the occasional bonus source. The Vale uses `0.2`."
)
parts.append(
"\nOre rides the satchel as a stack, so it shares the `satchel_max` "
"DISTINCT-stack budget with potions (per-stack quantity is unbounded). "
"Tune the two sources so a hero who descends steadily earns enough ore "
"to forge without grinding — the `simulate` bot will tell you if the "
"gate stalls a winnable run."
)
return "\n".join(parts)
def _forge_ore_worked_example() -> str:
"""Render the per-step ore costs from the bundled Vale's live forge settings.
The starter template :func:`cli_newpack` copies IS the bundled Vale, so the
worked figures are computed from its actual ``forge_ore_per_plus`` and
``forge_max_plus`` rather than hardcoded a retune of the template moves
the manual with it. The steps are ``per_plus * (i + 1)`` for each ``i`` in
``range(forge_max_plus)``; the total is what it costs to max one slot.
"""
settings = loader.load_world(PACKAGED_WORLD_DIR).settings
per_plus = settings.forge_ore_per_plus
max_plus = settings.forge_max_plus
steps = [per_plus * (i + 1) for i in range(max_plus)]
if not steps:
return (
f"At the template's value of {per_plus}, slots cannot be forged (`forge_max_plus` 0)."
)
ladder = ", ".join(str(cost) for cost in steps)
total = sum(steps)
return (
f"At the template's value of {per_plus}, the steps cost {ladder} ore "
f"({total} ore to max a slot at `forge_max_plus` {max_plus})."
)
def _reserved_glyph_list() -> list[str]:
"""Return the reserved glyphs in a stable, readable order for the manual."""
box = [g for g in "┌┐└┘─│═" if g in loader.RESERVED_GLYPHS]
actors = [g for g in "@☻" if g in loader.RESERVED_GLYPHS]
return box + actors
def _render_palette() -> str:
"""Render the safe-glyph appendix straight from ``textwidth.SAFE_PALETTE``.
The glyphs are emitted in their declared order, wrapped in backticks so the
monospace renders them as discrete cells. Generated from the live constant,
so the suggested palette is exactly the set the loader's width gate admits.
"""
glyphs = " ".join(f"`{g}`" for g in SAFE_PALETTE)
return (
"Any single-column glyph the loader accepts is fair game, but these "
"carry the period BBS / CP437 flavour and are all guaranteed safe:\n\n"
f"{glyphs}"
)
def _render_color_roles() -> str:
"""Render the author-assignable colour roles, generated from the Color enum.
The Watch knows how to paint exactly the roles in ``screen.palette.Color``;
``Color.assignable()`` is the single source for which of those an author may
put on terrain or a location (the runtime overlay roles an actor/item wears,
and the DEFAULT fallback, are filtered out there). Generated from the enum,
so the documented vocabulary can never drift from what the Watch can
actually colour the same can't-drift discipline as the bands and the
safe-glyph palette. ``color`` itself stays advisory: the loader does not
validate it, so a typo is harmless and an unknown role just paints as the
default; these are simply the roles the Watch recognises.
"""
from understone.screen.palette import Color
return ", ".join(f"`{role.value}`" for role in Color.assignable())
def _render_validate_coverage() -> str:
"""Render the list of rules the loader actually enforces, generated from it.
The figures that can drift (the number of banded settings, the name-length
cap, the reserved glyphs) are read from the live loader so the list cannot
fall out of step with what `validate` does; the prose names each family of
check. This is the machine-enforced half of the honesty split in the manual
the eyeball-only half is hand-written below it, because "is the fiction
any good" is exactly what the loader can never see.
"""
settings_count = len(loader.SETTINGS_BANDS)
reserved = ", ".join(f"`{g}`" for g in _reserved_glyph_list())
bullets = [
f"* **Economy and progression bands** — every one of the {settings_count} `settings` fields must sit in its allowed range (the table above), and `growth` must be present and non-negative.",
f"* **Glyph safety** — every terrain, location, and legend glyph must render exactly one column and must not be a reserved marker ({reserved}).",
"* **Map integrity** — `width`/`height` in band, every `terrain_rows` row exactly `width` long with `height` rows, and every row character in the `legend`.",
"* **Walkability** — `spawn` and every placed location must sit on walkable terrain (and no two locations share a cell).",
f"* **Display-name length** — every monster, item, and location name within `{loader.MAX_NAME_LEN}` printable characters; content lists within their caps.",
"* **The fight row** — `events.json` must hold at least one `fight` entry, with weights `> 0`, `min <= max`, and amounts in their per-kind band.",
'* **Cross-references** — `legend` → terrain key, location placements → `locations.json` keys, `starting_weapon`/`starting_armor` → item ids, `boss_monster` → a monster flagged `"boss": true`, `rare_drop_item` → a consumable item id, and `forge_ore_item` → a `material` item id.',
"* **Zone tiers** — every zone's tier band must overlap at least one monster tier.",
"* **Dungeon ladder** — every `dungeon_tiers` tier must have a non-boss monster, and that tier's FIRST monster (its fixed rung guardian) must not be `rare`.",
'* **Exactly one boss** — at most one monster may carry `"boss": true`.',
]
return "\n".join(bullets)
_AUTHORING_TEMPLATE = """\
# Authoring a world pack for Understone
A *world pack* is a directory of six JSON files that the server loads at start
to become the entire game world its map, its monsters, its economy, its
endgame. There is no code to write: you describe a world as data, the loader
validates it hard, and the server runs it. This file is the manual; you can
follow it cold, by hand or with an LLM.
The loop is short:
1. `understone newpack mypack` scaffold this template (you are reading the
copy it wrote into `mypack/AUTHORING.md`).
2. Edit or regenerate the JSON files to describe your world.
3. `understone validate mypack` the loader checks the pack and either prints
a report ending **"This pack is sound. The door stands open."** or tells you
exactly which file, row, and field is wrong.
4. Repeat step 2 until it is sound, then serve it:
`UNDERSTONE_WORLD=mypack understone`.
The loader's error messages are written FOR you: every failure names the file,
the index, and the field, and says what was expected. Treat them as the
feedback loop iterate until the report says the door stands open.
---
## The six files and how they fit together
| file | shape | holds |
| --- | --- | --- |
| `terrain.json` | object keyed by legend char | terrain kinds: glyph, walkability, encounter rate |
| `monsters.json` | list | monster stat blocks, tiered; one flagged the boss |
| `items.json` | list | weapons, armour, consumables for the shop |
| `locations.json` | object keyed by location key | building kinds: name, glyph, menu actions, flavour |
| `events.json` | object with an `events` list | the weighted overworld encounter table |
| `world.json` | object | the map, placements, zones, and `settings` that bind it all |
The cross-references the loader enforces:
* every character in `world.json` `legend` must name a terrain `key` from
`terrain.json`; every character in `terrain_rows` must be in that legend;
* every placement in `world.json` `locations` must name a key defined in
`locations.json`, and must sit on walkable terrain;
* `settings.starting_weapon` / `starting_armor` must be ids from
`items.json`; `settings.boss_monster` must be an id from `monsters.json`
that is flagged `"boss": true`; `settings.rare_drop_item` must be an id from
`items.json` whose `slot` is `consumable`; `settings.forge_ore_item` must be
an id from `items.json` whose `slot` is `material`;
* every tier in `settings.dungeon_tiers` must be backed by a NON-boss monster;
* every zone's tier band must overlap at least one monster tier.
---
## File-by-file schema
### `terrain.json`
An object whose keys are the single-character legend symbols used in the map.
```json
{
".": {"key": "grass", "glyph": ".", "walkable": true, "encounter_rate": 0.1, "color": "floor"}
}
```
* `key` internal name the map legend resolves to.
* `glyph` the single character drawn on the map (see glyph rules below).
* `walkable` may a player stand here.
* `encounter_rate` `0.0`..`1.0`, the per-step chance a walk rolls the event
table on this terrain.
* `color` a palette role string. It is **advisory and not validated**: the
loader stores it but the text frame draws glyphs only (it is monochrome), so
any string loads and an unrecognised role simply maps to the default at render
time. Where colour DOES show is the live Watch page, which paints each role a
distinct hue. The roles the Watch knows how to paint pick the closest fit
are: {{COLOR_ROLES}}. A typo here is harmless, not a load error; it just
paints as the default. The four runtime overlay colours (the hero, rival
players, monsters, dropped items) are set by the engine, not assignable here.
### `monsters.json`
A list of stat blocks. `tier` groups foes by difficulty; zones and the dungeon
gauntlet draw from tiers. Exactly one monster should be the boss.
```json
{"tier": 2, "name": "Goblin", "hp": 12, "atk": 5, "def": 1, "xp": 18, "gold": 7}
```
The boss adds an `id` and `"boss": true`, and is referenced by
`settings.boss_monster`:
```json
{"tier": 6, "name": "the Wyrm Below", "hp": 120, "atk": 24, "def": 8,
"xp": 400, "gold": 250, "boss": true, "id": "wyrm_below"}
```
Two optional fields tune random forest encounters. `weight` (default `10`,
must be `> 0`) biases the weighted draw within a zone band a low weight
surfaces seldom and `rare` (default `false`) marks a named beast that, on
its kill, fires a public Herald flash and drops the pack's `rare_drop_item`
into the slayer's satchel. Rung guardians ignore both (a rung always takes the
FIRST monster of its tier, never a weighted roll), so a rare should not be the
first entry of a tier that backs a `dungeon_tiers` rung.
```json
{"tier": 2, "name": "the Gilded Stag", "hp": 16, "atk": 6, "def": 2,
"xp": 40, "gold": 60, "weight": 1, "rare": true}
```
### `items.json`
A list of equipment, consumables, and crafting materials. `slot` is `weapon`,
`armor`, `consumable`, or `material`. Weapons add `atk`, armour adds `def`,
consumables `heal`; a `material` carries none of these it is the forge ORE,
carried in the satchel and spent at the forge.
```json
{"id": "short_sword", "name": "Short Sword", "slot": "weapon", "atk": 5, "price": 40}
```
The forge ore is a `material` item the player EARNS in combat (not the shop):
price it `0` ore is never bought or sold and point `settings.forge_ore_item`
at its id. A won dungeon rung always drops `settings.ore_dungeon_drop` of it, and
a won forest fight has a `settings.ore_forest_chance` chance of one.
```json
{"id": "iron_ore", "name": "Iron Ore", "slot": "material", "price": 0}
```
### `locations.json`
An object keyed by location key. Each entry is a building kind with a menu of
`actions` the player may take inside it.
```json
{
"inn": {"kind": "inn", "name": "The Sleeping Drake", "glyph": "I",
"color": "town", "actions": ["rest", "gamble", "leave"],
"flavor": ["Lamplight pools on worn oak tables."]}
}
```
Give each building the menu that matches its role. The four building kinds and
the verbs the engine honours inside each are:
| `kind` | actions the engine understands |
| --- | --- |
| `inn` | `rest`, `deposit`, `withdraw`, `gamble`, `leave` |
| `shop` | `buy`, `sell`, `forge`, `leave` |
| `healer` | `heal`, `leave` |
| `dungeon` | `descend`, `challenge`, `leave` |
The inn's `deposit`/`withdraw` are the VAULT: a player banks gold into the inn
strongbox (`deposit amount=<gold>`) and draws it back (`withdraw amount=<gold>`).
Banked gold is SAFE from ambush a sleeping-robber only ever lifts gold in hand
and it SURVIVES the Wyrm-win legacy reset, so it is the one store of wealth
that carries across runs. Both cost no turn.
`quaff` (drink a satchel tonic) is legal **anywhere** and needs no menu entry.
The `actions` list is advisory it is the menu the narrator offers, NOT a
validated whitelist (see "What `validate` checks" below): a verb the engine does
not back simply confuses the narrator, so give each building only the verbs from
its row above.
### `events.json`
An object with an `events` list the weighted overworld encounter table the
server rolls as a player walks.
```json
{"events": [
{"kind": "fight", "weight": 82, "text": "Something snarls out of the brush."},
{"kind": "gold", "weight": 8, "text": "a rotted coin-purse", "min": 4, "max": 12}
]}
```
* `kind` `fight`, `gold`, `heal`, `trap`, or `lore`.
* `weight` relative draw weight (`> 0`).
* `text` required (non-empty) for every kind except `fight`.
* `min`/`max` required for the value-bearing kinds (`gold`, `heal`, `trap`).
There MUST be at least one `fight` row, or a walk could never find a monster.
### `world.json`
The binding file: `name`, `width`, `height`, `spawn` `[x, y]` (the hero's start
cell, which must be on walkable terrain), a `legend` mapping characters to
terrain keys, `terrain_rows` (one string per row, each exactly `width` long), a
`locations` list of `{"key", "x", "y"}` placements (each also on walkable
terrain), a `zones` list (rectangles that bias monster tiers), and a `settings`
object.
```json
{"key": "forest_near", "rect": [30, 18, 60, 36], "tier_lo": 1, "tier_hi": 2}
```
---
## The bands — the limits the loader enforces
These are generated from the loader's own tables, so they are exactly what
`validate` checks. A value outside its band is a load error.
{{BANDS}}
---
## Glyph width — the one-column rule
Every glyph drawn on the map must occupy **exactly one terminal column**. The
frames are box-drawing rectangles; a glyph that renders two columns (a CJK
ideograph like ``, an emoji like `🌲`, a fullwidth ``) shoves its row right
and tears the border, and a combining mark (a decomposed `é`, a lone accent)
stacks onto its neighbour and breaks the count the other way. The loader
rejects all of these at load.
What is admitted is judged for the **Western monospace** metrics every
Understone surface actually uses (the Watch's pinned font stack, a chat
client's code block): under those metrics the East-Asian "Ambiguous" width
class renders single-column, and that class is the CP437 heartland ``, ``,
``, ``, ``, `` all live there so the rule admits it and bars only the
genuinely double-width Wide and Fullwidth classes.
### Safe glyph palette
{{PALETTE}}
---
## Design guidance
**Turn economy.** `daily_turns` is the whole pacing lever: only fighting,
descending, and challenging the Wyrm spend a turn (moving, resting, shopping
are free). A small budget (the Vale uses 10) makes this a correspondence game
played a little each day. Set `rest_cost`, `heal_cost_per_hp`, and shop prices
so a day's gold roughly covers a day's recovery too cheap and there is no
tension, too dear and a hero stalls.
**Tier curve.** Lay monster tiers as a rising staircase: each tier should be a
real step up in `hp`/`atk` and a real step up in `xp`/`gold`, so the reward of
pushing into a harder zone pays for the risk. Keep two or three foes per tier
for variety. The boss should tower over the top random tier it is the climax.
**Encounter feel.** Aim for roughly 55% of overworld encounter WEIGHT on
`fight` rows; the rest is the texture of travel small gold finds, healing
springs, harmless traps, and lore that hints at the endgame. (The validate
report prints your actual fight share so you can tune it.)
**Glyphs.** Map glyphs must render as exactly one terminal column (see the
one-column rule above) and must never collide with the frame's box-drawing
lines or the `@`/`` player markers. Pick glyphs that read at a glance the
bundled Vale uses `.` open ground, `` water, `` tree, `` inn, `$` shop, ``
healer, `` dungeon and lean on the safe palette for period flavour.
**Boss rules.** Exactly one monster carries `"boss": true` and an `id`, and
`settings.boss_monster` points at it. The boss is the only win condition and is
faced only through the `challenge` verb, gated by `settings.wyrm_min_level`. A
boss tier must NOT appear in `settings.dungeon_tiers`: the gauntlet excludes
boss monsters, so a boss-only rung would be unfillable back every dungeon
tier with at least one ordinary monster.
**The deep, the satchel, and the forge.** `dungeon_tiers` is now a RUNG LADDER
fought one rung per `descend` list the tiers shallow-to-deep, and make it long
enough to feel like a journey (the Vale uses three). The Wyrm gates on reaching
the floor as well as on level. Size the satchel with `satchel_max` it caps the
DISTINCT stacks the bag holds (potions and ore each take a slot; per-stack
quantity is unbounded), and it is the death-save reserve, so keep it small (the
Vale carries 3). The forge is the late-game GOLD-AND-ORE sink: `forge_base_cost`
is the gold price of a +1 edge and scales up each tier (`base * (current_plus +
1)`), capped at `forge_max_plus`, and each step ALSO costs ore (see the ore-gated
forge above). Ore is won in the deep (and seldom in the forest), so the forge is
fed by descending price the gold so a fully-forged piece is a multi-day saving,
and set the ore sources so a steady delver can afford it without a grind.
**Rare beasts.** A rare monster is a small legend: give it a low `weight` so it
surfaces seldom, stats and rewards a clear notch above its tier, and remember it
always drops `rare_drop_item` (a consumable) into the satchel. Keep rares OFF
the first slot of any `dungeon_tiers` tier, or they would become a fixed rung
guardian instead of a rare roll `validate` now ENFORCES this, so a rare in a
dungeon tier's lead slot is a load error, not just bad form. Place the rare
anywhere after that tier's first ordinary monster.
**Location menus.** Give each building only the actions it can honour, drawn
from the per-kind table under `locations.json` above. An inn that offers `buy`
but no shop logic will confuse the narrator. This is the one major thing
`validate` does NOT check (see below): a wrong or invented verb loads fine and
only muddles the narration, so it is on you to match each menu to its building.
---
## The validate loop
Run `understone validate mypack` after every change. On success you get a
report name, size, monster/item/zone/event counts, fight share, and the key
settings ending in **"This pack is sound. The door stands open."** On
failure you get one precise line naming the file, the row, and the field.
The error messages are deliberately instructive: they are the authoring API.
Keep editing and re-validating until the door stands open, then point the
server at your pack with `UNDERSTONE_WORLD=mypack`.
### What `validate` checks, and what it cannot
`validate` runs your pack through the very loader the server uses, so a pack
that validates will load and serve. But the loader checks *structure and
references*, not *meaning* it cannot read your fiction. Keep the split honest:
**`validate` DOES catch (a load error if wrong):**
{{VALIDATE_COVERAGE}}
**`validate` does NOT catch (the eyeball-only short list):**
* **Location menu `actions` contents.** The list is the narrator's menu, not a
validated whitelist: a verb the engine does not back (a typo, or a fictional
`pray`) loads fine and only confuses the narration. Match each building's menu
to the per-kind table under `locations.json`.
* **Flavour and narration quality.** Names, `flavor` lines, event `text`, the
feel of the tier curve and the economy the loader checks they are present
and in band, never whether they are *good*. That judgement is yours; the
`simulate` bot can tell you a world is winnable and sanely paced, but only you
can tell whether it is worth playing.
"""
@@ -1,6 +0,0 @@
"""Game engine — pure stdlib mechanics with injectable clock and RNG.
This package has no knowledge of MCP, persistence, or rendering. Every
function takes its inputs explicitly (world, player, rng, clock) so the
mechanics are deterministic under test.
"""
@@ -1,117 +0,0 @@
"""Combat resolution — pure math over an injected RNG.
A fight runs deterministic rounds: both sides trade blows until one drops.
Damage is ``max(1, attacker_atk - defender_def)`` jittered by a small RNG
swing so identical stats still produce varied logs. The result is a value
object; turn accounting and the spawn-bounce on defeat are applied by the
caller (the game façade), keeping this module side-effect free.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from understone.engine.models import Monster, Player
from understone.engine.rng import GameRNG
_MAX_ROUNDS = 50
class Outcome(StrEnum):
"""How a fight ended."""
WIN = "win"
LOSE = "lose"
FLED = "fled"
@dataclass(slots=True)
class FightResult:
"""The full outcome of a combat exchange.
Deltas are signed and meant to be applied to the player by the caller.
``bounce_to_spawn`` signals a defeat: the caller sets ``hp`` to 1 and
moves the player back to the spawn point.
"""
outcome: Outcome
log: list[str] = field(default_factory=list)
xp_delta: int = 0
gold_delta: int = 0
hp_delta: int = 0
bounce_to_spawn: bool = False
monster_name: str = ""
def _swing(rng: GameRNG, atk: int, def_: int) -> int:
"""Return one blow's damage: floor of 1, with a small RNG jitter."""
base = atk - def_
jitter = rng.randint(-1, 2)
return max(1, base + jitter)
def resolve_fight(rng: GameRNG, player: Player, monster: Monster) -> FightResult:
"""Run a full fight between *player* and *monster*.
The player strikes first each round. On victory the player banks the
monster's xp/gold and keeps any hp lost during the exchange. On defeat
the result flags a spawn bounce for the caller to apply.
"""
result = FightResult(outcome=Outcome.WIN, monster_name=monster.name)
player_hp = player.hp
monster_hp = monster.hp
result.log.append(f"You close with the {monster.name}.")
for _ in range(_MAX_ROUNDS):
dealt = _swing(rng, player.atk, monster.def_)
monster_hp -= dealt
result.log.append(f"You strike for {dealt}. ({monster.name}: {max(monster_hp, 0)} HP)")
if monster_hp <= 0:
result.outcome = Outcome.WIN
result.xp_delta = monster.xp
result.gold_delta = monster.gold
result.hp_delta = player_hp - player.hp
# The kill round (the strike line above) stays; the "falls + reward"
# sentence is composed by the caller at the moment it actually banks
# the xp/gold, so a reward is never narrated where none is applied
# (e.g. the Wyrm-win legacy reset, which keeps no xp/gold).
return result
taken = _swing(rng, monster.atk, player.def_)
player_hp -= taken
result.log.append(f"It hits back for {taken}. (You: {max(player_hp, 0)} HP)")
if player_hp <= 0:
result.outcome = Outcome.LOSE
result.bounce_to_spawn = True
result.log.append(
f"The {monster.name} lays you low. You wake at the spawn, barely alive."
)
return result
# Stalemate guard: treat an unresolved marathon as a flight to safety.
result.outcome = Outcome.FLED
result.hp_delta = player_hp - player.hp
result.log.append("The fight grinds on until you break away, winded.")
return result
def resolve_flee(rng: GameRNG, player: Player, monster: Monster) -> FightResult:
"""Attempt to flee a fight.
A successful flee escapes clean. A failed flee costs one free blow from
the monster but never drops the player below 1 HP (fleeing is a way out,
not a death trap).
"""
result = FightResult(outcome=Outcome.FLED, monster_name=monster.name)
if rng.chance(0.6):
result.log.append(f"You slip away from the {monster.name}.")
return result
taken = _swing(rng, monster.atk, player.def_)
taken = min(taken, max(player.hp - 1, 0))
result.hp_delta = -taken
result.log.append(f"You turn to run; the {monster.name} catches you for {taken} as you go.")
return result
@@ -1,108 +0,0 @@
"""Experience, level-ups, and the inn/healer restorative maths.
The XP curve and stat growth come from the content pack's settings, so no
progression constants live in this module. Level-ups loop (a single XP
award can cross several thresholds), grant flat stat growth, and fully
heal on each level gained.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from understone.engine.models import Player, Settings
@dataclass(slots=True)
class LevelUp:
"""A record of a single level gained, for narration."""
new_level: int
hp_gain: int
atk_gain: int
def_gain: int
def xp_for_level(level: int, settings: Settings) -> int:
"""Return cumulative XP required to *reach* ``level``.
Level 1 needs 0. The default curve is ``base * n*(n+1)/2`` over the
completed levels, i.e. a triangular ramp scaled by ``xp_base``.
"""
if level <= 1:
return 0
completed = level - 1
return settings.xp_base * completed * (completed + 1) // 2
def apply_xp(player: Player, amount: int, settings: Settings) -> list[LevelUp]:
"""Award ``amount`` XP to *player*, applying every level-up it unlocks.
Returns one :class:`LevelUp` per level gained (empty when none). Each
level grants flat growth from settings and fully heals the player.
"""
player.xp += max(0, amount)
gains: list[LevelUp] = []
while player.xp >= xp_for_level(player.level + 1, settings):
player.level += 1
player.max_hp += settings.growth_max_hp
player.atk += settings.growth_atk
player.def_ += settings.growth_def
player.hp = player.max_hp
gains.append(
LevelUp(
new_level=player.level,
hp_gain=settings.growth_max_hp,
atk_gain=settings.growth_atk,
def_gain=settings.growth_def,
)
)
return gains
def rest(player: Player, cost: int) -> bool:
"""Fully heal *player* at the inn for a flat ``cost``.
Returns ``False`` without mutation when the player cannot afford it.
Resting when already at full HP still succeeds (and still charges),
matching the inn's flat-rate fiction.
"""
if player.gold < cost:
return False
player.gold -= cost
player.hp = player.max_hp
return True
@dataclass(slots=True)
class HealResult:
"""Outcome of a healer purchase: HP actually restored and gold spent."""
healed: int
cost: int
def heal(player: Player, amount: int, cost_per_hp: int) -> HealResult:
"""Restore up to ``amount`` HP at ``cost_per_hp`` gold each.
Heals only the missing portion, charges only for HP actually restored,
and is further bounded by what the player can afford. Returns the amount
healed and the gold spent (both zero when nothing could be done).
"""
missing = player.max_hp - player.hp
want = max(0, min(amount, missing))
if want <= 0 or cost_per_hp < 0:
return HealResult(healed=0, cost=0)
if cost_per_hp == 0:
player.hp += want
return HealResult(healed=want, cost=0)
affordable = player.gold // cost_per_hp
apply = min(want, affordable)
if apply <= 0:
return HealResult(healed=0, cost=0)
spent = apply * cost_per_hp
player.hp += apply
player.gold -= spent
return HealResult(healed=apply, cost=spent)
@@ -1,63 +0,0 @@
"""The shared event log — a world-wide feed players catch up on.
Events are append-only and ordered by insertion. Each player tracks a
cursor (the id of the last event they have seen); ``since`` returns the
slice after a cursor and the new cursor to persist.
An event carries a ``target``: empty means PUBLIC (the broadsheet and the
lobby TV), a player name means a PRIVATE note that only that player reads in
their own catch-up. Targeted rows ride the same id order as public ones, so
the cursor advances identically whether or not a private note was shown.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Event:
"""A single logged happening in the shared world.
``target`` is the empty string for public events (heralded to everyone)
or a player's name for a private note delivered only to that player.
"""
event_id: int
ts: str
kind: str
actor: str
text: str
target: str = ""
def since(events: list[Event], cursor: int) -> tuple[list[Event], int]:
"""Return events newer than ``cursor`` and the cursor to store next.
Events are kept in ascending id order (the store hydrates the newest tail
and reverses it to ascending; appends are monotonic), so the last fresh
event carries the highest id; that becomes the new cursor.
When nothing is new the input cursor is returned, so advancing is
idempotent.
"""
fresh = [e for e in events if e.event_id > cursor]
if not fresh:
return [], cursor
return fresh, fresh[-1].event_id
def since_visible(events: list[Event], cursor: int, viewer: str) -> tuple[list[Event], int]:
"""Like :func:`since`, but hide private notes not addressed to *viewer*.
Returns the events newer than ``cursor`` that *viewer* may read every
public event (empty ``target``) plus the private notes addressed to them
and the new cursor. The cursor advances to the highest id PAST the old
cursor regardless of visibility, so a private note for someone else is
consumed (never re-scanned) without ever being shown here.
"""
fresh = [e for e in events if e.event_id > cursor]
if not fresh:
return [], cursor
new_cursor = fresh[-1].event_id
visible = [e for e in fresh if not e.target or e.target == viewer]
return visible, new_cursor
@@ -1,232 +0,0 @@
"""Core data models for the game engine.
All models are plain dataclasses. ``Player`` is mutable (the engine applies
deltas in place); the static content models (``Monster``, ``Item``,
``TerrainDef``, ``LocationDef``, ``Zone``) are frozen.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
class Mode(StrEnum):
"""Which interaction surface the player is currently on."""
TILE = "tile"
MENU = "menu"
class Slot(StrEnum):
"""Equipment / item slot kinds."""
WEAPON = "weapon"
ARMOR = "armor"
CONSUMABLE = "consumable"
# v0.10 forge ore: a crafting MATERIAL carried in the satchel and spent at
# the forge. It is never equipped, never quaffed (no atk/def/heal), and
# never sold or bought — ore is earned in combat, not traded.
MATERIAL = "material"
@dataclass(slots=True)
class Player:
"""A single adventurer's durable state.
Coordinates are map cells; ``mode`` and ``at_location`` track whether
the player is on the overworld or inside a location menu. Turn fields
gate the daily action budget; bestow fields gate the daily fortune pool.
"""
name: str
x: int
y: int
hp: int
max_hp: int
level: int
xp: int
gold: int
atk: int
def_: int
weapon_id: str
armor_id: str
turns_left: int
turn_day: int
mode: Mode
at_location: str
created_at: str
last_seen: str
log_cursor: int
bestow_spent: int
bestow_day: int
wins: int = 0
posts_sent: int = 0
post_day: int = 0
gambles: int = 0
gamble_day: int = 0
# v0.7 "depth below" retention columns: how far the dungeon has been
# plumbed (0 = never descended; N = cleared rung N, 1-indexed), the
# carried satchel (see below), and the enhancement plus on whichever
# weapon/armour is CURRENTLY equipped in each slot.
deepest_rung: int = 0
# v0.10 STACK-BASED satchel: comma-joined "id:qty" stacks ('' = empty),
# e.g. "minor_potion:3,iron_ore:5". ``satchel_max`` caps DISTINCT stacks,
# not total items; per-stack qty is unbounded. Replaces the v0.7 flat id
# list. The "id:qty" wire format is owned by understone.engine.satchel
# (decode_satchel/encode_satchel); every reader goes through that codec.
satchel: str = ""
weapon_plus: int = 0
armor_plus: int = 0
# v0.10 the Vault: gold banked at the inn. SAFE from ambush (the steal only
# ever touches carried ``gold``) and SURVIVES the Wyrm-win legacy reset (a
# small persistent reward across runs, like a win ★).
banked: int = 0
@dataclass(frozen=True, slots=True)
class Monster:
"""A static monster definition from the content pack.
``boss`` monsters are the fixed endgame foe (the Wyrm Below): they are
excluded from random tier-band selection and only ever faced through the
deliberate ``challenge`` verb.
"""
tier: int
name: str
hp: int
atk: int
def_: int
xp: int
gold: int
monster_id: str = ""
boss: bool = False
# v0.7 weighted forest encounters: ``weight`` biases the random pick (a
# low weight surfaces seldom), ``rare`` marks a named beast that fires a
# public Herald flash and drops a guaranteed draught on the kill. Rung
# guardians ignore both (a rung is a fixed foe, never a weighted roll).
weight: int = 10
rare: bool = False
@dataclass(frozen=True, slots=True)
class Item:
"""A static item / equipment definition from the content pack."""
item_id: str
name: str
slot: Slot
atk: int
def_: int
heal: int
price: int
@dataclass(frozen=True, slots=True)
class TerrainDef:
"""A terrain kind: its glyph, walkability, encounter rate, colour role."""
key: str
glyph: str
walkable: bool
encounter_rate: float
color: str
@dataclass(frozen=True, slots=True)
class LocationDef:
"""A named location placed on the map (inn, shop, healer, dungeon)."""
key: str
kind: str
name: str
x: int
y: int
glyph: str
color: str
actions: tuple[str, ...]
flavor: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True, slots=True)
class Zone:
"""A rectangular region that biases which monster tiers spawn."""
key: str
x0: int
y0: int
x1: int
y1: int
tier_lo: int
tier_hi: int
def contains(self, x: int, y: int) -> bool:
"""Return whether ``(x, y)`` falls inside this zone's rectangle."""
return self.x0 <= x <= self.x1 and self.y0 <= y <= self.y1
@dataclass(frozen=True, slots=True)
class WorldEvent:
"""One row of the weighted overworld encounter table.
``kind`` is one of ``fight``/``gold``/``heal``/``trap``/``lore``.
``weight`` biases random selection. ``lo``/``hi`` bound the rolled amount
for the value-bearing kinds (gold/heal/trap); they are unused for
``fight`` (the foe comes from the zone band) and ``lore`` (pure flavour).
"""
kind: str
weight: int
text: str
lo: int
hi: int
@dataclass(frozen=True, slots=True)
class Settings:
"""Economy and progression parameters sourced from the content pack."""
daily_turns: int
rest_cost: int
heal_cost_per_hp: int
starting_gold: int
starting_weapon: str
starting_armor: str
start_hp: int
start_atk: int
start_def: int
xp_base: int
growth_max_hp: int
growth_atk: int
growth_def: int
bestow_daily_budget: int
dungeon_tiers: tuple[int, ...]
boss_monster: str
wyrm_min_level: int
ambush_min_level: int
ambush_level_band: int
ambush_gold_pct: int
post_daily_cap: int
gamble_max_bet: int
gamble_daily_cap: int
# v0.7 "depth below": the carried-potion satchel size, the forge cost
# ladder (base * (current_plus + 1)) and its enhancement ceiling, and the
# consumable item a rare beast is guaranteed to drop on its kill.
satchel_max: int
forge_base_cost: int
forge_max_plus: int
rare_drop_item: str
# v0.10 the ore-gated forge: the world's forge MATERIAL item id (validated
# to slot=material), the ore each +1 step costs (need = (plus + 1) *
# per_plus), and the two ore sources — a guaranteed drop on a won dungeon
# rung and a chance of one ore on a won forest fight. Ore is combat-earned,
# never purchasable; the forge spends gold AND ore.
forge_ore_item: str
forge_ore_per_plus: int
ore_dungeon_drop: int
ore_forest_chance: float
# v0.8 "worlds without authors": the Watch's per-world CRT palette. One of
# the names in WATCH_THEMES; defaults to "phosphor" (the original green), so
# a pack that omits it looks exactly as the Vale always has.
watch_theme: str = "phosphor"
@@ -1,223 +0,0 @@
"""Overworld movement resolution.
Movement walks tile by tile so each intermediate cell is checked for
walls/edges and rolls an encounter. When a roll fires it weighted-picks one
row from the world's event table. A ``fight`` row STOPS the walk (a wandering
monster bars the path); the value-bearing rows (gold/heal/trap) and pure
``lore`` are applied immediately and the walk continues but only one event
fires per walk, so once any row has fired no further cells roll.
The walk stops early on the first of: running out of steps, hitting a blocked
cell, stepping onto a location door (flips to MENU), or a ``fight`` encounter.
Movement spends no daily turns only fighting does. Gold/heal/trap deltas are
applied straight to the player here (movement already mutates the player's
position), floored/capped so a trap never kills and a spring never overfills.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from understone.engine.models import Mode
if TYPE_CHECKING:
from understone.engine.models import Player, WorldEvent
from understone.engine.rng import GameRNG
from understone.engine.world import World
MAX_STEPS = 8
_DELTAS: dict[str, tuple[int, int]] = {
"N": (0, -1),
"S": (0, 1),
"E": (1, 0),
"W": (-1, 0),
}
_HEADINGS: dict[str, str] = {
"north": "N",
"south": "S",
"east": "E",
"west": "W",
"n": "N",
"s": "S",
"e": "E",
"w": "W",
}
@dataclass(slots=True)
class MoveEvent:
"""A non-fight overworld event already applied to the player.
``kind`` is ``gold``/``heal``/``trap``/``lore``; ``text`` is the pack's
flavour line; ``amount`` is the rolled magnitude (0 for ``lore``). The
player's hp/gold have already been mutated by ``resolve_move`` — this
record exists only so the façade can narrate what happened.
"""
kind: str
text: str
amount: int = 0
@dataclass(slots=True)
class MoveResult:
"""Outcome of a movement attempt.
``steps_taken`` counts cells actually entered. ``blocked`` is set when a
wall/edge stopped the walk. ``entered_location`` carries a location key
when the walk ended on a door. ``pending_fight`` carries an opponent
tier band when a ``fight`` encounter interrupted the walk. ``event``
carries a non-fight overworld event (already applied) when one fired.
"""
steps_taken: int
blocked: bool = False
blocked_reason: str = ""
entered_location: str | None = None
pending_fight: tuple[int, int] | None = None
event: MoveEvent | None = None
path_notes: list[str] = field(default_factory=list)
def parse_directions(steps: str, heading: str, distance: int) -> list[str]:
"""Translate either input form into a clamped list of cardinal steps.
The ``steps`` string (e.g. ``"NNEE"``) takes precedence when non-empty;
otherwise ``heading`` + ``distance`` is expanded. Either way the result
is clamped to ``MAX_STEPS``. Unknown direction characters are rejected.
"""
raw = steps.strip().upper()
if raw:
dirs: list[str] = []
for ch in raw:
if ch not in _DELTAS:
raise ValueError(f"unknown direction {ch!r} (use N/S/E/W)")
dirs.append(ch)
return dirs[:MAX_STEPS]
head = heading.strip().lower()
if not head:
return []
if head not in _HEADINGS:
raise ValueError(f"unknown heading {heading!r} (use north/south/east/west)")
count = max(0, min(distance, MAX_STEPS))
return [_HEADINGS[head]] * count
def resolve_move(
world: World,
player: Player,
rng: GameRNG,
*,
steps: str = "",
heading: str = "",
distance: int = 1,
max_steps: int = MAX_STEPS,
) -> MoveResult:
"""Walk *player* across *world* one cell at a time, mutating position.
Stops at the first blocking edge/wall, location door, or encounter.
Returns a :class:`MoveResult` describing where and why the walk ended.
"""
directions = parse_directions(steps, heading, distance)[:max_steps]
result = MoveResult(steps_taken=0)
fired = False # at most one overworld event per walk
for direction in directions:
dx, dy = _DELTAS[direction]
nx, ny = player.x + dx, player.y + dy
if not world.in_bounds(nx, ny):
result.blocked = True
result.blocked_reason = "the edge of the known world"
break
if not world.is_walkable(nx, ny):
terrain = world.terrain_at(nx, ny)
result.blocked = True
result.blocked_reason = _blocked_phrase(terrain.key)
break
player.x, player.y = nx, ny
result.steps_taken += 1
location = world.location_at(nx, ny)
if location is not None:
player.mode = Mode.MENU
player.at_location = location.key
result.entered_location = location.key
break
if fired:
continue
band = _encounter_band(world, nx, ny)
if band is None:
continue
terrain = world.terrain_at(nx, ny)
if not rng.chance(terrain.encounter_rate):
continue
fired = True
picked = _pick_event(world, rng)
if picked is None or picked.kind == "fight":
result.pending_fight = band
break
result.event = _apply_event(player, rng, picked)
return result
def _pick_event(world: World, rng: GameRNG) -> WorldEvent | None:
"""Weighted-pick one row from the world's event table, or ``None``.
Returns ``None`` only when the pack ships no event table at all, in which
case the caller falls back to the legacy always-a-fight behaviour.
"""
weights = world.event_weights()
if not weights:
return None
return world.events[rng.weighted_index(weights)]
def _apply_event(player: Player, rng: GameRNG, event: WorldEvent) -> MoveEvent:
"""Apply a non-fight event to *player* and return a record for narration.
``gold`` credits a rolled amount; ``heal`` adds hp capped at ``max_hp``;
``trap`` subtracts hp floored at 1 (a trap never kills, and never touches
gold); ``lore`` mutates nothing. Amounts roll over ``[lo, hi]``.
"""
if event.kind == "lore":
return MoveEvent(kind="lore", text=event.text)
amount = rng.randint(event.lo, event.hi)
if event.kind == "gold":
player.gold += amount
elif event.kind == "heal":
amount = min(amount, player.max_hp - player.hp)
player.hp += amount
elif event.kind == "trap":
amount = min(amount, max(player.hp - 1, 0))
player.hp -= amount
return MoveEvent(kind=event.kind, text=event.text, amount=amount)
def _encounter_band(world: World, x: int, y: int) -> tuple[int, int] | None:
"""Return the tier band for an encounter at ``(x, y)``, or ``None``.
Encounters only happen inside a zone; open terrain with no zone is safe.
"""
zone = world.zone_for(x, y)
if zone is None:
return None
return (zone.tier_lo, zone.tier_hi)
def _blocked_phrase(terrain_key: str) -> str:
"""Return an in-fiction phrase for being blocked by *terrain_key*."""
phrases = {
"water": "deep water",
"tree": "an impassable thicket",
"wall": "a sheer wall",
}
return phrases.get(terrain_key, "rough ground")
@@ -1,42 +0,0 @@
"""Leaderboard ordering.
Adventurers are ranked by level (desc), then XP (desc), then name (asc)
so ties break deterministically and alphabetically. The Hall of Legends is a
separate, append-only roll of completed runs (Wyrm kills), ordered newest
first by the store.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class RankEntry:
"""One row of the leaderboard.
``wins`` is the number of times the adventurer has slain the Wyrm Below
(each shown as a beside the name); it does not affect ordering.
"""
name: str
level: int
xp: int
gold: int
wins: int = 0
@dataclass(frozen=True, slots=True)
class HallEntry:
"""One immortalised run in the Hall of Legends (a Wyrm slain)."""
name: str
win_ts: str
run_days: int
level_at_win: int
def leaderboard(entries: list[RankEntry], limit: int = 10) -> list[RankEntry]:
"""Return the top ``limit`` entries in leaderboard order."""
ordered = sorted(entries, key=lambda e: (-e.level, -e.xp, e.name))
return ordered[:limit]
@@ -1,58 +0,0 @@
"""Randomness with deterministic test injection.
A single master ``GameRNG`` is seeded once at startup (from ``os.urandom``
in production). Per-encounter child generators are derived from the master
so a fight's rolls are reproducible given the same child seed. No tool
argument ever carries a seed randomness is server-authoritative.
"""
from __future__ import annotations
import os
import random
class GameRNG:
"""A thin wrapper over ``random.Random`` with child-RNG derivation."""
def __init__(self, seed: int | None = None) -> None:
if seed is None:
seed = int.from_bytes(os.urandom(8), "big")
self._random = random.Random(seed)
def chance(self, probability: float) -> bool:
"""Return ``True`` with the given probability in ``[0.0, 1.0]``."""
if probability <= 0.0:
return False
if probability >= 1.0:
return True
return self._random.random() < probability
def randint(self, lo: int, hi: int) -> int:
"""Return a random integer in the inclusive range ``[lo, hi]``."""
return self._random.randint(lo, hi)
def choice_index(self, count: int) -> int:
"""Return a random index in ``[0, count)``."""
return self._random.randrange(count)
def weighted_index(self, weights: list[int]) -> int:
"""Return an index into ``weights`` chosen in proportion to them.
A single uniform draw is mapped through the cumulative sum, so the
result is deterministic under a fixed seed. ``weights`` must be
non-empty with a positive total (the loader guarantees this for the
content pack's event table).
"""
total = sum(weights)
roll = self._random.randrange(total)
cumulative = 0
for index, weight in enumerate(weights):
cumulative += weight
if roll < cumulative:
return index
return len(weights) - 1
def child(self) -> GameRNG:
"""Derive an independent child RNG seeded from the master stream."""
return GameRNG(self._random.getrandbits(64))
@@ -1,62 +0,0 @@
"""The satchel wire codec — the one home for the ``"id:qty"`` stack encoding.
A player's satchel is stored as a single string: comma-joined ``id:qty`` stacks,
e.g. ``"minor_potion:3,iron_ore:5"``; an empty string is an empty bag. This
module is the SINGLE source of truth for that format. Three readers carried a
byte-identical decode loop (the game façade, the Watch payload builder, and the
balance simulator); they all delegate here so the format is described and
parsed in exactly one place.
The codec is pure and stdlib-only: it knows the wire shape and nothing else.
It does NOT collapse duplicate ids into one stack, resolve ids against a content
pack, or enforce the distinct-stack cap those are stack *semantics* the
callers own. The codec only encodes and decodes.
"""
from __future__ import annotations
def decode_satchel(s: str) -> list[tuple[str, int]]:
"""Decode the ``"id:qty"`` satchel string into ordered ``(item_id, qty)`` stacks.
Splits on ``","`` and skips empty chunks (so an empty string, a leading or
trailing comma, and a doubled comma all yield no spurious stack). Each chunk
is partitioned on ``":"``:
* a chunk with no colon (a bare id) parses as quantity ``1`` a colonless
fragment is treated as a single item, never silently dropped;
* a chunk whose quantity is present but not an integer, or is ``<= 0``, is
skipped;
* a chunk with an empty id is skipped.
Order is preserved (first-stowed first), which fixes which potion a heal tie
resolves to. The codec collapses nothing callers own stack semantics.
"""
stacks: list[tuple[str, int]] = []
for chunk in s.split(","):
if not chunk:
continue
item_id, sep, qty_str = chunk.partition(":")
if not item_id:
continue
if not sep:
# A bare id with no colon is a single item (defensive: never drop it).
stacks.append((item_id, 1))
continue
try:
qty = int(qty_str)
except ValueError:
continue
if qty > 0:
stacks.append((item_id, qty))
return stacks
def encode_satchel(stacks: list[tuple[str, int]]) -> str:
"""Encode ``(item_id, qty)`` stacks back into the comma-joined ``"id:qty"`` string.
Any stack at quantity ``<= 0`` is dropped, so the encoding never emits
``"id:0"``; this is the single home for the drop-at-empty rule, letting
callers decrement freely and rely on a spent-to-zero stack falling away.
"""
return ",".join(f"{item_id}:{qty}" for item_id, qty in stacks if qty > 0)
@@ -1,99 +0,0 @@
"""The one-glyph-one-column contract for everything drawn on the grid.
Every surface Understone paints the bordered text frames, the golden frames
the screen tests pin, and the Watch's CSS ``1ch``-per-cell map — assumes each
map glyph occupies *exactly one* terminal column. A glyph that renders two
columns (a CJK ideograph, an emoji) shoves the row right and tears the
box-drawing border; a zero-width combining mark stacks onto its neighbour and
desynchronises the column count the other way. :func:`is_grid_safe` is the
single predicate that admits a character to the grid, and :data:`SAFE_PALETTE`
is the curated set of glyphs known to satisfy it with period CP437 flavour.
THE WESTERN-MONOSPACE ASSUMPTION. Width here is judged for the Western
monospace metrics every Understone surface actually uses the pinned Watch
font stack and the monospace of a chat client's code block. Under those
metrics the East-Asian-Width *Ambiguous* class renders single-column, and
Ambiguous is the CP437 heartland: `` `` are all EAW=A. So the rule
bars only the genuinely double-width classes Wide (``W``) and Fullwidth
(``F``) and admits Ambiguous, Narrow, Neutral, and Halfwidth. The trade is
deliberate: on a CJK-width terminal an Ambiguous glyph would take two columns,
but Understone's surfaces are not those terminals.
"""
from __future__ import annotations
import unicodedata
# East-Asian-Width classes that render two columns under Western monospace and
# would therefore tear a frame; everything else (Na/N/H/A) renders one column.
_DOUBLE_WIDTH_EAW = frozenset({"W", "F"})
# Unicode general categories that carry no column of their own — combining
# marks (Mn/Mc/Me) stack onto a neighbour, format/control codes (Cf/Cc) are
# invisible — so a single such code point is not a paintable cell.
_ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Mc", "Me", "Cf", "Cc"})
def is_grid_safe(ch: str) -> bool:
"""Return whether *ch* may occupy a single grid cell.
A grid-safe character is exactly one code point, is printable, is not an
East-Asian Wide or Fullwidth glyph (the only classes that render two
columns under the Western monospace metrics our surfaces use see the
module docstring), and is not a combining mark or format/control code (a
zero-width code point that would desynchronise the column count).
"""
if len(ch) != 1:
return False
if not ch.isprintable():
return False
if unicodedata.east_asian_width(ch) in _DOUBLE_WIDTH_EAW:
return False
return unicodedata.category(ch) not in _ZERO_WIDTH_CATEGORIES
# A curated set of single-column glyphs with BBS / CP437 character, grouped by
# the role an author is likely to want them for. Every entry is grid-safe AND
# free of the loader's reserved markers (two tests assert both), so a pack
# author can pull any of these for terrain, structures, or actors without
# risking a torn frame or colliding with the '@'/'☻' player markers. The black
# smiling face (☻) is the other-player marker and so is NOT here; its white
# twin (☺) is a free being glyph. The grouping is documentation; the set is
# what callers iterate.
SAFE_PALETTE: tuple[str, ...] = (
# terrain
"",
"",
"",
"",
"",
"",
"",
"",
".",
",",
"'",
'"',
"=",
"~",
"§",
"ø",
"¤",
"Ω",
# structures
"",
"",
"",
"",
"",
"$",
"",
"",
# beings
"",
"",
# misc
"",
"",
"",
)
@@ -1,65 +0,0 @@
"""Daily action budget and the UTC-day rollover.
Turns refresh lazily: the first action on a new UTC day resets the
budget rather than relying on a scheduled job. The same rollover resets
the per-player bestow pool and the social daily caps (posts left, dice
played), so every daily allowance shares one boundary. The clock is
injected so tests can cross midnight deterministically.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
from understone.engine.models import Player
def _utc_ordinal(clock: Callable[[], datetime]) -> int:
"""Return today's proleptic-Gregorian ordinal in UTC."""
return clock().toordinal()
def ensure_day(player: Player, clock: Callable[[], datetime], daily_turns: int) -> bool:
"""Refresh daily allowances if the UTC day has advanced.
Returns ``True`` when a reset occurred. On a new UTC day this resets the
turn budget (to *daily_turns*), the bestow pool, the daily post count, and
the daily dice count each back to its baseline stamping the current UTC
ordinal onto every day marker. Each counter is reset independently so a
stale stamp on one never suppresses the refresh of another.
"""
today = _utc_ordinal(clock)
reset = False
if player.turn_day != today:
player.turns_left = daily_turns
player.turn_day = today
reset = True
if player.bestow_day != today:
player.bestow_spent = 0
player.bestow_day = today
reset = True
if player.post_day != today:
player.posts_sent = 0
player.post_day = today
reset = True
if player.gamble_day != today:
player.gambles = 0
player.gamble_day = today
reset = True
return reset
def spend_turn(player: Player) -> bool:
"""Consume one daily turn.
Returns ``True`` and decrements when a turn is available; returns
``False`` and leaves state untouched when the budget is exhausted.
"""
if player.turns_left <= 0:
return False
player.turns_left -= 1
return True
@@ -1,117 +0,0 @@
"""Runtime world model — terrain, locations, zones, content tables, settings.
Built by ``world.loader`` from JSON. The engine queries this for
walkability, encounter rates, location lookups, and tier-banded monster
selection. It holds no mutable game state.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from understone.engine.models import (
Item,
LocationDef,
Monster,
Settings,
TerrainDef,
WorldEvent,
Zone,
)
class World:
"""An immutable-after-construction view of the game map and content."""
def __init__(
self,
*,
name: str,
width: int,
height: int,
spawn: tuple[int, int],
terrain: list[list[TerrainDef]],
locations: list[LocationDef],
zones: list[Zone],
monsters: list[Monster],
items: list[Item],
settings: Settings,
events: list[WorldEvent] | None = None,
) -> None:
self.name = name
self.width = width
self.height = height
self.spawn = spawn
self.terrain = terrain
self.locations = locations
self.zones = zones
self.monsters = monsters
self.items = items
self.settings = settings
self.events: list[WorldEvent] = events or []
self._event_weights: list[int] = [e.weight for e in self.events]
self._loc_by_xy: dict[tuple[int, int], LocationDef] = {
(loc.x, loc.y): loc for loc in locations
}
self._loc_by_key: dict[str, LocationDef] = {loc.key: loc for loc in locations}
self._item_by_id: dict[str, Item] = {it.item_id: it for it in items}
self._monster_by_id: dict[str, Monster] = {
m.monster_id: m for m in monsters if m.monster_id
}
def in_bounds(self, x: int, y: int) -> bool:
"""Return whether ``(x, y)`` is inside the map rectangle."""
return 0 <= x < self.width and 0 <= y < self.height
def terrain_at(self, x: int, y: int) -> TerrainDef:
"""Return the terrain definition at ``(x, y)`` (caller bounds-checks)."""
return self.terrain[y][x]
def location_at(self, x: int, y: int) -> LocationDef | None:
"""Return the location placed at ``(x, y)``, if any."""
return self._loc_by_xy.get((x, y))
def location_by_key(self, key: str) -> LocationDef | None:
"""Return the location with the given key, if any."""
return self._loc_by_key.get(key)
def item_by_id(self, item_id: str) -> Item | None:
"""Return the item with the given id, if any."""
return self._item_by_id.get(item_id)
def monster_by_id(self, monster_id: str) -> Monster | None:
"""Return the monster with the given id, if any (boss lookup)."""
return self._monster_by_id.get(monster_id)
def event_weights(self) -> list[int]:
"""Return the parallel weight list for the overworld event table."""
return self._event_weights
def is_walkable(self, x: int, y: int) -> bool:
"""Return whether a player may stand on ``(x, y)``.
Out-of-bounds is never walkable. A location tile is always walkable
regardless of its underlying terrain (you can step onto the door).
"""
if not self.in_bounds(x, y):
return False
if (x, y) in self._loc_by_xy:
return True
return self.terrain[y][x].walkable
def zone_for(self, x: int, y: int) -> Zone | None:
"""Return the first zone whose rectangle contains ``(x, y)``."""
for zone in self.zones:
if zone.contains(x, y):
return zone
return None
def monsters_for_tier_band(self, lo: int, hi: int) -> list[Monster]:
"""Return non-boss monsters whose tier falls within ``[lo, hi]``.
Boss monsters (the Wyrm Below) are never returned: they are the fixed
endgame foe, faced only through the deliberate ``challenge`` verb, and
must never surface as a random encounter or a dungeon-gauntlet rung.
"""
return [m for m in self.monsters if lo <= m.tier <= hi and not m.boss]
-24
View File
@@ -1,24 +0,0 @@
"""Shared exception types for Understone.
These never cross the MCP boundary the server layer catches everything
and renders an in-fiction line but they let internal layers fail with a
readable, specific message.
"""
from __future__ import annotations
class UnderstoneError(Exception):
"""Base class for all Understone errors."""
class WorldLoadError(UnderstoneError):
"""Raised when a content pack fails to parse or validate.
The message is written to be readable by a pack author: it names the
file, the offending field, and what was expected.
"""
class PersistenceError(UnderstoneError):
"""Raised when the save store cannot be opened or migrated."""
File diff suppressed because it is too large Load Diff
@@ -1,391 +0,0 @@
"""SQLite persistence — the only storage layer, ``sqlite3`` only.
A single connection is held for the process lifetime. MCP tool handlers are
synchronous and run on one event-loop thread, so writes serialise naturally
and no connection pool or lock is needed [VERIFIED: handlers are sync def].
WAL journaling is enabled so reads never block the single writer.
The store loads all players and recent events into memory at construction
(a write-through cache). State-changing tools update the cache and the DB in
one transaction; the game façade owns the per-action commit policy.
The connection is opened with ``check_same_thread=False`` because the Store
may be CONSTRUCTED on a different thread than the event-loop thread that later
serves tools (both the test fixture and ``main`` do this). Post-construction
access is single-threaded: sync tools run inline on the loop [verified against
mcp 1.27 func_metadata], so writes still serialise without a lock.
"""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
from understone.engine.log import Event
from understone.engine.models import Mode, Player
from understone.engine.rank import HallEntry, RankEntry
if TYPE_CHECKING:
from pathlib import Path
# Pre-1.0 the schema mutates in place and the stamp is not yet meaningful;
# version discipline (and migrations) begins at 1.0.
_SCHEMA_VERSION = 1
# How many of the newest events to hydrate at construction. Full history stays
# in SQLite; this bounds the in-memory tail. Single source of truth — game.py
# imports it for the runtime trim, so the load size and the trim size cannot
# diverge. An ops knob (memory ceiling), never an economy value.
EVENT_TAIL_KEEP = 500
_PLAYER_COLUMNS = (
"name",
"x",
"y",
"hp",
"max_hp",
"level",
"xp",
"gold",
"atk",
"def_",
"weapon_id",
"armor_id",
"turns_left",
"turn_day",
"mode",
"at_location",
"created_at",
"last_seen",
"log_cursor",
"bestow_spent",
"bestow_day",
"wins",
"posts_sent",
"post_day",
"gambles",
"gamble_day",
"deepest_rung",
"satchel",
"weapon_plus",
"armor_plus",
"banked",
)
class Store:
"""A write-through SQLite store for players and the shared event log."""
def __init__(self, db_path: str | Path) -> None:
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA foreign_keys=ON")
self._init_schema()
# -- schema ----------------------------------------------------------
def _init_schema(self) -> None:
self._conn.executescript(
"""
CREATE TABLE IF NOT EXISTS players (
name TEXT PRIMARY KEY,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
hp INTEGER NOT NULL,
max_hp INTEGER NOT NULL,
level INTEGER NOT NULL,
xp INTEGER NOT NULL,
gold INTEGER NOT NULL,
atk INTEGER NOT NULL,
def_ INTEGER NOT NULL,
weapon_id TEXT NOT NULL,
armor_id TEXT NOT NULL,
turns_left INTEGER NOT NULL,
turn_day INTEGER NOT NULL,
mode TEXT NOT NULL,
at_location TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen TEXT NOT NULL,
log_cursor INTEGER NOT NULL,
bestow_spent INTEGER NOT NULL,
bestow_day INTEGER NOT NULL,
wins INTEGER NOT NULL DEFAULT 0,
posts_sent INTEGER NOT NULL DEFAULT 0,
post_day INTEGER NOT NULL DEFAULT 0,
gambles INTEGER NOT NULL DEFAULT 0,
gamble_day INTEGER NOT NULL DEFAULT 0,
deepest_rung INTEGER NOT NULL DEFAULT 0,
satchel TEXT NOT NULL DEFAULT '',
weapon_plus INTEGER NOT NULL DEFAULT 0,
armor_plus INTEGER NOT NULL DEFAULT 0,
banked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
actor TEXT NOT NULL,
kind TEXT NOT NULL,
text TEXT NOT NULL,
target TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS ambushes (
attacker TEXT NOT NULL,
target TEXT NOT NULL,
day INTEGER NOT NULL,
PRIMARY KEY (attacker, target, day)
);
CREATE TABLE IF NOT EXISTS hall_of_fame (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
win_ts TEXT NOT NULL,
run_days INTEGER NOT NULL,
level_at_win INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
self._conn.execute(
"INSERT OR IGNORE INTO meta(key, value) VALUES('schema_version', ?)",
(str(_SCHEMA_VERSION),),
)
self._conn.commit()
def set_meta(self, key: str, value: str) -> None:
"""Upsert a meta key (e.g. ``world_name``) and commit."""
self._conn.execute(
"INSERT INTO meta(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
self._conn.commit()
def get_meta(self, key: str) -> str | None:
"""Return a meta value, or ``None`` if unset."""
row = self._conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
return None if row is None else str(row["value"])
# -- load ------------------------------------------------------------
def load_all(self) -> tuple[dict[str, Player], list[Event]]:
"""Load every player and the most recent events into memory.
Only the newest ``EVENT_TAIL_KEEP`` events are resident; the full history
remains in SQLite. The tail is fetched newest-first then reversed so
the returned list stays ascending by id (the order ``since`` expects).
"""
players = {
row["name"]: _row_to_player(row) for row in self._conn.execute("SELECT * FROM players")
}
rows = self._conn.execute(
"SELECT * FROM events ORDER BY id DESC LIMIT ?", (EVENT_TAIL_KEEP,)
).fetchall()
events = [_row_to_event(row) for row in reversed(rows)]
return players, events
# -- writes (no commit here; the façade commits per action) ----------
def upsert_player(self, player: Player) -> None:
"""Insert or update a player row (no commit)."""
placeholders = ", ".join("?" for _ in _PLAYER_COLUMNS)
assignments = ", ".join(f"{col}=excluded.{col}" for col in _PLAYER_COLUMNS if col != "name")
self._conn.execute(
f"INSERT INTO players ({', '.join(_PLAYER_COLUMNS)}) VALUES ({placeholders}) "
f"ON CONFLICT(name) DO UPDATE SET {assignments}",
_player_to_row(player),
)
def insert_event(self, ts: str, actor: str, kind: str, text: str, target: str = "") -> int:
"""Append an event row (no commit) and return its new id.
``target`` is empty for a public event or a player name for a private
note that only that player reads in their own catch-up.
"""
cur = self._conn.execute(
"INSERT INTO events(ts, actor, kind, text, target) VALUES(?, ?, ?, ?, ?)",
(ts, actor, kind, text, target),
)
return int(cur.lastrowid or 0)
def insert_hall_row(self, name: str, win_ts: str, run_days: int, level_at_win: int) -> int:
"""Append a Hall of Legends row (no commit) and return its new id."""
cur = self._conn.execute(
"INSERT INTO hall_of_fame(name, win_ts, run_days, level_at_win) VALUES(?, ?, ?, ?)",
(name, win_ts, run_days, level_at_win),
)
return int(cur.lastrowid or 0)
def record_ambush(self, attacker: str, target: str, day: int) -> None:
"""Mark that *attacker* has spent their ambush on *target* for *day*.
Idempotent: the ``(attacker, target, day)`` primary key means a repeat
write is ignored, so re-recording the same attempt is harmless. No
commit the façade folds this into the per-action transaction.
"""
self._conn.execute(
"INSERT OR IGNORE INTO ambushes(attacker, target, day) VALUES(?, ?, ?)",
(attacker, target, day),
)
def has_ambushed(self, attacker: str, target: str, day: int) -> bool:
"""Return whether *attacker* already ambushed *target* on *day*."""
row = self._conn.execute(
"SELECT 1 FROM ambushes WHERE attacker=? AND target=? AND day=?",
(attacker, target, day),
).fetchone()
return row is not None
def commit(self) -> None:
"""Commit the current transaction."""
self._conn.commit()
# -- read-only queries ----------------------------------------------
def top_ranks(self, limit: int = 10) -> list[RankEntry]:
"""Return the leaderboard ordered by level, xp, then name."""
rows = self._conn.execute(
"SELECT name, level, xp, gold, wins FROM players "
"ORDER BY level DESC, xp DESC, name ASC LIMIT ?",
(limit,),
)
return [
RankEntry(
name=row["name"],
level=row["level"],
xp=row["xp"],
gold=row["gold"],
wins=row["wins"],
)
for row in rows
]
def top_hall(self, limit: int = 5) -> list[HallEntry]:
"""Return the most recent Hall of Legends rows, newest first."""
rows = self._conn.execute(
"SELECT name, win_ts, run_days, level_at_win FROM hall_of_fame "
"ORDER BY id DESC LIMIT ?",
(limit,),
)
return [
HallEntry(
name=row["name"],
win_ts=row["win_ts"],
run_days=row["run_days"],
level_at_win=row["level_at_win"],
)
for row in rows
]
def targeted_events_since(self, viewer: str, cursor: int) -> list[Event]:
"""Return *viewer*'s private notes past *cursor*, ascending by id.
Public history older than the resident tail is ephemeral by design (the
broadsheet does not keep), but private mail is durable: a note left while
the recipient was away must survive however many public events have since
pushed it out of the in-memory tail. The façade pulls the recipient's
targeted rows from SQLite to backfill that gap before rendering.
"""
rows = self._conn.execute(
"SELECT * FROM events WHERE target=? AND id>? ORDER BY id",
(viewer, cursor),
).fetchall()
return [_row_to_event(row) for row in rows]
def journal_mode(self) -> str:
"""Return the active journal mode (for diagnostics / tests)."""
row = self._conn.execute("PRAGMA journal_mode").fetchone()
return str(row[0])
def close(self) -> None:
"""Close the underlying connection."""
self._conn.close()
def _player_to_row(player: Player) -> tuple[object, ...]:
return (
player.name,
player.x,
player.y,
player.hp,
player.max_hp,
player.level,
player.xp,
player.gold,
player.atk,
player.def_,
player.weapon_id,
player.armor_id,
player.turns_left,
player.turn_day,
str(player.mode),
player.at_location,
player.created_at,
player.last_seen,
player.log_cursor,
player.bestow_spent,
player.bestow_day,
player.wins,
player.posts_sent,
player.post_day,
player.gambles,
player.gamble_day,
player.deepest_rung,
player.satchel,
player.weapon_plus,
player.armor_plus,
player.banked,
)
def _row_to_player(row: sqlite3.Row) -> Player:
return Player(
name=row["name"],
x=row["x"],
y=row["y"],
hp=row["hp"],
max_hp=row["max_hp"],
level=row["level"],
xp=row["xp"],
gold=row["gold"],
atk=row["atk"],
def_=row["def_"],
weapon_id=row["weapon_id"],
armor_id=row["armor_id"],
turns_left=row["turns_left"],
turn_day=row["turn_day"],
mode=Mode(row["mode"]),
at_location=row["at_location"],
created_at=row["created_at"],
last_seen=row["last_seen"],
log_cursor=row["log_cursor"],
bestow_spent=row["bestow_spent"],
bestow_day=row["bestow_day"],
wins=row["wins"],
posts_sent=row["posts_sent"],
post_day=row["post_day"],
gambles=row["gambles"],
gamble_day=row["gamble_day"],
deepest_rung=row["deepest_rung"],
satchel=row["satchel"],
weapon_plus=row["weapon_plus"],
armor_plus=row["armor_plus"],
banked=row["banked"],
)
def _row_to_event(row: sqlite3.Row) -> Event:
return Event(
event_id=row["id"],
ts=row["ts"],
kind=row["kind"],
actor=row["actor"],
text=row["text"],
target=row["target"],
)
@@ -1,5 +0,0 @@
"""Screen layer — pure rendering of game state into text frames.
No ANSI SGR escape sequences are emitted in v1; colour is carried as
metadata on cells for a future renderer but never written to output.
"""
@@ -1,35 +0,0 @@
"""Shared box-drawing glyphs and the title-in-border helper.
The frame renderer and the menu renderer both draw a single box with a
centred title in the top border. The glyph set and that border logic live
here so the two renderers cannot drift apart.
"""
from __future__ import annotations
TL = "" # top-left corner
TR = "" # top-right corner
BL = "" # bottom-left corner
BR = "" # bottom-right corner
H = "" # horizontal run
V = "" # vertical edge
def border_with_title(inner: int, title: str) -> str:
"""Build the top border ``┌──title──┐`` with the title centred in the run.
*inner* is the interior width (between the corners). The title is wrapped
in single spaces and centred; if it does not fit the run it is truncated.
An empty/blank title yields a plain horizontal run.
"""
label = title.strip()
if not label:
return TL + (H * inner) + TR
framed = f" {label} "
if len(framed) > inner:
framed = framed[:inner]
pad = inner - len(framed)
left = pad // 2
right = pad - left
middle = (H * left) + framed + (H * right)
return TL + middle + TR
@@ -1,61 +0,0 @@
"""A 2-D grid of single-glyph cells.
The grid is the renderer's input surface: callers paint terrain and actors
into cells, then hand the grid to ``text_renderer`` for framing.
"""
from __future__ import annotations
from dataclasses import dataclass
from understone.screen.palette import Color
@dataclass(frozen=True, slots=True)
class Cell:
"""A single rendered position: exactly one glyph plus a colour role."""
glyph: str
color: Color
def __post_init__(self) -> None:
if len(self.glyph) != 1:
raise ValueError(f"cell glyph must be exactly one character, got {self.glyph!r}")
_BLANK = Cell(" ", Color.DEFAULT)
class CellGrid:
"""A mutable ``rows`` x ``cols`` grid of cells."""
def __init__(self, rows: int, cols: int) -> None:
if rows <= 0 or cols <= 0:
raise ValueError(f"grid must be positive, got {rows}x{cols}")
self.rows = rows
self.cols = cols
self._cells: list[list[Cell]] = [[_BLANK for _ in range(cols)] for _ in range(rows)]
def blank(self) -> None:
"""Reset every cell to the blank cell."""
for r in range(self.rows):
for c in range(self.cols):
self._cells[r][c] = _BLANK
def set(self, r: int, c: int, cell: Cell) -> None:
"""Paint *cell* at row *r*, column *c* (bounds-checked)."""
if not (0 <= r < self.rows and 0 <= c < self.cols):
raise IndexError(f"cell ({r},{c}) out of bounds for {self.rows}x{self.cols}")
self._cells[r][c] = cell
def get(self, r: int, c: int) -> Cell:
"""Return the cell at row *r*, column *c* (bounds-checked)."""
if not (0 <= r < self.rows and 0 <= c < self.cols):
raise IndexError(f"cell ({r},{c}) out of bounds for {self.rows}x{self.cols}")
return self._cells[r][c]
def row_glyphs(self, r: int) -> str:
"""Return row *r* as a string of its glyphs."""
if not (0 <= r < self.rows):
raise IndexError(f"row {r} out of bounds for {self.rows} rows")
return "".join(cell.glyph for cell in self._cells[r])
@@ -1,47 +0,0 @@
"""Classic door-game menu rendering for location interiors.
A menu is a boxed title, a block of flavour/body lines, an option line of
the ``(B)uy (S)ell (L)eave`` form, and a footer status line.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from understone.screen.box import BL, BR, H, V, border_with_title
if TYPE_CHECKING:
from collections.abc import Sequence
def render_menu(
title: str,
lines: Sequence[str],
options: Sequence[str],
status: str,
) -> str:
"""Render a boxed location menu.
*title* is centred in the top border, *lines* form the body (each
left-padded inside the box), *options* are joined with two spaces into
an option line, and *status* prints under the box.
"""
body = list(lines)
option_line = " ".join(options)
if option_line:
body.append("")
body.append(option_line)
inner = _inner_width(title, body)
out = [border_with_title(inner, title)]
for line in body:
out.append(V + " " + line.ljust(inner - 1) + V)
out.append(BL + (H * inner) + BR)
out.append(status)
return "\n".join(out)
def _inner_width(title: str, body: Sequence[str]) -> int:
"""Choose an inner width that fits the title and the widest body line."""
title_need = len(title.strip()) + 4
body_need = max((len(line) + 2 for line in body), default=0)
return max(title_need, body_need, 24)
@@ -1,65 +0,0 @@
"""Colour vocabulary for cells.
Colours are *stored* on cells and rendered by the live Watch page, which maps
each role to a hue (see ``watch.PALETTE``). The text frame renderer stays
monochrome it emits glyphs only so a cell's colour rides the grid model
untouched until a colour-aware renderer (the Watch today, an ANSI terminal
later) reads it.
"""
from __future__ import annotations
from enum import Enum
class Color(Enum):
"""Semantic colour roles for grid cells.
One global vocabulary, shared by every world there are no per-world or
per-theme palettes. Roles are split into two families: the runtime overlay
colours an actor or item wears (``PLAYER``/``OTHER_PLAYER``/``MONSTER``/
``ITEM``) and the author-assignable terrain/location roles a pack paints its
map with (everything else). A future colour renderer maps each role to a
hue; the Watch already does (see ``watch.PALETTE``).
"""
DEFAULT = "default"
WALL = "wall"
FLOOR = "floor"
PLAYER = "player"
OTHER_PLAYER = "other_player"
MONSTER = "monster"
ITEM = "item"
WATER = "water"
TREE = "tree"
TOWN = "town"
DUNGEON = "dungeon"
# Expanded terrain/location roles (v0.9) — so distinct types read by hue and
# not only by glyph. ROAD splits paths off FLOOR; FOREST is lush dense
# vegetation; SCRUB is its barren counterpart — rough, non-lush dense terrain
# (volcanic cinder, desert scrub) that must NOT read as green woods; LAVA
# gives molten ground its own orange (no longer mis-sharing WATER's blue);
# BARREN gives open wasteland ground a taupe; INN/SHOP/HEALER give each town
# building its own hue (TOWN stays as a generic fallback).
ROAD = "road"
FOREST = "forest"
SCRUB = "scrub"
LAVA = "lava"
BARREN = "barren"
INN = "inn"
SHOP = "shop"
HEALER = "healer"
@classmethod
def assignable(cls) -> list[Color]:
"""The roles a pack may paint terrain or a location with.
One source of truth for the overlay-vs-assignable split. Excludes the
runtime overlay colours an actor/item wears (``PLAYER``/
``OTHER_PLAYER``/``MONSTER``/``ITEM``) and the ``DEFAULT`` fallback
none of which an author assigns. Consumers (the authoring manual and
its test) read this so the documented vocabulary can never drift from
the enum. Returned in definition order.
"""
overlay = {cls.DEFAULT, cls.PLAYER, cls.OTHER_PLAYER, cls.MONSTER, cls.ITEM}
return [role for role in cls if role not in overlay]
@@ -1,32 +0,0 @@
"""Frame rendering — wrap a grid in a single box border with a title and status.
Deterministic and glyph-only (no ANSI). The title is centred within the
top border run; the status line is printed under the closed box.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from understone.screen.box import BL, BR, H, V, border_with_title
if TYPE_CHECKING:
from understone.screen.grid import CellGrid
def render_frame(grid: CellGrid, *, title: str, status: str) -> str:
"""Render *grid* inside a box, with *title* in the top border and *status* below.
The inner width equals the grid's column count. The title is centred
in the horizontal run of the top border; if it does not fit it is
truncated to the available run.
"""
inner = grid.cols
top = border_with_title(inner, title)
bottom = BL + (H * inner) + BR
lines = [top]
for r in range(grid.rows):
lines.append(V + grid.row_glyphs(r) + V)
lines.append(bottom)
lines.append(status)
return "\n".join(lines)
@@ -1,54 +0,0 @@
"""Deterministic terrain texturing — vary a terrain glyph by map position.
A field of identical `.`s reads flat; swapping in an occasional `,` or `'`
gives the overworld a hand-stippled BBS texture without storing anything on the
map. The variation is a PURE FUNCTION OF THE CELL COORDINATE, so it is stable
across redraws (a cell always picks the same variant) and reproducible the
model never sees it, only the renderer.
Only *terrain* cells are textured. The player marker, the other-player marker,
and location glyphs are painted on top and are never varied, so the eye can
always find them.
LOCKSTEP CONTRACT. The Watch page (``understone.watch.WATCH_HTML``) paints its
own base map in JavaScript and reproduces the EXACT same selection the same
``VARIANTS`` rows and the same ``(x * _HASH_X + y * _HASH_Y) % n`` index. The
page builds that index string FROM the :data:`_HASH_X` / :data:`_HASH_Y`
constants here (``understone.watch`` imports them), so a retune of either
number moves the JS with it; only the ``VARIANTS`` table must still be mirrored
by hand, or the live map and the tool frames will drift apart.
"""
from __future__ import annotations
# The position hash multipliers. The variant index for a cell is
# ``(x * _HASH_X + y * _HASH_Y) % len`` — two odd, coprime constants chosen so
# neighbouring cells spread across the variant row rather than banding. The
# Watch JS builds its own copy of this formula FROM these same two numbers
# (``understone.watch`` imports them), so a retune here moves the page in
# lockstep; a guard test pins the agreement.
_HASH_X = 31
_HASH_Y = 17
# Base glyph -> the ordered string of glyphs it may render as. The base glyph
# is index 0, so a cell that hashes to 0 is unchanged. Glyphs not listed here
# are never varied. Keep in lockstep with the Watch JS VARIANTS map.
VARIANTS: dict[str, str] = {
".": ".,'",
"": "≋≈",
}
def textured(glyph: str, x: int, y: int) -> str:
"""Return the variant of *glyph* for cell ``(x, y)``, or *glyph* unchanged.
When *glyph* has a :data:`VARIANTS` row, the cell coordinate selects one of
its variants by ``(x * _HASH_X + y * _HASH_Y) % len`` a fixed,
position-only hash so the choice is stable per cell and identical to the
Watch's. Glyphs with no row (every actor and location glyph, and any
un-listed terrain) are returned as-is.
"""
choices = VARIANTS.get(glyph)
if choices is None:
return glyph
return choices[(x * _HASH_X + y * _HASH_Y) % len(choices)]
@@ -1,40 +0,0 @@
"""Viewport window maths — pure integer arithmetic, no state.
Computes the top-left corner of a view window over a larger map, centred
on a focus point but clamped to map edges so the window never wraps and
never runs off the map. At edges the focus point sits off-centre.
"""
from __future__ import annotations
def compute_window(
map_w: int,
map_h: int,
view_w: int,
view_h: int,
cx: int,
cy: int,
) -> tuple[int, int]:
"""Return ``(x0, y0)`` top-left map coords for a view centred on ``(cx, cy)``.
The window is clamped so ``[x0, x0 + view_w)`` stays within
``[0, map_w)`` (and likewise for the vertical axis). When the map is
smaller than the view the origin pins to ``0``.
"""
x0 = _clamp_axis(map_w, view_w, cx)
y0 = _clamp_axis(map_h, view_h, cy)
return x0, y0
def _clamp_axis(map_size: int, view_size: int, center: int) -> int:
"""Clamp one axis: centre on ``center`` then pull inside the map edges."""
if view_size >= map_size:
return 0
origin = center - view_size // 2
max_origin = map_size - view_size
if origin < 0:
return 0
if origin > max_origin:
return max_origin
return origin
-648
View File
@@ -1,648 +0,0 @@
"""MCP server for Understone — the only module that imports ``mcp`` (or ``starlette``).
Nine ``door_*`` tools form the entire player interface. Every handler is a
synchronous ``def`` that takes and returns ``str``; no exception is allowed
to cross the MCP boundary (each handler catches, logs server-side, and
returns an in-fiction line). The handlers are thin wrappers over a single
module-level :class:`~understone.game.Game`; all rules live behind that
façade.
Three extra HTTP routes (``/watch`` and its two JSON feeds) serve the
read-only spectator page from :mod:`understone.watch`. They are registered via
FastMCP's ``custom_route`` and ride inside the streamable-http app; the
``starlette`` request/response types appear ONLY here, mirroring the MCP SDK's
own ``custom_route`` examples. The routes are unauthenticated by design and
strictly read-only they never mutate or persist world state.
Usage::
understone # via entry point (stdio transport)
python -m understone # via module
understone validate PATH # check a content pack loads cleanly
understone newpack PATH # scaffold a new pack + authoring manual
understone worlds # list the bundled worlds and their soundness
Environment variables
---------------------
UNDERSTONE_DB SQLite path (default: ./understone.db)
UNDERSTONE_WORLD Content-pack directory (default: packaged world/data)
UNDERSTONE_TRANSPORT "stdio" (default) or "streamable-http"
UNDERSTONE_HOST Bind host for http transport (default: 127.0.0.1)
UNDERSTONE_PORT Bind port for http transport (default: 8077)
UNDERSTONE_PATH HTTP path for the MCP endpoint (default: /mcp)
"""
from __future__ import annotations
import argparse
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.responses import HTMLResponse, JSONResponse, Response
from understone import cli, sim, watch
from understone.errors import WorldLoadError
from understone.game import Game
from understone.persistence import Store
from understone.world import PACKAGED_WORLD_DIR
from understone.world.loader import load_world
if TYPE_CHECKING:
from starlette.applications import Starlette
from starlette.requests import Request
log = logging.getLogger(__name__)
_PREMISE = (
"Understone is a shared-world BBS door game played through these tools. "
"Call door_help first to learn how to narrate it."
)
# The DM manual returned by door_help — a module constant so it is stable and
# greppable. It teaches an assistant how to run the game responsibly.
_DM_MANUAL = """\
UNDERSTONE A GUIDE FOR THE GAME MASTER
WHAT THIS IS
Understone is a multiplayer, BBS-style ANSI door game a small text RPG in
the lineage of the classic BBS door games. Many players share ONE
persistent world hosted by this server. You are the storyteller at the
terminal; the server is the rules engine and the single source of truth.
THE GOLDEN RULE
The server is authoritative. Never invent dice rolls, loot, gold, hit
points, map tiles, or outcomes. Every number and event comes back from a
tool call. Narrate AROUND the facts the tools return never ahead of them.
If you want something to happen, call the tool and see what the world says.
THE TWO MODES OF PLAY
1. The overworld (TILE mode). Tools return an ASCII "keyframe": a bordered
map window centred on the player. '@' is the player, '' is another
adventurer, glyphs are buildings ( inn, $ shop, healer, dungeon).
Movement here is FREE it costs no daily turns.
2. Location interiors (MENU mode). Stepping onto a building opens a menu of
options like (R)est, (B)uy, (H)eal, (D)escend, (L)eave.
PRESENTING FRAMES
When a tool returns a map or a menu, show it to the player VERBATIM inside a
fenced code block so the box-drawing lines stay aligned. Then add your prose
underneath. Do not redraw or paraphrase the frame.
NARRATION
Be vivid and in-fiction. Turn the terse result lines ("You travel 3 steps.",
"+8 XP, +3 gold.") into atmosphere. Keep your additions consistent with the
returned facts and the high-fantasy tone of the Vale of Understone.
THE DAILY RHYTHM
Each adventurer has a small budget of turns per real-world UTC day. Only
fighting, descending, and challenging the Wyrm spend a turn; moving,
resting, shopping and looking do not. When the budget is gone, the day is
done encourage the player to return tomorrow. This is a correspondence
game: a little each day.
WANDERING THE FOREST (the texture of a walk)
A step through wild country may turn up more than a monster. The server
rolls a private encounter table as the player walks: most often a foe (which
stops the walk for a fight or flight), but sometimes a purse of gold, a
healing spring, a small trap (it can never kill it floors at 1 HP), or a
scrap of Vale lore. The non-combat finds are applied at once, narrated in
the move result, and do NOT stop the walk; at most one such event happens
per move. These finds are PRIVATE they are not Herald news so narrate
them as the quiet texture of travelling, and watch the lore: it whispers of
something coiled beneath the dungeon.
DELVING DEEP (the reasons to come back)
Beneath the daily reset are four standing draws that reward a returning hero.
* THE RUNG LADDER. The dungeon is a ladder of guardians fought one rung per
'descend' (each costs a daily turn). A descent faces the NEXT rung past your
deepest; a win advances your depth and you climb back out, a loss bounces
you home but your depth PERSISTS you re-enter where you left off. Reaching
the last rung opens the Wyrm's door (the depth gate above). Narrate the deep
as a slow, earned descent, a rung at a time.
* THE SATCHEL AND THE DEATH-SAVE. A small satchel carries a few potions
(buying one at the shop now STOWS it instead of drinking it). 'quaff'
(anywhere, no turn) drinks the strongest. The heart of it: if a fight would
KILL the active fighter and they carry a potion, the strongest is drunk
AUTOMATICALLY they survive standing at the potion's value, no bounce, no
spawn reset. Play that beat big: the elixir burning down their throat at the
edge of death. (A sleeping ambush victim never auto-quaffs they are
asleep.)
* THE FORGE GOLD AND ORE. The shop's forge adds a +1 edge to the equipped
weapon or armour ('forge' target="weapon"/"armour"), up to a cap, each tier
dearer than the last. A step costs gold AND forge ore a material the hero
EARNS in combat, never buys: every cleared dungeon rung drops some, and a won
forest fight sometimes turns up a little. So the forge is fed by descending,
not just by a fat purse; a hero short of ore is told so. Swapping or selling
a forged piece loses the edge with it.
* RARE BEASTS. A few named beasts prowl the forest, surfacing seldom. Felling
one is loud a public Herald flash and it always guards a draught that
drops into the satchel (if there is room). Treat a rare kill as a small
legend in itself.
THE WYRM BELOW (the endgame, and how to win)
Deep under the dungeon sleeps the Wyrm Below a fixed, fearsome boss and
the ONLY win condition. At the dungeon, a sufficiently seasoned hero may
'challenge' it (door_action action="challenge"). The Wyrm gates on BOTH
level AND depth: an under-level hero is turned away first, and even a high
hero who has not plumbed the deep to its floor (see DELVING DEEP) is told the
Wyrm will not stir. Once both are met, the challenge spends a daily turn and
resolves in one call, like a fight.
* On victory the hero FREES THE VALE. The triumph is heralded to everyone,
the run is carved into the Hall of Legends (shown by door_rank), and the
hero is reborn in a classic-door-game-style legacy reset: level, gold, gear and stats
return to first-day values and they stand again at the town but they
keep a permanent for the win, and may set out to do it all again. Their
remaining turns for the day and their place in the world carry over.
* On defeat the Wyrm devours them; they wake at the spawn, barely alive.
Play this beat big: it is the climax of a whole run. Narrate the reset as the
Vale renewing itself around an undying legend, not as a death.
BESTOWING FORTUNE (use sparingly)
door_bestow lets you, the storyteller, grant a little gold or healing to
mark a great story moment a heroic rescue, a clever solution, a poignant
death-defiance. It NEVER grants items (gear comes from the shop) and NEVER
grants turns (the clock does not bend). It is capped by a small daily pool
per player, and every bestowal is written to the public log for all to see.
Treat it as seasoning, not a salt-shaker: reserve it for the rare, earned
beat, and never promise a reward you cannot actually deliver within the cap.
THE SOCIAL LAYER (rivals, mail, and dice)
Understone is a SHARED world, and three verbs let players touch one another.
* AMBUSH (door_action action="ambush" target=<player>, on the overworld).
A classic-door-game-style player-kill: you fall upon a RIVAL WHO HAS NOT YET ACTED
TODAY and rob them. The SLEEP RULE is the heart of it a player who has
already taken their turn that day is awake and cannot be ambushed, so the
surest defence is simply to play. The gatekeeper shields the young (both of
you must clear a level floor) and only matches near-equals (a level band).
On a win you take a slice of their gold and they wake at the spawn at 1 HP;
a public Herald crows the deed and the victim gets a PRIVATE note. But the
sleeper may WAKE: lose, and YOU are the one who flees bleeding, shamed on
the feed and gaining nothing. You get one attempt per rival per day, win or
lose. Narrate ambush as a real betrayal and losing one as just deserts.
* POST (door_action action="post" target=<player> text=<message>, anywhere).
Leave a private note at the inn for another player; they read it on their
next door_log under "While you were away". It costs no turn, is capped per
day, and the note is PRIVATE it never reaches the public Herald or the
lobby TV. Good for taunts after an ambush, alliances, or a kind word.
* GAMBLE (door_action action="gamble" amount=<gold>, at the inn).
Wager gold on a single throw of 2d6 against the house: roll higher to
double your stake, tie to push, roll lower to lose it. It costs no turn but
is capped per day. A big win is heralded; a quiet one is just a story you
tell. Remind players the house has no mercy and the odds are even at best.
THE VAULT (banking coin at the inn)
The inn keeps a strongbox. DEPOSIT (door_action action="deposit" amount=<gold>)
moves coin from the hero's hand into the vault; WITHDRAW (action="withdraw"
amount=<gold>) draws it back. Neither costs a turn. Two things make the vault
matter: banked gold is SAFE FROM AMBUSH (a sleeping-robber lifts only what the
victim carries), so banking before logging off is the way to protect a purse;
and banked gold SURVIVES THE WYRM-WIN RESET it is the one wealth a reborn
hero keeps, alongside their . Suggest a wary player bank their winnings.
TOOL CHEAT-SHEET
door_help This manual.
door_join(player) Sign in (creates or resumes a character).
door_status(player) Read the character sheet.
door_look(player) Redraw the current view (map or menu).
door_move(player, ...) Walk the overworld (free). steps="NNEE" or
heading="east" + distance=3 (max 8 per call).
door_action(player, action) Context verb: fight, flee, ambush (a rival),
rest, deposit/withdraw (the inn vault), buy,
sell, forge (a +1 edge, gold + ore), heal,
gamble (dice at the inn), descend (one rung),
challenge (the Wyrm), post (a note), quaff (a
carried potion), leave.
door_log(player) Read the Understone Herald (the shared feed).
door_rank(player) The leaderboard + Hall of Legends ( = wins).
door_bestow(player, reason...) Grant a little gold/healing for a story beat.
GETTING STARTED
Ask the player their adventurer's name, call door_join, present the opening
keyframe, and set the scene: a small town at the western edge of a wooded
vale, a road running east toward darker country and a dungeon mouth.
"""
_BLANK_NAME = 'The gatekeeper squints. "I didn\'t catch your name, traveller."'
# Module-level game singleton, built lazily so tests can inject their own.
_GAME: Game | None = None
def _build_game(watch_url: str | None = None) -> Game:
"""Construct the module Game from environment configuration."""
db_path = os.environ.get("UNDERSTONE_DB", "understone.db")
world_dir = os.environ.get("UNDERSTONE_WORLD") or str(PACKAGED_WORLD_DIR)
world = load_world(world_dir)
store = Store(db_path)
return Game(world, store, watch_url=watch_url)
def _game() -> Game:
"""Return the module game, building it on first use."""
global _GAME
if _GAME is None:
_GAME = _build_game()
return _GAME
def _set_game(game: Game) -> None:
"""Install a prebuilt game (used by create_app / tests)."""
global _GAME
_GAME = game
def _guard_name(player: str) -> str | None:
"""Return the blank-name refusal when *player* is empty, else None."""
return None if player.strip() else _BLANK_NAME
mcp: FastMCP = FastMCP(
"understone",
instructions=_PREMISE,
)
@mcp.tool()
def door_help() -> str:
"""Read the Understone game-master manual — start here.
Returns a short guide for running this multiplayer, BBS-style ANSI door
game (a classic text RPG / dungeon adventure): the two play modes, how
to present the ASCII map frames, the daily-turn rhythm, and the full tool
cheat-sheet. Call door_help before your first session to learn how to run
the game, then call door_join to begin.
"""
watch_line = _game().watch_line()
if watch_line:
return f"{_DM_MANUAL}\nTHE LOBBY TV\n {watch_line}\n"
return _DM_MANUAL
@mcp.tool()
def door_join(player: str) -> str:
"""Sign an adventurer into the shared world of Understone — call this first.
Understone is a multiplayer, BBS-style ANSI door game: a text adventure /
dungeon RPG in the spirit of the classic BBS door games, played entirely
through these tools. This creates a new character at the town, or resumes
an existing one by name, and returns the opening overworld map frame. New
to running it? Call door_help before your first session.
Args:
player: The adventurer's name (their identity in the world).
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().join(player)
except Exception:
log.exception("door_join failed for %r", player)
return _unexpected()
@mcp.tool()
def door_status(player: str) -> str:
"""Show an adventurer's character sheet (level, HP, gear, gold, turns).
Read-only. Use it to check progress before deciding what to do next.
Args:
player: The adventurer's name.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().status(player)
except Exception:
log.exception("door_status failed for %r", player)
return _unexpected()
@mcp.tool()
def door_look(player: str) -> str:
"""Redraw what the adventurer currently sees (read-only).
On the overworld this is an ASCII map keyframe centred on the player
('@' is you, '' are other players, glyphs are buildings). Inside a
building it is that location's menu. Present the result verbatim in a
fenced code block, then narrate.
Args:
player: The adventurer's name.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().look(player)
except Exception:
log.exception("door_look failed for %r", player)
return _unexpected()
@mcp.tool()
def door_move(player: str, steps: str = "", heading: str = "", distance: int = 1) -> str:
"""Walk the overworld — movement is free and never costs a daily turn.
Only valid on the overworld (in a building, use door_action 'leave'
first). Give EITHER a compact ``steps`` string of cardinal letters such
as "NNEE", OR a ``heading`` ("north"/"south"/"east"/"west") with a
``distance``. At most 8 cells move per call; the walk stops early at
walls, water, a building door, or a wandering monster.
Args:
player: The adventurer's name.
steps: Cardinal letters, e.g. "NNEE" (takes precedence if given).
heading: A compass direction used with distance.
distance: How many cells to travel along heading (1-8).
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().move(player, steps, heading, distance)
except Exception:
log.exception("door_move failed for %r", player)
return _unexpected()
@mcp.tool()
def door_action(
player: str,
action: str,
target: str = "",
item: str = "",
text: str = "",
amount: int = 0,
) -> str:
"""Take a context-sensitive action in the world.
The legal verbs depend on where the adventurer is. On the overworld:
'fight' or 'flee' a wandering monster (fighting spends one daily turn), or
'ambush' a named rival who has not yet acted today a sleeping-rival
robbery in the spirit of the classic door-game player-kill (target=<name>, spends a turn).
Inside a building: 'rest' (inn), 'buy'/'sell'/'forge' (shop), 'heal'
(healer), or 'leave'. At the inn you may also 'gamble' a stake of gold at
dice (amount=<gold>), or bank coin in the vault with 'deposit'/'withdraw'
(amount=<gold>) banked gold is safe from ambush and survives a Wyrm-win
reset. Buying a potion now stows it in your satchel rather than drinking it;
'forge' (target="weapon"/"armour", at the shop) spends gold AND forge ore
(won in the deep) to add a +1 edge to your equipped gear, up to a cap. At
the dungeon:
'descend' ONE rung of the deep the next guardian past your deepest, one
per turn or 'challenge' the Wyrm Below, the endgame boss and the only way
to win. Descending a rung advances your depth; reaching the floor opens the
Wyrm's door. The challenge is gated by BOTH level and depth (you must have
plumbed the deep to its floor) and, once allowed, spends a daily turn and
resolves in a single call like a fight: a victory frees the Vale and begins
a new life (see door_help), a defeat bounces you home. Anywhere and at no
turn cost: 'post' leaves a private note for another player (target=<name>,
text=<message>) read on their next door_log, and 'quaff' drinks the
strongest potion from your satchel. An illegal verb returns the verbs valid
right here.
Args:
player: The adventurer's name.
action: The verb to attempt (fight, flee, ambush, rest, deposit,
withdraw, buy, sell, forge, heal, gamble, descend, challenge, post,
quaff, leave).
target: The other player's name for 'ambush'/'post', or the slot
("weapon"/"armour") for 'forge'.
item: For shop 'buy', the item id to purchase.
text: For 'post', the note left for the target (<= 120 characters).
amount: For inn 'gamble', the gold wagered on the dice; for the vault
'deposit'/'withdraw', the gold moved.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().action(player, action, target, item, text, amount)
except Exception:
log.exception("door_action failed for %r action=%r", player, action)
return _unexpected()
@mcp.tool()
def door_log(player: str) -> str:
"""Catch up on what happened in the shared world while the player was away.
Returns the events since this adventurer last checked (fights, deaths,
blessings, descents by anyone) and advances their personal marker.
Args:
player: The adventurer's name.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().log(player)
except Exception:
log.exception("door_log failed for %r", player)
return _unexpected()
@mcp.tool()
def door_rank(player: str = "") -> str:
"""Show the Roll of Heroes — the top-ten leaderboard.
Ordered by level, then experience, then name. If the caller names
themselves and they place in the top ten, their row is marked. Each
beside a name is one slaying of the Wyrm Below. Below the table, the Hall
of Legends lists the most recent completed runs (name, level at the kill,
days the run took, date); it is omitted while no one has yet won.
Args:
player: The caller's name (optional; marks their row when present).
"""
try:
return _game().rank(player)
except Exception:
log.exception("door_rank failed for %r", player)
return _unexpected()
@mcp.tool()
def door_bestow(player: str, reason: str, gold: int = 0, heal: int = 0) -> str:
"""Grant a small gift of gold or healing to mark a great story moment.
This is the game master's discretionary channel, to be used SPARINGLY for
earned, story-driven beats. It grants only gold and/or healing never
items (gear comes from the shop) and never turns (the daily clock does not
bend). Each grant is capped by a small daily pool per adventurer and is
written to the public log, so spend it on the rare moment that deserves
it; do not promise more than the cap allows. When a pack sets healing to
cost nothing, a bestowed heal is free and draws nothing from the pool.
Args:
player: The adventurer receiving the gift.
reason: A short, in-fiction reason (<= 120 characters).
gold: Gold to grant (>= 0).
heal: HP to restore (>= 0); only the missing portion is applied and
charged against the pool.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().bestow(player, reason, gold, heal)
except Exception:
log.exception("door_bestow failed for %r", player)
return _unexpected()
# FastMCP.custom_route has no return annotation upstream (mcp 1.27.2), so mypy
# reads the decorator as untyped; the ignore is scoped to that single gap.
@mcp.custom_route("/watch", methods=["GET"]) # type: ignore[untyped-decorator]
async def watch_page(_request: Request) -> Response:
"""Serve the read-only CRT spectator page (static HTML, no world reads)."""
return HTMLResponse(watch.WATCH_HTML)
@mcp.custom_route("/watch/world.json", methods=["GET"]) # type: ignore[untyped-decorator]
async def watch_world(_request: Request) -> Response:
"""Serve the STATIC map payload (dimensions, coloured rows, locations)."""
return JSONResponse(watch.build_world_payload(_game().world))
@mcp.custom_route("/watch/state.json", methods=["GET"]) # type: ignore[untyped-decorator]
async def watch_state(_request: Request) -> Response:
"""Serve the DYNAMIC snapshot (players, Herald, Hall) — read-only.
The builder reads the module Game with no ``await`` in between, so each
response is a consistent point-in-time snapshot of the shared world.
"""
return JSONResponse(watch.build_state_payload(_game()))
def _unexpected() -> str:
"""In-fiction line for an unexpected server-side error."""
return (
"A strange fog rolls through the Vale and the moment slips away. "
"(Something went wrong; try again.)"
)
def create_app(
db_path: str, world_dir: str | None = None, watch_url: str | None = None
) -> Starlette:
"""Build the streamable-HTTP ASGI app backed by a fresh game.
Used by both ``main`` (for the http transport) and the integration tests,
so tests can point at a temp DB without environment juggling. ``watch_url``,
when given, is the spectator page URL the join banner and help manual
advertise; ``main`` derives it from the bind host/port.
"""
world = load_world(world_dir or str(PACKAGED_WORLD_DIR))
store = Store(db_path)
_set_game(Game(world, store, watch_url=watch_url))
return mcp.streamable_http_app()
def _serve() -> None:
"""Serve the Understone MCP world over the configured transport.
Honours UNDERSTONE_TRANSPORT: "stdio" (default) or "streamable-http".
For http, host/port/path are read from the environment and applied to the
FastMCP settings before serving. This is the actual transport launch; it is
kept separate from argument parsing so the parse step has no side effects.
"""
logging.basicConfig(level=logging.INFO)
transport = os.environ.get("UNDERSTONE_TRANSPORT", "stdio")
if transport == "streamable-http":
host = os.environ.get("UNDERSTONE_HOST", "127.0.0.1")
port = int(os.environ.get("UNDERSTONE_PORT", "8077"))
mcp.settings.host = host
mcp.settings.port = port
mcp.settings.streamable_http_path = os.environ.get("UNDERSTONE_PATH", "/mcp")
mcp.settings.stateless_http = False
# FastMCP freezes DNS-rebinding protection (a localhost-only Host
# allowlist) at CONSTRUCTION, and this module builds `mcp` at import
# with the default 127.0.0.1 host — so a 0.0.0.0/LAN bind would 421
# "Invalid Host" on /mcp for every remote node (the multi-node case).
# Binding off localhost means we intend to accept other hosts, so drop
# the allowlist here (matching the SDK's own default for a non-localhost
# bind). These routes are unauthenticated by design — serve only on a
# trusted network. UNDERSTONE_HOST controls the bind.
if host not in ("127.0.0.1", "localhost", "::1"):
mcp.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=False
)
# The spectator page is only reachable over http, so its URL is composed
# here from the bind address. A 0.0.0.0 bind should advertise a host a
# browser can actually reach (see the README Watch section).
watch_url = f"http://{host}:{port}/watch"
# Build the game eagerly (with the watch URL) so a config error surfaces
# before serving and the join/help advertisements carry the page link.
try:
_set_game(_build_game(watch_url))
except WorldLoadError as exc:
raise SystemExit(f"failed to load world: {exc}") from exc
mcp.run(transport="streamable-http")
return
try:
_game()
except WorldLoadError as exc:
raise SystemExit(f"failed to load world: {exc}") from exc
mcp.run(transport="stdio")
def _build_parser() -> argparse.ArgumentParser:
"""Build the ``understone`` argument parser: serve (default), validate, newpack.
Parsing is deliberately free of side effects no world load, no port bind
so the resolved subcommand can be inspected without serving anything.
"""
parser = argparse.ArgumentParser(
prog="understone",
description=(
"Understone — a BBS-style ANSI door game served over MCP, plus the "
"tools to author its world packs."
),
)
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("serve", help="serve the MCP world (the default with no command)")
validate = sub.add_parser("validate", help="validate a content pack and print a report")
validate.add_argument("path", type=Path, help="the pack directory to validate")
newpack = sub.add_parser("newpack", help="scaffold a new content pack from the bundled world")
newpack.add_argument("path", type=Path, help="the directory to create the pack in")
sub.add_parser("worlds", help="list the bundled worlds and whether each is sound")
sim = sub.add_parser("simulate", help="run a greedy balance bot over a pack and report")
sim.add_argument("path", type=Path, help="the pack directory to simulate")
sim.add_argument("--days", type=int, default=30, help="sim-days to play (default 30)")
sim.add_argument("--seed", type=int, default=1, help="RNG seed (default 1)")
sim.add_argument(
"--seeds",
type=int,
default=None,
help="run a sweep of this many seeds from --seed and aggregate",
)
return parser
def main(argv: list[str] | None = None) -> None:
"""Run the Understone command line: serve, or author a world pack.
With no arguments (the entry point and ``python -m understone``) this serves
the MCP world exactly as before. ``validate PATH`` and ``newpack PATH`` drive
the pack-authoring loop and exit with the verb's status code.
"""
args = _build_parser().parse_args(argv)
if args.cmd == "validate":
raise SystemExit(cli.cli_validate(args.path))
if args.cmd == "newpack":
raise SystemExit(cli.cli_newpack(args.path))
if args.cmd == "worlds":
raise SystemExit(cli.cli_worlds())
if args.cmd == "simulate":
raise SystemExit(sim.cli_simulate(args.path, args.days, args.seed, seeds=args.seeds))
_serve()
File diff suppressed because it is too large Load Diff
-745
View File
@@ -1,745 +0,0 @@
"""The Watch page — a read-only CRT spectator view of the shared world.
This module is PURE: it imports nothing from ``mcp`` or ``starlette``. It owns
two payload builders and one self-contained HTML page; the server wires them to
HTTP routes. Input never flows through here the Watch is the lobby TV, not a
controller.
* :func:`build_world_payload` the STATIC map: dimensions, the legend-coloured
terrain rows, and the placed locations. Fetched once by the page.
* :func:`build_state_payload` the DYNAMIC snapshot: every player's position
and vitals, the recent Herald feed, and the Hall of Legends. Polled.
* :data:`WATCH_HTML` one inline-everything page (vanilla JS, phosphor CRT
styling) that paints the base map once and overlays the players each poll.
A correspondence game leaves every adventurer on the board between their turns,
so the state payload reports *all* players, not just the active ones.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from understone.engine.models import Mode
from understone.engine.satchel import decode_satchel
from understone.screen import texture
if TYPE_CHECKING:
from understone.engine.log import Event
from understone.engine.world import World
from understone.game import Game
# How many of the newest Herald events the Watch shows, oldest-first.
_HERALD_LIMIT = 15
# How many Hall-of-Legends runs the Watch shows.
_HALL_LIMIT = 5
def build_world_payload(world: World) -> dict[str, object]:
"""Return the STATIC map payload the Watch page fetches once.
``glyph_rows`` is the base terrain rendered glyph-for-glyph (locations are
NOT burned in here they ride in ``locations`` so the client can colour
them as an overlay). ``legend`` maps every terrain glyph that appears to a
palette colour *name*; the client owns the namehex mapping. Completeness is
a contract: every glyph in ``glyph_rows`` has a ``legend`` entry.
``theme`` is the pack's Watch CRT palette (``settings.watch_theme``); the
page looks it up in its own JS THEME table on fetch and swaps the CSS
custom-property values, so each world has its own phosphor colour.
"""
glyph_rows: list[str] = []
legend: dict[str, str] = {}
for y in range(world.height):
chars: list[str] = []
for x in range(world.width):
terrain = world.terrain_at(x, y)
chars.append(terrain.glyph)
legend.setdefault(terrain.glyph, terrain.color)
glyph_rows.append("".join(chars))
locations = [
{
"x": loc.x,
"y": loc.y,
"glyph": loc.glyph,
"name": loc.name,
"color": loc.color,
}
for loc in world.locations
]
return {
"name": world.name,
"width": world.width,
"height": world.height,
"theme": world.settings.watch_theme,
"glyph_rows": glyph_rows,
"legend": legend,
"locations": locations,
}
def build_state_payload(game: Game) -> dict[str, object]:
"""Return the DYNAMIC snapshot payload the Watch page polls.
Reports every player (a correspondence game keeps idle pieces on the
board), the last :data:`_HERALD_LIMIT` events oldest-first, and the top
:data:`_HALL_LIMIT` completed runs. ``ts`` is the game clock, so a seeded
test clock drives a deterministic payload.
"""
players = [
{
"name": p.name,
"x": p.x,
"y": p.y,
"level": p.level,
"wins": p.wins,
"hp": p.hp,
"max_hp": p.max_hp,
"mode": p.mode.value if isinstance(p.mode, Mode) else str(p.mode),
"gold": p.gold,
"banked": p.banked,
"satchel": _satchel_entries(game, p.satchel),
}
for p in game.players.values()
]
herald = [
{"ts": event.ts, "kind": event.kind, "text": event.text} for event in _recent_events(game)
]
hall = [
{
"name": entry.name,
"level_at_win": entry.level_at_win,
"run_days": entry.run_days,
"win_ts": entry.win_ts,
}
for entry in game.store.top_hall(_HALL_LIMIT)
]
return {
"ts": game.clock().isoformat(),
"players": players,
"herald": herald,
"hall": hall,
}
def _satchel_entries(game: Game, satchel: str) -> list[dict[str, object]]:
"""Decode a player's ``"id:qty"`` satchel into ``[{"name", "qty"}, ...]``.
Decodes the bag through the shared
:func:`~understone.engine.satchel.decode_satchel` codec, then resolves each
stack's id to its display name via the world's item table; an id no longer in
the pack (a save edited out from under it) falls back to the raw id, so the
lobby TV never shows a blank entry. The Watch is read-only, so it only
decodes the name-resolution is the only work that lives here.
"""
entries: list[dict[str, object]] = []
for item_id, qty in decode_satchel(satchel):
item = game.world.item_by_id(item_id)
entries.append({"name": item.name if item is not None else item_id, "qty": qty})
return entries
def _recent_events(game: Game) -> list[Event]:
"""Return the last :data:`_HERALD_LIMIT` resident PUBLIC events, oldest-first.
PRIVATE notes (a non-empty ``target`` ambush victim alerts, inn mail)
are filtered out first: the lobby TV is a public broadsheet and must never
show a message addressed to one player. The façade keeps events in
ascending id order, so the tail of the public slice IS the newest public
window correct even when AUTOINCREMENT ids are sparse.
"""
public = [event for event in game.events if not event.target]
return public[-_HERALD_LIMIT:]
# The Watch page. One self-contained document: inline CSS + vanilla JS, no
# external assets, no innerHTML-with-data (every dynamic node is built with
# createElement / textContent). The base map is painted once from world.json;
# players are an absolutely-positioned overlay repainted from state.json every
# two seconds. On a fetch failure the page dims and shows "SIGNAL LOST".
#
# ``__HASH_EXPR__`` is filled below from the texture-module hash constants, so
# the JS index formula tracks a Python-side retune (see _build_watch_html).
_WATCH_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Understone Live Watch</title>
<style>
:root {
--phosphor: #7dffa0;
--phosphor-dim: #2f7a46;
--amber: #ffb44d;
--bg: #050a06;
--panel: #0a140d;
--edge: #163a22;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--phosphor);
font-family: "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "Courier New", monospace;
font-size: 14px;
line-height: 1.2;
}
body::after {
/* Scanline overlay faint, non-interactive. */
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.22) 3px,
rgba(0, 0, 0, 0) 4px
);
z-index: 50;
}
body.lost { filter: grayscale(0.7) brightness(0.55); }
header {
padding: 10px 16px;
border-bottom: 1px solid var(--edge);
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
text-shadow: 0 0 6px rgba(125, 255, 160, 0.5);
}
header h1 {
margin: 0;
font-size: 18px;
letter-spacing: 2px;
text-transform: uppercase;
}
.live {
color: var(--amber);
font-size: 13px;
letter-spacing: 1px;
text-shadow: 0 0 6px rgba(255, 180, 77, 0.5);
}
.live .dot {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 8px var(--amber);
animation: pulse 2s ease-in-out infinite;
}
body.lost .live .dot { animation: none; background: var(--phosphor-dim); box-shadow: none; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
main {
display: flex;
gap: 16px;
padding: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.map-frame {
position: relative;
border: 1px solid var(--edge);
background: var(--panel);
padding: 8px;
overflow: auto;
max-width: 100%;
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.6);
transition: filter 1.2s ease;
}
/* Time-of-day wash, toggled from the UTC hour of the state payload. The
overlay is non-interactive and sits above the map but below the scanlines.
night: a subtle blue dim; dawn/dusk: a faint amber wash; day: nothing. */
.map-frame::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 1.2s ease, background-color 1.2s ease;
z-index: 5;
}
.map-frame.night { filter: brightness(0.78) saturate(0.85); }
.map-frame.night::after { opacity: 1; background-color: rgba(74, 120, 200, 0.16); }
.map-frame.twilight::after { opacity: 1; background-color: rgba(255, 180, 77, 0.12); }
#map {
position: relative;
white-space: pre;
text-shadow: 0 0 4px rgba(125, 255, 160, 0.35);
}
#map .row { display: block; }
#overlay {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
#overlay .pc {
position: absolute;
color: var(--amber);
text-shadow: 0 0 6px rgba(255, 180, 77, 0.8);
}
aside {
flex: 1 1 280px;
min-width: 260px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
border: 1px solid var(--edge);
background: var(--panel);
padding: 10px 12px;
}
.card h2 {
margin: 0 0 8px;
font-size: 13px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--phosphor);
border-bottom: 1px solid var(--edge);
padding-bottom: 4px;
}
ul { margin: 0; padding: 0; list-style: none; }
li { padding: 2px 0; }
.muted { color: var(--phosphor-dim); }
.subline { font-size: 12px; padding-left: 2px; }
.adv-name { color: var(--amber); }
.stars { color: var(--amber); letter-spacing: 1px; }
.feed li { border-bottom: 1px dotted var(--edge); padding: 4px 0; }
.feed li:last-child { border-bottom: none; }
.feed .ts { color: var(--phosphor-dim); margin-right: 6px; }
</style>
</head>
<body>
<header>
<h1 id="world-name">The Understone Watch</h1>
<div class="live"><span class="dot"></span><span id="live-label">CONNECTING</span></div>
</header>
<main>
<div class="map-frame">
<div id="map"><div id="overlay"></div></div>
</div>
<aside>
<section class="card">
<h2>Adventurers</h2>
<ul id="adventurers"><li class="muted"></li></ul>
</section>
<section class="card">
<h2>Hall of Legends</h2>
<ul id="hall"><li class="muted">No legends yet.</li></ul>
</section>
<section class="card">
<h2>The Understone Herald</h2>
<ul id="herald" class="feed"><li class="muted"></li></ul>
</section>
</aside>
</main>
<script>
(function () {
"use strict";
// Per-world CRT palette. Each named theme is a set of CSS custom-property
// values applied to :root when world.json arrives (the pack's
// settings.watch_theme picks one). "phosphor" holds the EXACT values of the
// :root block above, so the default Vale is pixel-for-pixel unchanged; the
// others re-tint the whole console:
// phosphor the original green CRT (default).
// amber a warm gold CRT (classic amber monochrome monitor).
// ice a pale, cold blue CRT.
// ember a hot red/orange CRT.
// The day/night wash from v0.6 composes ON TOP of whichever theme is set.
var THEMES = {
phosphor: {
"--phosphor": "#7dffa0",
"--phosphor-dim": "#2f7a46",
"--amber": "#ffb44d",
"--bg": "#050a06",
"--panel": "#0a140d",
"--edge": "#163a22"
},
amber: {
"--phosphor": "#ffc14d",
"--phosphor-dim": "#7a5320",
"--amber": "#fff0a8",
"--bg": "#0a0702",
"--panel": "#14100a",
"--edge": "#3a2c16"
},
ice: {
"--phosphor": "#9fe6ff",
"--phosphor-dim": "#2f5f7a",
"--amber": "#ffe07d",
"--bg": "#04080a",
"--panel": "#0a1014",
"--edge": "#16303a"
},
ember: {
"--phosphor": "#ff8a6b",
"--phosphor-dim": "#7a3320",
"--amber": "#ffd07d",
"--bg": "#0a0503",
"--panel": "#140a07",
"--edge": "#3a1c16"
}
};
// Swap the CSS custom-property values for the pack's theme. Unknown or
// missing theme names fall back to "phosphor", so the console always has a
// coherent palette even if a future theme reaches the page unknown.
function applyTheme(name) {
var theme = THEMES[name] || THEMES.phosphor;
for (var prop in theme) {
if (Object.prototype.hasOwnProperty.call(theme, prop)) {
document.documentElement.style.setProperty(prop, theme[prop]);
}
}
}
// Palette colour-name -> phosphor-tinted hex. ONE global map, shared by every
// world (no per-world or per-theme palettes). Mirrors understone.screen.palette
// Color values 1:1 a guard test asserts every Color role has an entry here,
// so a shipped role can never silently fall back to default. The base map is
// coloured from this, never from the server.
var PALETTE = {
default: "#7dffa0",
wall: "#5a6b60",
floor: "#3f7a52",
player: "#ffb44d",
other_player: "#ffd089",
monster: "#ff6b6b",
item: "#ffe07d",
water: "#4aa6c8",
tree: "#3fae6a",
town: "#ffd089",
dungeon: "#c98bff",
// v0.9 expanded terrain/location roles, chosen for hue separation:
road: "#b89a6a",
forest: "#6a9f3f",
scrub: "#9c6038",
lava: "#ff7a3c",
barren: "#9a8b7a",
inn: "#ff9d4d",
shop: "#ffd24d",
healer: "#5fd6b0"
};
function colorFor(name) {
return PALETTE[name] || PALETTE.default;
}
// Deterministic terrain texture. MUST stay in lockstep with
// understone.screen.texture: the same base->variants rows and the same
// index formula. The formula below is INTERPOLATED from texture._HASH_X /
// _HASH_Y at module build time, so a Python-side retune rewrites this line;
// only the VARIANTS rows must still be mirrored by hand.
var VARIANTS = {
".": ".,'",
"\\u224b": "\\u224b\\u2248"
};
function textured(ch, x, y) {
var choices = VARIANTS[ch];
if (!choices) { return ch; }
return choices.charAt((__HASH_EXPR__) % choices.length);
}
var overlay = document.getElementById("overlay");
var mapEl = document.getElementById("map");
var liveLabel = document.getElementById("live-label");
var dims = null; // {width, height} once the map is painted.
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function clockLabel(iso) {
var d = new Date(iso);
if (isNaN(d.getTime())) { return "--:--:--"; }
return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
}
function stars(wins) {
if (wins <= 0) { return ""; }
if (wins <= 5) { return "\\u2605".repeat(wins); }
return "\\u2605x" + wins;
}
// Paint the base map ONCE. Each row is a sequence of <span> runs, a new run
// only where the legend colour changes, so a row is a handful of spans.
function paintMap(world) {
applyTheme(world.theme);
document.getElementById("world-name").textContent = world.name + " — Live Watch";
var legend = world.legend || {};
var rows = world.glyph_rows || [];
for (var y = 0; y < rows.length; y++) {
var row = rows[y];
var rowEl = document.createElement("div");
rowEl.className = "row";
var runText = "";
var runColor = null;
for (var x = 0; x < row.length; x++) {
var ch = row.charAt(x);
// Colour keys off the BASE terrain glyph; the rendered glyph is the
// position-keyed variant (a variant shares its terrain's colour).
var col = colorFor(legend[ch]);
if (runColor === null) { runColor = col; }
if (col !== runColor) {
rowEl.appendChild(makeSpan(runText, runColor));
runText = "";
runColor = col;
}
runText += textured(ch, x, y);
}
if (runText.length) { rowEl.appendChild(makeSpan(runText, runColor)); }
mapEl.insertBefore(rowEl, overlay);
}
dims = { width: world.width, height: world.height };
paintLocations(world.locations || []);
}
function makeSpan(text, color) {
var span = document.createElement("span");
span.style.color = color;
span.textContent = text;
return span;
}
// Locations are painted into the overlay layer (above the base terrain) so
// their glyph and colour win over the terrain beneath the door.
function paintLocations(locations) {
for (var i = 0; i < locations.length; i++) {
var loc = locations[i];
var el = document.createElement("span");
el.className = "pc";
el.style.left = "calc(" + loc.x + " * 1ch)";
el.style.top = "calc(" + loc.y + " * 1lh)";
el.style.color = colorFor(loc.color);
el.style.textShadow = "0 0 6px " + colorFor(loc.color);
el.textContent = loc.glyph;
el.title = loc.name;
overlay.appendChild(el);
}
}
// Player markers live in their own layer, cleared and repainted each poll.
var pcLayer = document.createElement("div");
pcLayer.id = "pc-layer";
overlay.appendChild(pcLayer);
function paintPlayers(players) {
while (pcLayer.firstChild) { pcLayer.removeChild(pcLayer.firstChild); }
for (var i = 0; i < players.length; i++) {
var p = players[i];
var el = document.createElement("span");
el.className = "pc";
el.style.left = "calc(" + p.x + " * 1ch)";
el.style.top = "calc(" + p.y + " * 1lh)";
// Every adventurer on the lobby TV is "another player" (there is no
// viewer here), so all wear the other-player marker. Mirrors the ''
// the game frame paints for rivals.
el.textContent = "\\u263b";
el.title = p.name;
pcLayer.appendChild(el);
}
}
function renderAdventurers(players) {
var list = document.getElementById("adventurers");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!players.length) {
list.appendChild(muted("The Vale is empty."));
return;
}
var sorted = players.slice().sort(function (a, b) {
return b.level - a.level || a.name.localeCompare(b.name);
});
for (var i = 0; i < sorted.length; i++) {
var p = sorted[i];
var li = document.createElement("li");
var name = document.createElement("span");
name.className = "adv-name";
name.textContent = p.name;
li.appendChild(name);
var star = stars(p.wins);
if (star) {
var s = document.createElement("span");
s.className = "stars";
s.textContent = " " + star;
li.appendChild(s);
}
var rest = document.createElement("span");
rest.className = "muted";
rest.textContent = " Lv" + p.level + " HP " + p.hp + "/" + p.max_hp;
li.appendChild(rest);
// A dim sub-line: gold on hand and (if any) gold in the vault. The whole
// shared world is on the lobby TV, so every hero's purse is public here.
var gold = document.createElement("div");
gold.className = "muted subline";
var goldText = (p.gold || 0) + "g";
if (p.banked) { goldText += " +" + p.banked + " vault"; }
gold.textContent = goldText;
li.appendChild(gold);
// A second dim sub-line: the satchel stacks ("Name ×qty"), or empty.
var sat = document.createElement("div");
sat.className = "muted subline";
sat.textContent = satchelText(p.satchel || []);
li.appendChild(sat);
list.appendChild(li);
}
}
// Render the satchel stacks as a compact dot-joined line, or an empty note.
function satchelText(stacks) {
if (!stacks.length) { return "satchel empty"; }
var parts = [];
for (var i = 0; i < stacks.length; i++) {
parts.push(stacks[i].name + " \\u00d7" + stacks[i].qty);
}
return parts.join(" \\u00b7 ");
}
function renderHall(hall) {
var list = document.getElementById("hall");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!hall.length) {
list.appendChild(muted("No legends yet."));
return;
}
for (var i = 0; i < hall.length; i++) {
var h = hall[i];
var li = document.createElement("li");
var star = document.createElement("span");
star.className = "stars";
star.textContent = "\\u2605 ";
li.appendChild(star);
var name = document.createElement("span");
name.className = "adv-name";
name.textContent = h.name;
li.appendChild(name);
var rest = document.createElement("span");
rest.className = "muted";
rest.textContent = " Lv" + h.level_at_win + " " + h.run_days + "d " + (h.win_ts || "").slice(0, 10);
li.appendChild(rest);
list.appendChild(li);
}
}
function renderHerald(herald) {
var list = document.getElementById("herald");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!herald.length) {
list.appendChild(muted("The Vale is still."));
return;
}
for (var i = 0; i < herald.length; i++) {
var e = herald[i];
var li = document.createElement("li");
var ts = document.createElement("span");
ts.className = "ts";
ts.textContent = clockLabel(e.ts);
li.appendChild(ts);
var text = document.createElement("span");
text.textContent = e.text;
li.appendChild(text);
list.appendChild(li);
}
}
function muted(text) {
var li = document.createElement("li");
li.className = "muted";
li.textContent = text;
return li;
}
function setLive(connected, iso) {
if (connected) {
document.body.classList.remove("lost");
liveLabel.textContent = "LIVE \\u2022 updated " + clockLabel(iso);
} else {
document.body.classList.add("lost");
liveLabel.textContent = "SIGNAL LOST";
}
}
var mapFrame = document.querySelector(".map-frame");
// Tint the map by the UTC hour of the world clock. The bands:
// night 20:00-05:59 -> subtle dim + blue ('night' class)
// dawn 06:00-07:59 -> faint amber wash ('twilight' class)
// dusk 18:00-19:59 -> faint amber wash ('twilight' class)
// day 08:00-17:59 -> no tint
// UTC (not local) so every spectator sees the same sky as the game clock.
function applyDayPhase(iso) {
var d = new Date(iso);
mapFrame.classList.remove("night", "twilight");
if (isNaN(d.getTime())) { return; }
var h = d.getUTCHours();
if (h >= 20 || h < 6) {
mapFrame.classList.add("night");
} else if (h < 8 || h >= 18) {
mapFrame.classList.add("twilight");
}
}
function getJSON(url) {
return fetch(url, { cache: "no-store" }).then(function (r) {
if (!r.ok) { throw new Error("HTTP " + r.status); }
return r.json();
});
}
function poll() {
getJSON("./watch/state.json").then(function (state) {
paintPlayers(state.players || []);
renderAdventurers(state.players || []);
renderHall(state.hall || []);
renderHerald(state.herald || []);
applyDayPhase(state.ts);
setLive(true, state.ts);
}).catch(function () {
setLive(false, null);
});
}
var POLL_MS = 2000;
// Bootstrap retries until the base map loads, so a spectator who opens the
// page during a server blip recovers without a manual reload. The poll
// interval starts exactly once, on the first successful boot.
function boot() {
getJSON("./watch/world.json").then(function (world) {
paintMap(world);
poll();
setInterval(poll, POLL_MS);
}).catch(function () {
setLive(false, null);
setTimeout(boot, POLL_MS);
});
}
boot();
})();
</script>
</body>
</html>
"""
def _build_watch_html() -> str:
"""Fill the texture hash formula into the page template.
The JS ``textured`` index is interpolated from
:data:`~understone.screen.texture._HASH_X` / ``_HASH_Y`` so the page's
formula is a derivation of the same two constants the Python renderer uses;
a retune of either moves both, and a guard test pins the agreement.
"""
hash_expr = f"x * {texture._HASH_X} + y * {texture._HASH_Y}"
return _WATCH_HTML_TEMPLATE.replace("__HASH_EXPR__", hash_expr)
WATCH_HTML = _build_watch_html()
@@ -1,41 +0,0 @@
"""Content-pack loading — JSON on disk becomes a runtime ``World``."""
from __future__ import annotations
from pathlib import Path
# The bundled starter pack ("The Vale of Understone"). Single source of truth
# for where packaged content lives — the server's default world and the
# scaffolder's template both resolve here.
PACKAGED_WORLD_DIR = Path(__file__).resolve().parent / "data"
# Zero-or-more bundled ALTERNATE worlds live one directory deeper, each in its
# own ``<slug>/`` subdirectory carrying a ``world.json``. The Vale is special
# (it is the default and lives at ``data/``); alternates are discovered here.
PACKS_DIR = Path(__file__).resolve().parent / "packs"
# The reserved slug of the default Vale — it is never a packs/ subdirectory but
# is always listed first by the discovery helper below.
VALE_SLUG = "vale"
def bundled_world_dirs() -> list[tuple[str, Path]]:
"""Return every bundled world as ``(slug, directory)``, the Vale first.
The default Vale (slug :data:`VALE_SLUG`, the ``data/`` directory) always
leads; the alternates follow in slug-alphabetical order. An alternate is
any immediate subdirectory of :data:`PACKS_DIR` that contains a
``world.json`` non-pack files (the README placeholder) and directories
without a world file are skipped, so the list is exactly the loadable
worlds. This is the single discovery path the ``worlds`` listing and any
future world resolver share.
"""
found: list[tuple[str, Path]] = [(VALE_SLUG, PACKAGED_WORLD_DIR)]
if PACKS_DIR.is_dir():
alternates = [
(entry.name, entry)
for entry in PACKS_DIR.iterdir()
if entry.is_dir() and (entry / "world.json").is_file()
]
found.extend(sorted(alternates, key=lambda pair: pair[0]))
return found
@@ -1,102 +0,0 @@
{
"events": [
{
"kind": "fight",
"weight": 82,
"text": "Something snarls out of the brush."
},
{
"kind": "gold",
"weight": 8,
"text": "a rotted coin-purse half-buried in moss",
"min": 4,
"max": 12
},
{
"kind": "gold",
"weight": 7,
"text": "a few coins spilled from some luckless traveller",
"min": 2,
"max": 9
},
{
"kind": "gold",
"weight": 2,
"text": "a hoard-cache prised from beneath a toppled menhir",
"min": 40,
"max": 80
},
{
"kind": "heal",
"weight": 5,
"text": "a clearwater spring",
"min": 5,
"max": 12
},
{
"kind": "heal",
"weight": 5,
"text": "a quiet fae blessing",
"min": 4,
"max": 10
},
{
"kind": "heal",
"weight": 5,
"text": "a moss-bed where weary travellers mend",
"min": 6,
"max": 14
},
{
"kind": "trap",
"weight": 5,
"text": "a snare of old briars",
"min": 3,
"max": 9
},
{
"kind": "trap",
"weight": 5,
"text": "loose stones twist your ankle",
"min": 2,
"max": 8
},
{
"kind": "trap",
"weight": 5,
"text": "a hidden pit-deadfall, its cover long rotted through",
"min": 4,
"max": 11
},
{
"kind": "lore",
"weight": 3,
"text": "an old waystone, its rune worn to a coiled, scaled shape."
},
{
"kind": "lore",
"weight": 3,
"text": "the wind carries a low note from the east, like something vast turning in its sleep."
},
{
"kind": "lore",
"weight": 4,
"text": "a charcoal sketch nailed to a tree: a stair of stone descending into a great open mouth."
},
{
"kind": "lore",
"weight": 3,
"text": "a scorched ring in the grass where no plant grows, the soil still faintly warm."
},
{
"kind": "lore",
"weight": 3,
"text": "a ranger's cairn marking the dungeon road, three skulls set facing the deep as warning."
},
{
"kind": "lore",
"weight": 4,
"text": "fishermen swear the vale lake has no bottom, and that on still nights it breathes."
}
]
}
@@ -1,85 +0,0 @@
[
{
"id": "rusty_dagger",
"name": "Rusty Dagger",
"slot": "weapon",
"atk": 2,
"price": 0
},
{
"id": "short_sword",
"name": "Short Sword",
"slot": "weapon",
"atk": 5,
"price": 40
},
{
"id": "iron_sword",
"name": "Iron Sword",
"slot": "weapon",
"atk": 7,
"price": 80
},
{
"id": "war_axe",
"name": "War Axe",
"slot": "weapon",
"atk": 9,
"price": 120
},
{
"id": "cloth_tunic",
"name": "Cloth Tunic",
"slot": "armor",
"def": 1,
"price": 0
},
{
"id": "padded_jerkin",
"name": "Padded Jerkin",
"slot": "armor",
"def": 2,
"price": 25
},
{
"id": "leather_armor",
"name": "Leather Armor",
"slot": "armor",
"def": 3,
"price": 50
},
{
"id": "chainmail",
"name": "Chainmail",
"slot": "armor",
"def": 6,
"price": 140
},
{
"id": "minor_potion",
"name": "Minor Potion",
"slot": "consumable",
"heal": 15,
"price": 12
},
{
"id": "greater_potion",
"name": "Greater Potion",
"slot": "consumable",
"heal": 40,
"price": 35
},
{
"id": "elixir_of_the_vale",
"name": "Elixir of the Vale",
"slot": "consumable",
"heal": 70,
"price": 60
},
{
"id": "iron_ore",
"name": "Iron Ore",
"slot": "material",
"price": 0
}
]

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