mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bcbcb73b9 | |||
| af6749421a | |||
| 4d6cb77075 | |||
| 5c225ef39b | |||
| f5a843f44a | |||
| cf44841624 | |||
| 7ffab6a272 | |||
| ba3bc9d989 | |||
| dbe023b4dd | |||
| 3d3a8b7367 | |||
| 99eff73a97 | |||
| 99fcd30299 | |||
| 423c2e80b7 | |||
| a0eb77360d | |||
| 3dd0e196fe | |||
| 19c3db5329 | |||
| 0bea72019e | |||
| b9ff52d582 | |||
| 961f999c93 | |||
| 8bdb916064 | |||
| 25fe4e728a | |||
| 0d1a32ff65 |
+33
-45
@@ -1,61 +1,49 @@
|
||||
# =============================================================================
|
||||
# Turnstone environment overrides — ALL OPTIONAL for the dev stack.
|
||||
# Turnstone Environment Variables
|
||||
# Copy to .env and adjust values for your deployment.
|
||||
#
|
||||
# `docker compose up` from a clone works with zero config: every value below
|
||||
# has a built-in (insecure) default. Copy this file to `.env` only to override.
|
||||
#
|
||||
# The PRODUCTION stack (turnstone/deploy/compose.yaml) has no baked-in secrets
|
||||
# and DOES require TURNSTONE_JWT_SECRET and POSTGRES_PASSWORD.
|
||||
#
|
||||
# Note: for a turnstone process running on bare metal (not in a container),
|
||||
# put secrets in ~/.config/turnstone/config.toml (chmod 0600), not the
|
||||
# environment. See docs/docker.md "Join a bare-metal host".
|
||||
# Usage:
|
||||
# Single node: docker compose --profile production up
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# =============================================================================
|
||||
|
||||
# -- LLM backend --------------------------------------------------------------
|
||||
# Optional: nodes boot without an LLM. Add real model backends from the console
|
||||
# UI (Models tab). These only set the bootstrap default a node starts with.
|
||||
# LLM_BASE_URL=http://host.docker.internal:8000/v1
|
||||
# OPENAI_API_KEY=dummy
|
||||
# ANTHROPIC_API_KEY=sk-ant-... # set instead of OPENAI_API_KEY for Anthropic
|
||||
# TURNSTONE_SEARXNG_URL=http://searxng:8080 # web_search backend (default: bundled service; set to an external SearxNG)
|
||||
# MODEL= # default model alias
|
||||
# -- LLM Backend --------------------------------------------------------------
|
||||
LLM_BASE_URL=http://host.docker.internal:8000/v1
|
||||
OPENAI_API_KEY=dummy
|
||||
# ANTHROPIC_API_KEY=sk-ant-...# Set instead of OPENAI_API_KEY for Anthropic
|
||||
# TAVILY_API_KEY=tvly-... # Web search fallback (local models only)
|
||||
# MODEL=# Override default model alias
|
||||
|
||||
# -- Secrets ------------------------------------------------------------------
|
||||
# The dev stack defaults these to INSECURE values. Always set real ones for
|
||||
# anything reachable beyond localhost. Generate the JWT secret with:
|
||||
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||
# TURNSTONE_JWT_SECRET=
|
||||
# POSTGRES_PASSWORD=
|
||||
# -- Authentication (required) ------------------------------------------------
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex
|
||||
|
||||
# -- Database -----------------------------------------------------------------
|
||||
# Defaults to the bundled PostgreSQL (shared by every service — required for
|
||||
# the console to discover nodes). Override to point at an external database:
|
||||
# -- Database ------------------------------------------------------------------
|
||||
# Single-node default is SQLite (zero config). Set these for PostgreSQL:
|
||||
# TURNSTONE_DB_BACKEND=postgresql
|
||||
# POSTGRES_USER=turnstone
|
||||
# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:<pw>@postgres:5432/turnstone
|
||||
# POSTGRES_PASSWORD=changeme
|
||||
# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
|
||||
|
||||
# -- Ports / networking -------------------------------------------------------
|
||||
# The dashboard is reached via Caddy only (HTTP/2 avoids the browser's
|
||||
# 6-connection cap on the console's SSE streams). Both stacks expose the same
|
||||
# two host ports; everything else is proxied through the console.
|
||||
# CONSOLE_HTTPS_PORT=8443 # Caddy (dashboard HTTPS)
|
||||
# POSTGRES_PORT=5432 # exposed for bare-metal host joins
|
||||
# POSTGRES_BIND=127.0.0.1 # set 0.0.0.0 to let another machine join
|
||||
# -- Ports ---------------------------------------------------------------------
|
||||
# SERVER_PORT=8080
|
||||
# CONSOLE_PORT=8090
|
||||
|
||||
# -- Workspace ----------------------------------------------------------------
|
||||
# Bind-mount a host directory the model can read/write at /workspace:
|
||||
# -- Workspace -----------------------------------------------------------------
|
||||
# Bind-mount a host directory into the container at /workspace.
|
||||
# The model can read/write files here. Default: empty Docker volume.
|
||||
# WORKSPACE_MOUNT=/path/to/your/project
|
||||
|
||||
# -- Agent behavior -----------------------------------------------------------
|
||||
# SKIP_PERMISSIONS=true # auto-approve all tool calls (dev only)
|
||||
# MCP_CONFIG=/workspace/mcp.json # MCP server config file
|
||||
# -- Agent behavior ------------------------------------------------------------
|
||||
# SKIP_PERMISSIONS=true # Auto-approve all tool calls (dev only)
|
||||
# MCP_CONFIG=/workspace/mcp.json# MCP server configuration file
|
||||
|
||||
# -- Channel gateway (Discord / Slack) ----------------------------------------
|
||||
# -- Discord channel gateway ---------------------------------------------------
|
||||
# TURNSTONE_DISCORD_TOKEN=
|
||||
# TURNSTONE_DISCORD_GUILD=0
|
||||
# TURNSTONE_SLACK_TOKEN=xoxb-...
|
||||
# TURNSTONE_SLACK_APP_TOKEN=xapp-...
|
||||
|
||||
# -- Production image tag ------------------------------------------------------
|
||||
# TURNSTONE_IMAGE_TAG=latest # pin the ghcr.io image (production stack)
|
||||
# -- Cluster (profile: cluster) -----------------------------------------------
|
||||
# These are set per-node in compose.yaml; only override for custom topologies.
|
||||
# TURNSTONE_NODE_ID=node-1
|
||||
# TURNSTONE_ADVERTISE_URL=http://server-1:8080
|
||||
|
||||
|
||||
+16
-26
@@ -14,7 +14,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -47,9 +47,9 @@ jobs:
|
||||
# explicit setup, that suite silently skips if the runner
|
||||
# image happens not to ship Node, masking regressions in
|
||||
# the browser-side renderer.
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "20"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
@@ -75,14 +75,14 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
node-version: "20"
|
||||
- run: pip install -e ".[test,postgres]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
wheel-completeness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
@@ -137,8 +137,8 @@ jobs:
|
||||
lock-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- run: uv lock --check
|
||||
@@ -146,8 +146,8 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
@@ -156,17 +156,7 @@ jobs:
|
||||
- run: uv sync --frozen --all-extras
|
||||
- run: uv pip install pip-audit
|
||||
- name: Security audit (dependencies)
|
||||
# PYSEC-2025-183 (pyjwt): "weak encryption" — disputed by the
|
||||
# supplier because the key length is chosen by the calling
|
||||
# application, not the library. Turnstone generates its JWT
|
||||
# signing keys via the standard ``secrets`` module at
|
||||
# operator-controlled strength (see ``turnstone/core/auth.py``),
|
||||
# so the advisory does not apply. pyjwt 2.12.1 is the current
|
||||
# latest release; no fix version exists.
|
||||
run: >-
|
||||
uv export --no-emit-project --frozen
|
||||
| uv run pip-audit --strict --desc -r /dev/stdin
|
||||
--ignore-vuln PYSEC-2025-183
|
||||
run: uv export --no-emit-project --frozen | uv run pip-audit --strict --desc -r /dev/stdin
|
||||
|
||||
security-ts:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -174,7 +164,7 @@ jobs:
|
||||
run:
|
||||
working-directory: sdk/typescript
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -67,12 +67,12 @@ jobs:
|
||||
fi
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
|
||||
- name: Build and push
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # 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/
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fi
|
||||
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.head_ref }}
|
||||
|
||||
|
||||
@@ -9,11 +9,6 @@ build/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
# Local compose overrides (e.g. run.sh's node-count limiter, bootstrap output)
|
||||
compose.override.yaml
|
||||
compose.override.yml
|
||||
docker-compose.override.yaml
|
||||
docker-compose.override.yml
|
||||
*.so
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
+6
-837
@@ -6,845 +6,14 @@ 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).
|
||||
|
||||
Three release tracks are maintained — the current stable, one prior
|
||||
stable, and the experimental line:
|
||||
Three release tracks are maintained:
|
||||
|
||||
- **`stable/1.5`** — patch-only (`v1.5.x`)
|
||||
- **`stable/1.6`** — patch-only (`v1.6.x`)
|
||||
- **`main`** — experimental (next major)
|
||||
- **`stable/1.0`** — patch-only (`v1.0.x`)
|
||||
- **`stable/1.3`** — patch-only (`v1.3.x`)
|
||||
- **`stable/1.4`** — patch-only (`v1.4.x`)
|
||||
- **`main`** — experimental (`v1.5.0aN`)
|
||||
|
||||
## [1.6.0]
|
||||
|
||||
The first stable release of the 1.6 line — and the first under Apache 2.0.
|
||||
|
||||
> **⚠️ Before upgrading from 1.5.x:** 1.6.0 changes the internal
|
||||
> conversation storage schema (Alembic migration `060`, applied
|
||||
> automatically on first start). The migration converts existing
|
||||
> workstreams and attachments in place — **back up your storage before
|
||||
> upgrading** (`pg_dump` for PostgreSQL; copy the database file for
|
||||
> SQLite). Background: discussion
|
||||
> [#631](https://github.com/turnstonelabs/turnstone/discussions/631).
|
||||
|
||||
**Breaking changes at a glance** (details in the sections below):
|
||||
`web_search` backend overhaul (Tavily/DuckDuckGo removed, `topic` →
|
||||
`category`), the `man` / `math` / `plan_agent` built-in tools and the
|
||||
plan-review protocol removed, and the body-keyed `/v1/api/command`
|
||||
endpoint replaced by path-keyed workstream verbs.
|
||||
|
||||
### License
|
||||
|
||||
- **Relicensed to Apache 2.0** — from BUSL-1.1, effective with this
|
||||
release (#546, contributor assent record in #548). Versions 1.5.x and
|
||||
earlier remain under BUSL-1.1 as shipped, and the `stable/1.5` branch
|
||||
keeps its original LICENSE. New `NOTICE` and
|
||||
`CONTRIBUTORS.md` files; `THIRD-PARTY-NOTICES` refreshed to match the
|
||||
bundled library versions.
|
||||
|
||||
### Added
|
||||
|
||||
- **Mid-conversation system messages** — advisories, watch results,
|
||||
skill hints, and operator interjections are now first-class
|
||||
`role=system` turns in the trajectory instead of ad-hoc reminder
|
||||
envelopes. Models with native mid-conversation system support receive
|
||||
them verbatim; for everything else they fold into a nonce-fenced
|
||||
wrapper. The one-shot `_reminders` side-channel is gone.
|
||||
- **Self-hosted SearxNG web search** — the `web_search` backend for
|
||||
local/vLLM models is now a bundled [SearxNG](https://searxng.org)
|
||||
service (in both compose stacks; internal network only). Configure via
|
||||
`tools.searxng_url` / `tools.searxng_engines`. Commercial providers
|
||||
keep their native server-side search; the model can target a corpus by
|
||||
passing `category` (`general`, `news`, `it`, `science`). Operators
|
||||
exposing the bundled SearxNG publicly: see the AGPL-3.0 §13 note in
|
||||
[docs/docker.md](docs/docker.md).
|
||||
- **Endpoint-backed reranking** — a reranker is now a per-model
|
||||
definition (Cohere/Jina-compatible wire: vLLM, TEI, llama.cpp, or a
|
||||
commercial endpoint), disabled by default. When configured it scores
|
||||
`web_search` results and the BM25 retrieval surfaces (deferred tools,
|
||||
skills, memory) behind a `tools.rerank_bm25` toggle with a relevance
|
||||
floor; a calibration CLI (and calibrate-on-detect) tunes the floor
|
||||
per model.
|
||||
- **Proactive memory relevance** — injected memories are selected by
|
||||
BM25 + reranker against the recent user messages instead of recency
|
||||
alone, and first composition defers to the first user turn so fresh
|
||||
sessions select against a real query.
|
||||
- **Smart Approvals** — opt-in (default off): high-confidence `approve`
|
||||
verdicts from the intent judge auto-approve the tool call instead of
|
||||
waiting for a human, with a confidence threshold and verdict
|
||||
bookkeeping designed so a denied or reset judge never auto-fires.
|
||||
- **Early-painted tool calls** — committed tool calls render immediately
|
||||
as pending cards (both UIs upgrade the card in place by `call_id`)
|
||||
instead of waiting for the judge verdict, so big parallel batches no
|
||||
longer sit invisible during judging.
|
||||
- **Voice I/O v1** — speech-to-text and text-to-speech as model roles
|
||||
speaking the OpenAI audio wire protocol (#618); the interactive
|
||||
composer grows a mic button.
|
||||
- **Rewind / retry / edit-first-message** — full UX in both the
|
||||
interactive UI and the coordinator pane, backed by shared path-keyed
|
||||
verb handlers (#549).
|
||||
- **Workstream export** — download a conversation as OpenAI-format
|
||||
messages JSON.
|
||||
- **Skills platform round** — `SKILL.md` ingestion learns
|
||||
`when_to_use` / `model` / `effort` / `paths`; prompt substitution
|
||||
supports `$ARGUMENTS`, `$N`, `$<name>`, and `${CLAUDE_*}` (#572);
|
||||
per-skill `disable-model-invocation` and `user-invocable` flags
|
||||
(#571); `skill` + `list_skills` unify into one dual-kind tool; new
|
||||
`model.skills.write` permission.
|
||||
- **Coordinator hardening for small models** — workstream references in
|
||||
coordinator tool calls are validated with did-you-mean recovery, and
|
||||
`wait_for_workstream` fails fast with uniform `not_found` entries
|
||||
instead of hanging on a hallucinated `ws_id`.
|
||||
- **Provider support** — Claude Fable 5 and Claude Opus 4.8; xAI/Grok
|
||||
via the OpenAI Responses lane; vLLM reasoning-field replay completes
|
||||
the reasoning-persistence work (#537).
|
||||
- **Cluster-by-default deployment** — the compose stack fronts
|
||||
everything with Caddy and supports bare-metal node join; a one-line
|
||||
`curl | bash` installer bootstraps a node; nodes with no configured
|
||||
models boot into a degraded state instead of crash-looping; channel
|
||||
gateways stand by when no adapter token is set.
|
||||
- **MCP OAuth tokens encrypted at rest**.
|
||||
- **`turnstone-admin` reads `config.toml`** — same `[database]` section
|
||||
and precedence as the server (`CLI / config.toml > TURNSTONE_DB_* env
|
||||
> defaults`), including `pool_size` and the `ssl*` knobs it previously
|
||||
dropped; new `--config PATH` flag.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Conversation storage and the provider wire are rebuilt around a
|
||||
canonical trajectory** (migration `060` — see the upgrade note).
|
||||
Internally a conversation is now a provider-neutral `Turn` sequence
|
||||
lowered to each provider's wire format at send time; provider-specific
|
||||
tool-call metadata rides an opaque producer-tagged lane (replayed
|
||||
verbatim to the producing provider, rebuilt for others); attachments
|
||||
become content-addressed, reference-counted rows resolved at the
|
||||
provider boundary; orphan tool-call repair happens once, at send time.
|
||||
Wire-visible behavior is unchanged for OpenAI-compatible providers;
|
||||
histories are preserved across the migration.
|
||||
- **The console and web UI share one L-shell** — a left glyph rail, a
|
||||
tab bar, and a pane host now frame interactive chats, coordinator
|
||||
sessions, dashboards, and the admin panel as tabs in a single window;
|
||||
the standalone web UI adopts the same shell and the old split-pane
|
||||
layout is retired. Coordinator and interactive conversations render
|
||||
through shared `.conv-*` card builders, the rail collapses to a glyph
|
||||
strip (remembered per browser), mobile gets an off-canvas drawer, and
|
||||
the frontend is now ES modules end to end.
|
||||
- **Admin panel modals → the Service Hatch shelf** — all ~35 admin
|
||||
modals are replaced by pane-scoped shelves plus a small dialog tier
|
||||
for confirmations. Schedules gain a cron builder with a next-3-runs
|
||||
preview endpoint, model capabilities render as an LED tile matrix, and
|
||||
the legacy modal machinery is deleted.
|
||||
- **SSE delivery is resumable end to end** — per-workstream ring buffer
|
||||
with `Last-Event-ID` replay (cap raised 2,000 → 50,000), fresh-connect
|
||||
and reconnect unified on one event-id cursor (in-flight tool batches
|
||||
included), persisted `last_error` replays on connect, the console
|
||||
proxy forwards `Last-Event-ID`, and panes close their connections on
|
||||
`beforeunload` to stop multi-pane refresh from exhausting the
|
||||
browser's per-host connection cap (#539).
|
||||
- **Workstream verbs are path-keyed** *(BREAKING)* — `rewind` / `retry`
|
||||
/ `edit-first-message` live at
|
||||
`/v1/api/workstreams/{ws_id}/<verb>` alongside the other session
|
||||
verbs; the body-keyed `/v1/api/command` endpoint is removed (#549).
|
||||
- **`/history` is projected server-side** — both UIs consume the same
|
||||
REST-first wire shape instead of re-deriving it client-side.
|
||||
- **Saved workstreams & coordinators: card grid → sortable table** with
|
||||
model/skill/context columns, pagination, and a unified selector across
|
||||
both dashboards.
|
||||
- **`tools.web_search_backend` accepted values** *(BREAKING)* — now `""`
|
||||
(auto), `"searxng"`, or `"mcp:server:tool"`. The old `"tavily"` and
|
||||
`"ddg"` values are gone; a config still set to either disables web
|
||||
search and logs a warning. Auto-detect resolves to SearxNG when
|
||||
`searxng_url` is set.
|
||||
- **`web_search` tool: `topic` → `category`** *(BREAKING)* — renamed
|
||||
LLM-facing parameter; values map to SearxNG categories. The Tavily-era
|
||||
`finance` topic is gone.
|
||||
- **Core install includes what most deployments use** — `anthropic`,
|
||||
`postgres`, `console`, and `tls` are core dependencies rather than
|
||||
extras.
|
||||
- **NODES table → bottom-bar node picker** in the console.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cluster mTLS actually survives operations** — certificate identity
|
||||
keys on the advertised host rather than the container ID, renewals are
|
||||
scoped per node, reloaded certs hot-swap into the live SSL context,
|
||||
and healthchecks/boot retries are mTLS-aware.
|
||||
- **Intent-verdict lifecycle** — history replay ships risk-none verdict
|
||||
rows (live/replay parity), late verdicts persist as `superseded` for
|
||||
the audit trail instead of vanishing, bulk verdict insert tolerates
|
||||
per-row conflicts, and cancel-on-approval honors its run-to-completion
|
||||
contract.
|
||||
- **Usage accounting** — dashboard totals were under-counting; auxiliary
|
||||
LLM spend (judge, rerank, memory) is now recorded.
|
||||
- **Concurrent first-boot migrations** no longer deadlock on the
|
||||
advisory lock.
|
||||
- **Output renderer** — single-`$` inline math no longer false-positives
|
||||
in prose; `strip_html` preserves block structure and drops a ReDoS
|
||||
risk.
|
||||
- **Model registry** orders versions numerically (no more `1.10 < 1.9`
|
||||
selection).
|
||||
|
||||
### Removed
|
||||
|
||||
- **Tavily and DuckDuckGo `web_search` backends** *(BREAKING)* —
|
||||
replaced by the bundled SearxNG service. Removed:
|
||||
`tools.tavily_api_key`, `$TAVILY_API_KEY`, `[api].tavily_key`, and the
|
||||
`ddg` install extra. Point `TURNSTONE_SEARXNG_URL` at an existing
|
||||
instance or use the bundled one; no database migration required.
|
||||
- **`man`, `math`, and `plan_agent` built-in tools** *(BREAKING)* —
|
||||
`man`/`math` duplicated `bash`; planning is better expressed as a
|
||||
`task_agent` running a planning skill. Also removed: the `math`
|
||||
sandbox executor, the read-only `AGENT_TOOLS` sub-agent set, the
|
||||
plan-review protocol (`/v1/api/plan`, `plan_review`/`plan_resolved`
|
||||
SSE events, `on_plan_review` hooks), and the `model.plan_*` settings.
|
||||
Interactive built-in tool count: 19 → 16.
|
||||
- **`stable/1.4` track retired** — the maintenance policy is now the
|
||||
current stable plus one prior (`stable/1.6` + `stable/1.5` as of this
|
||||
release). 1.4's final release was `v1.4.0`; its tags and released
|
||||
artifacts remain available, under BUSL-1.1 as shipped.
|
||||
|
||||
### Security
|
||||
|
||||
- **Zero direct-HTML frontend** — every `innerHTML` sink across the
|
||||
console and web UI is replaced with DOM construction or `setSafeHtml`,
|
||||
inline handlers became delegated bindings, and CI lints pin the
|
||||
invariant (plus `var`-free and const-reassign checks) across all
|
||||
swept bundles.
|
||||
- **Output guard grows an LLM stage** — merged with the heuristics as
|
||||
escalate-only (an LLM verdict can raise but never lower a heuristic
|
||||
positive), with annotated findings, a capability gate, and hardening
|
||||
against domain-camouflaged injection (#560, #573).
|
||||
- **One trust-fence primitive** — operator and judge envelopes share a
|
||||
nonce-fenced wrapper (64-bit nonces, host-escaping); the output guard
|
||||
flags nonce forgery, and skill hints no longer echo model-controlled
|
||||
filter values into trusted text.
|
||||
- **RBAC** — built-in role overrides get an editor, and several
|
||||
under-enforced permission gates are tightened (#585).
|
||||
- **Permissive `config.toml` warns** — a single startup warning when the
|
||||
resolved config file is group- or world-readable; operators usually
|
||||
want `0600`.
|
||||
- **Dependency floors** — `starlette>=1.0.1` (PYSEC-2026-161 host-header
|
||||
path injection) and `aiohttp>=3.14.0` (security release).
|
||||
|
||||
## [1.5.17]
|
||||
|
||||
Backports a clutch of coordinator-tool clarity fixes plus a watch-delivery
|
||||
correctness fix from `main` to the `stable/1.5` track, plus a previously-
|
||||
latent intent-verdicts persistence bug exposed by the new heuristic-verdict
|
||||
INSERT paths. No schema changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`intent_verdicts` PK collisions on every llm_fallback delivery** —
|
||||
async LLM-tier "llm_fallback" verdicts (`turnstone/core/judge.py` —
|
||||
`_deliver_fallbacks` and the in-loop fallback path) deliberately
|
||||
reuse the heuristic verdict's `verdict_id` so the row gets
|
||||
"upgraded in place" from `tier="heuristic"` → `tier="llm_fallback"`
|
||||
when the LLM judge times out, is cancelled, or returns no content.
|
||||
The consumer `_persist_intent_verdict` was doing a plain INSERT,
|
||||
hitting the `intent_verdicts_pkey` constraint on every fallback
|
||||
delivery; Postgres logged the duplicate-key error, the application
|
||||
try/except swallowed it at `log.debug`, and the row never actually
|
||||
got upgraded — the LLM judge's annotation
|
||||
(`"(LLM judge did not return a verdict)"`) was lost. The collision
|
||||
rate exploded on this release because the new heuristic-INSERT
|
||||
paths in the auto-approve early-return branches of `approve_tools`
|
||||
(introduced below) leave no gap for the fallback to land cleanly
|
||||
into. Fix: new `upsert_intent_verdict` storage method using
|
||||
`ON CONFLICT (verdict_id) DO UPDATE` that updates only `tier`,
|
||||
`reasoning`, `judge_model` — the three fields that genuinely
|
||||
change between heuristic and llm_fallback. Every other column
|
||||
(identity, carried-verbatim, and `user_decision`) is excluded;
|
||||
`user_decision` in particular would otherwise be clobbered back
|
||||
to `"pending"` when a fallback arrives after the operator has
|
||||
already resolved the approval. The bulk-INSERT path stays as
|
||||
plain INSERT — fresh UUIDs in `judge.evaluate` make in-turn dups
|
||||
impossible; the inverse race (fallback wins before bulk lands) is
|
||||
reachable but unchanged in observable behavior by this fix,
|
||||
documented at the bulk site for a future hardening pass.
|
||||
- **Coordinator LLM re-spawn loops on large fan-outs** — the spawn-tool
|
||||
return JSON used `ws_id` as its key, which primed the model's recency
|
||||
bias to feed the spawn result straight back into another
|
||||
`spawn_workstream(ws_id=...)` call instead of progressing to
|
||||
`wait_for_workstream(ws_ids=[...])`. On 10+ child fan-outs this cascaded
|
||||
into self-inflicted re-spawn loops. The LLM-facing tool result now emits
|
||||
`child_ws_id` (the storage column / HTTP API contract is unchanged); the
|
||||
field name is already an existing project term so the rename aligns
|
||||
rather than introduces new vocabulary. Also handles the silent
|
||||
upstream-omits-ws_id success-shape edge that previously emitted
|
||||
`{"child_ws_id": null}` to the LLM — now surfaces a tool error so the
|
||||
model retries rather than chasing a null id.
|
||||
- **`inspect_workstream` blowing the coordinator context budget** — a
|
||||
coord doing a fan-out wave against tool-heavy children could land
|
||||
>100 KB of raw output per inspect call, and the previous safety net
|
||||
(`_truncate_output`'s head+tail strategy) silently dropped *middle*
|
||||
messages — exactly the wrong shape for understanding a child's
|
||||
trajectory (the FIRST sets the brief, the LAST shows the conclusion,
|
||||
the middle is the connective tissue). Output now goes through a
|
||||
three-tier degradation ladder mirroring the search tool's
|
||||
`_format_search_results`: `_tier="full"` (every message verbatim) →
|
||||
`_tier="compact"` (per-message head/tail-snipped content + snipped
|
||||
`tool_calls.arguments`, falling through a `(20,30)` / `(10,20)` /
|
||||
`(5,10)` message-list trim ladder) → `_tier="skeleton"` (counts, role
|
||||
distribution, last-assistant preview). Budget 32 KiB matches the
|
||||
search tool's; the chosen tier is annotated on the response so the
|
||||
model can recall with a tighter `message_limit` if signal was lost.
|
||||
- **Auto-approved verdicts indistinguishable from pending review** —
|
||||
`intent_verdict` rows for auto-approved tool calls landed with
|
||||
`user_decision=""`, which read identically to "still waiting for the
|
||||
operator" in the audit trail and led to a real misdiagnosis incident.
|
||||
The column now carries an explicit vocabulary at insert: `pending` /
|
||||
`approved` / `denied` / `timeout` / `policy` / `blanket` / `skill` /
|
||||
`always` / `auto_approve_tools`. The auto-approve early-return
|
||||
branches in `approve_tools` now persist heuristic verdicts stamped
|
||||
with their reason (previously dropped on the floor), and late LLM-tier
|
||||
verdicts that arrive for an already-auto-approved call_id are stamped
|
||||
via a TTL-pruned lookup map — so the audit row carries the
|
||||
auto-approve reason even when the LLM judge daemon completes after
|
||||
the synchronous approval cycle finished. `resolve_approval` gains a
|
||||
`timeout` kwarg writing `"timeout"` (the previous shape collapsed
|
||||
passive timeouts and active denials into the same column).
|
||||
- **`list_skills` empty `allowed_tools` misread as "no tool access"** —
|
||||
the response previously emitted `"allowed_tools": []` for every skill
|
||||
that hadn't declared an auto-approve allowlist, which a coordinator
|
||||
model read as "this skill can't use any tools" (real misdiagnosis: a
|
||||
code-review child appeared to have been spawned with zero tool
|
||||
access). The field is now omitted entirely when empty — absence
|
||||
carries the unambiguous meaning "no tool is pre-approved for this
|
||||
skill", presence (non-empty list) keeps the standard Claude Code
|
||||
skill-spec shape. The tool description rewrite makes the
|
||||
auto-approve-allowlist semantics explicit so a future reader doesn't
|
||||
re-derive the gating misread.
|
||||
- **Watch terminal-fires silently dropped on backpressure** —
|
||||
delivery now routes terminal events through the same path as
|
||||
normal fires instead of being filtered out when the consumer was
|
||||
saturated.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Storage `LIKE_ESCAPE` contract** — clarify that callers passing
|
||||
`.like(escape=...)` must use the same escape character that the
|
||||
storage helper assumes; previous wording let a reader pass a
|
||||
different escape and silently produce no matches.
|
||||
|
||||
## [1.5.15]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Admin console blank-page on MCP server rows with consented users** — a
|
||||
Phase 9 (1.5.14) regression in `admin.js` used double-quote string
|
||||
delimiters on the bulk-revoke button HTML literal, but the literal embeds
|
||||
a `"` mid-attribute. JS closed the string early, turned `bulk-revoke (`
|
||||
into bare tokens, and the resulting `SyntaxError` wiped out every global
|
||||
in `admin.js` — `showAdmin` and all other admin entry points became
|
||||
undefined, so the console UI was non-functional whenever the rendered MCP
|
||||
server list contained at least one row with `consented_users_count > 0`.
|
||||
Switch the literal to single-quote delimiters to match the surrounding
|
||||
block.
|
||||
|
||||
## [1.5.14]
|
||||
|
||||
Backports OAuth-MCP Phase 9 from `main` to the `stable/1.5` track.
|
||||
|
||||
### Added
|
||||
|
||||
- **OAuth-MCP Phase 9 — admin status, deferred-consent persistence, operator
|
||||
docs** — completes the per-(user, server) OAuth-MCP build-out. The sync pool
|
||||
dispatchers now upsert into a new `mcp_pending_consent` table on
|
||||
`mcp_consent_required` / `mcp_insufficient_scope`, so a non-interactive run
|
||||
(scheduled / channel) that hits an unconsented server surfaces the deferred
|
||||
prompt to the user on their next dashboard load via the gear-icon badge —
|
||||
rows are cleared automatically by the OAuth callback handler on consent
|
||||
completion, or via new DELETE endpoints for manual dismiss. The MCP Servers
|
||||
admin row gains a `consented_users_count` pill and a two-step-confirm
|
||||
bulk-revoke button for `auth_type=oauth_user` servers (upstream RFC 7009
|
||||
revoke is intentionally not attempted in bulk to avoid N synchronous
|
||||
round-trips against the provider). Operator-facing docs land at
|
||||
`docs/mcp-oauth.md` and `docs/operations/mcp-oauth-headless.md`.
|
||||
|
||||
Introduces forward-only migrations `054_mcp_pending_consent` and
|
||||
`055_mcp_user_tokens_server_index`.
|
||||
|
||||
## [1.5.13]
|
||||
|
||||
This release introduces one forward-only schema migration:
|
||||
`053_services_notify_trigger` — installs the `services_notify` PostgreSQL
|
||||
trigger that backs the new LISTEN/NOTIFY dispatcher (no-op on SQLite, where
|
||||
the dispatcher uses in-process fan-out).
|
||||
|
||||
### Added
|
||||
|
||||
- **Reactive node discovery via PG LISTEN/NOTIFY** — the console gains a
|
||||
`NotifyDispatcher` that holds a dedicated session-mode PostgreSQL `LISTEN`
|
||||
connection (bypasses pgbouncer transaction pooling) and fans wake-ups out to
|
||||
per-channel handlers on a separate dispatch thread. The cluster collector
|
||||
subscribes to a new `services` channel and reacts to node register /
|
||||
deregister within ~500 ms instead of waiting up to 60 s for the next discovery
|
||||
loop; the 60 s loop is retained as the backstop for crash-shaped loss
|
||||
(NOTIFY only fires on real writes). The storage layer also gains a uniform
|
||||
`notify` / `listen` API with an SQLite synthetic-sweep fallback so consumer
|
||||
code is identical across backends. `TURNSTONE_DB_LISTEN_URL` (or
|
||||
`[database] listen_url` in `config.toml`) points the dispatcher at a
|
||||
direct-to-Postgres URL; defaults to the main DB URL when unset.
|
||||
- **Event-driven `wait_for_workstream`** — coord's block-wait tool no longer
|
||||
polls storage every 500 ms. A new in-process `ChildEventBus` notifies waiters
|
||||
whenever a child state change is dispatched to the UI, and the wait loop
|
||||
blocks on `threading.Event.wait` with a 2 s heartbeat cap (matching the
|
||||
existing `wait_progress` SSE cadence). A 600 s wait that previously hit
|
||||
storage ~2400 times now wakes only on real state transitions, with ~4× lower
|
||||
SSE traffic in the quiescent case.
|
||||
- **Memory tool audit trail** — the memory tool now emits `memory.save`,
|
||||
`memory.update`, and `memory.delete` audit events (the admin-console DELETE
|
||||
route previously emitted only `memory.delete`, so tool-initiated mutations
|
||||
had no audit footprint). All emissions are best-effort and never break the
|
||||
tool call itself.
|
||||
- **`task_agent` per-call personas via `skill=`** — `task_agent` now accepts
|
||||
an optional `skill=<name>` argument that loads the named skill's content as
|
||||
the sub-agent's persona in place of the hardcoded identity statement. The
|
||||
fixed operating-guidance block (one-shot, tool-use over narration,
|
||||
no follow-up questions) is still layered on top of every persona. High- and
|
||||
critical-risk skills surface their risk tier in the approval header and
|
||||
emit a `task_agent.high_risk_skill` warning, matching the existing
|
||||
session-load gate.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Per-role plan / task model overrides could be bypassed by the LLM** — the
|
||||
back-compat `default` alias auto-synthesised by `load_model_registry`
|
||||
remained visible to the model even when an operator had configured
|
||||
`model.task_alias` / `model.plan_alias`, so `task_agent(model="default")`
|
||||
routed to whichever backend the synthesised alias was attached to at boot
|
||||
instead of the configured per-role default. The synthesised alias is now
|
||||
only added when neither the DB nor `[models.*]` populates the registry,
|
||||
filtered out of the LLM-visible alias list, and explicitly rejected at the
|
||||
validator chokepoint as defense-in-depth.
|
||||
- **Mermaid streaming parse errors + progressive `hljs`** — live-streamed
|
||||
mermaid blocks with bare `(`, `[`, `{` inside unquoted edge or rectangle
|
||||
node labels were re-entering the shape parser and producing
|
||||
`Parse error, got 'PS'` messages. The renderer now autoquotes the two
|
||||
affected label forms (`|content|` and `ID[content]`) before the SVG cache
|
||||
lookup; shapes whose syntax already nests delimiters (cylinders, subroutines,
|
||||
trapezoids, etc.) are intentionally left alone. The companion `hljs` change
|
||||
highlights code blocks progressively as they stream rather than only after
|
||||
completion.
|
||||
- **Re-auth from inside the proxy-prefixed UI** — on a proxied node page
|
||||
(`/node/{id}/...`), an expiring JWT triggered an in-page login modal whose
|
||||
POST went to `/v1/api/auth/login` and was rewritten to
|
||||
`/node/{id}/v1/api/auth/login`. Two latent bugs both blocked re-auth: the
|
||||
console's `AuthMiddleware` didn't recognise the `/node/{id}/` prefix over a
|
||||
public path, and `proxy_api` would have forwarded the login request to the
|
||||
upstream node (which mints `JWT_AUD_SERVER` tokens the console then rejects).
|
||||
Both fixed: proxied public paths stay public, and `proxy_api` now dispatches
|
||||
every entry in `_PROXY_AUTH_LOCAL_HANDLERS` (login, logout, setup, refresh,
|
||||
status, whoami, oidc/authorize, oidc/callback) to the console's own auth
|
||||
handlers. The dispatch table is a single `(method, path) → handler` mapping
|
||||
so the test parametrize list can't drift from the implementation.
|
||||
- **Appbar visibility + gear-icon dropdown on the dashboard** — the dashboard
|
||||
overlay was covering the entire appbar, hiding the proxy-injected node
|
||||
picker. The overlay now starts at `top: 48px` and the dashboard's role
|
||||
downgrades from `dialog+aria-modal` to `region` so the appbar above it
|
||||
remains reachable. The gear icon converts from a direct settings-panel
|
||||
click into a dropdown with "MCP connections" and "Logout" (the latter with
|
||||
`.destructive` styling). The settings-menu keydown handler is now attached
|
||||
synchronously so `Escape` can't fall through the brief window between the
|
||||
menu opening and its listeners being installed.
|
||||
- **PostgreSQL test backend on the notify dispatcher suite** — migration 053's
|
||||
`services_notify` trigger lives only in the alembic chain, but the test
|
||||
fixture creates tables via `metadata.create_all`. The trigger function +
|
||||
trigger are now declared in `_schema.py` and attached via
|
||||
`sa.event.listen(services, "after_create", ...)` DDL events gated on the
|
||||
PostgreSQL dialect, with the same SQL constants imported by migration 053
|
||||
so there's a single source of truth.
|
||||
|
||||
## [1.5.12]
|
||||
|
||||
### Added
|
||||
|
||||
- **Enriched backend error messages** — provider name and attempted URL are now
|
||||
included in session error responses, so operators can triage connectivity
|
||||
failures without enabling debug logging.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/rewind` always emits a `history` SSE event** — pre-fix, if the session
|
||||
had no messages remaining after a rewind the history event was skipped,
|
||||
leaving connected UIs with stale content and blocking edit-and-resend flows.
|
||||
|
||||
## [1.5.11]
|
||||
|
||||
This release introduces one forward-only schema migration:
|
||||
`052_model_reasoning_persistence` — `surface_persisted_reasoning` and
|
||||
`replay_reasoning_to_model` flag columns on `model_definitions`.
|
||||
|
||||
### Added
|
||||
|
||||
- **SSE refresh-resume** — clients that reload mid-stream (browser refresh, tab
|
||||
restore) now receive an `in_progress_snapshot` event carrying the buffered
|
||||
partial response, so the UI can resume rendering the in-flight turn without
|
||||
losing content. The snapshot is keyed by a monotonic `_ws_inflight_seq`
|
||||
counter so a reconnecting client can skip events it already saw.
|
||||
- **Reasoning persistence** (Phases 1–4) — model reasoning text can now be
|
||||
persisted to conversation history and optionally replayed to the model on
|
||||
subsequent turns. Phase 1 persists reasoning text on the history payload.
|
||||
Phase 2 wires a build-time shape filter and a per-model
|
||||
`replay_reasoning_to_model` flag. Phases 3+4 add full OpenAI Responses API
|
||||
(`include=["reasoning.encrypted_content"]`) and Chat Completions support;
|
||||
an `ANTHROPIC_VALID_BLOCK_TYPES` shape filter guards the Anthropic path. Two
|
||||
new per-model capability flags (`surface_persisted_reasoning`,
|
||||
`replay_reasoning_to_model`) both default `False` on unknown and
|
||||
local-server models.
|
||||
- **Console home composer: placeholders + toggle** — the console landing-page
|
||||
composer now shows context-aware placeholder text and a toggle component for
|
||||
advanced options; an admin polish pass tightened spacing and focus behaviour
|
||||
across the form.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`judge.model` now requires a named alias** — raw provider model IDs on
|
||||
`judge.model` in config are no longer accepted; the judge must reference an
|
||||
alias registered in the model registry. The session-provider raw-model
|
||||
fallback is removed. Existing configs using an unregistered model ID need a
|
||||
corresponding alias entry.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`replay_reasoning_to_model` AND-gated with model capability** — setting the
|
||||
flag for a model that does not declare reasoning-replay support now silently
|
||||
no-ops instead of forwarding reasoning blocks and triggering a provider error.
|
||||
- **Coordinator alias resolution unified across placeholder + factory** — a
|
||||
placeholder coordinator and the real coordinator factory could previously
|
||||
resolve to different model aliases, producing a visible mismatch in the model
|
||||
display. Both paths now share the same resolution logic.
|
||||
- **Console `cs=None` fallback in `/v1/api/models` placeholder** — an
|
||||
under-initialised coordinator state no longer 500s when the models endpoint
|
||||
is hit before the coordinator subsystem is fully bootstrapped.
|
||||
- **SSE `_ws_inflight_seq` always advances** — sequence numbers were previously
|
||||
skipped when an emit was past the buffer cap, leaving gaps in the monotonic
|
||||
counter that broke `state_change` / `in_progress_snapshot` ordering on
|
||||
reconnect.
|
||||
- **Reasoning persistence shape + replay fixes** — per-block
|
||||
`ANTHROPIC_VALID_BLOCK_TYPES` filter applied; `reasoning_text` is now
|
||||
synthesised alongside non-reasoning `provider_blocks` so both appear
|
||||
together in the history payload.
|
||||
|
||||
## [1.5.10]
|
||||
|
||||
This release introduces one forward-only schema migration:
|
||||
`051_skill_notify_on_complete_array_default` — backfills
|
||||
`prompt_templates.notify_on_complete` from `'{}'` to `'[]'`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Skills unlock action** — operators can unlock an installed skill to allow
|
||||
local customisation. Once unlocked, the skill's resource content, system
|
||||
prompt additions, and notify configuration are editable through the admin UI.
|
||||
Skills shipped as part of a bundle remain locked (read-only) until explicitly
|
||||
unlocked; the unlock is logged to the audit trail. A lock icon in the
|
||||
top-right of the Skills detail pane doubles as the unlock trigger.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`skills.sh` install endpoint** — the install script was targeting an
|
||||
endpoint removed in an earlier refactor; switched to `/api/download`.
|
||||
- **Skills `notify_on_complete` default** — the field defaulted to `{}`
|
||||
(object) instead of `[]` (array), causing notify configurations to be
|
||||
rejected at schema validation.
|
||||
- **Skills admin UI modal errors** — `.is-visible` class used consistently
|
||||
instead of inline `style.display`; stale error text is cleared on submit;
|
||||
designer-review lock-icon UX applied.
|
||||
|
||||
## [1.5.9]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`repair=False` on all display-read `load_messages` call sites** —
|
||||
passing `repair=True` on display paths was silently mutating the stored
|
||||
message list, causing divergence between what the UI showed and what the
|
||||
model received on the next turn.
|
||||
|
||||
## [1.5.8]
|
||||
|
||||
This release introduces two forward-only schema migrations:
|
||||
`049_mcp_oauth_schema` — OAuth token + consent tables for MCP servers;
|
||||
`050_conversations_source_and_reminders` — `_source` and `_reminders` columns
|
||||
on `conversations`.
|
||||
|
||||
### Added
|
||||
|
||||
- **MCP OAuth 2.1 + PKCE** — MCP servers that require OAuth can now be
|
||||
configured with a client ID and secret through the admin UI. The full token
|
||||
lifecycle (acquire → refresh → rotate) is managed automatically; tokens are
|
||||
stored encrypted at rest using a key derived from the JWT secret. The consent
|
||||
flow runs in-browser via a provider redirect. Rolled out in phases:
|
||||
|
||||
- Minimum admin form and OAuth schema (`21663d15`).
|
||||
- Token-at-rest AES-GCM encryption layer (`a4c335d7`).
|
||||
- Per-(user, server) OAuth 2.1 + PKCE flow (`b0f7029f`).
|
||||
- Per-(user, server) `ClientSession` pool with OAuth dispatch (`1a1043c4`).
|
||||
- SDK 401/403 introspection via httpx response hook (`bde09134`).
|
||||
- Phase 7 — per-user tool catalog scoping: each user sees only the tools
|
||||
their OAuth token is permitted to call (`cfc8a6c8`).
|
||||
- Phase 7b — per-user resource + prompt pool dispatch (`b368bdee`).
|
||||
- Phase 8 — per-user MCP consent UX: users see a consent dialog on first
|
||||
use of an OAuth-gated server and can revoke consent from their profile;
|
||||
admins see per-server consent counts in the MCP Servers tab (`61051339`).
|
||||
|
||||
- **Metacognition NudgeQueue** — all advisory channels (repeat-tool nudges,
|
||||
watch reminders, wake triggers) are unified into a pull-model `NudgeQueue`
|
||||
that delivers at most one nudge per turn, preventing multi-channel pile-ups
|
||||
that inflate context. Observable changes:
|
||||
|
||||
- Watch results carry metadata (watch ID, `valid_until`, trigger type)
|
||||
through to the system message so the model can reason about recency.
|
||||
- Coordinator idle-children observer: a coordinator with no in-flight
|
||||
children for longer than the configured idle threshold receives a nudge.
|
||||
- Wake trigger (`IdleNudgeWatcher`): sessions waiting on an external event
|
||||
can be unblocked via `ChatSession.deliver_wake_nudge_from_queue`.
|
||||
- Watch switchover: watch results are now enqueued on the `NudgeQueue`
|
||||
rather than the previous `_watch_pending` list, giving them the same
|
||||
delivery guarantees and priority handling as other advisories.
|
||||
|
||||
- **Structured watch-result card** — the UI renders watch results as a styled
|
||||
card with a system-nudge marker, distinct from the assistant message body.
|
||||
On history replay, system-nudge turns are visually distinguished from normal
|
||||
assistant turns.
|
||||
- **Side-channel persistence** — `_source` and `_reminders` side-channel
|
||||
fields are persisted to the `conversations` storage table and restored on
|
||||
session resume, so metacognitive context survives process restarts. A
|
||||
`REMINDER_TEXT_STORAGE_CAP` byte clamp prevents unbounded growth.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Replay consistency** — queued user messages captured mid-loop are now
|
||||
persisted and replayed in the correct order on a subsequent `events`
|
||||
subscription. Coordinator history replay fixed: blank assistant cards and
|
||||
out-of-order tool results on the coordinator tree no longer occur when the
|
||||
coordinator has mixed queued + delivered messages.
|
||||
- **Session reminder preservation on fork + resume** — `_source` and
|
||||
`_reminders` are carried through workstream fork and restored from storage
|
||||
on resume.
|
||||
- **NUL-byte sanitization in storage** — PostgreSQL rejects `\x00` in text
|
||||
columns; `_source` and `_reminders` now strip NUL bytes on write.
|
||||
- **Console coordinator subsystem bootstrap** — the coordinator subsystem is
|
||||
now committed atomically on first model add; startup teardown is offloaded
|
||||
to avoid blocking the event loop.
|
||||
- **MCP `asyncio.timeout` over `asyncio.wait_for`** — Python 3.11's
|
||||
`wait_for` wraps the coroutine in a fresh task, breaking anyio's `aclose`
|
||||
scope exit. Replaced with `async with asyncio.timeout(N)` for safe cleanup.
|
||||
- **MCP pool-reuse 401 recovery** — a reused `ClientSession` returning 401
|
||||
now replaces the pool entry with a fresh session; the carrier token is
|
||||
owned by the pool entry to prevent a race between the 401 handler and a
|
||||
concurrent request.
|
||||
- **OIDC hardening** — multiple security and correctness fixes:
|
||||
SSRF + plaintext credential exfil via discovery document (sec-1, sec-3);
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` now required, Host-header fallback removed
|
||||
(sec-2); atomic user + identity provisioning prevents orphan rows (bug-1);
|
||||
callback robustness — typed exceptions, shape checks, log sanitization, JS
|
||||
race (bug-4–6, sec-4); role-mapping concurrency serialized (bug-2, perf-1);
|
||||
stranded-user self-heal on role-mapping failure (cumulative bug-1).
|
||||
|
||||
## [1.5.7]
|
||||
|
||||
### Added
|
||||
|
||||
- **Inline node picker** — a compact node-switcher dropdown in the console
|
||||
header replaces the "← Back to console" banner, so operators can switch
|
||||
between nodes without a full navigation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Queued user messages injected mid-loop** — messages queued while a
|
||||
generation was in progress were not being delivered at the correct seam and
|
||||
could be dropped or reordered when the worker consumed the queue.
|
||||
- **Search tool output bounded** — pathological inputs (very long lines with
|
||||
no whitespace) could produce search results exceeding the context budget.
|
||||
Output is now clamped before reaching the message.
|
||||
|
||||
## [1.5.6]
|
||||
|
||||
### Added
|
||||
|
||||
- **`api_surface` toggle** — model definitions gain an `api_surface` field
|
||||
(`"chat"` | `"responses"`) that selects which OpenAI-compatible API surface
|
||||
the provider client uses. Enables Mistral Medium reasoning via the Responses
|
||||
surface; Chat Completions remains the default for all other models.
|
||||
- **Healthy model aliases per node** — `GET /v1/api/cluster/nodes` now
|
||||
includes a `healthy_aliases` list per node, so the coordinator and operators
|
||||
can see which model aliases are currently reachable without a separate
|
||||
per-model health probe.
|
||||
- **Plan/task agent settings in Models → Roles** — the Models admin tab's
|
||||
Roles sub-tab gains `plan_agent` and `task_agent` rows so operators can
|
||||
configure per-kind reasoning effort and alias overrides from the UI rather
|
||||
than editing `config.toml`. Live-refresh dropdowns update in place when
|
||||
model definitions change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Memory candidate selection** — recall now uses OR-of-terms BM25 with
|
||||
query-aware candidate-set selection, dramatically improving recall for
|
||||
queries whose terms span multiple stored entries.
|
||||
- **Workstream model + config preserved on rehydrate** — reopening a closed
|
||||
workstream no longer overwrites the model alias and per-workstream config
|
||||
with session defaults.
|
||||
- **Console home composer: attachments + user-message pills** — multipart
|
||||
attachments in the home composer were not forwarded correctly; user-message
|
||||
pills in the coordinator chat pane were missing.
|
||||
|
||||
## [1.5.5]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Saved-workstream tool result rendering** — tool results in closed
|
||||
workstreams were not rendering on history replay. Audit-trail decoration for
|
||||
tool calls is now applied on the replay path.
|
||||
|
||||
## [1.5.4]
|
||||
|
||||
### Added
|
||||
|
||||
- **Stage 3 SessionManager Children primitive lift** — child workstreams are
|
||||
first-class citizens in the cluster event bus. `child_ws_state` events are
|
||||
pushed through the cluster SSE stream so the console tree view updates in
|
||||
real time without polling. `list_children` and `get_child` primitives on
|
||||
`SessionManager` provide a consistent cross-node view of the coordinator's
|
||||
spawn tree.
|
||||
- **Multi-select delete for Saved Coordinators** — the Saved Coordinators grid
|
||||
in the console admin panel now supports checkbox multi-select with a
|
||||
bulk-delete action.
|
||||
|
||||
## [1.5.3]
|
||||
|
||||
This release introduces one forward-only schema migration:
|
||||
`048_workstream_reaper_index` — partial composite index on `workstreams` for
|
||||
the orphan-reaper query.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Coordinator orphan reaping scoped by heartbeat** — the session manager's
|
||||
`close_idle` pass now scopes the DB-orphan reaper by
|
||||
`services.last_heartbeat` so workstreams belonging to a live node are not
|
||||
incorrectly reaped. `bulk_close_stale_orphans` and `touch_workstream`
|
||||
storage primitives added; a partial composite index keeps the reaper scan
|
||||
cheap.
|
||||
- **Coordinator pool idle cleanup** — a periodic task on the console now
|
||||
closes coordinator pool entries whose session has gone idle past the
|
||||
configurable threshold, preventing pool exhaustion on long-running consoles.
|
||||
|
||||
## [1.5.2]
|
||||
|
||||
### Added
|
||||
|
||||
- **Metacognition themed reminder bubble** — repeat-tool and user-reminder
|
||||
nudges are rendered as a distinct styled bubble rather than being injected
|
||||
inline into the assistant message, making it easier to distinguish model
|
||||
output from metacognitive annotations. The CLI REPL gains matching
|
||||
`on_user_reminder` / `on_tool_reminder` callbacks.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Metacog streak detector** — the N≥3 sequential-same-call streak detector
|
||||
now fires correctly on the third repetition; a write-success-clear that
|
||||
reset the counter after a successful tool call (preventing streaks across
|
||||
mixed-outcome sequences) was removed.
|
||||
- **Metacog reminders isolated to side-channel** — reminder text no longer
|
||||
appears in the user content turn; it flows through a dedicated side-channel
|
||||
the session injects into the system context, preventing the model from
|
||||
attributing it to the user.
|
||||
|
||||
## [1.5.1]
|
||||
|
||||
### Added
|
||||
|
||||
- **`pending_approval_detail` on child `ws_state` SSE events** — coordinators
|
||||
now receive the child's pending approval detail in `child_ws_state` events,
|
||||
enabling the coordinator to surface approval prompts without a separate poll.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Coordinator registry auto-refresh** — the console coordinator registry now
|
||||
refreshes when model definitions change, so a newly added alias is visible
|
||||
to coordinators without restarting.
|
||||
- **Coordinator fan-out default** — coordinators now fan out to independent
|
||||
child workstreams by default instead of serialising them, matching the
|
||||
documented contract for parallel-work patterns.
|
||||
- **`wait_for_workstream` message cap raised to 10 KiB** — large plan
|
||||
summaries and tool results from child workstreams were silently truncated at
|
||||
the previous 4 KiB cap.
|
||||
- **Coordinator SSE isolated on dedicated thread pool** — coordinator SSE
|
||||
polling now runs on a dedicated 200-thread executor, matching interactive's
|
||||
`sse_executor`, so coordinator long-poll blocking no longer contends with
|
||||
storage and routing workers on the default pool.
|
||||
|
||||
## [1.5.0]
|
||||
|
||||
User-visible additions: a unified workstream HTTP surface (interactive and
|
||||
coordinator under one URL family), inline child approvals, coordinator
|
||||
composer parity, progressive rendering, OIDC authentication, MCP OAuth
|
||||
foundations, and a redesigned UI built on the Design System v1 token layer.
|
||||
|
||||
This release removes the pre-1.5 body-keyed and query-keyed URL family.
|
||||
See **Removed (BREAKING)** below before upgrading from a 1.x stable line.
|
||||
|
||||
This release introduces the following forward-only schema migrations that the
|
||||
server applies automatically on first startup. All are additive; no data loss.
|
||||
|
||||
- `039_workstream_kind` — `kind` + `parent_ws_id` columns on `workstreams`.
|
||||
- `040_coord_cluster_admin_perms` — grants `admin.coordinator` +
|
||||
`admin.cluster.inspect` to the builtin-admin role.
|
||||
- `041_workstream_index_tuning` — refined indexes for the workstream query mix
|
||||
introduced by 039.
|
||||
- `042_coord_trust_send_perm` — adds `coordinator.trust.send` permission to
|
||||
builtin-admin.
|
||||
- `043_skill_description_required` — backfills empty `description` rows in
|
||||
`prompt_templates`.
|
||||
- `044_skill_kind` — adds `kind` classifier column to `prompt_templates`
|
||||
(`interactive` / `coordinator` / `any`).
|
||||
- `045_skill_risk_level_rename` — renames `prompt_templates.scan_status` →
|
||||
`risk_level`.
|
||||
- `046_drop_hash_ring_tables` — drops the hash-ring bucket tables superseded
|
||||
by rendezvous routing in 1.4.
|
||||
- `047_drop_coord_spawn_quota_settings` — removes the spawn-quota settings
|
||||
rows removed from the coordinator in 1.5.0a4.
|
||||
|
||||
### Added
|
||||
|
||||
- **Inline child approvals** — pending tool approvals on coordinator child
|
||||
workstreams surface directly in the coordinator tree view. A risk pill shows
|
||||
the judge verdict (or "pending" while the judge evaluates); Approve/Deny
|
||||
buttons appear inline so operators do not need to navigate to the child's
|
||||
workstream. `pending_approval_detail` is exposed on
|
||||
`GET /v1/api/dashboard` and passed through the cluster live-bulk SSE payload
|
||||
so all connected clients render approval prompts simultaneously. LLM judge
|
||||
verdicts are cached client-side and replayed on SSE reconnect.
|
||||
- **Coordinator composer parity** — the coordinator composer now supports
|
||||
Stop, Send-to-queue, and Attach (file upload), matching the interactive
|
||||
workstream composer feature set.
|
||||
- **Per-call model and judge override on coordinator composer** — operators
|
||||
can override the model alias and judge model for a single coordinator send
|
||||
from the composer, without changing the node-wide or role-wide defaults. Bad
|
||||
aliases return a corrective error listing available choices.
|
||||
- **Coordinator status bar + richer history replay** — each coordinator
|
||||
workstream gains a per-coordinator status bar showing active children, token
|
||||
spend, and generation state. History replay in the coordinator panel is
|
||||
extended to include tool results and thinking blocks.
|
||||
- **Coordinator child error surfacing + memory tool** — child workstream
|
||||
errors are surfaced as distinct error rows in the coordinator tree view
|
||||
rather than disappearing silently. The coordinator gains access to a
|
||||
`memory` tool (same interface as interactive) for retrieving stored facts.
|
||||
- **Coordinator inline tool-batch construct** — the coordinator tool approval
|
||||
UI replaces the separate approval dock with an inline batch construct that
|
||||
groups all pending tool calls for a given turn into a single review card.
|
||||
- **Node capability auto-detection** — nodes report kernel-level capabilities
|
||||
(available memory, CPU count, accelerator presence) via
|
||||
`/v1/api/node/capabilities` at startup, enabling the console to filter model
|
||||
aliases offered to coordinators routing to that node.
|
||||
- **Skills: paste `SKILL.md` to auto-fill the Create Skill modal** — pasting
|
||||
a `SKILL.md` file's content into the modal auto-populates the name,
|
||||
description, and configuration fields.
|
||||
- **Progressive mermaid rendering** — Mermaid diagrams begin rendering as
|
||||
soon as a complete diagram block is detected in the stream rather than
|
||||
waiting for the full response; the diagram re-renders in place as the model
|
||||
extends it.
|
||||
- **LaTeX and MathML delimiter support** — `\(…\)` inline and `\[…\]` block
|
||||
math delimiters are now recognised alongside the existing `$$` fences.
|
||||
## [Unreleased]
|
||||
|
||||
### Removed (BREAKING — 1.5.0)
|
||||
|
||||
|
||||
+1
-1
@@ -56,4 +56,4 @@ Open an issue at https://github.com/turnstonelabs/turnstone/issues with:
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the
|
||||
project's [Apache License 2.0](LICENSE).
|
||||
project's [Business Source License 1.1](LICENSE).
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Contributors
|
||||
|
||||
Turnstone is written and maintained by Patrick Buckley
|
||||
([@eous](https://github.com/eous)).
|
||||
|
||||
The following people have contributed code to the project — thank you:
|
||||
|
||||
- Burhan ([@Burhan-Q](https://github.com/Burhan-Q))
|
||||
- chrismuzyn ([@chrismuzyn](https://github.com/chrismuzyn))
|
||||
- daoxley ([@daoxley](https://github.com/daoxley))
|
||||
- Robert DeAngelis ([@OriginalOrangeXD](https://github.com/OriginalOrangeXD))
|
||||
- William ([@sillyWillieBilly](https://github.com/sillyWillieBilly))
|
||||
- [@pizzaandcheese](https://github.com/pizzaandcheese)
|
||||
+4
-7
@@ -8,17 +8,14 @@ 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.21 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.7 /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
|
||||
|
||||
# System dependencies: psycopg (libpq5), developer tooling for agent workflows.
|
||||
# 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.
|
||||
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
|
||||
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 \
|
||||
libpq5 git curl jq man-db manpages procps file \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
|
||||
@@ -33,7 +30,7 @@ RUN useradd --create-home --shell /bin/bash turnstone
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (cached layer — only re-runs when deps change)
|
||||
COPY pyproject.toml uv.lock README.md LICENSE NOTICE THIRD-PARTY-NOTICES ./
|
||||
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--no-compile --extra all
|
||||
|
||||
|
||||
@@ -1,201 +1,62 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
|
||||
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Parameters
|
||||
|
||||
1. Definitions.
|
||||
Licensor: Patrick Buckley
|
||||
Licensed Work: Turnstone 0.2.0. The Licensed Work is (c) 2025-2026 Patrick Buckley.
|
||||
Additional Use Grant: You may make production use of the Licensed Work, provided
|
||||
your use does not include providing the Licensed Work to third
|
||||
parties as a hosted or managed service, where the service
|
||||
provides users with access to any substantial set of the
|
||||
features or functionality of the Licensed Work.
|
||||
Change Date: 2030-03-01
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
For information about alternative licensing arrangements for the Licensed Work,
|
||||
please contact buckleypm@gmail.com.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
Notice
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
Business Source License 1.1
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
Terms
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||
works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited production use.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
Effective on the Change Date, or the fourth anniversary of the first publicly
|
||||
available distribution of a specific version of the Licensed Work under this
|
||||
License, whichever comes first, the Licensor hereby grants you rights under
|
||||
the terms of the Change License, and the rights granted in the paragraph
|
||||
above terminate.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
separately for each version of the Licensed Work and the Change Date may vary
|
||||
for each version of the Licensed Work released by Licensor.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
You must conspicuously display this License on each original or modified copy
|
||||
of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in this
|
||||
License apply to your use of that work.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo of
|
||||
Licensor as expressly required by this License).
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||
TITLE.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Turnstone
|
||||
Copyright 2025-2026 Patrick Buckley
|
||||
|
||||
Licensed under the Apache License, Version 2.0; see the LICENSE file.
|
||||
|
||||
Third-party software bundled with this distribution is listed in the
|
||||
THIRD-PARTY-NOTICES file; each component remains under its own license.
|
||||
+6
-6
@@ -42,13 +42,13 @@ That's it — no flags, no arguments. The wizard prompts for everything.
|
||||
|
||||
## 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.
|
||||
The wizard supports two deployment modes:
|
||||
|
||||
See [docs/docker.md](docs/docker.md) for both.
|
||||
- **Single-node production** (`docker compose --profile production up`) —
|
||||
1 server + console + PostgreSQL. Good for most use cases.
|
||||
- **Multi-node cluster** (`docker compose --profile cluster up`) —
|
||||
10-node server fleet + console + PostgreSQL. For high-throughput or
|
||||
HA deployments.
|
||||
|
||||
## Example Session
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
[](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/Nh3bWMacaq)
|
||||
[](LICENSE)
|
||||
|
||||
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.
|
||||
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero.png" alt="Turnstone coordinator — parallel tool batches with judge-graded approval and child workstream tracking" width="960"/>
|
||||
@@ -27,13 +26,12 @@ 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, 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
|
||||
- **Intent validation** — an LLM judge (your model) grades every tool call with a risk assessment and evidence before it runs
|
||||
- **Cluster dashboard** — real-time view of all nodes and workstreams with console routing proxy
|
||||
- **Intent validation** — LLM judge evaluates every tool call with risk assessments and evidence
|
||||
- **Governance** — RBAC, OIDC SSO, tool policies, skills, usage tracking, audit logs
|
||||
- **Multi-provider** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), Anthropic Messages API, and Google Gemini
|
||||
- **MCP support** — external tool servers with native deferred loading (Anthropic/OpenAI) or BM25 fallback
|
||||
- **Team controls when you need them** — optional RBAC, SSO, tool policies, and audit logs, all stored in your own database
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture" width="960"/>
|
||||
@@ -51,12 +49,14 @@ turnstone --base-url http://localhost:8000/v1
|
||||
turnstone-server --port 8080 --base-url http://localhost:8000/v1
|
||||
|
||||
# Cluster dashboard
|
||||
pip install turnstone[console]
|
||||
turnstone-console --port 8090
|
||||
```
|
||||
|
||||
For PostgreSQL (recommended for production):
|
||||
|
||||
```bash
|
||||
pip install turnstone[postgres]
|
||||
export TURNSTONE_DB_BACKEND=postgresql
|
||||
export TURNSTONE_DB_URL="postgresql+psycopg://user:pass@localhost:5432/turnstone"
|
||||
turnstone-server --port 8080 --base-url http://localhost:8000/v1
|
||||
@@ -64,29 +64,12 @@ turnstone-server --port 8080 --base-url http://localhost:8000/v1
|
||||
|
||||
### Docker
|
||||
|
||||
One-line install — autodetects Ubuntu/Debian, Fedora/RHEL, Arch, and WSL,
|
||||
installs git + Docker if missing, generates secrets, and starts the stack:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
|
||||
cp .env.example .env # edit LLM_BASE_URL, OPENAI_API_KEY, etc.
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
Or, if you already have Docker, clone the repo and run it yourself:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
That builds one image and brings up a full local cluster — PostgreSQL, console,
|
||||
Caddy, channel gateway, and 10 server nodes — with no `.env` required (it ships
|
||||
with insecure dev defaults). Open the dashboard at https://localhost:8443 (Caddy
|
||||
serves it over TLS with its own local CA — trust it once). Nodes boot without an
|
||||
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 bootstrap wizard 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 and profiles.
|
||||
|
||||
### Programmatic (SDK)
|
||||
|
||||
@@ -159,14 +142,9 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
|
||||
|
||||
- Python 3.11+
|
||||
- An OpenAI-compatible API endpoint, Anthropic API key, or Google Gemini API key
|
||||
- Optional: Discord / Slack channel integrations (`pip install turnstone[discord,slack]`)
|
||||
- Optional: PostgreSQL (`pip install turnstone[postgres]`), Anthropic (`pip install turnstone[anthropic]`)
|
||||
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
|
||||
|
||||
## Community
|
||||
|
||||
Questions, ideas, or want to show what you're building? Join us on Discord:
|
||||
**[discord.gg/Nh3bWMacaq](https://discord.gg/Nh3bWMacaq)**.
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE), as of version 1.6.0. Versions 1.5.x and earlier remain under the Business Source License 1.1 they shipped with.
|
||||
[Business Source License 1.1](LICENSE) — free for all use except hosting as a managed service. Converts to Apache 2.0 on 2030-03-01.
|
||||
|
||||
+4
-4
@@ -2,11 +2,11 @@ Turnstone — Third-Party Notices
|
||||
|
||||
This file contains the licenses and notices for third-party software bundled
|
||||
with Turnstone. Each bundled dependency retains its original license; the
|
||||
Turnstone Apache-2.0 license does not apply to these components.
|
||||
Turnstone BUSL-1.1 license does not apply to these components.
|
||||
|
||||
================================================================================
|
||||
|
||||
KaTeX 0.17.0
|
||||
KaTeX 0.16.38
|
||||
https://katex.org/
|
||||
https://github.com/KaTeX/KaTeX
|
||||
|
||||
@@ -70,7 +70,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
================================================================================
|
||||
|
||||
Mermaid 11.15.0
|
||||
Mermaid 11.13.0
|
||||
https://mermaid.js.org/
|
||||
https://github.com/mermaid-js/mermaid
|
||||
|
||||
@@ -98,7 +98,7 @@ SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
|
||||
hls.js 1.6.16
|
||||
hls.js 1.6.15
|
||||
https://github.com/video-dev/hls.js
|
||||
|
||||
Copyright 2017 Dailymotion
|
||||
|
||||
+141
-204
@@ -1,50 +1,16 @@
|
||||
# =============================================================================
|
||||
# Turnstone — local cluster stack (docker compose)
|
||||
# Turnstone Docker Compose Stack — Development
|
||||
#
|
||||
# Clone the repo and run:
|
||||
# This file is for local development from a git clone. It builds images
|
||||
# locally from the Dockerfile. If you installed via pip/pipx, run
|
||||
# `turnstone-bootstrap` instead — it writes a production compose.yaml
|
||||
# that pulls pre-built images from ghcr.io.
|
||||
#
|
||||
# docker compose up
|
||||
#
|
||||
# That builds one image and brings up a complete, console-visible cluster:
|
||||
# PostgreSQL + console + Caddy + channel gateway + 10 server nodes (node-1…10).
|
||||
#
|
||||
# Dashboard: https://localhost:8443 (Caddy's local CA — trust it once)
|
||||
#
|
||||
# Access is via Caddy only — the console's plain-HTTP port is intentionally not
|
||||
# published (HTTP/2 from Caddy avoids the browser's 6-connection cap on the
|
||||
# dashboard's SSE streams). Trust Caddy's root once:
|
||||
# docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
|
||||
#
|
||||
# It works out of the box with INSECURE dev defaults (see the secret/password
|
||||
# values below) so there's nothing to configure first. A .env file still
|
||||
# overrides any value. For a real deployment use the bundled production stack
|
||||
# at turnstone/deploy/compose.yaml — it pulls released images from ghcr.io and
|
||||
# requires you to set real secrets.
|
||||
#
|
||||
# Bring your own LLM: nodes boot without one and show up in the console
|
||||
# immediately. Add model backends (OpenAI / Anthropic / local vLLM) from the
|
||||
# console UI's Models tab, or point LLM_BASE_URL / OPENAI_API_KEY (below) at an
|
||||
# OpenAI-compatible endpoint.
|
||||
#
|
||||
# Fewer nodes (lighter machines):
|
||||
# docker compose up postgres console caddy channel node-1 node-2 node-3
|
||||
#
|
||||
# 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]
|
||||
# backend = "postgresql"
|
||||
# url = "postgresql+psycopg://turnstone:turnstone@localhost:5432/turnstone"
|
||||
# [api]
|
||||
# base_url = "http://localhost:8000/v1"
|
||||
# api_key = "dummy"
|
||||
# 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-server --host 0.0.0.0 --port 8080
|
||||
# It registers in Postgres and the console reaches it back via host.docker.internal.
|
||||
# Usage:
|
||||
# Infra only: docker compose up
|
||||
# Single node: docker compose --profile production up
|
||||
# Production (PG): TURNSTONE_DB_BACKEND=postgresql docker compose --profile production up
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -57,30 +23,16 @@ volumes:
|
||||
turnstone-data:
|
||||
workspace:
|
||||
postgres-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
searxng-cache:
|
||||
|
||||
# -- Shared values (scalar anchors) -------------------------------------------
|
||||
# Defined once here, referenced (*alias) by every service so the dev defaults
|
||||
# can't drift. All `${VAR:-default}` values are still overridable via .env.
|
||||
x-shared:
|
||||
# INSECURE dev default. Every service MUST share ONE secret — the console
|
||||
# mints its own service token (signed with this) to reach the nodes. Override
|
||||
# TURNSTONE_JWT_SECRET in .env for anything that isn't a local sandbox.
|
||||
jwt-secret: &jwt-secret "${TURNSTONE_JWT_SECRET:-dev-only-insecure-jwt-secret-change-me-for-real-deployments}"
|
||||
db-backend: &db-backend "${TURNSTONE_DB_BACKEND:-postgresql}"
|
||||
# All services point at the same Postgres. Node discovery REQUIRES a shared
|
||||
# DB: each server registers + heartbeats into a `services` table that the
|
||||
# console polls. (SQLite-per-container can't see other containers.)
|
||||
db-url: &db-url "${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}"
|
||||
|
||||
services:
|
||||
# -------------------------------------------------------------------
|
||||
# PostgreSQL — the shared database that ties the cluster together.
|
||||
# PostgreSQL — production database (profile: production)
|
||||
# -------------------------------------------------------------------
|
||||
postgres:
|
||||
image: pgautoupgrade/pgautoupgrade:18-alpine
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
@@ -90,16 +42,8 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: turnstone
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
|
||||
# INSECURE dev default — override POSTGRES_PASSWORD in .env for real use.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-turnstone}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
# 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:
|
||||
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -113,21 +57,65 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
memory: 4G
|
||||
cpus: '4.0'
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-console — cluster dashboard. Reach it ONLY through Caddy at
|
||||
# https://localhost:8443 (see the caddy service below).
|
||||
#
|
||||
# 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
|
||||
# advertising http://host.docker.internal:8080 (see "Join a host" below).
|
||||
# turnstone-server — Web UI + chat workstreams + LLM interaction
|
||||
# -------------------------------------------------------------------
|
||||
server:
|
||||
image: turnstone:local
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-server
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
--base-url "$${LLM_BASE_URL}"
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ${WORKSPACE_MOUNT:-workspace}:/workspace
|
||||
environment:
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
|
||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- MODEL=${MODEL:-}
|
||||
- MCP_CONFIG=${MCP_CONFIG:-}
|
||||
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
|
||||
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
|
||||
- TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-console — Cluster dashboard
|
||||
# -------------------------------------------------------------------
|
||||
console:
|
||||
image: turnstone:local
|
||||
@@ -138,18 +126,16 @@ services:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
ports:
|
||||
- "${CONSOLE_PORT:-8090}:8090"
|
||||
environment:
|
||||
TURNSTONE_JWT_SECRET: *jwt-secret
|
||||
TURNSTONE_DB_BACKEND: *db-backend
|
||||
TURNSTONE_DB_URL: *db-url
|
||||
TURNSTONE_CONSOLE_URL: http://console:8090
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
|
||||
- TURNSTONE_CONSOLE_URL=http://console:8090
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"]
|
||||
interval: 10s
|
||||
@@ -159,33 +145,14 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# caddy — browser TLS for the console dashboard.
|
||||
# Terminates HTTPS (Caddy's own local CA, see turnstone/deploy/Caddyfile) → console:8090.
|
||||
# Dashboard over TLS: https://localhost:${CONSOLE_HTTPS_PORT:-8443}
|
||||
# -------------------------------------------------------------------
|
||||
caddy:
|
||||
image: caddy:2.11
|
||||
depends_on:
|
||||
- console
|
||||
ports:
|
||||
- "${CONSOLE_HTTPS_PORT:-8443}:443"
|
||||
# SearxNG web UI — localhost-only (it has no auth). Browse https://localhost:8444.
|
||||
- "127.0.0.1:${SEARXNG_HTTPS_PORT:-8444}:8444"
|
||||
volumes:
|
||||
- ./turnstone/deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data # persist Caddy's local CA across restarts
|
||||
- caddy-config:/config
|
||||
networks:
|
||||
- turnstone-net
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-channel — channel gateway (Discord and/or Slack).
|
||||
# Runs HTTP-only with no adapters until you set a token, so it's safe
|
||||
# to leave running. See docs/channels.md.
|
||||
# turnstone-channel — Channel gateway (Discord, Slack, etc.)
|
||||
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
|
||||
# -------------------------------------------------------------------
|
||||
channel:
|
||||
image: turnstone:local
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -194,59 +161,36 @@ services:
|
||||
--http-host=0.0.0.0
|
||||
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
|
||||
environment:
|
||||
TURNSTONE_JWT_SECRET: *jwt-secret
|
||||
TURNSTONE_DB_BACKEND: *db-backend
|
||||
TURNSTONE_DB_URL: *db-url
|
||||
TURNSTONE_DISCORD_TOKEN: ${TURNSTONE_DISCORD_TOKEN:-}
|
||||
TURNSTONE_DISCORD_GUILD: ${TURNSTONE_DISCORD_GUILD:-0}
|
||||
TURNSTONE_SLACK_TOKEN: ${TURNSTONE_SLACK_TOKEN:-}
|
||||
TURNSTONE_SLACK_APP_TOKEN: ${TURNSTONE_SLACK_APP_TOKEN:-}
|
||||
TURNSTONE_CHANNEL_ADVERTISE_URL: http://channel:8091
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# searxng — self-hosted metasearch backing the web_search tool.
|
||||
# Internal-network only (no published port): nodes reach it at
|
||||
# http://searxng:8080. Config (JSON output on, limiter off) lives in
|
||||
# turnstone/deploy/searxng/settings.yml, mounted read-only. Commercial
|
||||
# models use native provider search and never hit this; it serves
|
||||
# local/vLLM models. Override the tag with SEARXNG_IMAGE_TAG in .env.
|
||||
# -------------------------------------------------------------------
|
||||
searxng:
|
||||
image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
- ./turnstone/deploy/searxng:/etc/searxng:ro
|
||||
- searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts)
|
||||
networks:
|
||||
- turnstone-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
# ===================================================================
|
||||
# Server fleet — node-1 … node-10
|
||||
# 10-node cluster (profile: cluster)
|
||||
#
|
||||
# Each node registers itself in Postgres on boot (unique
|
||||
# TURNSTONE_NODE_ID + TURNSTONE_ADVERTISE_URL) and the console
|
||||
# discovers it automatically — no static node list anywhere.
|
||||
# All nodes share the same PostgreSQL instance.
|
||||
# Access via console at :8090.
|
||||
#
|
||||
# node-1 carries the shared definition (&node / &node-env); node-2…10
|
||||
# inherit it and override only their identity.
|
||||
# Start: docker compose --profile cluster up
|
||||
# ===================================================================
|
||||
node-1: &node
|
||||
|
||||
# -- cluster servers ------------------------------------------------
|
||||
|
||||
server-1: &cluster-server
|
||||
image: turnstone:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster]
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -262,30 +206,23 @@ services:
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ${WORKSPACE_MOUNT:-workspace}:/workspace
|
||||
environment: &node-env
|
||||
TURNSTONE_JWT_SECRET: *jwt-secret
|
||||
TURNSTONE_DB_BACKEND: *db-backend
|
||||
TURNSTONE_DB_URL: *db-url
|
||||
# Bootstrap LLM defaults — real backends are configured in the console UI.
|
||||
environment: &cluster-server-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
# web_search backend. Defaults to the bundled searxng service; point at an
|
||||
# external SearxNG by setting TURNSTONE_SEARXNG_URL in .env (empty disables).
|
||||
TURNSTONE_SEARXNG_URL: ${TURNSTONE_SEARXNG_URL:-http://searxng:8080}
|
||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
MODEL: ${MODEL:-}
|
||||
MCP_CONFIG: ${MCP_CONFIG:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
TURNSTONE_DB_BACKEND: ${TURNSTONE_DB_BACKEND:-postgresql}
|
||||
TURNSTONE_DB_URL: ${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
|
||||
TURNSTONE_NODE_ID: node-1
|
||||
TURNSTONE_ADVERTISE_URL: http://node-1:8080
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- turnstone-net
|
||||
TURNSTONE_ADVERTISE_URL: http://server-1:8080
|
||||
extra_hosts: ["host.docker.internal:host-gateway"]
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
searxng:
|
||||
condition: service_healthy
|
||||
postgres: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
@@ -294,34 +231,34 @@ services:
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
limits: { memory: 4G, cpus: '4' }
|
||||
restart: unless-stopped
|
||||
|
||||
node-2:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://node-2:8080" }
|
||||
node-3:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://node-3:8080" }
|
||||
node-4:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://node-4:8080" }
|
||||
node-5:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://node-5:8080" }
|
||||
node-6:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://node-6:8080" }
|
||||
node-7:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://node-7:8080" }
|
||||
node-8:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://node-8:8080" }
|
||||
node-9:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://node-9:8080" }
|
||||
node-10:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://node-10:8080" }
|
||||
server-2:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://server-2:8080" }
|
||||
server-3:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://server-3:8080" }
|
||||
server-4:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://server-4:8080" }
|
||||
server-5:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://server-5:8080" }
|
||||
server-6:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://server-6:8080" }
|
||||
server-7:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://server-7:8080" }
|
||||
server-8:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://server-8:8080" }
|
||||
server-9:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://server-9:8080" }
|
||||
server-10:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://server-10:8080" }
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Bare-metal overlay — expose PostgreSQL and let the console reach
|
||||
# a turnstone-server running outside Docker on the host machine.
|
||||
#
|
||||
# Requires TURNSTONE_HOST_IP set to the host's routable IP address.
|
||||
#
|
||||
# Usage:
|
||||
# export TURNSTONE_HOST_IP="$(hostname -I | awk '{print $1}')"
|
||||
# docker compose --profile production \
|
||||
# -f compose.yaml -f deploy/docker-compose.bare-metal.yml up
|
||||
#
|
||||
# Then on the host:
|
||||
# export TURNSTONE_JWT_SECRET="<same as .env>"
|
||||
# export TURNSTONE_DB_BACKEND=postgresql
|
||||
# export TURNSTONE_DB_URL="postgresql://turnstone:<pw>@localhost:5432/turnstone"
|
||||
# export TURNSTONE_NODE_ID="bare-metal-1"
|
||||
# export TURNSTONE_ADVERTISE_URL="http://${TURNSTONE_HOST_IP}:8080"
|
||||
# python -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
# --base-url http://localhost:8000/v1 --api-key "$OPENAI_API_KEY"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
|
||||
console:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Console needs to reach the bare-metal server on the host
|
||||
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
|
||||
|
||||
channel:
|
||||
ports:
|
||||
- "${CHANNEL_PORT:-8091}:8091"
|
||||
environment:
|
||||
# Channel gateway advertises with host-routable IP so the
|
||||
# bare-metal server can reach it for schedule notifications
|
||||
TURNSTONE_CHANNEL_ADVERTISE_URL: "http://${TURNSTONE_HOST_IP}:${CHANNEL_PORT:-8091}"
|
||||
# Channel needs to reach the bare-metal server on the host
|
||||
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
|
||||
@@ -1,8 +1,7 @@
|
||||
# TLS overlay — enables mTLS across the turnstone deployment.
|
||||
# TLS overlay — enables mTLS across the turnstone cluster.
|
||||
#
|
||||
# Layers on the production stack (it patches the `server`, `console`, and
|
||||
# `channel` services that file defines):
|
||||
# docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
# Usage (requires base compose.yaml with production profile):
|
||||
# docker compose -f compose.yaml -f deploy/docker-compose.tls.yml --profile production up
|
||||
#
|
||||
# The tls-init service bootstraps a CA and issues certs.
|
||||
# All turnstone services auto-provision their own certs via the
|
||||
@@ -13,7 +12,7 @@ services:
|
||||
# Runs as root to create directories in the volume, then chowns
|
||||
# to turnstone:turnstone with restrictive perms (keys 0600).
|
||||
tls-init:
|
||||
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
|
||||
build: .
|
||||
user: root
|
||||
command:
|
||||
- sh
|
||||
|
||||
@@ -7,6 +7,6 @@ appVersion: "0.3.0"
|
||||
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: ~18.7.0
|
||||
version: ~18.6.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: postgresql.enabled
|
||||
|
||||
@@ -103,18 +103,13 @@ network_policies:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Web search (SearxNG) ---
|
||||
# Turnstone talks only to its SearxNG instance over HTTP; SearxNG itself makes
|
||||
# the outbound calls to search engines (and is NOT governed by this policy —
|
||||
# it runs as a separate service). The host/port below is the bundled compose
|
||||
# service name; if your SearxNG runs elsewhere, set it to match
|
||||
# TURNSTONE_SEARXNG_URL.
|
||||
# --- Web search fallback (Tavily) ---
|
||||
|
||||
searxng:
|
||||
name: searxng-search
|
||||
tavily_api:
|
||||
name: tavily-search
|
||||
endpoints:
|
||||
- host: searxng
|
||||
port: 8080
|
||||
- host: api.tavily.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
+11
-75
@@ -2,69 +2,13 @@
|
||||
"""Health check for turnstone containers.
|
||||
|
||||
Usage: healthcheck.py <url>
|
||||
Exit 0 if the endpoint returns {"status": "ok"} or {"status": "degraded"},
|
||||
exit 1 otherwise. Uses only stdlib — no pip dependencies required.
|
||||
|
||||
When the node serves mTLS (tls.enabled), a plain-HTTP probe is rejected at
|
||||
the socket, so on failure this script retries over HTTPS, presenting the
|
||||
node's own certificate as the client cert and pinning the cluster CA. The
|
||||
PEM files are the ones the server writes at boot under
|
||||
$TURNSTONE_TLS_PEM_DIR (default: <tmpdir>/turnstone-tls). The host is
|
||||
rewritten to "localhost" for the TLS attempt because the internal CA issues
|
||||
DNS SANs only — certificate verification rejects a literal-IP dial.
|
||||
|
||||
When mTLS is disabled (the default), the plain probe succeeds and nothing
|
||||
here changes: the PEM directory is never consulted.
|
||||
Exit 0 if the endpoint returns {"status": "ok"}, exit 1 otherwise.
|
||||
Uses only stdlib — no pip dependencies required.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
|
||||
def _check(url: str, context: ssl.SSLContext | None = None) -> None:
|
||||
"""Probe one URL; raise if unreachable or the payload is unhealthy."""
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5, context=context) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
if data.get("status") not in ("ok", "degraded"):
|
||||
raise RuntimeError(f"unhealthy payload: {data}")
|
||||
|
||||
|
||||
def _pem_root() -> Path:
|
||||
"""PEM runtime root.
|
||||
|
||||
Must mirror turnstone.core.tls.tls_pem_runtime_dir — this script is
|
||||
standalone stdlib and cannot import turnstone; a drift-guard test in
|
||||
tests/test_docker_healthcheck.py pins the two together.
|
||||
"""
|
||||
root_env = os.environ.get("TURNSTONE_TLS_PEM_DIR")
|
||||
return Path(root_env) if root_env else Path(tempfile.gettempdir()) / "turnstone-tls"
|
||||
|
||||
|
||||
def _find_pem_dir() -> Path | None:
|
||||
"""Locate the newest complete PEM dir written by the server at boot."""
|
||||
root = _pem_root()
|
||||
candidates = [
|
||||
d
|
||||
for d in root.glob("lacme-pem-*")
|
||||
if all((d / name).is_file() for name in ("fullchain.pem", "key.pem", "ca.pem"))
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda d: d.stat().st_mtime)
|
||||
|
||||
|
||||
def _tls_url(url: str) -> str:
|
||||
"""Rewrite scheme to https and host to localhost, keeping port and path."""
|
||||
parts = urlsplit(url)
|
||||
netloc = f"localhost:{parts.port}" if parts.port else "localhost"
|
||||
return urlunsplit(("https", netloc, parts.path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -74,24 +18,16 @@ def main() -> None:
|
||||
|
||||
url = sys.argv[1]
|
||||
try:
|
||||
_check(url)
|
||||
sys.exit(0)
|
||||
except Exception as plain_exc:
|
||||
pem_dir = _find_pem_dir()
|
||||
if pem_dir is None:
|
||||
print(f"Health check failed: {plain_exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
context = ssl.create_default_context(cafile=str(pem_dir / "ca.pem"))
|
||||
context.load_cert_chain(str(pem_dir / "fullchain.pem"), str(pem_dir / "key.pem"))
|
||||
_check(_tls_url(url), context=context)
|
||||
sys.exit(0)
|
||||
except Exception as tls_exc:
|
||||
print(
|
||||
f"Health check failed: plain: {plain_exc}; mtls: {tls_exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
if data.get("status") in ("ok", "degraded"):
|
||||
sys.exit(0)
|
||||
print(f"Unhealthy: {data}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"Health check failed: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+38
-46
@@ -281,7 +281,6 @@ Each message in the `messages` array has:
|
||||
| `role` | string | `"user"`, `"assistant"`, or `"tool"` |
|
||||
| `content` | string or null | Text content of the message |
|
||||
| `tool_calls` | array or null | Present only on assistant messages with calls |
|
||||
| `reasoning` | string (optional) | Concatenated reasoning / chain-of-thought text on assistant turns whose `provider_data` carried reasoning-bearing blocks (Anthropic `thinking`, OpenAI Responses `reasoning`, or synthetic `reasoning_text` from local-model servers). Present only when the active model's `surface_persisted_reasoning` flag is True. |
|
||||
|
||||
Each entry in `tool_calls`:
|
||||
|
||||
@@ -326,44 +325,6 @@ finalize any in-progress assistant message.
|
||||
{"type": "stream_end"}
|
||||
```
|
||||
|
||||
**`state_change`** -- the worker thread transitioned to a new state. Drives
|
||||
the client's busy-mode (composer in send vs. stop, spinner indicators,
|
||||
auto-focus on idle). Sent live during normal operation AND on every fresh
|
||||
SSE subscribe (so a mid-stream page refresh restores the correct composer
|
||||
state without waiting for the next live transition).
|
||||
|
||||
```json
|
||||
{"type": "state_change", "state": "running"}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------|--------|----------------------------------------------------------------------|
|
||||
| `state` | string | One of `"running"`, `"thinking"`, `"attention"`, `"idle"`, `"error"` |
|
||||
|
||||
**`in_progress_snapshot`** -- one-shot replay of the in-progress turn's
|
||||
content + reasoning text-so-far when this client connects mid-stream.
|
||||
Lets a refreshing browser tab restore partial assistant text immediately
|
||||
instead of waiting for the response to complete. Yielded once after the
|
||||
kind-specific replay phase (history + pending), only when at least one
|
||||
of `content` / `reasoning` is non-empty. Both halves render into the same
|
||||
assistant bubble the live `content` / `reasoning` events would target;
|
||||
clients should treat the snapshot as idempotent (skip overwrite if the
|
||||
current local buffer is already a superset prefix — covers EventSource
|
||||
auto-reconnect re-replays).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "in_progress_snapshot",
|
||||
"content": "Here is the answer so far: it depends on ",
|
||||
"reasoning": "The user is asking about a comparison; let me think about..."
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|------------------------------------------------------------|
|
||||
| `content` | string | Joined assistant content text accumulated this turn |
|
||||
| `reasoning` | string | Joined reasoning / chain-of-thought text accumulated |
|
||||
|
||||
**`tool_info`** -- one or more tool calls that were auto-approved (no user
|
||||
action required).
|
||||
|
||||
@@ -455,6 +416,13 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
|
||||
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
|
||||
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
|
||||
|
||||
**`plan_review`** -- the model is proposing a plan and wants feedback. The
|
||||
client must respond via `POST /v1/api/plan`.
|
||||
|
||||
```json
|
||||
{"type": "plan_review", "content": "Step 1: ...\nStep 2: ..."}
|
||||
```
|
||||
|
||||
**`info`** -- an informational message (e.g. command output).
|
||||
|
||||
```json
|
||||
@@ -554,13 +522,7 @@ Each SSE connection to a workstream receives its own delivery queue. Events
|
||||
produced by the worker thread are fanned out to all registered listener queues,
|
||||
so multiple consumers (browser, console proxy, SDK) can connect
|
||||
simultaneously and each receives every event. On reconnect the client receives
|
||||
the kind-specific replay (`connected` + `status` + `history` + pending
|
||||
approval / plan for interactive; `connected` + `status` + pending for coord)
|
||||
followed by a `state_change` carrying the current worker state and an
|
||||
optional `in_progress_snapshot` carrying any partial content / reasoning
|
||||
buffered for the in-progress turn — so a mid-stream refresh restores both
|
||||
the busy-mode UI and the partial assistant text without waiting for the
|
||||
response to complete.
|
||||
a full history replay, so no catch-up mechanism is needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -772,6 +734,36 @@ automatically approved without prompting.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/plan`
|
||||
|
||||
Responds to a plan review dialog. The SSE stream must have previously sent a
|
||||
`plan_review` event for the given workstream.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"feedback": "", "ws_id": "abc123"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|---------------------------------------------------------|
|
||||
| `feedback` | string | yes | Feedback text; empty string means approval |
|
||||
| `ws_id` | string | yes | Target workstream ID |
|
||||
|
||||
To approve the plan, send an empty string for `feedback`. To reject or request
|
||||
changes, send a non-empty feedback string (e.g. `"reject"` or specific
|
||||
revision instructions).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**Error:** `404` with `{"error": "Unknown workstream"}` if `ws_id` is invalid.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/command`
|
||||
|
||||
Executes a slash command in the given workstream.
|
||||
|
||||
+63
-141
@@ -3,8 +3,8 @@
|
||||
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
|
||||
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
|
||||
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
||||
model 16 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
reading, writing, searching, and executing code.
|
||||
model 19 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
reading, writing, searching, planning, and executing code.
|
||||
|
||||
The core design principle is a **UI-agnostic engine with pluggable frontends**.
|
||||
The engine (`ChatSession`) drives the conversation loop -- streaming, tool
|
||||
@@ -61,6 +61,7 @@ turnstone/
|
||||
ratelimit.py Per-IP token-bucket rate limiter (RateLimiter, TokenBucket)
|
||||
edit.py File edit utilities (find_occurrences, pick_nearest)
|
||||
safety.py Command safety validation (blocked patterns, sanitization)
|
||||
sandbox.py Math code sandboxing (AST validation, subprocess execution)
|
||||
web.py Web utilities (HTML stripping, SSRF prevention)
|
||||
api/
|
||||
schemas.py Shared Pydantic v2 models (auth, errors, WorkstreamState)
|
||||
@@ -90,7 +91,7 @@ turnstone/
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.17.0/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.16.45/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
@@ -101,7 +102,7 @@ turnstone/
|
||||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||||
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
|
||||
tools/
|
||||
*.json 16 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
*.json 19 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
```
|
||||
|
||||
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
|
||||
@@ -189,6 +190,7 @@ Phase 3: EXECUTE (parallel)
|
||||
(cancel_event also checked per line — kills process group on cancel)
|
||||
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
|
||||
call_id links tool_info items → streaming chunks → final result
|
||||
For plan tool: post-execution gate via ui.on_plan_review()
|
||||
```
|
||||
|
||||
### State Transitions
|
||||
@@ -207,7 +209,7 @@ The engine emits state changes via `_emit_state()` which calls
|
||||
"running" ---> tool execution
|
||||
|
|
||||
v
|
||||
"attention" ---> waiting for user approval
|
||||
"attention" ---> waiting for user approval / plan review
|
||||
|
|
||||
v
|
||||
"running" ---> executing approved tools
|
||||
@@ -229,13 +231,11 @@ The engine emits state changes via `_emit_state()` which calls
|
||||
|
||||
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
|
||||
|
||||
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 15
|
||||
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 14
|
||||
methods. Every frontend must implement all of them.
|
||||
|
||||
```python
|
||||
class SessionUI(Protocol):
|
||||
def on_turn_start(self) -> None: ...
|
||||
def on_turn_committed(self) -> None: ...
|
||||
def on_thinking_start(self) -> None: ...
|
||||
def on_thinking_stop(self) -> None: ...
|
||||
def on_reasoning_token(self, text: str) -> None: ...
|
||||
@@ -245,20 +245,13 @@ class SessionUI(Protocol):
|
||||
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||||
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
|
||||
def on_plan_review(self, content: str) -> str: ...
|
||||
def on_info(self, message: str) -> None: ...
|
||||
def on_error(self, message: str) -> None: ...
|
||||
def on_state_change(self, state: str) -> None: ...
|
||||
def on_rename(self, name: str) -> None: ... # propagate alias to tab/UI label
|
||||
```
|
||||
|
||||
`on_turn_start` fires at the top of each iteration of the send-loop;
|
||||
`on_turn_committed` fires immediately after `messages.append(assistant_msg)`.
|
||||
`SessionUIBase` uses both to reset the per-turn inflight buffers
|
||||
(`_ws_inflight_content` / `_ws_inflight_reasoning` / `_ws_inflight_seq`)
|
||||
that fuel the SSE refresh-resume `in_progress_snapshot` event — see
|
||||
the per-workstream events stream in
|
||||
[`docs/api-reference.md`](api-reference.md#get-v1apiworkstreamsws_idevents).
|
||||
|
||||
`on_rename` is called by the `/name` command (on success) and after a successful `/resume` (if the resumed session has an alias or title). `WebUI.on_rename` broadcasts a `ws_rename` event on the global SSE channel and updates the in-memory `Workstream.name`; `TerminalUI.on_rename` is a no-op.
|
||||
|
||||
### Three Implementations
|
||||
@@ -266,7 +259,7 @@ the per-workstream events stream in
|
||||
| Class | Module | Notes |
|
||||
|-------|--------|-------|
|
||||
| `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). |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval/plan. `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` | Discards all output; `approve_tools` always returns `(True, None)` |
|
||||
|
||||
### WorkstreamTerminalUI
|
||||
@@ -278,9 +271,10 @@ awareness:
|
||||
are appended to `_output_buffer` instead of written to stdout. When the user
|
||||
switches to this workstream, `flush_buffer()` replays them.
|
||||
|
||||
- **Approval blocking**: `approve_tools()` calls `_fg_event.wait()` when in
|
||||
background, blocking the worker thread until the workstream is foregrounded.
|
||||
This ensures the user sees the approval prompt in the correct context.
|
||||
- **Approval blocking**: `approve_tools()` and `on_plan_review()` call
|
||||
`_fg_event.wait()` when in background, blocking the worker thread until the
|
||||
workstream is foregrounded. This ensures the user sees the approval prompt
|
||||
in the correct context.
|
||||
|
||||
- **Foreground/background toggle**: `set_foreground(bool)` sets or clears
|
||||
`_fg_event` (a `threading.Event`). The manager calls this during `/ws <N>`
|
||||
@@ -417,6 +411,7 @@ turnstone metadata keys:
|
||||
|
||||
| Metadata Key | Type | Meaning |
|
||||
|-------------|------|---------|
|
||||
| `agent` | `bool` | Include this tool when running as a plan/task sub-agent |
|
||||
| `task_agent` | `bool` | Include this tool when running as a task sub-agent |
|
||||
| `auto_approve` | `bool` | Tool is read-only; skip user approval |
|
||||
| `primary_key` | `str` | Fallback argument name for bare-string JSON recovery |
|
||||
@@ -436,6 +431,7 @@ Example (`read_file.json`):
|
||||
},
|
||||
"required": ["path"]
|
||||
},
|
||||
"agent": true,
|
||||
"task_agent": true,
|
||||
"auto_approve": true,
|
||||
"primary_key": "path"
|
||||
@@ -446,17 +442,19 @@ At import time, `turnstone.core.tools._load_tools()` strips the metadata keys
|
||||
from each schema and builds:
|
||||
|
||||
- `TOOLS` -- list of `{"type": "function", "function": {...}}` dicts for the API
|
||||
- `AGENT_TOOLS` -- subset with `agent: true`
|
||||
- `TASK_AGENT_TOOLS` -- subset with `task_agent: true`
|
||||
- `TASK_AUTO_TOOLS` -- set of tool names with `auto_approve: true`
|
||||
- `AGENT_AUTO_TOOLS` / `TASK_AUTO_TOOLS` -- sets of tool names with `auto_approve: true`
|
||||
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
|
||||
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
|
||||
|
||||
### 16 Tools by Category
|
||||
### 19 Tools by Category
|
||||
|
||||
**Read-only (auto-approve)**:
|
||||
- `read_file` -- read file contents with optional offset/limit
|
||||
- `diff_file` -- show diff between two files / versions
|
||||
- `search` -- ripgrep-based codebase search
|
||||
- `man` -- read man pages
|
||||
- `recall` -- search conversation history
|
||||
- `read_resource` -- read an MCP resource by URI
|
||||
|
||||
@@ -464,21 +462,23 @@ from each schema and builds:
|
||||
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
|
||||
- `write_file` -- create or overwrite a file
|
||||
- `edit_file` -- string replacement in an existing file (requires prior `read_file`)
|
||||
- `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`)
|
||||
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
|
||||
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, self-hosted SearxNG fallback for local models)
|
||||
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, Tavily fallback for local models)
|
||||
- `notify` -- send a user-facing notification (Discord/Slack, optional reply routing)
|
||||
- `watch` -- schedule a recurring poll with condition DSL
|
||||
|
||||
**Agent (delegated sub-sessions)**:
|
||||
- `task_agent` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||||
- `plan_agent` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
|
||||
|
||||
**Memory / skills / prompts**:
|
||||
- `memory` -- save, search, delete, or list memories (typed and scoped)
|
||||
- `skill` -- invoke a skill (governed, versioned procedure)
|
||||
- `use_prompt` -- fetch and apply a prompt template
|
||||
|
||||
The tool name uses the `_agent` suffix — bare `task` collides with
|
||||
chat-template channels on some local models.
|
||||
Tool names are `plan_agent` / `task_agent` (not `plan` / `task`); bare words
|
||||
collide with chat-template channels on some local models.
|
||||
|
||||
### Prepare / Execute Pattern
|
||||
|
||||
@@ -497,11 +497,17 @@ separation allows the UI to show previews before any side effects occur.
|
||||
|
||||
### Agent Tools
|
||||
|
||||
`task_agent` invokes `_run_agent()`, which runs a multi-turn loop with a
|
||||
subset of tools and its own system prompt. The sub-agent runs independently,
|
||||
then returns the final content as the tool result.
|
||||
`task_agent` and `plan_agent` invoke `_run_agent()`, which runs a multi-turn
|
||||
loop with a subset of tools and its own system prompt. The sub-agent runs
|
||||
independently, then returns the final content as the tool result.
|
||||
|
||||
- **task_agent**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||||
- **plan_agent**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
|
||||
to `.plan-<ws_id>.md` — unique per `ChatSession` so concurrent workstreams
|
||||
don't collide. On repeat invocations the prior `plan_agent` tool call and its result
|
||||
are forwarded from `self.messages` so the agent refines the existing plan rather
|
||||
than starting over. Planning instructions are injected as a developer message
|
||||
prepended to the agent's conversation.
|
||||
- **Turn limit**: controlled by `agent_max_turns` (default: `-1`, unlimited).
|
||||
When a limit is set and reached, the agent is forced to synthesize a final
|
||||
response without tools. When unlimited, the loop only exits when the model
|
||||
@@ -540,16 +546,18 @@ adds, removes, or reconnects servers as needed.
|
||||
6. `_exec_mcp_tool()` calls `call_tool_sync()` which dispatches to the async loop
|
||||
via `asyncio.run_coroutine_threadsafe()`
|
||||
|
||||
**Tool refresh:** Two mechanisms keep tools up-to-date without restart:
|
||||
**Tool refresh:** Three mechanisms keep tools up-to-date without restart:
|
||||
- **Push:** Servers declaring `tools.listChanged` send `ToolListChangedNotification`;
|
||||
the registered `message_handler` triggers immediate single-server refresh.
|
||||
- **Periodic:** Servers without push support are polled on a staggered interval
|
||||
(default 4 h, configurable via `[mcp] refresh_interval` or `--mcp-refresh-interval`).
|
||||
- **Manual:** `/mcp refresh [server]` calls `refresh_sync()` for on-demand refresh
|
||||
(also attempts reconnection for disconnected servers).
|
||||
|
||||
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
|
||||
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
|
||||
rebuilds its `_tools` and `_task_tools` lists and reconstructs `ToolSearchManager`
|
||||
(preserving expanded tools).
|
||||
rebuilds its merged tool lists and reconstructs `ToolSearchManager` (preserving
|
||||
expanded tools).
|
||||
|
||||
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
||||
at connection time (server names with `__` are rejected).
|
||||
@@ -564,11 +572,10 @@ from a healthy connection do not trip the breaker. When the cooldown expires
|
||||
(`call_tool_sync`, `read_resource_sync`, `get_prompt_sync`, `refresh_sync`)
|
||||
cancel orphaned futures on timeout to prevent coroutine accumulation on the
|
||||
background event loop. Push notification refreshes are debounced (5 s per
|
||||
server) to protect against notification storms. Operators can force a
|
||||
catalog refresh or full reconnect from the admin panel; reconnects clear
|
||||
the circuit breaker and run a fresh handshake. Transport stream references
|
||||
are pre-closed before stack teardown to work around the MCP SDK's anyio
|
||||
cancel-scope CPU busy-loop (SDK #2147).
|
||||
server) to protect against notification storms. The periodic refresh loop
|
||||
attempts reconnection for disconnected servers with exponential backoff
|
||||
(60 s–1 h). Transport stream references are pre-closed before stack teardown to
|
||||
work around the MCP SDK's anyio cancel-scope CPU busy-loop (SDK #2147).
|
||||
|
||||
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
|
||||
servers are unaffected. Tool execution errors return error strings to the LLM
|
||||
@@ -613,15 +620,14 @@ LLMProvider (protocol)
|
||||
| `get_capabilities()` | Per-model flags (`ModelCapabilities`) |
|
||||
| `convert_tools()` | Translate OpenAI tool schemas to provider format |
|
||||
| `retryable_error_names` | Exception class names that trigger retry |
|
||||
| `extract_reasoning_text()` | Walk stored `provider_blocks`, return concatenated reasoning text for UI rehydration (per-provider block-type knowledge: Anthropic `thinking`, OpenAI Responses `reasoning`, OpenAI Chat synthetic `reasoning_text`) |
|
||||
|
||||
**Normalized data types:**
|
||||
|
||||
| Type | Fields |
|
||||
|------|--------|
|
||||
| `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` |
|
||||
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason` |
|
||||
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision` |
|
||||
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
|
||||
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
|
||||
@@ -634,7 +640,7 @@ 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.
|
||||
permissive defaults with `supports_vision=False` and use Tavily for web search.
|
||||
|
||||
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
|
||||
Anthropic content blocks, maps `system`/`developer` roles to the `system`
|
||||
@@ -652,8 +658,9 @@ display). Automatic prompt caching is enabled via top-level `cache_control:
|
||||
cacheable block and advances it as conversations grow (90% input cost
|
||||
reduction on cache hits, 1.25x write on first turn). Cache metrics
|
||||
(`cache_creation_input_tokens`, `cache_read_input_tokens`) are extracted from
|
||||
both streaming and non-streaming responses. The `anthropic` SDK is a core
|
||||
dependency — the Anthropic provider is first-class alongside OpenAI.
|
||||
both streaming and non-streaming responses. The `anthropic` SDK is imported
|
||||
lazily so it remains an optional dependency (`pip install
|
||||
turnstone[anthropic]`).
|
||||
|
||||
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
|
||||
the Gemini `/v1beta/openai/` endpoint. Uses a single default
|
||||
@@ -702,41 +709,12 @@ 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
|
||||
ConfigStore. When unset (`NULL`), the global default is used.
|
||||
|
||||
**Per-model reasoning persistence:** Two booleans on `model_definitions`
|
||||
(migration 052) control how reasoning text round-trips:
|
||||
|
||||
* `surface_persisted_reasoning` (default `True`) — gates whether stored
|
||||
reasoning text is surfaced on `/history` payloads for UI rehydration.
|
||||
**Storage of reasoning bytes happens regardless of this flag** — they
|
||||
ride in `provider_data` independently. Phase-1 admin UI label "Surface
|
||||
persisted reasoning."
|
||||
* `replay_reasoning_to_model` (default `False`) — gates whether stored
|
||||
reasoning blocks are sent back to the provider on subsequent turns.
|
||||
Capability-gated: `ModelCapabilities.supports_reasoning_replay` must
|
||||
also be `True` for the wire path to actually replay (canonical OpenAI
|
||||
gpt-5*/o-series and Anthropic Claude entries set it; unknown / local-
|
||||
server models default to `False`).
|
||||
|
||||
Three reasoning paths are recognised:
|
||||
|
||||
| Path | Provider | Capture | Persist | Replay |
|
||||
|------|----------|---------|---------|--------|
|
||||
| 1 | Anthropic Messages API | `thinking_delta` | `provider_blocks` (`type="thinking"`) | Verbatim via `_provider_content` |
|
||||
| 2 | OpenAI Responses (gpt-5*, o-series) | `response.reasoning_text.delta` events | `provider_blocks` (`type="reasoning"`) — only when `include=["reasoning.encrypted_content"]` | `ResponseReasoningItemParam` input items |
|
||||
| 3 | OpenAI Chat Completions (vLLM, llama.cpp, Gemini-compat) | `delta.reasoning_content` Pydantic extras | Synthetic `{type: "reasoning_text", text, source}` block stamped at end-of-stream | None — no API surface for replay on Chat Completions |
|
||||
|
||||
Cross-provider safety is enforced by `ANTHROPIC_VALID_BLOCK_TYPES` (a
|
||||
shape filter in `_anthropic.py:_convert_messages`): foreign blocks
|
||||
(OpenAI `reasoning`, synthetic `reasoning_text`) fall through to the
|
||||
text+tool_calls rebuild path rather than reaching Anthropic's input
|
||||
boundary as malformed content.
|
||||
|
||||
```toml
|
||||
[models.local]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
@@ -765,63 +743,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
|
||||
```
|
||||
|
||||
The reasoning toggle does NOT use Anthropic's `thinking` request param.
|
||||
Toggle it through the chat template instead: set `{"chat_template_kwargs":
|
||||
{"thinking": false}}` as extra body params in the admin Models
|
||||
server-compat section (for this provider the section shows only the
|
||||
extra-body field — server type, API surface, and thinking mode are
|
||||
openai-compatible-only knobs); the provider forwards it via the SDK's
|
||||
`extra_body`.
|
||||
|
||||
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
|
||||
@@ -843,7 +764,7 @@ with the same alias in-memory (the DB rows are never modified).
|
||||
parameters
|
||||
6. `_create_stream_with_retry()` tries the primary model, then each fallback
|
||||
alias in order if the primary is unreachable
|
||||
7. `_run_agent()` resolves `registry.agent_model` (if set) for task
|
||||
7. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
|
||||
sub-agents, allowing a cheaper model for autonomous loops
|
||||
|
||||
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
|
||||
@@ -852,7 +773,7 @@ which can override the model before workstream creation.
|
||||
|
||||
### Tool Output Truncation
|
||||
|
||||
Tool execution results (bash, read_file, search) are truncated by
|
||||
Tool execution results (bash, read_file, search, math, man) are truncated by
|
||||
`_truncate_output()` when they exceed `tool_truncation` characters. Truncation
|
||||
preserves the first half and last half of the output, with a message in
|
||||
between:
|
||||
@@ -1098,12 +1019,11 @@ warns if the summary was truncated.
|
||||
unhandled promise rejections
|
||||
- **Pending approval across tab switches**: `WebUI._pending_approval` stores
|
||||
the `approve_request` event payload while the session is blocked waiting
|
||||
for user response. On tab switch / reconnect the pane reloads history via
|
||||
REST `GET /history` and then reconnects SSE; the live approval event is
|
||||
re-injected. The server-side `project_history_messages` projection marks
|
||||
the trailing orphan tool-call turn `"pending": true` so `replayHistory`
|
||||
skips the false `✓ approved` badge; the live approval UI is rendered by
|
||||
the re-injected event instead.
|
||||
for user response. On SSE reconnect (e.g., switching back to the tab),
|
||||
the event is re-injected after history replay. `_build_history` marks the
|
||||
pending tool call as `"pending": true` so `replayHistory` skips the
|
||||
false `✓ approved` badge; the live approval UI is rendered by the
|
||||
re-injected event instead.
|
||||
- **Browser history integration**: `history.pushState` is called in
|
||||
`switchTab()` with `{turnstone: 'workstream', wsId}`. The initial state is
|
||||
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
|
||||
@@ -1280,6 +1200,7 @@ Starlette ASGI app (served by uvicorn)
|
||||
+-- Async request handlers (all under /v1/ prefix)
|
||||
| POST /v1/api/workstreams/{ws_id}/send -> starts worker thread per workstream
|
||||
| POST /v1/api/workstreams/{ws_id}/approve -> unblocks WebUI._approval_event
|
||||
| POST /v1/api/plan -> unblocks WebUI._plan_event
|
||||
| POST /v1/api/workstreams/new -> creates workstream + worker
|
||||
| GET /v1/api/workstreams/{ws_id}/events -> SSE via EventSourceResponse (per workstream)
|
||||
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
|
||||
@@ -1289,7 +1210,7 @@ Starlette ASGI app (served by uvicorn)
|
||||
|
|
||||
+-- Worker thread per workstream (daemon)
|
||||
| Runs session.send() synchronously -- ChatSession is fully blocking
|
||||
| Blocks on WebUI._approval_event (threading.Event)
|
||||
| Blocks on WebUI._approval_event / _plan_event (threading.Event)
|
||||
|
|
||||
+-- Background daemon threads
|
||||
Global SSE fan-out: reads global_queue, copies to per-client queues
|
||||
@@ -1313,7 +1234,7 @@ registry).
|
||||
|
||||
Each workstream's `WebUI` has:
|
||||
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
|
||||
- `_approval_event` (`threading.Event` for blocking)
|
||||
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
|
||||
- `_global_queue` (class variable, shared, for state broadcasts)
|
||||
|
||||
The SSE handlers bridge these sync queues to async via
|
||||
@@ -1569,7 +1490,8 @@ implemented in `turnstone/core/judge.py`:
|
||||
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
|
||||
approval, and configured via the `[judge]` config section or `--judge` CLI
|
||||
flags. By default it uses self-consistency (same model), but supports
|
||||
cross-model and cross-provider configurations. Task sub-agents are exempt. All verdicts are persisted to the `intent_verdicts` table
|
||||
cross-model and cross-provider configurations. Sub-agents (plan, task)
|
||||
are exempt. All verdicts are persisted to the `intent_verdicts` table
|
||||
(migration 012) with the user's final decision, enabling future calibration.
|
||||
The console exposes `GET /v1/api/admin/verdicts` for audit queries
|
||||
(requires `admin.judge` permission).
|
||||
|
||||
@@ -110,18 +110,11 @@ owns it; the node is just currently unreachable.
|
||||
|
||||
### Example — `spawn_batch`
|
||||
|
||||
This is the coordinator-tool result shape (the JSON the LLM receives),
|
||||
not an HTTP API response — the table above keys it under "model tool"
|
||||
to distinguish it from the `/v1/api/...` endpoints in the same table.
|
||||
The underlying HTTP spawn endpoint still returns `ws_id`; the tool
|
||||
result re-keys it to `child_ws_id` to defuse a coordinator-LLM recency
|
||||
bias (see `docs/coordinator-skills.md`).
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"0": {"child_ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3"},
|
||||
"2": {"child_ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1"}
|
||||
"0": {"ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3"},
|
||||
"2": {"ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1"}
|
||||
},
|
||||
"denied": [
|
||||
{"idx": 1, "reason": "skill not found: nonexistent-skill"}
|
||||
|
||||
+15
-5
@@ -99,10 +99,10 @@ TURNSTONE_DISCORD_GUILD=123456789
|
||||
Then start the stack:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
The `channel` gateway runs by default; the Discord adapter activates once
|
||||
The `channel` service starts automatically when
|
||||
`TURNSTONE_DISCORD_TOKEN` is set.
|
||||
|
||||
### 3. Link User Accounts
|
||||
@@ -179,6 +179,7 @@ both and the gateway hosts both adapters in one process.
|
||||
see starts a per-user channel session.
|
||||
- Tool approvals render as Slack **Block Kit** buttons; only the user
|
||||
who owns the workstream can approve/reject.
|
||||
- Plan reviews render as a modal with approve / request-changes actions.
|
||||
- Notifications and reply routing work identically to Discord.
|
||||
- Session recovery: persisted channel routes are re-subscribed when the
|
||||
bot restarts, so existing Slack conversations keep flowing.
|
||||
@@ -234,6 +235,15 @@ config, the bot auto-responds with approval and posts a
|
||||
field (useful for allowing specific tools like `bash` or `read_file` while
|
||||
still requiring manual approval for others).
|
||||
|
||||
### Plan Reviews
|
||||
|
||||
Plan review requests are displayed as a blue embed with:
|
||||
|
||||
- **Approve Plan** (green) button — approves the plan with empty feedback
|
||||
- **Request Changes** (gray) button — opens a modal for feedback text
|
||||
(up to 2000 characters)
|
||||
- Feedback is forwarded to the server via HTTP
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
@@ -421,9 +431,9 @@ message with a `ws_id` so that user replies can be routed back to the
|
||||
originating workstream. Adapters must track the mapping from outgoing
|
||||
message ID to `(ws_id, target_user_id)` and handle DM replies.
|
||||
|
||||
Platform-specific concerns — approval prompts, message edits, thread
|
||||
creation — live inside the adapter implementation and are not part of
|
||||
the protocol surface. Each adapter drives those via its
|
||||
Platform-specific concerns — approval prompts, plan reviews, message
|
||||
edits, thread creation — live inside the adapter implementation and are
|
||||
not part of the protocol surface. Each adapter drives those via its
|
||||
own `_on_ws_event` dispatcher using SDK-native APIs.
|
||||
|
||||
To add a new platform:
|
||||
|
||||
@@ -115,8 +115,7 @@ with a `type` field. The recurring shapes a UI has to handle:
|
||||
| `tool_output_chunk` | Streaming tool output (e.g. long bash command) | `call_id`, `chunk` |
|
||||
| `approve_request` | One or more tool calls need operator approval | `items: [{call_id, header, preview, func_name, approval_label, needs_approval}]` |
|
||||
| `approval_resolved` | Operator answered the approval prompt | `approved`, `feedback` |
|
||||
| `state_change` | Worker-thread state transition (also re-emitted with the current state on every fresh subscribe so refresh-mid-stream restores composer mode) | `state` ∈ `running`, `thinking`, `attention`, `idle`, `error` |
|
||||
| `in_progress_snapshot` | One-shot replay of the in-progress turn's content + reasoning when this client connects mid-stream | `content`, `reasoning` |
|
||||
| `state_change` | Worker-thread state transition | `state` ∈ `running`, `thinking`, `attention`, `idle`, `error` |
|
||||
| `status` | Token usage + context-window snapshot (fires on every streaming tick) | `prompt_tokens`, `completion_tokens`, `total_tokens`, `context_window`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
| `rename` | Session's display name changed | `name` |
|
||||
| `intent_verdict` | Intent judge produced a verdict on a pending tool call | `risk_level`, `recommendation`, `reasons` |
|
||||
@@ -131,13 +130,9 @@ with a `type` field. The recurring shapes a UI has to handle:
|
||||
|
||||
**Reconnection contract:** a freshly-opened SSE connection receives
|
||||
the current snapshot of any pending tool approval (`approve_request`
|
||||
is re-sent if unresolved), any in-flight `wait_*` / `batch_*`
|
||||
indicator, the worker's current `state_change`, and an
|
||||
`in_progress_snapshot` carrying any partial content / reasoning the
|
||||
model has produced for the in-progress turn — so a tab refresh
|
||||
mid-approval, mid-tool-execution, or mid-stream restores both the
|
||||
correct composer mode and the partial assistant text without waiting
|
||||
for the response to complete.
|
||||
is re-sent if unresolved) and any in-flight `wait_*` / `batch_*`
|
||||
indicator — so a tab refresh mid-approval doesn't strand the
|
||||
operator.
|
||||
|
||||
---
|
||||
|
||||
@@ -237,21 +232,14 @@ Key properties:
|
||||
tool with a fresh timeout.
|
||||
- **Modes** — `mode="any"` returns as soon as one child reaches a
|
||||
real terminal state (`idle` / `error` / `closed` / `deleted`);
|
||||
`mode="all"` waits for every polled child to reach a real
|
||||
terminal state.
|
||||
`mode="all"` waits for every polled child.
|
||||
- **Progress throttling** — the poll loop runs every 500 ms but the
|
||||
SSE emission is diff-on-state-change plus a 5-second heartbeat. A
|
||||
600 s wait generates O(dozens) of progress events, not 1200.
|
||||
- **Unresolvable ids** — ws_ids are validated up front (exactly
|
||||
32 hex chars; copy them verbatim): a malformed id fails the call
|
||||
immediately with did-you-mean suggestions and a roster of the
|
||||
coord's children. An id the caller doesn't own, a missing row, or
|
||||
a child hard-deleted mid-wait is reported as `state="not_found"`
|
||||
and aborts the wait on the tick that observes it (top-level
|
||||
`error` / `not_found` / `children` fields, `complete=false`) — the
|
||||
LLM should fix the id and re-issue, not conclude the child died.
|
||||
Foreign and missing collapse into one shape, so the wait can't be
|
||||
used as an existence oracle.
|
||||
- **Denied rows** — an id the caller doesn't own (cross-tenant) or a
|
||||
missing row is reported as a `denied` state in the results dict;
|
||||
`mode="any"` won't satisfy on a pure-denied list (the LLM should
|
||||
treat it as a config error, not a completion).
|
||||
|
||||
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
|
||||
loop — a wait consumes one assistant turn regardless of how long the
|
||||
|
||||
+39
-74
@@ -14,46 +14,34 @@ care about.
|
||||
|
||||
---
|
||||
|
||||
## `kind` — authored audience metadata
|
||||
## The two-surface model
|
||||
|
||||
A row in `prompt_templates` carries a `kind` column (see
|
||||
[`turnstone/core/skill_kind.py`](../turnstone/core/skill_kind.py);
|
||||
migration 044 added the column). Three values:
|
||||
|
||||
| `SkillKind` enum | Stored as | Meaning |
|
||||
|-------------------------|-----------------|----------------------------------------------------------------------------|
|
||||
| `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. |
|
||||
| `SkillKind` enum | Stored as | Visible in |
|
||||
|----------------------|-----------------------------|---------------------------------------------------------------------------|
|
||||
| `SkillKind.INTERACTIVE` | `"interactive"` | Only the interactive-session activation path. `list_skills` on a coord won't show it. |
|
||||
| `SkillKind.COORDINATOR` | `"coordinator"` | Only the coordinator's `list_skills` tool. Hidden from interactive activation pickers. |
|
||||
| `SkillKind.ANY` | `"any"` | Both surfaces. Default for legacy rows predating the classifier. |
|
||||
|
||||
The `kind` field is a `StrEnum` — drop-in `str` compatible — so DB
|
||||
rows, JSON payloads, and `==` comparisons all work without translation
|
||||
at the edge.
|
||||
The `kind` field is a `StrEnum` — drop-in ``str`` compatible — so
|
||||
DB rows, JSON payloads, and `==` comparisons all work without
|
||||
translation at the edge.
|
||||
|
||||
**`kind` is metadata, not an enforcement boundary.** The model can
|
||||
`skills(action='find')` across every kind from any session, `get` any
|
||||
row by name, and `load` any visible skill regardless of session kind.
|
||||
Actual runtime capability is gated by `allowed_tools` + `auto_approve`
|
||||
on the skill and the operator's approval card on every `load` /
|
||||
`spawn_workstream(skill=...)` decision — `kind` doesn't add or remove
|
||||
any of that. It's a sorting / grouping / search-narrowing hint.
|
||||
When a coordinator calls `list_skills`, the SQL filter narrows to
|
||||
`kind IN ('coordinator', 'any')`. When an interactive session picks
|
||||
a skill at activation, the filter narrows to
|
||||
`kind IN ('interactive', 'any')`. A skill author tags once at
|
||||
creation; the two surfaces stay partitioned without any
|
||||
per-call filtering on the LLM side.
|
||||
|
||||
The opt-in filter is on `skills(action='find', kind='coordinator')`
|
||||
(or `'interactive'`) — pass it when you want to narrow a catalog
|
||||
browse to a specific authored audience. Omitting it (or passing
|
||||
`kind='any'`) returns the full catalog. When supplied, the storage
|
||||
filter widens to `[<kind>, 'any']` so audience-neutral rows remain
|
||||
visible inside the narrowed view.
|
||||
|
||||
**Tagging a new skill as coordinator-targeted** — set `kind` to
|
||||
`SkillKind.COORDINATOR` (or the literal `"coordinator"`) when you
|
||||
`skills(action='create', kind='coordinator', ...)` or POST to
|
||||
`/v1/api/admin/skills`. Use this to signal intent to other skill
|
||||
authors and to make the orchestrator-targeted catalog easy to
|
||||
browse — not to hide the skill from interactive sessions. Existing
|
||||
rows default to `SkillKind.ANY`; bump them to `COORDINATOR` if
|
||||
you've rewritten the prompt around the orchestrator toolset and
|
||||
want the kind filter to surface them as such.
|
||||
**Tagging a new skill as coordinator-only** — set `kind` to
|
||||
`SkillKind.COORDINATOR` (or the literal string `"coordinator"`) when
|
||||
you POST to `/v1/api/admin/skills`. Existing rows default to
|
||||
`SkillKind.ANY`; bump them to `COORDINATOR` if you've rewritten the
|
||||
prompt around the orchestrator toolset.
|
||||
|
||||
---
|
||||
|
||||
@@ -76,9 +64,7 @@ or MCP config can do adds to it. Current members:
|
||||
| `cancel_workstream` | wind-down | Drop the in-flight generation; leaves child idle for a fresh send. |
|
||||
| `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). |
|
||||
| `notify` | broadcast | Post a status update to a human channel at a narrative beat. |
|
||||
| `list_skills` | discover | Coordinator-visible skills only (SkillKind filter above). |
|
||||
| `tasks` | plan | Orchestrator-only scratchpad. Children don't see it. |
|
||||
|
||||
Explicitly **not** in the coordinator set:
|
||||
@@ -86,8 +72,8 @@ Explicitly **not** in the coordinator set:
|
||||
- `bash` / `edit_file` / `write_file` / `append_file` / `diff_file` — no local FS.
|
||||
- `read_file` / `search` — no local FS reads.
|
||||
- `web_fetch` / `web_search` — no direct web access.
|
||||
- `task_agent` — sub-agent tool is zeroed on coord sessions.
|
||||
- `recall` / `watch` / `read_resource` / `use_prompt` — UX / persistence tools that belong to interactive sessions. The dual-kind `memory` / `skills` / `notify` tools are available on both kinds (see the table above).
|
||||
- `task_agent` / `plan_agent` — sub-agent tools are zeroed on coord sessions.
|
||||
- `memory` / `recall` / `notify` / `watch` / `read_resource` / `use_prompt` / `skill` — the orchestrator's "memory" is its children's outputs; these UX / persistence tools belong to interactive sessions.
|
||||
|
||||
If your skill needs a coordinator to "run a command" or "read a
|
||||
file", write the delegate pattern instead: spawn a child with an
|
||||
@@ -168,50 +154,29 @@ Every ws_id returned by `spawn_workstream` / `spawn_batch` is a
|
||||
invent ws_ids — a model that hallucinates `"child-1"` or `"ws-abc"`
|
||||
hits the tenant guard in `CoordinatorClient._is_own_subtree`, which
|
||||
validates ws_id against `parent_ws_id=coord_ws_id` AND
|
||||
`user_id=owner` in storage. The rejection shape is uniform and
|
||||
recovery-oriented:
|
||||
`user_id=owner` in storage. The rejection shape varies by tool:
|
||||
|
||||
- **Mutating ops** (`send_to_workstream`, `close_workstream`,
|
||||
`cancel_workstream`, `delete_workstream`) and
|
||||
**`inspect_workstream`** return
|
||||
`{"error": "no workstream matching '<ref>' among your children; …",
|
||||
"status": 404, "ws_id": "<ref>", "did_you_mean": [...],
|
||||
"children": [...], "children_truncated": bool}` — a did-you-mean
|
||||
(edit distance ≤ 3 against the coord's own children, which catches
|
||||
the garbled-hex incident class: a 32-char id whose `aaa` run
|
||||
collapsed to `a`) plus a roster of the coord's children. A ref
|
||||
that matches a child's display NAME is called out explicitly with
|
||||
the right id (names are mutable labels, not addresses). Foreign
|
||||
and nonexistent ids produce the same payload (no existence
|
||||
oracle), every hint references only the coord's own children, and
|
||||
near-miss ids are never auto-resolved — the skill should fix the
|
||||
id and re-issue, not treat the child as dead.
|
||||
- **`wait_for_workstream`** validates ids before waiting: a
|
||||
malformed id fails the whole call immediately (`invalid_ws_ids`
|
||||
carries the per-id payloads above, `elapsed=0`); a well-formed id
|
||||
that is foreign, nonexistent, or hard-deleted mid-wait surfaces as
|
||||
`state="not_found"` and aborts the wait on that tick with
|
||||
top-level `error` / `not_found` / `children` fields.
|
||||
`complete=true` therefore means every polled lane really finished
|
||||
— an unobservable id can neither burn the timeout nor ride along
|
||||
to a "complete" result.
|
||||
`cancel_workstream`, `delete_workstream`) return
|
||||
`{"error": "workstream not in coordinator subtree: <ws_id>", "status": 404}`
|
||||
— the skill should treat this as a tool error, not an empty result.
|
||||
- **`inspect_workstream`** returns `{"error": "workstream not found", "ws_id": "<ws_id>"}`
|
||||
(same shape as a genuinely missing row, so the guard can't be
|
||||
used as an existence oracle).
|
||||
- **`wait_for_workstream`** reports the offending id with
|
||||
`state="denied"` in its `results` dict; `mode="any"` won't
|
||||
satisfy on a pure-denied list, so a hallucinated id won't trick
|
||||
the wait into reporting "complete".
|
||||
|
||||
Pattern: capture each spawn result in the next tool call's input.
|
||||
The JSON tool-result carries `{"child_ws_id": "...", "name": "...",
|
||||
The JSON tool-result carries `{"ws_id": "...", "name": "...",
|
||||
"node_id": "...", "routing_strategy": "..."}`; the model should
|
||||
extract the `child_ws_id` and pass it as `ws_id` (or in the `ws_ids`
|
||||
list) to `inspect_workstream` / `wait_for_workstream` /
|
||||
`send_to_workstream` / `close_workstream` verbatim. The asymmetry
|
||||
— spawn returns `child_ws_id` but the other tools accept `ws_id` /
|
||||
`ws_ids` — is intentional: it defuses a coordinator-LLM recency
|
||||
bias where seeing `ws_id` in a spawn return primed re-spawn loops
|
||||
instead of progression to the wait phase.
|
||||
extract the ws_id and pass it to `inspect_workstream` /
|
||||
`wait_for_workstream` / `send_to_workstream` / `close_workstream`
|
||||
verbatim.
|
||||
|
||||
A UI that wants human-readable identifiers should render the `name`
|
||||
field and keep the workstream id as the click-through key — note
|
||||
that the id *value* is the same regardless of whether it arrived
|
||||
under the `child_ws_id` key (spawn return) or the `ws_id` key
|
||||
(every other tool's input/output); only the field name differs.
|
||||
field and keep the ws_id as the click-through key.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,12 +34,13 @@ package "turnstone/core/" <<Rectangle>> {
|
||||
component [metrics.py\nPrometheus metrics] as metrics <<core>>
|
||||
component [config.py\nTOML config] as config <<core>>
|
||||
component [safety.py\nPath validation] as safety <<core>>
|
||||
component [sandbox.py\nCommand sandbox] as sandbox <<core>>
|
||||
component [edit.py\nFile editing] as edit <<core>>
|
||||
component [web.py\nWeb helpers] as web <<core>>
|
||||
component [auth.py\nAuthentication] as auth <<core>>
|
||||
component [healthcheck.py\nBackendHealthMonitor] as healthcheck <<core>>
|
||||
component [ratelimit.py\nRateLimiter] as ratelimit <<core>>
|
||||
component [mcp_client.py\nMCPClientManager\n(push + manual refresh)] as mcp <<core>>
|
||||
component [mcp_client.py\nMCPClientManager\n(push + periodic refresh)] as mcp <<core>>
|
||||
component [tool_search.py\nToolSearchManager, BM25] as toolsearch <<core>>
|
||||
component [model_registry.py\nModelRegistry] as registry <<core>>
|
||||
}
|
||||
@@ -122,6 +123,7 @@ session --> tools
|
||||
session --> memory
|
||||
memory --> storage
|
||||
session --> safety
|
||||
session --> sandbox
|
||||
session --> edit
|
||||
session --> web
|
||||
session --> healthcheck
|
||||
|
||||
@@ -15,6 +15,7 @@ interface "SessionUI" as SessionUI <<Protocol>> {
|
||||
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
|
||||
+ on_tool_output_chunk(call_id: str, chunk: str)
|
||||
+ on_status(usage: dict, ctx_window: int, effort: str)
|
||||
+ on_plan_review(content: str) → str
|
||||
+ on_info(message: str)
|
||||
+ on_error(message: str)
|
||||
+ on_state_change(state: str)
|
||||
@@ -42,13 +43,15 @@ class "WorkstreamTerminalUI" as WsTermUI {
|
||||
class "WebUI" as WebUI {
|
||||
- _listeners: list[Queue]
|
||||
- _approval_event: Event
|
||||
- _plan_event: Event
|
||||
- _ws_prompt_tokens: int
|
||||
- _ws_tool_calls: dict
|
||||
+ resolve_approval(approved, feedback)
|
||||
+ resolve_plan(feedback)
|
||||
--
|
||||
Enqueues JSON events for SSE.
|
||||
Blocks on threading.Event for
|
||||
approval.
|
||||
approval/plan review.
|
||||
SSE handlers bridge Queue to
|
||||
async via run_in_executor().
|
||||
--
|
||||
@@ -66,10 +69,9 @@ class "NullUI" as NullUI {
|
||||
interface "LLMProvider" as LLMProvider <<Protocol>> {
|
||||
+ provider_name: str {property}
|
||||
+ get_capabilities(model) → ModelCapabilities
|
||||
+ create_streaming(client, model, messages, ..., replay_reasoning_to_model) → Iterator[StreamChunk]
|
||||
+ create_completion(client, model, messages, ..., replay_reasoning_to_model) → CompletionResult
|
||||
+ create_streaming(client, model, messages, ...) → Iterator[StreamChunk]
|
||||
+ create_completion(client, model, messages, ...) → CompletionResult
|
||||
+ convert_tools(tools) → list[dict]
|
||||
+ extract_reasoning_text(provider_blocks) → str
|
||||
+ retryable_error_names: frozenset[str] {property}
|
||||
--
|
||||
core/providers/_protocol.py
|
||||
@@ -124,7 +126,6 @@ class "ModelCapabilities" as ModelCaps <<frozen>> {
|
||||
+ supports_web_search: bool
|
||||
+ supports_tool_search: bool
|
||||
+ supports_vision: bool
|
||||
+ supports_reasoning_replay: bool
|
||||
}
|
||||
|
||||
' ChatSession
|
||||
@@ -142,6 +143,7 @@ class "ChatSession" as ChatSession {
|
||||
+ model_alias: str | None {property}
|
||||
- _tools: list[dict]
|
||||
- _task_tools: list[dict]
|
||||
- _agent_tools: list[dict]
|
||||
- _read_files: set[str]
|
||||
- system_messages: list[dict]
|
||||
--
|
||||
@@ -251,7 +253,7 @@ class "MCPClientManager" as MCPMgr {
|
||||
Background asyncio event loop
|
||||
bridges async MCP SDK to
|
||||
sync ChatSession dispatch.
|
||||
Push + manual refresh.
|
||||
Push + periodic + manual refresh.
|
||||
Resources + prompts discovered
|
||||
alongside tools at startup.
|
||||
--
|
||||
|
||||
@@ -24,14 +24,6 @@ CS -> DB : save_message(ws_id, "user", input)
|
||||
|
||||
group loop [while tool_calls present]
|
||||
|
||||
CS -> UI : on_turn_start()
|
||||
note right of UI
|
||||
SessionUIBase resets the per-turn inflight
|
||||
buffers (_ws_inflight_content / reasoning /
|
||||
seq) that fuel the SSE in_progress_snapshot
|
||||
event for mid-stream refresh resume.
|
||||
end note
|
||||
|
||||
CS -> UI : on_state_change("thinking")
|
||||
CS -> UI : on_thinking_start()
|
||||
|
||||
@@ -81,14 +73,6 @@ group loop [while tool_calls present]
|
||||
|
||||
CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio
|
||||
CS -> CS : messages.append(assistant_msg)
|
||||
CS -> UI : on_turn_committed()
|
||||
note right of UI
|
||||
Drops the per-turn inflight buffers — the
|
||||
assistant message is now in the history
|
||||
list, so the in_progress_snapshot must
|
||||
not re-render it during the next tool-
|
||||
execution window or the next streaming turn.
|
||||
end note
|
||||
CS -> DB : save_message(ws_id, "assistant", content)
|
||||
CS -> DB : save_message(ws_id, "tool_call", ...) ×N
|
||||
|
||||
@@ -139,9 +123,10 @@ group loop [while tool_calls present]
|
||||
read_file → open().read() or base64 image
|
||||
search → grep subprocess
|
||||
edit_file → string replace
|
||||
task → _run_agent() sub-loop
|
||||
task/plan → _run_agent() sub-loop
|
||||
math → sandboxed subprocess
|
||||
web_fetch → httpx + LLM summarize
|
||||
web_search → provider-native or SearxNG fallback
|
||||
web_search → provider-native or Tavily fallback
|
||||
memory/recall → SQLite
|
||||
end note
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (16 built-in + tool_search):**
|
||||
**Dispatch table (19 built-in + tool_search):**
|
||||
┌───────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├───────────────┼──────────────────┤
|
||||
@@ -34,10 +34,13 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ diff_file │ ✗ Auto-approve │
|
||||
│ math │ ✗ Auto-approve │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✗ Auto-approve │
|
||||
│ web_search │ ✗ Auto-approve │
|
||||
│ tool_search │ ✗ Auto-approve │
|
||||
│ task_agent │ ✓ Yes │
|
||||
│ plan_agent │ ✓ Yes │
|
||||
│ memory │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
@@ -107,10 +110,13 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
├─ _exec_write_file: makedirs + write
|
||||
├─ _exec_edit_file: find_occurrences + replace
|
||||
├─ _exec_search: grep subprocess
|
||||
├─ _exec_math: sandboxed subprocess
|
||||
├─ _exec_man: man/info subprocess
|
||||
├─ _exec_web_fetch: httpx.get + LLM summary
|
||||
├─ _exec_web_search: SearxNG JSON GET (fallback for local models)
|
||||
├─ _exec_web_search: Tavily API POST (fallback for local models)
|
||||
├─ _exec_tool_search: BM25 search + expand_visible()
|
||||
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
|
||||
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
|
||||
├─ _exec_notify: HTTP POST to channel gateway
|
||||
├─ _exec_memory: structured memory save/search/delete/list
|
||||
├─ _exec_recall: conversation history FTS5 search
|
||||
@@ -125,6 +131,11 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
|
||||
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
|
||||
:ui.on_tool_result(call_id, name, output, is_error) for each;
|
||||
|
||||
if (plan tool was executed?) then (yes)
|
||||
:ui.on_plan_review(output);
|
||||
:Block for user review/feedback;
|
||||
endif
|
||||
}
|
||||
|
||||
:Return (results, user_feedback);
|
||||
|
||||
@@ -13,7 +13,7 @@ skinparam state {
|
||||
state "IDLE" as idle <<idle>> : Waiting for user input.\nNo active LLM call or tool execution.
|
||||
state "THINKING" as thinking <<thinking>> : LLM streaming response.\nTokens flowing (reasoning + content).
|
||||
state "RUNNING" as running <<running>> : Tools executing.\nThreadPoolExecutor active.
|
||||
state "ATTENTION" as attention <<attention>> : Blocked on user action.\nTool approval needed.
|
||||
state "ATTENTION" as attention <<attention>> : Blocked on user action.\nTool approval or plan review needed.
|
||||
state "ERROR" as error <<error>> : Exception occurred.\nRecoverable on next send().
|
||||
|
||||
[*] --> idle : Session created
|
||||
@@ -34,6 +34,8 @@ attention --> running : User denies\n(denial recorded)\n_emit_state("running")
|
||||
|
||||
running --> thinking : Tool results appended,\nnext LLM call\n_emit_state("thinking")
|
||||
|
||||
running --> attention : Plan tool complete,\non_plan_review()\n_emit_state("attention")
|
||||
|
||||
running --> error : Exception during\ntool execution
|
||||
|
||||
error --> thinking : New send() call\n_emit_state("thinking")
|
||||
@@ -42,7 +44,7 @@ thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
|
||||
|
||||
running --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
attention --> idle : cancel() unblocks\napproval wait\n_emit_state("idle")
|
||||
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
|
||||
|
||||
note left of idle
|
||||
**Cancel escalation:**
|
||||
|
||||
@@ -30,6 +30,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ close_workstream()
|
||||
+ send(message, ws_id)
|
||||
+ approve()
|
||||
+ plan_feedback()
|
||||
+ command()
|
||||
+ cancel(ws_id)
|
||||
+ stream_events(ws_id)
|
||||
|
||||
@@ -190,25 +190,21 @@ group Push Notifications (debounced 5s per server)
|
||||
MCPMgr -> Storage : sync_prompts_to_storage()
|
||||
end
|
||||
|
||||
group Manual Refresh
|
||||
Session -> MCPMgr : refresh_sync()
|
||||
group Periodic Polling (default 4h)
|
||||
MCPMgr -> MCPMgr : _periodic_refresh()
|
||||
note right
|
||||
/mcp refresh [server] —
|
||||
re-fetches catalog and
|
||||
attempts reconnect for
|
||||
disconnected servers.
|
||||
Only polls capabilities
|
||||
without push support.
|
||||
Staggered per-server.
|
||||
Disconnected servers get
|
||||
reconnect attempts with
|
||||
exponential backoff (60s-1h).
|
||||
end note
|
||||
end
|
||||
|
||||
group Manual Reconnect
|
||||
Session -> MCPMgr : reconnect_sync(name)
|
||||
note right
|
||||
Operator-driven via the
|
||||
console admin panel —
|
||||
tears down session, clears
|
||||
circuit breaker, runs a
|
||||
fresh handshake.
|
||||
end note
|
||||
group Manual Refresh
|
||||
Session -> MCPMgr : refresh_sync()
|
||||
note right: /mcp refresh [server]
|
||||
end
|
||||
|
||||
== Policy Evaluation ==
|
||||
|
||||
@@ -202,7 +202,7 @@ note over Session, Judge
|
||||
Cross-model: separate provider/client from [judge] config.
|
||||
|
||||
**Sub-agent exemption:**
|
||||
Task sub-agents skip intent validation entirely.
|
||||
Plan agent and task agent skip intent validation entirely.
|
||||
|
||||
**Output guard:**
|
||||
Runs when judge_config.output_guard is true (default).
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:95dd5ebc899a1261d516686a5aa3319a7f45015d411302825fa28afbfc82e1ce
|
||||
size 326766
|
||||
oid sha256:a3b5c59403a6febd81667fc8fd2a7d22bc59da6130eba0dea5449c42668d0ede
|
||||
size 387044
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9857db23fe3c4316d492073aac69c7e7558b1abe3b95ad7756d4a5933bd0ece7
|
||||
size 620214
|
||||
oid sha256:474b900448ec04d1117b48a2b55614524721b2f04ac4bda66170bd0a06aae0f2
|
||||
size 624573
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d9c7769a600c38e6387390e6c42db8152e0f80c31d17b2218f7f636b71c7b868
|
||||
size 355459
|
||||
oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61
|
||||
size 325245
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:23ca090b5656baaf70820cbe4ab6c27f0a3a02e18b4db0695614cf9489c23980
|
||||
size 281440
|
||||
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
|
||||
size 281519
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d6aff446a062aa08f316985d00c2183148694f786d7f22172bc50b30046c728b
|
||||
size 379259
|
||||
oid sha256:7623df33be9baf7647ca1c2450640df57e1cd73e8be1f8168aae16e546ad683c
|
||||
size 459941
|
||||
|
||||
+110
-212
@@ -1,266 +1,164 @@
|
||||
# Docker Deployment
|
||||
|
||||
Turnstone ships two Docker Compose stacks:
|
||||
Docker Compose stack for running the full turnstone platform.
|
||||
|
||||
| Stack | File | Use it for |
|
||||
|-------|------|------------|
|
||||
| **Dev cluster** | `compose.yaml` (repo root) | Clone-and-run. Builds locally, zero config, full 10-node cluster. |
|
||||
| **Production** | `turnstone/deploy/compose.yaml` | Pip/pipx installs. Pulls released images from ghcr.io, requires real secrets. |
|
||||
|
||||
## Quick start — local cluster
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/turnstonelabs/turnstone
|
||||
cd turnstone
|
||||
# Copy and edit environment config
|
||||
cp .env.example .env
|
||||
|
||||
# Full stack (needs an LLM API on the host)
|
||||
docker compose up
|
||||
```
|
||||
|
||||
That builds one image and brings up the whole stack: PostgreSQL, the console,
|
||||
Caddy, the channel gateway, and **10 server nodes** (`node-1`…`node-10`). No
|
||||
`.env` is required — it ships with insecure dev defaults so it just works.
|
||||
Console dashboard: http://localhost:8090
|
||||
|
||||
Open the dashboard at **https://localhost:8443**. It's served by Caddy with its
|
||||
own local CA, so trust the root certificate once (or click through the browser
|
||||
warning):
|
||||
> See also: [Deployment diagram](diagrams/png/12-deployment.png)
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Port | Profile | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `server` | 8080 | default | Web UI + chat workstreams + LLM |
|
||||
| `console` | 8090 | default | Cluster dashboard |
|
||||
| `channel` | — | production | Channel gateway (Discord and/or Slack adapters) |
|
||||
| `server-1`…`server-10` | — | cluster | 10-node server fleet (PostgreSQL required) |
|
||||
|
||||
## Profiles
|
||||
|
||||
**Default** (no flag) — starts `server` and `console`. Requires an OpenAI-compatible LLM API running on the host (default: `http://localhost:8000/v1`).
|
||||
|
||||
```bash
|
||||
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Create your first admin user (any node works — they share one database):
|
||||
**Production** — adds PostgreSQL and the channel gateway. Requires `POSTGRES_PASSWORD` and (for Discord) `TURNSTONE_DISCORD_TOKEN`:
|
||||
|
||||
```bash
|
||||
docker compose exec node-1 turnstone-admin create-user --username admin --name "Admin"
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
### Bring your own LLM
|
||||
|
||||
Nodes boot **without** an LLM and appear in the console immediately. Add real
|
||||
model backends (OpenAI, Anthropic, or a local/vLLM endpoint) from the console
|
||||
UI's **Models** tab. To set a node's bootstrap default instead, point
|
||||
`LLM_BASE_URL` / `OPENAI_API_KEY` at an OpenAI-compatible endpoint in `.env`.
|
||||
|
||||
### Fewer nodes
|
||||
|
||||
Ten nodes is heavy on a laptop. Start a subset by naming the services (always
|
||||
include `postgres`, `console`, and `caddy`):
|
||||
**Cluster** — 10-node server fleet sharing PostgreSQL. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
|
||||
|
||||
```bash
|
||||
docker compose up postgres console caddy channel node-1 node-2 node-3
|
||||
docker compose --profile cluster up
|
||||
```
|
||||
|
||||
## Why HTTPS-only?
|
||||
|
||||
The console's plain-HTTP port (8090) is **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` on the internal network, so the cap
|
||||
is gone. Everything goes through `https://localhost:8443`.
|
||||
|
||||
## Join a bare-metal host
|
||||
|
||||
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`,
|
||||
the loader warns otherwise):
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
|
||||
|
||||
[database]
|
||||
backend = "postgresql"
|
||||
url = "postgresql+psycopg://turnstone:turnstone@localhost:5432/turnstone"
|
||||
|
||||
[api]
|
||||
base_url = "http://localhost:8000/v1" # your local model endpoint
|
||||
api_key = "dummy"
|
||||
```
|
||||
|
||||
Then start the server. The node identity isn't a secret, so it stays on the
|
||||
command line:
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.config/turnstone/config.toml
|
||||
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`. 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
|
||||
|
||||
For a real deployment use the bundled stack, which pulls released images
|
||||
instead of building:
|
||||
|
||||
```bash
|
||||
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 (`turnstone-bootstrap` generates them):
|
||||
|
||||
```bash
|
||||
TURNSTONE_JWT_SECRET=<python -c "import secrets; print(secrets.token_hex(32))">
|
||||
POSTGRES_PASSWORD=<a strong password>
|
||||
```
|
||||
|
||||
The dashboard is at **https://localhost:8443** (Caddy, same as the dev stack);
|
||||
the console's HTTP port isn't published. For a real domain and a publicly
|
||||
trusted cert, edit `turnstone/deploy/Caddyfile` to point Caddy at Let's Encrypt
|
||||
(see [tls.md](tls.md)). Pin the image with `TURNSTONE_IMAGE_TAG` (default:
|
||||
`latest`).
|
||||
|
||||
### mTLS
|
||||
|
||||
Layer the TLS overlay on the production stack to enable mutual TLS between
|
||||
services. A bootstrap container creates a CA and every service auto-provisions
|
||||
certs via the console's ACME endpoint:
|
||||
|
||||
```bash
|
||||
docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
```
|
||||
|
||||
See [tls.md](tls.md) for details.
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything is configured with environment variables in `.env` (copy from
|
||||
[`.env.example`](../.env.example)). The dev stack needs none of them — they're
|
||||
overrides.
|
||||
All configuration is via environment variables in `.env` (copy from `.env.example`):
|
||||
|
||||
### LLM backend
|
||||
### LLM Backend
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | Bootstrap OpenAI-compatible API URL (real backends go in the UI) |
|
||||
| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | OpenAI-compatible API URL |
|
||||
| `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) |
|
||||
| `TURNSTONE_SEARXNG_URL` | `http://searxng:8080` | SearxNG URL for the `web_search` tool (local/vLLM models only; Anthropic/OpenAI use native search). Defaults to the bundled `searxng` service; set to an external instance's URL. To turn web search off, clear `tools.searxng_url` in the admin Settings tab. |
|
||||
| `SEARXNG_IMAGE_TAG` | `latest` | Tag for the bundled `searxng/searxng` image |
|
||||
| `MODEL` | — | Override the default model alias |
|
||||
| `TAVILY_API_KEY` | — | Web search API key (only needed for local/vLLM models; Anthropic and OpenAI search models use native search) |
|
||||
|
||||
### Auth & database
|
||||
|
||||
| Variable | Default (dev / prod) | Description |
|
||||
|----------|----------------------|-------------|
|
||||
| `TURNSTONE_JWT_SECRET` | insecure default / **required** | JWT signing secret. Every service must share one value. |
|
||||
| `TURNSTONE_DB_BACKEND` | `postgresql` | `sqlite` or `postgresql`. Multi-node discovery requires `postgresql`. |
|
||||
| `TURNSTONE_DB_URL` | bundled Postgres | SQLAlchemy URL. Override to use an external database. |
|
||||
| `POSTGRES_USER` | `turnstone` | PostgreSQL username |
|
||||
| `POSTGRES_PASSWORD` | `turnstone` / **required** | PostgreSQL password |
|
||||
| `POSTGRES_MAX_CONNECTIONS` | `300` | `max_connections` for the bundled Postgres |
|
||||
|
||||
> **Discovery needs a shared database.** Each server registers and heartbeats
|
||||
> into a `services` table that the console polls. All services in these stacks
|
||||
> point at the same PostgreSQL by default; SQLite-per-container can't see other
|
||||
> containers.
|
||||
|
||||
> **Large clusters:** each process keeps a small pool (5 max). Beyond ~50 nodes,
|
||||
> put [PgBouncer](pgbouncer.md) (transaction pooling) between turnstone and
|
||||
> PostgreSQL.
|
||||
|
||||
### Ports
|
||||
|
||||
Both stacks publish Caddy (dashboard) and PostgreSQL; the dev stack additionally
|
||||
publishes the SearxNG UI on localhost. Everything else is reached through Caddy or
|
||||
proxied by the console:
|
||||
### Server
|
||||
|
||||
| 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) |
|
||||
| `POSTGRES_BIND` | `127.0.0.1` | Interface PostgreSQL binds on; set `0.0.0.0` for LAN access |
|
||||
| `SERVER_PORT` | `8080` | Host port mapping |
|
||||
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tools |
|
||||
|
||||
### Channel gateway
|
||||
### Console
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (enables the Discord adapter) |
|
||||
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to one guild (0 = all) |
|
||||
| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` |
|
||||
| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (with the Slack token) |
|
||||
| `CONSOLE_PORT` | `8090` | Host port mapping |
|
||||
|
||||
The channel runs HTTP-only with no adapters until a token is set, so it's safe
|
||||
to leave running. See [Channel Integrations](channels.md) for app setup.
|
||||
### Auth
|
||||
|
||||
### Web search (SearxNG)
|
||||
|
||||
The `web_search` tool for local/vLLM models is backed by a self-hosted
|
||||
[SearxNG](https://searxng.org) metasearch service, bundled into both stacks as the
|
||||
`searxng` service. The Turnstone nodes reach it over the internal docker network at
|
||||
`http://searxng:8080` — its API port is **not** published. Its config —
|
||||
[`turnstone/deploy/searxng/settings.yml`](../turnstone/deploy/searxng/settings.yml),
|
||||
mounted read-only — enables the JSON API and leaves the rate limiter off (the
|
||||
limiter would need a separate Valkey/Redis instance). A `searxng-cache` volume
|
||||
persists its favicon + internal cache across restarts. Commercial providers
|
||||
(Anthropic, OpenAI) use their own native search and never touch this service.
|
||||
|
||||
Point at an existing SearxNG instead of the bundled one with `TURNSTONE_SEARXNG_URL`,
|
||||
or narrow the engines via `tools.searxng_engines` in the admin Settings tab (e.g.
|
||||
`duckduckgo,wikipedia`).
|
||||
|
||||
**SearxNG web UI.** Caddy can also serve SearxNG's own search/Preferences UI on a
|
||||
dedicated port. The dev stack publishes it at **`https://localhost:8444`** bound to
|
||||
localhost only; the production stack does **not** publish it by default (uncomment
|
||||
the `8444` port on the `caddy` service to opt in). Change the port with
|
||||
`SEARXNG_HTTPS_PORT`. **SearxNG has no authentication** — never bind this to a public
|
||||
interface, or anyone who can reach it can search through your instance.
|
||||
|
||||
> **AGPL note for operators.** SearxNG is licensed AGPL-3.0. Kept on the internal
|
||||
> network (or bound to localhost), no external user interacts with it — so the AGPL
|
||||
> §13 (remote network interaction) source-offer obligation does not attach. If you
|
||||
> publish SearxNG to remote users (bind its port to a public interface, or front it
|
||||
> with your own reverse proxy) you become the operator of a network-reachable AGPL
|
||||
> service and must offer its corresponding source; that is trivially satisfied by
|
||||
> linking to upstream <https://github.com/searxng/searxng>. Turnstone's own license is
|
||||
> unaffected: it talks to SearxNG over HTTP as a separate process (mere aggregation),
|
||||
> not by linking.
|
||||
|
||||
### Other
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `WORKSPACE_MOUNT` | empty volume | Host directory bind-mounted at `/workspace` for the model to read/write |
|
||||
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tool calls (dev only) |
|
||||
| `MCP_CONFIG` | — | Path to an MCP server config file |
|
||||
| `TURNSTONE_IMAGE_TAG` | `latest` | ghcr.io image tag — production stack |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
|
||||
|
||||
## Building
|
||||
### Database
|
||||
|
||||
Both stacks install all entry points into a single image (`turnstone`,
|
||||
`turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`,
|
||||
`turnstone-eval`, `turnstone-bootstrap`):
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` |
|
||||
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql+psycopg://user:pass@postgres:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
|
||||
| `TURNSTONE_DB_POOL_SIZE` | `2` | PostgreSQL connection pool size per process (default: 2 base + 3 overflow = 5 max) |
|
||||
| `POSTGRES_USER` | `turnstone` | PostgreSQL container username (used in default `TURNSTONE_DB_URL` for cluster/channel) |
|
||||
| `POSTGRES_PASSWORD` | — | PostgreSQL container password (required for production and cluster profiles) |
|
||||
|
||||
The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage.
|
||||
|
||||
> **Upgrading from <1.3.0a4:** Earlier versions used `DB_BACKEND` and `DATABASE_URL` in `.env`, which `compose.yaml` mapped to the `TURNSTONE_`-prefixed names internally. These short aliases have been removed. Rename `DB_BACKEND` → `TURNSTONE_DB_BACKEND` and `DATABASE_URL` → `TURNSTONE_DB_URL` in your `.env` file.
|
||||
|
||||
> **Large clusters:** Each turnstone process maintains a small connection pool (5 max). At hundreds of nodes this adds up — use [PgBouncer](pgbouncer.md) in transaction pooling mode between turnstone and PostgreSQL.
|
||||
|
||||
> **First-time setup:** After deploying with auth enabled, create an initial admin user by running `turnstone-admin create-user` inside the container:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose exec server turnstone-admin create-user --username admin --name "Admin"
|
||||
> ```
|
||||
>
|
||||
> You will be prompted to set a password. Use it to log in via the UI or SDK, then create additional users through the admin API. Pass `--token --scopes read,write,approve` to also generate an initial API token.
|
||||
|
||||
### Channel Gateway
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord adapter) |
|
||||
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to a single Discord guild (0 = all guilds) |
|
||||
| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` (required to enable Slack adapter) |
|
||||
| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (required with `TURNSTONE_SLACK_TOKEN`) |
|
||||
| `TURNSTONE_SLACK_CHANNELS` | — | Comma-separated Slack channel IDs to allow (empty = all) |
|
||||
| `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command registered in the Slack app |
|
||||
|
||||
The channel service runs in the `production` profile. When
|
||||
`TURNSTONE_DISCORD_TOKEN` or the Slack pair is set the gateway starts the
|
||||
corresponding adapter; both can run in one process. See
|
||||
[Channel Integrations](channels.md) for platform app setup and user
|
||||
account linking.
|
||||
|
||||
## Scaling
|
||||
|
||||
For multi-node testing, use the `cluster` profile which provides 10 server instances with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL:
|
||||
|
||||
```bash
|
||||
docker compose build # build the dev image
|
||||
docker compose build --no-cache # rebuild from scratch
|
||||
POSTGRES_PASSWORD=secret docker compose --profile cluster up
|
||||
```
|
||||
|
||||
The default `server` also runs alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
|
||||
|
||||
For production clusters beyond ~50 nodes, add PgBouncer between turnstone services and PostgreSQL. See [PgBouncer Connection Pooling](pgbouncer.md) for Docker Compose and Helm configuration.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Volume | Purpose |
|
||||
|--------|---------|
|
||||
| `postgres-data` | PostgreSQL data directory |
|
||||
| `turnstone-data` | `/data` per node (SQLite fallback, local state) |
|
||||
| `workspace` | `/workspace` (unless `WORKSPACE_MOUNT` is set) |
|
||||
| `caddy-data` / `caddy-config` | Caddy's local CA and config (dev stack) |
|
||||
| Volume | Mount | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `turnstone-data` | `/data` | SQLite database (`.turnstone.db`) |
|
||||
|
||||
## Building
|
||||
|
||||
The image uses a multi-stage Dockerfile:
|
||||
|
||||
```bash
|
||||
# Build all services
|
||||
docker compose build
|
||||
|
||||
# Rebuild without cache
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
All entry points are installed in a single image: `turnstone`,
|
||||
`turnstone-server`, `turnstone-console`, `turnstone-channel`,
|
||||
`turnstone-admin`, `turnstone-eval`, and `turnstone-bootstrap`.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down # stop and remove containers
|
||||
docker compose down -v # also remove volumes (database, certs)
|
||||
# Stop and remove containers
|
||||
docker compose down
|
||||
|
||||
# Stop, remove containers and volumes
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
+2
-1
@@ -274,7 +274,8 @@ for iteration in 0..max_iterations:
|
||||
|
||||
### Phase 1: Analyst (`_run_analyst`)
|
||||
|
||||
A multi-turn agent with a `bash` tool for computing statistics. It receives per-case results with failure classifications and
|
||||
A multi-turn agent with `math` (Python) and `bash` tools for computing
|
||||
statistics. It receives per-case results with failure classifications and
|
||||
produces a structured diagnosis:
|
||||
|
||||
- **Failure patterns**: Shared root causes across failing cases
|
||||
|
||||
+13
-116
@@ -37,31 +37,13 @@ model = "" # empty = same as session model
|
||||
provider = "" # empty = same as session provider
|
||||
base_url = ""
|
||||
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)
|
||||
confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
|
||||
max_context_ratio = 0.5 # max % of judge context window for history
|
||||
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
|
||||
```
|
||||
|
||||
### Smart Approvals
|
||||
|
||||
With `smart_approvals = true` (off by default) a tool call is approved
|
||||
automatically — no operator prompt — when the intent judge's **LLM** verdict
|
||||
recommends `approve` with confidence at or above `confidence_threshold`. Every
|
||||
other outcome still reaches a human: `review` / `deny` recommendations,
|
||||
confidence below the threshold, judge errors or timeouts (`llm_fallback`), and
|
||||
any call the deterministic heuristic rules explicitly flagged `deny` or
|
||||
`critical`. That heuristic floor blocks only those explicit danger verdicts — it
|
||||
is **not** a general "never lower the heuristic" rule: the heuristic's default
|
||||
for an unmatched tool is `review`, and letting a confident LLM `approve` upgrade
|
||||
a `review` is exactly what Smart Approvals is for. Only `deny` / `critical`
|
||||
findings are off-limits to auto-approval. Requires the judge to be enabled;
|
||||
auto-approved calls are tagged `smart_approval` in the dashboard and audit trail.
|
||||
Smart Approvals applies to the web and coordinator surfaces, not the interactive
|
||||
CLI.
|
||||
|
||||
All fields are optional. The judge is enabled by default; use `enabled = false`
|
||||
(or `--no-judge` on the command line) to disable it.
|
||||
|
||||
@@ -72,12 +54,9 @@ All fields are optional. The judge is enabled by default; use `enabled = false`
|
||||
--judge-model MODEL Model for judge
|
||||
--judge-provider PROVIDER Provider for judge
|
||||
--judge-timeout SECONDS LLM judge timeout (default: 60)
|
||||
--judge-confidence FLOAT Confidence threshold, 0-1 (default: 0.95)
|
||||
--judge-confidence FLOAT Confidence threshold (default: 0.7)
|
||||
```
|
||||
|
||||
(Smart Approvals is configured via `[judge] smart_approvals` / the admin Judge
|
||||
settings, not a CLI flag — the interactive CLI prompts for approval directly.)
|
||||
|
||||
CLI flags override `config.toml` values.
|
||||
|
||||
---
|
||||
@@ -125,7 +104,7 @@ last) and returns the first matching rule. Each rule has:
|
||||
| Critical | 0.90 | deny | `rm -rf /`, `mkfs`, `dd if=`, pipe-to-shell, chmod 777 on root, write/edit to `/etc/` or `.ssh/`, download-then-execute chains (`curl -o file && chmod +x && bash`) |
|
||||
| High | 0.80 | review | `sudo`, `kill -9`, destructive git, DROP TABLE, write/edit secrets, HTTP mutations, `ssh`/`scp`, credential file access, browser automation + data export, transitive installs (`npx skills add`, `pip install git+`), control plane mutations (`crontab`, `systemctl enable/start/stop`) |
|
||||
| Medium | 0.70 | review | Content ingestion pipelines (`curl \| python3`), interpreter execution (`python3 script.py`, `node build.js`), cloud CLI mutations (`az/gcloud/aws/kubectl/terraform` with create/delete/destroy verbs), package installs, `write_file`, MCP tools, Docker operations |
|
||||
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `use_prompt`, `tool_search`, `read_resource`, `web_search`, read-only bash (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
|
||||
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `man`, `use_prompt`, `tool_search`, `read_resource`, `web_search`, read-only bash (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
|
||||
|
||||
When no rule matches, the heuristic returns a default verdict: medium risk,
|
||||
0.50 confidence, "review" recommendation.
|
||||
@@ -231,23 +210,6 @@ calls for approval, it calls `_evaluate_intent()` which:
|
||||
4. Attaches each heuristic verdict to its item as `_heuristic_verdict`
|
||||
5. The daemon thread runs the LLM judge and delivers results via `ui.on_intent_verdict()`
|
||||
|
||||
The daemon evaluates items sequentially, so a large parallel batch can outlive
|
||||
its approval gate. With `cancel_on_approval = false` (the default) the daemon
|
||||
runs every item to completion: verdicts that land after the operator decided
|
||||
still stream to the UI and persist, stamped with the decision. The daemon is
|
||||
aborted only when the next tool batch supersedes it or the session closes —
|
||||
then each unfinished item degrades to an `llm_fallback` verdict. With
|
||||
`cancel_on_approval = true` the abort additionally fires the moment the gate
|
||||
resolves, trading verdict completeness for inference savings — recommended
|
||||
when the judge shares a single local inference backend with the session model,
|
||||
where a large batch's remaining judge calls would otherwise compete with the
|
||||
next turn's completion.
|
||||
|
||||
Verdicts that arrive after a *newer batch* has replaced the judge generation
|
||||
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-agents (plan agent, task agent) are exempt from intent validation -- they
|
||||
always get full tool visibility without judge evaluation.
|
||||
|
||||
@@ -259,13 +221,7 @@ All verdicts are persisted to the `intent_verdicts` table (migration 012):
|
||||
|
||||
- Heuristic verdicts are stored when the `approve_request` event is emitted
|
||||
- LLM verdicts are stored when the `intent_verdict` event is delivered
|
||||
- The `user_decision` column is updated when the user approves or denies;
|
||||
auto-approved rows carry the bypass reason (`policy`, `blanket`,
|
||||
`auto_approve_tools`, `smart_approval`), and rows whose verdict landed only
|
||||
after a newer batch replaced the judge generation carry `superseded`
|
||||
- Every stored verdict — including the benign `risk_level = "none"` majority —
|
||||
is re-attached to its tool call on history replay, so a reloaded workstream
|
||||
shows the same verdict badges the live stream did
|
||||
- The `user_decision` column is updated when the user approves or denies
|
||||
|
||||
The console admin panel exposes verdict history via:
|
||||
|
||||
@@ -416,36 +372,10 @@ redact_secrets = true # auto-redact detected credentials (default)
|
||||
|
||||
Configurable at runtime via the admin Settings tab.
|
||||
|
||||
### Merge semantics (heuristic + LLM judge)
|
||||
|
||||
The chip is a **merge** of the two detectors (issue #560, "show, annotated"),
|
||||
not a winner-take-all:
|
||||
|
||||
- `risk_level` = **max**(heuristic, llm) and `flags` = **union**. A positive
|
||||
from either detector surfaces; a negative ("none") or failed/absent LLM
|
||||
**never lowers** a heuristic positive. The judge reads adversarial tool
|
||||
output, so it may raise the alarm but must not be able to hide a
|
||||
deterministic regex finding — defeating the judge can't erase the tripwire.
|
||||
- Credential **redaction** is a heuristic-only signal the LLM cannot override.
|
||||
- When the judge returned a verdict, its OWN verdict rides along as
|
||||
annotation (`judge_risk` / `confidence` / `reasoning` / `judge_model`) so
|
||||
the operator sees the judge's opinion even when it disagrees with the
|
||||
displayed (merged) risk.
|
||||
|
||||
The same merge runs live and on reconnect (both call
|
||||
`output_guard.merge_guard_display_payload`), so the chip can't drift between
|
||||
the two surfaces.
|
||||
|
||||
The MODEL on the other side of the conversation is shown the merged
|
||||
`risk_level` + `flags` (via the `GuardAdvisory` spliced into the tool-result
|
||||
envelope), but is **never** told the judge cleared a finding — a judge fooled
|
||||
into "none" must not get to talk the model out of caution. The judge's
|
||||
"benign" verdict is operator-facing only.
|
||||
|
||||
### SSE event: `output_warning`
|
||||
|
||||
When the merged finding is non-clean (or credentials were redacted), an
|
||||
`output_warning` SSE event is emitted to the frontend. A regex-only finding:
|
||||
When the output guard detects risk signals, an `output_warning` SSE event is
|
||||
emitted to the frontend:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -456,50 +386,17 @@ When the merged finding is non-clean (or credentials were redacted), an
|
||||
"flags": ["credential_leak"],
|
||||
"annotations": ["API key detected (sk-proj-...)"],
|
||||
"output_length": 1024,
|
||||
"redacted": true,
|
||||
"tier": "heuristic"
|
||||
"redacted": true
|
||||
}
|
||||
```
|
||||
|
||||
When the LLM judge returned a verdict, `tier` is `"llm"` and the event carries
|
||||
the judge's own verdict as annotation. Here the regex flagged MEDIUM but the
|
||||
judge assessed the output benign — the finding still surfaces (`risk_level`
|
||||
stays MEDIUM), annotated with the judge's dissent (`judge_risk: "none"`):
|
||||
The web UI renders this as an inline warning after the tool result. The CLI
|
||||
shows a colored terminal warning. The server forwards it as an
|
||||
`OutputWarningEvent` for console subscribers.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "output_warning",
|
||||
"call_id": "call_def456",
|
||||
"func_name": "web_fetch",
|
||||
"risk_level": "medium",
|
||||
"flags": ["camouflaged_injection"],
|
||||
"annotations": ["Authority-framed directive embedded in the document."],
|
||||
"output_length": 8192,
|
||||
"redacted": false,
|
||||
"tier": "llm",
|
||||
"judge_risk": "none",
|
||||
"confidence": 0.92,
|
||||
"reasoning": "Legitimate analyst commentary; no injection.",
|
||||
"judge_model": "gpt-5-mini"
|
||||
}
|
||||
```
|
||||
|
||||
`judge_risk` (the judge's OWN risk verdict, which may differ from the merged
|
||||
`risk_level`), `confidence` (0.0–1.0), `reasoning`, and `judge_model` are
|
||||
present only on the `"llm"` tier. The identical shape is projected onto
|
||||
history replay by `build_merged_output_assessment_payload`, so the inline chip
|
||||
renders the same live and on refresh.
|
||||
|
||||
The web UI renders this as an inline warning after the tool result — the
|
||||
`"llm"` tier adds a `⚖ LLM · NN%` badge (showing the judge's verdict when it
|
||||
differs from the displayed risk, e.g. `⚖ LLM: none · 92%`) and the judge's
|
||||
rationale. The CLI shows a colored terminal warning. The server forwards it as
|
||||
an `OutputWarningEvent` for console subscribers.
|
||||
|
||||
Assessments are persisted to the `output_assessments` table (one row per
|
||||
`(call_id, tier)`) for calibration. Raw tool output is never stored — only
|
||||
metadata: flags, risk level, annotations, output length, redaction status,
|
||||
and — for the LLM tier — confidence, reasoning, judge model, and latency.
|
||||
Assessments are persisted to the `output_assessments` table for v2
|
||||
calibration. Raw tool output is never stored — only metadata (flags, risk
|
||||
level, annotations, output length, redaction status).
|
||||
|
||||
### Session-level skill scan warning
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# MCP OAuth — per-user authorization for MCP servers
|
||||
|
||||
Turnstone supports **per-(user, MCP server) OAuth 2.1 + PKCE** delegation so each Turnstone user authorizes a remote MCP server with their own identity, rather than sharing a single bearer token across the deployment. This is the right shape for MCP servers that expose user-specific data (a personal CRM, an email inbox, a calendar) and for MCP servers that want per-user audit attribution.
|
||||
|
||||
Per-user OAuth is opt-in per `mcp_servers` row. Local-auth Turnstone installs with no `oauth_user` rows exercise zero new code paths — the entire feature is dark by default.
|
||||
|
||||
> **Note**: This is a separate authorization layer from Turnstone's own user authentication. A user who logs into Turnstone with a local username + password can still authorize a per-server OAuth MCP server. OIDC SSO and per-server OAuth are orthogonal.
|
||||
|
||||
---
|
||||
|
||||
## When to use which `auth_type`
|
||||
|
||||
The MCP server admin form exposes three authorization modes ("Multitenant Authorization"):
|
||||
|
||||
| `auth_type` | What it means | When to use |
|
||||
|---|---|---|
|
||||
| `none` | No headers attached. Open MCP server (or one gated by network policy only). | Internal MCP servers on a trusted network. |
|
||||
| `static` | One static bearer token, configured per server, sent on every request from every user. | Service-to-service MCP servers where per-user attribution doesn't matter, or single-tenant deployments. |
|
||||
| `oauth_user` *(recommended for user-data servers)* | Each user authorizes separately via OAuth 2.1 + PKCE; Turnstone stores per-user tokens encrypted at rest. | MCP servers that expose user-specific data or that want per-user audit attribution. |
|
||||
|
||||
Switching `auth_type` away from `oauth_user` orphans existing per-user tokens. Use the admin **bulk-revoke** affordance on the server row (Phase 9) to clear them, or let them expire naturally — they're inert without the matching `auth_type` value.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites for `auth_type=oauth_user`
|
||||
|
||||
1. **Encryption key**. Tokens are stored encrypted with Fernet. Set `[security] mcp_token_encryption_key` in `config.toml` (Turnstone won't start with an `oauth_user` row configured but no key installed). Rotate via `MultiFernet` — add the new key first, then later remove the old one once all rows have been re-encrypted.
|
||||
|
||||
2. **MCP server publishes RFC 9728 PRM and RFC 8414 AS metadata** *or* you configure the AS URL override on the server row. PKCE S256 is mandatory; Turnstone refuses to connect to authorization servers that don't advertise `code_challenge_methods_supported: ["S256"]`.
|
||||
|
||||
3. **OAuth client registration**. Two paths:
|
||||
- **Pre-registered** (most common): you create an OAuth client at the authorization server (manually, via admin console, or via Terraform), then paste the `client_id` / `client_secret` into the Turnstone admin form.
|
||||
- **Dynamic client registration** (RFC 7591): if the AS supports it and you select that mode in the admin form, Turnstone registers a client at first use and persists the `client_id` automatically.
|
||||
|
||||
4. **Redirect URI** registered at the authorization server: `https://your-turnstone-host/v1/api/mcp/oauth/callback`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Per-server fields (admin UI)
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| Server URL | Yes | The MCP server's `streamable-http` base URL. |
|
||||
| Multitenant Authorization | Yes | `none` / `static` / `oauth_user` (recommended). |
|
||||
| Authorization Server URL | No | Override for RFC 9728 PRM discovery. Set when your AS endpoint differs from the MCP server URL (e.g., corporate AS protecting a third-party MCP). When unset, Turnstone falls back to PRM discovery against the MCP server itself. |
|
||||
| Client Registration | Yes (oauth_user) | `preregistered` or `dynamic`. |
|
||||
| Client ID | Yes (preregistered) | OAuth 2.0 client ID. Stored unencrypted. |
|
||||
| Client Secret | Optional (write-only) | OAuth 2.0 client secret (confidential client). Encrypted at rest. Written but never re-read by the API; field stays masked. |
|
||||
| Scopes | No | Space-separated default scope set requested at the authorize endpoint. Per-tool step-up may union additional scopes from a server's `insufficient_scope` response. |
|
||||
| Audience | No | RFC 8707 `resource=` parameter sent on every authorize and token request. Defaults to the MCP server URL when unset. Validate against the `aud` claim in returned JWT tokens. |
|
||||
|
||||
### Encryption key
|
||||
|
||||
```toml
|
||||
[security]
|
||||
mcp_token_encryption_key = "base64-fernet-key"
|
||||
# For rotation, list the keys in priority order — first is used for new
|
||||
# writes, all are tried for reads.
|
||||
# mcp_token_encryption_keys = ["new-key", "old-key"]
|
||||
```
|
||||
|
||||
Keep this in `config.toml` rather than environment variables. An in-process LLM with shell-tool access can read the server's environment via `env` / `os.environ` and exfiltrate any secret stored there; secrets in `config.toml` are only loaded into the server at startup and never re-read on a tool-driven path, so a prompt-injection attack against the agent cannot reach them.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **First tool call** for a user against an `oauth_user` MCP server: pool dispatch finds no stored token, returns `mcp_consent_required` to the agent. Dashboard renders an inline "Connect" action card.
|
||||
|
||||
2. **User clicks Connect**: opens `/v1/api/mcp/oauth/start?server=<name>` in a popup. Browser redirects through the AS authorize endpoint, user grants consent, AS redirects back to `/v1/api/mcp/oauth/callback`. Turnstone exchanges code → tokens via PKCE, validates audience, encrypts, persists in `mcp_user_tokens`, redirects user back to the originating URL.
|
||||
|
||||
3. **Subsequent tool calls** by the same user against the same server reuse the persisted token via the per-(user, server) session pool. Tokens auto-refresh via the refresh-token grant when expired; failed refresh emits `mcp_consent_required` to drive re-consent.
|
||||
|
||||
4. **Step-up scope**: when a tool call hits `403` with `WWW-Authenticate: error="insufficient_scope"`, Turnstone emits `mcp_insufficient_scope` with the parsed scope set; the dashboard offers a "Connect with additional scopes" affordance that opens `/v1/api/mcp/oauth/start?server=<name>&scopes=<extra>` so the union of original + new scopes flows into the AS authorize request.
|
||||
|
||||
5. **User revoke** (settings modal): `DELETE /v1/api/mcp/oauth/connections/{server_name}` runs the authoritative local delete + best-effort RFC 7009 upstream revoke (fire-and-forget, capped at 256 concurrent in-flight tasks).
|
||||
|
||||
6. **Admin bulk-revoke** (Phase 9): `POST /v1/api/admin/mcp-servers/{name}/bulk-revoke` drops every user's token for the server. Upstream RFC 7009 revoke is intentionally **not** attempted in bulk (avoids N upstream HTTP calls per admin click); tokens at the AS expire naturally. Use the per-user revoke endpoint if you need guaranteed upstream invalidation.
|
||||
|
||||
---
|
||||
|
||||
## Admin status indicators
|
||||
|
||||
The MCP Servers admin tab shows per-server status pills (Phase 9):
|
||||
|
||||
- **Consented users count** — distinct users with a non-expired token for this server. Surfaced as a `bulk-revoke (N)` button when ≥1; clicking it opens a confirmation dialog. Hidden when 0.
|
||||
- **Last refresh** — timestamp + outcome (`ok` / `error:ClassName`) of the most recent manual or auto-reconnect refresh. Per node. Absent until at least one refresh has occurred (renders as "never" in the admin UI).
|
||||
|
||||
Additional indicators (circuit-breaker state, encryption-key mismatch) are exposed via `get_server_status` on the API but do not yet have a dedicated admin pill — operators see them today via the per-server status text + error tooltip and in audit logs. A future phase may surface these as discrete pills.
|
||||
|
||||
---
|
||||
|
||||
## Auth-type transitions
|
||||
|
||||
| From | To | What happens |
|
||||
|---|---|---|
|
||||
| `none` / `static` → `oauth_user` | — | New code path activates for this server. Existing static headers (if any) are no longer sent. Users must authorize on first use. |
|
||||
| `oauth_user` → `none` / `static` | — | Existing `mcp_user_tokens` rows are **orphaned** — inert without a matching `auth_type`. Use admin bulk-revoke to drop them, or let them expire. Switching back to `oauth_user` later re-activates the orphaned rows if they haven't been deleted. |
|
||||
| OAuth `client_id` or `client_secret` rotated | — | Existing tokens may stop refreshing if the AS treats them as bound to the previous client. Bulk-revoke after rotation. |
|
||||
|
||||
The orphan-by-default behavior is chosen so switching back to `oauth_user` is non-destructive. Bulk-revoke is the explicit cleanup path.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Action |
|
||||
|---|---|---|
|
||||
| `mcp_consent_required` even after consenting | Token persistence failed, or refresh-token rejected by AS | Check audit log for `mcp_server.oauth.persist_failed` or `mcp_server.oauth.token_revoked`. Re-consent via settings modal. |
|
||||
| `mcp_token_undecryptable_key_unknown` | Encryption key rotated without keeping the previous key in the keyring | Add the previous key back to `mcp_token_encryption_keys` until all rows have been re-encrypted, then drop. |
|
||||
| `mcp_oauth_url_insecure` | MCP server URL is `http://` (not `https://`) on a non-loopback host | Use `https://`. Per-user bearers must not transit cleartext. |
|
||||
| Tools fail in scheduled / Discord / Slack runs | OAuth-MCP requires browser-based consent | Users must pre-consent via the web UI. Phase 9 dashboard badge surfaces deferred consents from these runs on next login. |
|
||||
| Circuit breaker open repeatedly | Transport-level errors on the MCP server (DNS, TLS, 5xx) | Check the per-server error pill; auth errors do not trip the breaker. |
|
||||
|
||||
See also: `docs/operations/mcp-oauth-headless.md` for the cron / channel-driven run caveat.
|
||||
+5
-30
@@ -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:
|
||||
|
||||
+13
-82
@@ -39,19 +39,18 @@ are set.
|
||||
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) |
|
||||
| `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_REDIRECT_BASE` | No | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Recommended when running behind a reverse proxy. When unset, derived from the request Host header. |
|
||||
|
||||
All four required fields — issuer, client ID, client secret, and
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
|
||||
is disabled at startup (an error is logged when only `redirect_base`
|
||||
is missing) and the login screen shows only the password form.
|
||||
OIDC is enabled when all three required fields (issuer, client ID, client
|
||||
secret) are non-empty. If any is missing, OIDC is silently disabled and
|
||||
the login screen shows only the password form.
|
||||
|
||||
### Redirect base (required)
|
||||
### Reverse Proxy / Load Balancer
|
||||
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` pins the redirect URI sent to the identity
|
||||
provider to a known externally-visible origin. Set it to the public origin
|
||||
of your Turnstone deployment:
|
||||
When Turnstone runs behind a reverse proxy, the internal `Host` header may
|
||||
not match the externally-reachable URL. Set `TURNSTONE_OIDC_REDIRECT_BASE`
|
||||
to the public origin so the redirect URI sent to the identity provider is
|
||||
correct:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_REDIRECT_BASE=https://app.example.com
|
||||
@@ -61,44 +60,6 @@ The resulting callback URL will be
|
||||
`https://app.example.com/v1/api/auth/oidc/callback` — register this as the
|
||||
authorized redirect URI in your identity provider.
|
||||
|
||||
OIDC will refuse to start when this variable is unset. There is no
|
||||
Host-header fallback: a permissive reverse proxy or direct backend access
|
||||
would otherwise let an attacker spoof `Host` and steer the IdP redirect
|
||||
to a callback origin they control.
|
||||
|
||||
### Cross-host endpoints
|
||||
|
||||
By default, every endpoint in the IdP discovery document
|
||||
(`token_endpoint`, `jwks_uri`, `userinfo_endpoint`) must share the
|
||||
issuer's `(scheme, host, port)`. This prevents a hostile or compromised
|
||||
IdP from redirecting the token-exchange POST (which carries
|
||||
`client_secret`) to an arbitrary host, and prevents JWKS fetches from
|
||||
being aimed at internal services.
|
||||
|
||||
A few public IdPs legitimately split endpoints across hostnames. Google
|
||||
is the canonical example:
|
||||
|
||||
| Field | Hostname |
|
||||
|-------|----------|
|
||||
| issuer | `accounts.google.com` |
|
||||
| token_endpoint | `oauth2.googleapis.com` |
|
||||
| jwks_uri | `www.googleapis.com` |
|
||||
| userinfo_endpoint | `openidconnect.googleapis.com` |
|
||||
|
||||
Google's set is built in — operators using `https://accounts.google.com`
|
||||
need no extra configuration.
|
||||
|
||||
For other IdPs whose discovery document references a non-issuer host,
|
||||
extend the allow-list explicitly:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS=token.example.com,keys.example.com
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
### config.toml alternative
|
||||
|
||||
```toml
|
||||
@@ -237,19 +198,6 @@ TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,engineering:builtin-operator,viewer
|
||||
the user authenticates via OIDC, so new group memberships are picked
|
||||
up on the next login.
|
||||
|
||||
### `assigned_by` markers
|
||||
|
||||
Role assignments record an `assigned_by` value that controls how the
|
||||
sync logic treats them. OIDC-driven flows use two distinct markers:
|
||||
|
||||
- `oidc` — set by claim-driven role mapping; revoked automatically on
|
||||
the next login when the corresponding claim value is no longer
|
||||
present.
|
||||
- `oidc-default` — applied to brand-new OIDC users who have no
|
||||
claim-mapped roles, as a safety net so they still get
|
||||
`builtin-viewer` access on first login. Survives subsequent logins
|
||||
regardless of claim contents and is never revoked by `apply_role_mapping`.
|
||||
|
||||
### Built-in Roles
|
||||
|
||||
| Role ID | Permissions |
|
||||
@@ -427,27 +375,10 @@ callback validation. Entries are automatically cleaned up after 5 minutes.
|
||||
|
||||
### "OIDC not configured"
|
||||
|
||||
All four required environment variables must be set:
|
||||
`TURNSTONE_OIDC_ISSUER`, `TURNSTONE_OIDC_CLIENT_ID`,
|
||||
`TURNSTONE_OIDC_CLIENT_SECRET`, and `TURNSTONE_OIDC_REDIRECT_BASE`.
|
||||
Check that none are empty or whitespace-only.
|
||||
|
||||
### "OIDC enabled but TURNSTONE_OIDC_REDIRECT_BASE is unset"
|
||||
|
||||
This error is logged when the three credential variables are set but
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` is missing. OIDC is disabled at startup
|
||||
to prevent Host-header-derived redirect URI spoofing. Set the variable
|
||||
to your service's externally-visible origin (e.g.
|
||||
`https://app.example.com`) and restart the server. See
|
||||
[Redirect base](#redirect-base-required) for the rationale.
|
||||
|
||||
### Discovery silently disables OIDC with "host does not match issuer"
|
||||
|
||||
The IdP discovery document points `token_endpoint`, `jwks_uri`, or
|
||||
`userinfo_endpoint` at a hostname that doesn't share the issuer's
|
||||
origin. If the IdP is legitimate, add the additional hostname(s) to
|
||||
`TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS`. Google is allow-listed
|
||||
automatically; see [Cross-host endpoints](#cross-host-endpoints).
|
||||
All three required environment variables must be set:
|
||||
`TURNSTONE_OIDC_ISSUER`, `TURNSTONE_OIDC_CLIENT_ID`, and
|
||||
`TURNSTONE_OIDC_CLIENT_SECRET`. Check that none are empty or
|
||||
whitespace-only.
|
||||
|
||||
### "Login session expired"
|
||||
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ cannot bypass the proxy.
|
||||
|--------|-------|---------|
|
||||
| `openai_api` | `api.openai.com` | OpenAI LLM API |
|
||||
| `anthropic_api` | `api.anthropic.com` | Anthropic LLM API |
|
||||
| `searxng` | `searxng:8080` (bundled service) | Web search backend |
|
||||
| `tavily_api` | `api.tavily.com` | Web search fallback |
|
||||
| `skills_registry` | `skills.sh` | Skill discovery |
|
||||
| `github_api` | `api.github.com` (read-only L7), `raw.githubusercontent.com` | Skill fetch, GitHub API |
|
||||
| `mcp_registry` | `registry.modelcontextprotocol.io` (read-only L7) | MCP server discovery |
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# MCP OAuth in headless / scheduled / channel-driven runs
|
||||
|
||||
**Constraint**: OAuth-MCP servers (`auth_type=oauth_user`) require browser-based user consent. Users must pre-consent via the web UI before any run that cannot drive a browser redirect.
|
||||
|
||||
**Affected surfaces**:
|
||||
|
||||
- Scheduled workstreams (`turnstone-console` task scheduler).
|
||||
- Discord adapter runs.
|
||||
- Slack adapter runs.
|
||||
- Any future channel adapter without an interactive browser session.
|
||||
|
||||
**What happens when consent is missing**:
|
||||
|
||||
A tool call against an `oauth_user` server returns a structured `mcp_consent_required` error to the agent. The agent surfaces the deferred work in its output. Turnstone persists a record to `mcp_pending_consent` so the dashboard badge surfaces the deferred consent need to the user on next login.
|
||||
|
||||
**Recovery**:
|
||||
|
||||
The user opens the dashboard, sees the gear-icon badge counting pending consents, opens the settings modal, clicks Connect for each affected server, and completes the OAuth dance. The pending-consent record is cleared by the OAuth callback handler on success. Subsequent scheduled / channel runs use the freshly-stored token.
|
||||
|
||||
**Pre-consent recipe**:
|
||||
|
||||
Before scheduling a workstream that depends on an `oauth_user` MCP server, the user should:
|
||||
|
||||
1. Open the dashboard.
|
||||
2. Open the settings modal (gear icon).
|
||||
3. Click Connect on each MCP server the schedule will use.
|
||||
4. Confirm consent in the popup.
|
||||
|
||||
This stores tokens that the scheduled run will reuse. Refresh-token rotation is handled transparently on the run side; only the first consent requires browser interaction.
|
||||
+1
-25
@@ -108,7 +108,7 @@ pgbouncer:
|
||||
maxClientConn: 5000
|
||||
maxDbConnections: 80
|
||||
```
|
||||
|
||||
:
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
@@ -199,28 +199,4 @@ does not support prepared statements. Turnstone's SQLAlchemy layer does
|
||||
not use server-side prepared statements by default, so this is not an
|
||||
issue.
|
||||
|
||||
**LISTEN / NOTIFY not supported in transaction mode** — PgBouncer's
|
||||
transaction pooling assigns a real server connection only for the
|
||||
duration of each transaction, then returns it to the pool. PostgreSQL
|
||||
`LISTEN` is session state — a transaction-pooled client can't hold the
|
||||
multi-statement session a long-lived `LISTEN` needs. The console's
|
||||
`NotifyDispatcher` (reactive node discovery via the `services` channel)
|
||||
therefore opens a **dedicated, direct-to-Postgres** connection that
|
||||
bypasses PgBouncer.
|
||||
|
||||
Configure via `config.toml` `[database] listen_url` (preferred —
|
||||
co-located with the main `url`) or the `TURNSTONE_DB_LISTEN_URL` env var
|
||||
(config.toml wins when both are set). Defaults to the main DB URL when
|
||||
unset.
|
||||
|
||||
| Setting | Behaviour |
|
||||
|---|---|
|
||||
| unset | Listener uses `TURNSTONE_DB_URL` as-is. Fine when PgBouncer is in **session** mode, or when there's no pooler in front of Postgres. With transaction-mode PgBouncer the listener's `LISTEN` will fail and the dispatcher retries with exponential backoff (1 s → 30 s cap) without ever succeeding. Reactive NOTIFY-driven node discovery is silently lost; the cluster collector's 60 s `_discovery_loop` is the only remaining backstop. |
|
||||
| set to direct-to-PG URL (e.g. `postgresql://…/turnstone`) | Listener bypasses PgBouncer for its one dedicated connection. Reactive discovery latency drops from up-to-60 s to ~500 ms. The rest of the storage layer continues to go through PgBouncer in transaction mode. |
|
||||
|
||||
Set this whenever PgBouncer is in transaction mode (the recommended
|
||||
setting per this doc). The override only adds one long-lived PG
|
||||
connection per console process — sized into the cluster's
|
||||
`max_connections` budget alongside the pool.
|
||||
|
||||
See also: [Docker deployment](docker.md) · [Security](security.md)
|
||||
|
||||
+17
-18
@@ -6,9 +6,10 @@ Turnstone ships several parallel release tracks from a single PyPI package.
|
||||
|
||||
| Track | Versions | Branch | Docker tags | PyPI install |
|
||||
|-------|----------|--------|-------------|--------------|
|
||||
| **Stable 1.5** | `1.5.x` | `stable/1.5` | `:1.5.x`, `:1.5` | `pip install 'turnstone==1.5.*'` |
|
||||
| **Stable 1.6** | `1.6.x` | `stable/1.6` | `:1.6.x`, `:1.6`, `:stable`, `:latest` | `pip install turnstone` |
|
||||
| **Experimental** | `1.7.0aN` | `main` | `:1.7.0aN`, `:experimental` | `pip install turnstone --pre` |
|
||||
| **Legacy 1.0** | `1.0.x` | `stable/1.0` | `:1.0.x`, `:1.0` | `pip install 'turnstone==1.0.*'` |
|
||||
| **Stable 1.3** | `1.3.x` | `stable/1.3` | `:1.3.x`, `:1.3` | `pip install 'turnstone==1.3.*'` |
|
||||
| **Stable 1.4** | `1.4.x` | `stable/1.4` | `:1.4.x`, `:1.4`, `:stable`, `:latest` | `pip install turnstone` |
|
||||
| **Experimental** | `1.5.0aN` | `main` | `:1.5.0aN`, `:experimental` | `pip install turnstone --pre` |
|
||||
|
||||
- **Stable** tracks receive bugfixes only. The most-recent stable minor
|
||||
owns the `:stable` / `:latest` Docker tags and the default PyPI
|
||||
@@ -16,10 +17,8 @@ Turnstone ships several parallel release tracks from a single PyPI package.
|
||||
- **Experimental** (always on `main`) receives new features. May be
|
||||
rough around the edges.
|
||||
- When experimental matures, it is promoted to a new stable minor via
|
||||
a `stable/X.Y` branch. One prior stable track is maintained alongside
|
||||
the current one; at each promotion the oldest track is retired — its
|
||||
branch is deleted, while its tags and released artifacts remain
|
||||
available.
|
||||
a `stable/X.Y` branch; older stable branches continue to receive
|
||||
security fixes until explicitly retired.
|
||||
|
||||
## Version Scheme
|
||||
|
||||
@@ -34,17 +33,17 @@ Turnstone ships several parallel release tracks from a single PyPI package.
|
||||
## Releasing an Experimental Version (from main)
|
||||
|
||||
```bash
|
||||
scripts/release.sh 1.7.0a2 --push
|
||||
scripts/release.sh 1.5.0a2 --push
|
||||
```
|
||||
|
||||
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.7.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
|
||||
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.5.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
|
||||
|
||||
## Releasing a Stable Patch (from stable/X.Y)
|
||||
|
||||
```bash
|
||||
git checkout stable/1.6
|
||||
git checkout stable/1.4
|
||||
git cherry-pick <commit-hash> # bugfix from main
|
||||
scripts/release.sh 1.6.1 --push
|
||||
scripts/release.sh 1.4.1 --push
|
||||
```
|
||||
|
||||
## Promoting Experimental to Stable
|
||||
@@ -53,19 +52,19 @@ When `main` is ready for a stable release:
|
||||
|
||||
```bash
|
||||
# 1. Tag the stable release on main
|
||||
scripts/release.sh 1.6.0 --push
|
||||
scripts/release.sh 1.5.0 --push
|
||||
|
||||
# 2. Create the stable maintenance branch from that tag
|
||||
git branch stable/1.6 v1.6.0
|
||||
git push origin stable/1.6
|
||||
git branch stable/1.5 v1.5.0
|
||||
git push origin stable/1.5
|
||||
|
||||
# 3. Start the next experimental cycle on main
|
||||
scripts/release.sh 1.7.0a1 --push
|
||||
scripts/release.sh 1.6.0a1 --push
|
||||
```
|
||||
|
||||
The previous stable branch continues to receive security-only patches;
|
||||
the track before it is retired at each promotion (at 1.6.0:
|
||||
`stable/1.5` stays maintained, `stable/1.4` is retired).
|
||||
The previous stable branch (`stable/1.4`) continues to receive
|
||||
security-only patches; older tracks (`stable/1.0`, `stable/1.3`) are
|
||||
retired when they fall out of support.
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
|
||||
+2
-3
@@ -77,6 +77,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
| | `delete_attachment(ws_id, attachment_id)` | `StatusResponse` |
|
||||
| **Chat** | `send(message, ws_id)` | `SendResponse` |
|
||||
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
||||
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
|
||||
| | `command(*, ws_id, command)` | `StatusResponse` |
|
||||
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
|
||||
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
||||
@@ -133,12 +134,10 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
|
||||
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
|
||||
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
| `plan_review` | `PlanReviewEvent` | `content` |
|
||||
| `error` | `ErrorEvent` | `message` |
|
||||
| `info` | `InfoEvent` | `message` |
|
||||
| `stream_end` | `StreamEndEvent` | — |
|
||||
| `state_change` | `StateChangeEvent` | `state` ∈ `running`/`thinking`/`attention`/`idle`/`error` |
|
||||
| `in_progress_snapshot` | `InProgressSnapshotEvent` | `content`, `reasoning` (one-shot mid-stream refresh resume) |
|
||||
| `approval_resolved` | `ApprovalResolvedEvent` | `approved`, `feedback` |
|
||||
| `cancelled` | `CancelledEvent` | — |
|
||||
|
||||
**Global events** (from `stream_global_events()`):
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ Scopes are hierarchical — higher scopes imply all lower ones.
|
||||
| Method | Path pattern | Required scope |
|
||||
|--------|-------------|----------------|
|
||||
| GET | Any protected path | `read` |
|
||||
| POST | `/api/command` | `write` |
|
||||
| POST | `/api/plan`, `/api/command` | `write` |
|
||||
| POST | `/api/workstreams/new`, `/api/cluster/workstreams/new` | `write` |
|
||||
| POST | `/api/workstreams/{ws_id}/{send,cancel,close,delete,open,refresh-title,title,attachments}` | `write` |
|
||||
| DELETE | `/api/workstreams/{ws_id}/send` (dequeue), `/api/workstreams/{ws_id}/attachments/{attachment_id}` | `write` |
|
||||
|
||||
+10
-22
@@ -59,32 +59,20 @@ 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
|
||||
`config.toml`.
|
||||
|
||||
### Reasoning persistence (per-model)
|
||||
### Plan / task agent overrides
|
||||
|
||||
Two boolean flags on `model_definitions` (migration 052) control how
|
||||
reasoning text round-trips per model:
|
||||
|
||||
| Flag | Default | Effect |
|
||||
|------|---------|--------|
|
||||
| `surface_persisted_reasoning` | `True` | Surface stored reasoning text on `/history` payloads so a page reload re-renders the reasoning bubble. **Storage of reasoning bytes is independent of this flag** — they ride in `provider_data` regardless. |
|
||||
| `replay_reasoning_to_model` | `False` | Send stored reasoning blocks back to the provider on subsequent turns. Capability-gated: only takes effect when the model's `ModelCapabilities.supports_reasoning_replay` is also `True`. Set on canonical OpenAI gpt-5*/o-series and Anthropic Claude entries; unknown / local-server models default to `False` so an operator who flips the flag on a model whose API doesn't understand reasoning replay silently no-ops rather than 400-ing. |
|
||||
|
||||
Edit both via the admin Models tab. See the architecture doc for the
|
||||
provider-side mechanics (Anthropic `thinking`, OpenAI Responses
|
||||
`reasoning` + `include=["reasoning.encrypted_content"]`, synthetic
|
||||
`reasoning_text` for Chat Completions / vLLM / llama.cpp / Gemini-compat).
|
||||
|
||||
### Task agent overrides
|
||||
|
||||
`task_agent` sub-sessions resolve independently from the conversation model
|
||||
so operators can pick a cheaper/faster model for autonomous loops:
|
||||
`plan_agent` and `task_agent` sub-sessions resolve independently from the
|
||||
conversation model so operators can pick a cheaper/faster model for
|
||||
autonomous loops:
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Falls back to `[model].agent_model` in config.toml, then the session's active model. |
|
||||
| `model.plan_alias` | Alias used for `plan_agent` sub-sessions. Falls back to `[model].plan_model` in config.toml, then `[model].agent_model`, then the session's active model. |
|
||||
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Same fallback chain as `plan_alias`. |
|
||||
| `model.plan_effort` | Reasoning effort for `plan_agent` (`none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`). Defaults to `high`. |
|
||||
| `model.task_effort` | Reasoning effort for `task_agent`. Empty string means "inherit from the session". |
|
||||
|
||||
Both are live-editable from the Settings tab and take effect on the
|
||||
All four are live-editable from the Settings tab and take effect on the
|
||||
next sub-agent invocation — no restart required.
|
||||
|
||||
---
|
||||
@@ -107,12 +95,12 @@ initialization:
|
||||
|
||||
| Section | Settings |
|
||||
|---------|----------|
|
||||
| `model` | default_alias, temperature, max_tokens, reasoning_effort, task_alias, task_effort |
|
||||
| `model` | default_alias, temperature, max_tokens, reasoning_effort, plan_alias, task_alias, plan_effort, task_effort |
|
||||
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
|
||||
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
|
||||
| `server` | workstream_idle_timeout, max_workstreams |
|
||||
| `cluster` | node_fan_out_limit, mcp_max_servers |
|
||||
| `mcp` | config_path, registry_url |
|
||||
| `mcp` | config_path, refresh_interval, registry_url |
|
||||
| `ratelimit` | enabled, requests_per_second, burst, trusted_proxies |
|
||||
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
|
||||
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets, cancel_on_approval |
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
---
|
||||
name: import-conversation-history
|
||||
description: Use this skill when the user wants to import or migrate conversation history from another LLM chat or coding tool (e.g. ChatGPT, Claude.ai, Cursor, Copilot Chat, Aider, Gemini, a custom JSON export) into Turnstone. The skill teaches Turnstone's destination contracts — workstream identity, the OpenAI-shaped message rows, tool-call/result pairing, provider-fidelity blobs, attachments, and archive-vs-resumable choice — so the agent can map any source format onto them. Trigger phrases: "import my chats", "migrate this transcript into Turnstone", "bring my Claude.ai history over", "load this export as a workstream".
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Importing Conversation History into Turnstone
|
||||
|
||||
## Overview
|
||||
|
||||
Source formats vary; the destination does not. Your job is to translate whatever the user hands you (JSON dump, ZIP export, scraped HTML, screenshot OCR, raw transcript) into Turnstone's internal shape: **one workstream row** plus an ordered sequence of **conversation rows** in OpenAI message format. This skill documents the destination so you can write a correct mapper for any source.
|
||||
|
||||
Two questions to settle with the user before writing anything:
|
||||
|
||||
1. **Archive or resumable?** An archive ("saved" workstream — `state="closed"`) is read-only history. A resumable workstream (`state="idle"`) lets the user continue the conversation; this only works cleanly when the source LLM matches a Turnstone-supported provider/model and tool definitions still resolve.
|
||||
2. **One workstream per source thread, or merge?** Default to one-to-one unless the user explicitly asks to merge.
|
||||
|
||||
Default to **archive** when in doubt — resuming a foreign transcript with mismatched tool schemas or stale provider signatures will fail at the next turn.
|
||||
|
||||
## Turnstone Data Model (the destination)
|
||||
|
||||
Two tables carry the conversation:
|
||||
|
||||
### `workstreams` (one row per imported thread)
|
||||
|
||||
| Column | Required | Notes |
|
||||
|---|---|---|
|
||||
| `ws_id` | yes | 32-char lowercase hex. Auto-generate with `secrets.token_hex(16)` if you don't already have one. **First 4 hex chars are the routing bucket** — see "Identity & Routing" below. |
|
||||
| `name` | yes | Short title. Pull from source thread title; fall back to first ~60 chars of first user message. |
|
||||
| `state` | yes | `"closed"` for archive, `"idle"` for resumable. Never set `"running"` on import. |
|
||||
| `kind` | yes | `"interactive"` for normal threads. Do NOT use `"coordinator"` for imports — that's reserved for cluster-spawned coordinator workstreams. |
|
||||
| `parent_ws_id` | no | Leave NULL. Only set if you're importing a coordinator-spawned subtree and re-parenting it; rare. |
|
||||
| `user_id` | yes | Owner. Must exist in `users`; importer must know which Turnstone user owns the imported history. |
|
||||
| `node_id` | yes (multi-node) | Denormalized cache of the node that owns this `ws_id`'s bucket. Single-node deployments can leave it NULL or set it to the only node. |
|
||||
| `alias` | no | Human-typeable short name. Optional; must be unique cluster-wide if set. |
|
||||
| `title` | no | Auto-titled later by the LLM; safe to leave NULL on import. |
|
||||
| `skill_id`, `skill_version` | yes | Default `""` and `0` unless the source thread was scoped to a Turnstone skill. |
|
||||
| `created`, `updated` | yes | ISO8601 strings. Use the source's first/last message timestamps when available. |
|
||||
|
||||
### `conversations` (many rows per thread, ordered by `id`/`timestamp`)
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| `ws_id` | The workstream this row belongs to. |
|
||||
| `timestamp` | ISO8601 string. Preserve source timestamps; fall back to monotonically increasing values if unknown. **Order is canonical via `id` (autoincrement), not `timestamp`** — but always insert in conversational order so both agree. |
|
||||
| `role` | One of `system`, `user`, `assistant`, `tool`, `developer`. See role mapping below. |
|
||||
| `content` | Text. May be NULL for assistant rows that are *only* tool calls. |
|
||||
| `tool_name` | Set on `role="tool"` rows (the tool whose result this is). NULL otherwise. |
|
||||
| `tool_call_id` | Set on `role="tool"` rows (matches the assistant row's `tool_calls[].id`). NULL otherwise. |
|
||||
| `tool_calls` | JSON-encoded list, on `role="assistant"` rows that issued tool calls. OpenAI shape — see "Tool Calls" below. |
|
||||
| `provider_data` | JSON blob preserving provider-native content blocks (Anthropic `signature`, Gemini `thought_signature`, etc.). Optional; only matters for **resumable** imports against the same provider. Skip for archives. |
|
||||
|
||||
The internal format is **OpenAI-shaped**, even when the source was Anthropic or Gemini. Providers translate at their own API boundary; storage stays uniform.
|
||||
|
||||
## Identity & Routing (`ws_id`)
|
||||
|
||||
- `ws_id` is **32-char lowercase hex** (i.e. `secrets.token_hex(16)`).
|
||||
- The **routing bucket** is `int(ws_id[:4], 16)` — the first 4 hex chars place this workstream on a specific node via the consistent hash ring.
|
||||
- For multi-node imports: either insert through the console's routing proxy (which forwards to the owning node), or generate `ws_id`s and write directly to each node's database in batches grouped by bucket.
|
||||
- For single-node imports: bucket math is irrelevant; any `ws_id` works.
|
||||
- **Do not reuse the source platform's IDs as `ws_id`** unless they happen to be 32-char hex. Generate fresh; if you need the old ID for traceability, store it in `workstream_config` under a key like `import.source_id`.
|
||||
|
||||
## Recommended Import Path
|
||||
|
||||
Three options, in order of preference:
|
||||
|
||||
### 1. Storage protocol (recommended for full history)
|
||||
|
||||
Use `turnstone.core.storage.Storage.save_messages_bulk(rows)`. This is the canonical bulk-insert primitive and bypasses the LLM round-trip entirely.
|
||||
|
||||
```python
|
||||
from turnstone.core.storage import get_storage # construct via the same path the server uses
|
||||
|
||||
storage = get_storage(...) # see turnstone.core.storage.__init__ for the project's wiring
|
||||
|
||||
storage.create_workstream( # or whatever the project's exposed creator is — check turnstone/core/storage/_protocol.py
|
||||
ws_id=ws_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
state="closed",
|
||||
kind="interactive",
|
||||
...
|
||||
)
|
||||
|
||||
storage.save_messages_bulk([
|
||||
{"ws_id": ws_id, "role": "user", "content": "Hello"},
|
||||
{"ws_id": ws_id, "role": "assistant", "content": "Hi! What can I help with?"},
|
||||
{"ws_id": ws_id, "role": "assistant", "content": None,
|
||||
"tool_calls": json.dumps([{"id": "call_1", "type": "function",
|
||||
"function": {"name": "search", "arguments": "{\"q\":\"x\"}"}}])},
|
||||
{"ws_id": ws_id, "role": "tool", "tool_name": "search", "tool_call_id": "call_1",
|
||||
"content": "result text"},
|
||||
# ...
|
||||
])
|
||||
```
|
||||
|
||||
`save_messages_bulk` handles `timestamp` and the workstream's `updated` column internally, so you don't need to compute them per row. **Verify the exact creator signature** by reading `turnstone/core/storage/_protocol.py` — table layout has shifted across migrations and the Storage protocol is the source of truth.
|
||||
|
||||
### 2. SDK `create_workstream(resume_ws=...)` (when the source is already a Turnstone workstream)
|
||||
|
||||
Only useful for *Turnstone → Turnstone* re-parenting. Not relevant for foreign sources.
|
||||
|
||||
### 3. SDK `create_workstream(initial_message=...)` + `send()` per turn (last resort)
|
||||
|
||||
Only fits archives where the source had **no tool calls** and you don't care about preserving assistant turns verbatim. Each `send()` triggers a real LLM round-trip, which is expensive and rewrites assistant content. Don't use this for full history.
|
||||
|
||||
## Role Mapping
|
||||
|
||||
Common source-role conventions and how they map to Turnstone:
|
||||
|
||||
| Source role | Turnstone `role` | Notes |
|
||||
|---|---|---|
|
||||
| `user`, `human` | `user` | Direct map. |
|
||||
| `assistant`, `ai`, `model`, `bot` | `assistant` | Direct map. |
|
||||
| `system` | `system` | Preserve only if it's content the user wrote (custom instructions). Drop boilerplate provider preambles — Turnstone composes its own system message. |
|
||||
| `developer` (OpenAI o-series) | `developer` | Preserve. |
|
||||
| `tool`, `function`, `tool_result` | `tool` | Must carry `tool_name` and `tool_call_id` matching the prior assistant row's `tool_calls[].id`. |
|
||||
| `tool_use` (Anthropic) | `assistant` with `tool_calls` | Anthropic emits tool calls *inside* an assistant message; flatten to OpenAI shape. |
|
||||
| `human_feedback`, `revision` | `user` | Treat as a follow-up user turn. |
|
||||
|
||||
## Tool Calls (the most error-prone part)
|
||||
|
||||
Turnstone stores tool calls in OpenAI's nested-function shape on the assistant row, and matches them with `role="tool"` result rows by `tool_call_id`.
|
||||
|
||||
### Assistant row with tool calls
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_web",
|
||||
"arguments": "{\"query\":\"turnstone import\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`tool_calls[].function.arguments` is **a JSON-encoded string**, not an object. Source formats commonly get this wrong — Anthropic stores arguments as a parsed object, Gemini as a struct. Always re-serialize to a string.
|
||||
|
||||
### Tool result row
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_name": "search_web",
|
||||
"tool_call_id": "call_abc123",
|
||||
"content": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Pairing rules:
|
||||
- Every assistant `tool_calls[].id` MUST be followed by exactly one `role="tool"` row with the matching `tool_call_id`, before the next user/assistant turn.
|
||||
- If the source dropped the tool result (cut-off transcript), insert a synthetic `role="tool"` row with `content="[tool result missing in source]"` to keep the chain valid. An assistant row with an unanswered `tool_calls[].id` will break replay and any LLM round-trip.
|
||||
- Multi-tool assistant turns: one `role="tool"` row per call, in any order, all before the next non-tool row.
|
||||
|
||||
### Tool ID generation
|
||||
|
||||
If the source used opaque tool IDs that aren't unique within a thread (some platforms reuse them), regenerate with a stable scheme like `f"call_{i}"` where `i` is a per-thread counter. Update both the assistant and tool rows together.
|
||||
|
||||
## Provider Fidelity (`provider_data`)
|
||||
|
||||
Skip this entirely for **archive** imports.
|
||||
|
||||
For **resumable** imports against the same provider, populate `provider_data` to preserve provider-specific tool-call metadata that the next API round-trip will require:
|
||||
|
||||
- **Anthropic**: `signature` field on thinking blocks; required for round-tripping extended-thinking responses.
|
||||
- **Gemini**: `thought_signature` on tool calls; required for fidelity.
|
||||
- **OpenAI**: typically nothing to preserve.
|
||||
|
||||
The runtime-side dict key is `_provider_content` (a list of provider-native blocks); the persisted column is `provider_data` (the same list, JSON-encoded). If you don't have provider-native blocks from the source — and you usually won't, because a foreign export won't include them — leave `provider_data` NULL. The first new turn will succeed without it, but the previous assistant turn's reasoning won't replay back to the model.
|
||||
|
||||
## Attachments
|
||||
|
||||
If the source thread had image or file attachments:
|
||||
|
||||
- **Size limits**: images ≤ 4 MiB, text documents ≤ 512 KiB. Reject or downsample anything bigger.
|
||||
- **Allowed types**: server validates magic bytes for images and UTF-8-decodes for text. Binary blobs that aren't images won't pass.
|
||||
- **Lifecycle**: pending → reserved → consumed. For imports, the cleanest path is to upload as pending and immediately consume by attaching to the relevant `conversations.id`.
|
||||
|
||||
Two import paths:
|
||||
|
||||
1. **Bulk-insert + post-attach**: insert messages first, get back the assistant/user `conversations.id`, then write `workstream_attachments` rows linking the file to `message_id`.
|
||||
2. **SDK multipart create**: `create_workstream(attachments=[...], initial_message=...)` for the *first* turn only — the server reserves and consumes them onto that turn. Doesn't help for mid-thread attachments.
|
||||
|
||||
For full-history imports with multiple attachments at different turns, path (1) is the only option.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before declaring success, verify:
|
||||
|
||||
- [ ] `ws_id` is 32-char lowercase hex.
|
||||
- [ ] `workstreams` row exists with the right `user_id`, `state`, `kind`.
|
||||
- [ ] Conversation rows are inserted **in order** (autoincrement `id` will reflect insert order).
|
||||
- [ ] Every assistant `tool_calls[].id` has a matching `role="tool"` row with the same `tool_call_id`.
|
||||
- [ ] `tool_calls[].function.arguments` is a JSON-encoded **string**, not a parsed object.
|
||||
- [ ] First message is typically `role="user"` (not `system`) — Turnstone composes its own system prompt at runtime.
|
||||
- [ ] No empty assistant rows (`content=NULL` AND `tool_calls=NULL` is invalid).
|
||||
- [ ] If multi-node: the `ws_id`'s bucket maps to a node that exists; `workstreams.node_id` matches.
|
||||
- [ ] Round-trip test: run `Storage.load_messages(ws_id)` and confirm the reconstructed list matches what you inserted (modulo timestamps).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Don't import the source provider's system prompt verbatim.** Provider boilerplate ("You are Claude...", "You are ChatGPT...") will conflict with Turnstone's composed system message and confuse the model on resume. Drop it; preserve only user-authored custom instructions.
|
||||
- **Don't preserve foreign tool definitions as Turnstone tools.** If the source had custom tools that don't exist in Turnstone, the assistant rows that called them are still valid history (archive), but the workstream is **not resumable** — mark `state="closed"`.
|
||||
- **Don't fabricate `tool_call_id`s without re-pairing.** Mismatched ids silently break the replay chain on the next turn.
|
||||
- **Don't skip the `tool_name` field on `role="tool"` rows.** Some load paths use it for display and audit; NULL there will render as "unknown tool".
|
||||
- **Don't write through the LLM (`send()` per turn) for full history.** It's expensive, rewrites assistant turns, and rate-limits will bite long imports.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Path |
|
||||
|---|---|
|
||||
| Generate ws_id | `secrets.token_hex(16)` |
|
||||
| Bulk insert messages | `Storage.save_messages_bulk(rows)` |
|
||||
| Archive (read-only) | `state="closed"`, skip `provider_data` |
|
||||
| Resumable | `state="idle"`, populate `provider_data` if same provider |
|
||||
| Tool call id | OpenAI shape: `{"id": ..., "type": "function", "function": {"name": ..., "arguments": "<json string>"}}` |
|
||||
| Tool result row | `role="tool"`, `tool_name`, `tool_call_id`, `content` |
|
||||
| Source role → Turnstone role | See "Role Mapping" table |
|
||||
| Per-thread metadata | Store source IDs in `workstream_config` under `import.*` keys |
|
||||
|
||||
## Files to read before writing the importer
|
||||
|
||||
- `turnstone/core/storage/_schema.py` — authoritative table definitions.
|
||||
- `turnstone/core/storage/_protocol.py` — `save_message`, `save_messages_bulk`, `load_messages` signatures.
|
||||
- `turnstone/core/session.py` (around the message-save section) — how the runtime constructs in-memory message dicts; mirror this shape on import to round-trip cleanly.
|
||||
- `turnstone/api/server_schemas.py` — Pydantic shapes for the SDK paths if you go through HTTP.
|
||||
+8
-104
@@ -8,7 +8,7 @@ inter-service communication, powered by [lacme](https://pypi.org/project/lacme/)
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
docker compose -f compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
```
|
||||
|
||||
This:
|
||||
@@ -19,53 +19,6 @@ This:
|
||||
|
||||
---
|
||||
|
||||
## Browser access (dashboard HTTPS)
|
||||
|
||||
The mTLS above secures **service-to-service** traffic (node↔node, collector and
|
||||
routing proxy → nodes). The **console dashboard itself serves plain HTTP** — and
|
||||
must, because it is the cluster's ACME bootstrap endpoint: new nodes fetch
|
||||
`/acme/ca.pem` and provision their first cert over HTTP, before they have the CA
|
||||
to verify TLS. So the console cannot be HTTPS-only on its port.
|
||||
|
||||
To put the **browser → console** hop on HTTPS, terminate TLS at a reverse proxy
|
||||
in front of the console. The dev stack (root `compose.yaml`) ships a `caddy`
|
||||
service that does exactly this — and it's the only published entry point, so the
|
||||
dashboard is HTTPS by default:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
# dashboard: https://localhost:${CONSOLE_HTTPS_PORT:-8443}
|
||||
```
|
||||
|
||||
The production stack (`turnstone/deploy/compose.yaml`) bundles the same `caddy`
|
||||
service, so the dashboard is HTTPS there too. For a real domain and a publicly
|
||||
trusted cert, point Caddy at Let's Encrypt by editing `turnstone/deploy/Caddyfile`.
|
||||
|
||||
```
|
||||
browser --h2 / HTTPS--> caddy:443 --h1.1 / HTTP--> console:8090
|
||||
```
|
||||
|
||||
Caddy uses its **own local CA** (`tls internal`, see `turnstone/deploy/Caddyfile`), so the
|
||||
setup is self-contained with no dependency on the console's ACME path. Trust the
|
||||
local root once to silence the browser warning:
|
||||
|
||||
```bash
|
||||
docker compose exec caddy \
|
||||
cat /data/caddy/pki/authorities/local/root.crt # import into your OS/browser
|
||||
```
|
||||
|
||||
**Can Caddy get its cert from the console's internal CA instead?** Technically
|
||||
yes — the console exposes a real ACME directory (`/acme/directory`) with
|
||||
auto-approval, so Caddy's `tls { ca http://console:8090/acme/directory }` would
|
||||
mint a cert for any name. It's not recommended as the default: lacme's ACME
|
||||
responder is built for turnstone's own client (interop with Caddy's client is
|
||||
unverified), it couples Caddy startup to the console, and the browser must trust
|
||||
a private CA either way — so it buys nothing over `tls internal`. For a publicly
|
||||
trusted cert (no warning), point Caddy at Let's Encrypt with a real domain
|
||||
instead.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -88,32 +41,6 @@ Console (CA + ACME Server)
|
||||
- **Frontend cert** (HTTPS): From an external ACME CA (e.g. Let's Encrypt)
|
||||
if `tls.acme_directory` is set, otherwise self-issued from the internal CA.
|
||||
|
||||
### Boot, retry, and fallback
|
||||
|
||||
With `tls.enabled`, a node fetches the CA cert and requests its own cert
|
||||
during startup, retrying with exponential backoff (6 attempts, ~31 s total)
|
||||
— enough to absorb a whole-stack restart where every node races the console
|
||||
for its listener. If all attempts fail, the node **falls back to plain
|
||||
HTTP** (availability over confidentiality) and reports `"tls": "fallback"`
|
||||
in `GET /health`; a node serving HTTPS reports `"tls": "active"`, and the
|
||||
key is absent when TLS is disabled. Fallback persists until the next
|
||||
restart — it is not upgraded in place.
|
||||
|
||||
### Container healthcheck under mTLS
|
||||
|
||||
An mTLS listener rejects plain-HTTP probes at the socket, so
|
||||
`docker/healthcheck.py` falls back to HTTPS when the plain probe fails:
|
||||
it presents the node's own cert as the client cert and pins the cluster
|
||||
CA, using the PEM files the server writes at boot under
|
||||
`$TURNSTONE_TLS_PEM_DIR` (default `<tmpdir>/turnstone-tls`). The probe
|
||||
dials `localhost` for the TLS attempt — the internal CA issues DNS SANs
|
||||
only, so a literal-IP URL would fail verification. Cert renewal rewrites
|
||||
the PEM dir alongside the live listener swap, so the probe's client cert
|
||||
never outlives the served cert. With TLS disabled the plain probe succeeds
|
||||
and the PEM directory is never consulted. On bare metal with multiple
|
||||
nodes per host, set `TURNSTONE_TLS_PEM_DIR` per node (each boot clears
|
||||
stale `lacme-pem-*` dirs under its root).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
@@ -246,15 +173,8 @@ const client = new TurnstoneServer({
|
||||
1. Node starts, connects to shared database (plain connection)
|
||||
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
|
||||
`TURNSTONE_ADVERTISE_URL`, e.g. `node-1`) — the name peers actually dial,
|
||||
not the container hostname. This makes mTLS hostname verification succeed
|
||||
and keys the cert by a stable name that survives container recreation.
|
||||
5. Starts auto-renewal (24h interval, re-issues before expiry) **scoped to its
|
||||
own certificate**. Each node renews only its own cert; the shared store is
|
||||
never swept wholesale. Renewed certs are hot-swapped into the live HTTPS
|
||||
listener with no restart.
|
||||
4. Requests service cert via ACME protocol (plain HTTP, JWS-signed)
|
||||
5. Starts auto-renewal (24h interval, re-issues before expiry)
|
||||
6. All subsequent inter-service communication uses mTLS
|
||||
|
||||
### Console Startup Flow
|
||||
@@ -263,9 +183,7 @@ const client = new TurnstoneServer({
|
||||
2. Initialize CA (load from DB or generate new root key)
|
||||
3. Mount ACME responder at `/acme` (serves `/ca.pem` natively)
|
||||
4. Issue console certs (internal + optional frontend)
|
||||
5. Start CA-direct auto-renewal (no network, signs directly), scoped to the
|
||||
console's own cert, plus a periodic GC that reclaims cert rows for
|
||||
long-departed nodes
|
||||
5. Start CA-direct auto-renewal (no network, signs directly)
|
||||
6. Register console URL in services table with heartbeat
|
||||
|
||||
---
|
||||
@@ -277,31 +195,17 @@ const client = new TurnstoneServer({
|
||||
Certs are valid for 48 hours. If auto-renewal stopped (e.g. console was down),
|
||||
restart the service to re-request a cert.
|
||||
|
||||
### Collector/proxy can't reach a node (TLS hostname mismatch)
|
||||
|
||||
mTLS verifies a node's advertised host against the cert's SANs. Each node's
|
||||
cert is issued for the host in its `TURNSTONE_ADVERTISE_URL`, so that name is
|
||||
always a SAN automatically — you do **not** need to set `TURNSTONE_TLS_SANS`
|
||||
per node. Only set `TURNSTONE_TLS_SANS` to add *extra* names (e.g. a node
|
||||
fronted under a second hostname). Symptom if this is wrong: the console
|
||||
dashboard shows nodes as unreachable and `openssl s_client` reports the served
|
||||
cert's SANs don't include the dialed name.
|
||||
|
||||
### "No console service found"
|
||||
|
||||
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. Use `--console-url` explicitly.
|
||||
|
||||
### Browser HTTPS to the console
|
||||
### Let's Encrypt for console frontend
|
||||
|
||||
The console serves plain HTTP (it's the ACME bootstrap endpoint — see
|
||||
[Browser access](#browser-access-dashboard-https)). Put browser traffic on
|
||||
HTTPS by terminating TLS at a reverse proxy; the `cluster` profile's `caddy`
|
||||
service does this with Caddy's local CA. For a publicly trusted cert, front the
|
||||
console with a proxy pointed at Let's Encrypt using a real domain. The
|
||||
`tls.acme_directory` setting only governs the console's internal/frontend cert
|
||||
material — it does **not** make the console listen on HTTPS itself.
|
||||
Set `tls.acme_directory` to `https://acme-v02.api.letsencrypt.org/directory`
|
||||
in the admin Settings tab. The console will request a publicly trusted cert
|
||||
for its HTTPS endpoint. Internal mTLS still uses the private CA.
|
||||
|
||||
### Verifying the cert chain
|
||||
|
||||
|
||||
+131
-95
@@ -1,6 +1,6 @@
|
||||
# Tools Reference
|
||||
|
||||
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
|
||||
turnstone exposes 19 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
|
||||
@@ -22,6 +22,7 @@ schema plus turnstone-specific metadata keys:
|
||||
"properties": { ... },
|
||||
"required": ["param1"]
|
||||
},
|
||||
"agent": true,
|
||||
"task_agent": true,
|
||||
"auto_approve": true,
|
||||
"primary_key": "param1"
|
||||
@@ -32,7 +33,8 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|----------------|------|---------|
|
||||
| `task_agent` | bool | Tool is available to task sub-agents. |
|
||||
| `agent` | bool | Tool is available to plan/task sub-agents (read-only subset). |
|
||||
| `task_agent` | bool | Tool is available to task sub-agents (broader subset). |
|
||||
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
|
||||
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
|
||||
|
||||
@@ -44,10 +46,12 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------|-------------|
|
||||
| `TOOLS` | All 28 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
|
||||
| `TOOLS` | All 19 tool definitions (sent to the model). |
|
||||
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only 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 28 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
|
||||
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 19 built-in tool names. 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 +69,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 16
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 19
|
||||
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:
|
||||
@@ -107,6 +111,9 @@ Each item's `execute` callable is invoked:
|
||||
denials are tracked separately. This removes the need for text-prefix heuristics.
|
||||
Other tools deliver results atomically via
|
||||
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
|
||||
- Special post-execution gate for `plan`: the plan output is shown to the user
|
||||
for review, and the user can reject or annotate it.
|
||||
|
||||
---
|
||||
|
||||
## Tool Approval Flow
|
||||
@@ -114,6 +121,7 @@ Each item's `execute` callable is invoked:
|
||||
**Auto-approved** (no user confirmation needed at runtime):
|
||||
- `read_file` -- reads files, no side effects
|
||||
- `search` -- grep-style search, no side effects
|
||||
- `man` -- reads man pages, no side effects
|
||||
- `memory` -- structured persistent memory (save/search/delete/list)
|
||||
- `recall` -- searches conversation history
|
||||
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
|
||||
@@ -122,14 +130,16 @@ Each item's `execute` callable is invoked:
|
||||
- `bash` -- arbitrary command execution
|
||||
- `write_file` -- creates or overwrites files
|
||||
- `edit_file` -- modifies file content
|
||||
- `math` -- sandboxed computation (confirmation required despite being sandboxed)
|
||||
- `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
|
||||
- `web_search` -- web search via Tavily API (makes network requests)
|
||||
- `task` -- spawns an autonomous sub-agent
|
||||
- `plan` -- spawns a planning sub-agent, plus post-execution review gate
|
||||
|
||||
Note: The JSON schema metadata key `auto_approve` controls membership in
|
||||
`TASK_AUTO_TOOLS` (used for task agent sub-sessions). The actual runtime
|
||||
approval behavior is determined by the `needs_approval` field set in each
|
||||
`_prepare_*` method on `ChatSession`. These two mechanisms can differ.
|
||||
`AGENT_AUTO_TOOLS`/`TASK_AUTO_TOOLS` (used for agent sub-sessions). The actual
|
||||
runtime approval behavior is determined by the `needs_approval` field set in
|
||||
each `_prepare_*` method on `ChatSession`. These two mechanisms can differ.
|
||||
|
||||
---
|
||||
|
||||
@@ -155,9 +165,12 @@ Every tool defines a `primary_key`. The mapping is:
|
||||
| `write_file` | `content` |
|
||||
| `edit_file` | `old_string`|
|
||||
| `search` | `query` |
|
||||
| `math` | `code` |
|
||||
| `man` | `page` |
|
||||
| `web_fetch` | `url` |
|
||||
| `web_search` | `query` |
|
||||
| `task_agent` | `prompt` |
|
||||
| `plan_agent` | `goal` |
|
||||
| `memory` | `name` |
|
||||
| `recall` | `query` |
|
||||
| `notify` | `message` |
|
||||
@@ -181,7 +194,7 @@ Execute a bash command and return stdout + stderr.
|
||||
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
|
||||
- **Output format**: Stdout is returned directly. Stderr lines are prefixed with `[stderr]` so the model can distinguish them. When the command itself redirects stderr to stdout (`2>&1`), no prefix is added. Output exceeding 256KB is truncated (head + tail preserved, middle replaced with a truncation notice).
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
- **Agent availability**: `task_agent` only (not available to plan sub-agents).
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +212,7 @@ base64-encoded image data for supported image formats.
|
||||
- **What it does**: For text files, reads and returns content with line numbers. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns image data as multi-part content when the model supports vision, or a text description when it does not. SVG files are read as text. Images larger than 4 MB are rejected. Must be called before `edit_file` on the same path (the session tracks which files have been read).
|
||||
- **Vision support**: Controlled by `ModelCapabilities.supports_vision`. All commercial OpenAI and Anthropic models have vision enabled. Local models (vLLM, llama.cpp, NIM) default to off — enable via `[models.*.capabilities] supports_vision = true` in config.toml.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
@@ -255,7 +268,7 @@ Show a unified diff between two files, or between a file and a provided string.
|
||||
|
||||
- **What it does**: Returns unified diff output using Python's `difflib`. Binary files (containing null bytes) are rejected with a clear error. Files read through `diff_file` satisfy `edit_file`'s read guard — you can diff then edit without a separate `read_file` call. Large diffs are streamed with early cutoff at the tool truncation limit.
|
||||
- **Auto-approve**: Yes (read-only).
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
@@ -270,12 +283,44 @@ Search file contents for a regex pattern.
|
||||
|
||||
- **What it does**: Recursively searches for the pattern using `grep -rn`. Returns matching lines with file paths and line numbers.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
## Computation
|
||||
|
||||
### math
|
||||
|
||||
Execute Python code for math and computation in a sandbox.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `code` | string | yes | Python code to execute. Must use `print()` for output. |
|
||||
|
||||
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported. `pytest` is also available for import.
|
||||
- **Installation**: `sympy`, `numpy`, `scipy`, and `pytest` require the `[sandbox]` extras group: `pip install turnstone[sandbox]` (included in `[all]`).
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
## Information
|
||||
|
||||
### man
|
||||
|
||||
Read a man page.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | string | yes | The man page name (e.g. `grep`, `socket`, `printf`). |
|
||||
| `section` | string | no | Manual section (e.g. `1` commands, `2` syscalls, `3` library). |
|
||||
|
||||
- **What it does**: Returns the full formatted manual entry. Preferred over `bash('man ...')` or `web_search` for command/API documentation.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
### web_fetch
|
||||
|
||||
Fetch a URL and extract specific information from it.
|
||||
@@ -287,7 +332,7 @@ Fetch a URL and extract specific information from it.
|
||||
|
||||
- **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`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
@@ -299,55 +344,20 @@ Search the web using a text query.
|
||||
|---------------|---------|----------|-------------|
|
||||
| `query` | string | yes | The search query. |
|
||||
| `max_results` | integer | no | Max results to return (default 5, max 20). |
|
||||
| `category` | string | no | Search category: `general` (default), `news`, `it` (code/tech), or `science`. Maps to SearxNG categories; the model picks per query. |
|
||||
| `topic` | string | no | Search topic: `general`, `news`, or `finance` (default `general`). |
|
||||
|
||||
- **What it does**: Searches the web and returns ranked results with titles, URLs, and content snippets. Uses provider-native search when available:
|
||||
- **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No backend needed.
|
||||
- **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No Tavily key needed.
|
||||
- **OpenAI search models** (`gpt-5-search-api`): Replaced with `web_search_options` parameter. The model always searches and returns `url_citation` annotations.
|
||||
- **Local/vLLM models**: Falls back to a self-hosted [SearxNG](https://searxng.org) instance. Set `searxng_url` in `config.toml` `[tools]` or `$TURNSTONE_SEARXNG_URL` (the docker-compose stack bundles a `searxng` service and points at it by default). Operators with a custom MCP search server can instead set `web_search_backend = "mcp:server:tool"`.
|
||||
- **Local/vLLM models**: Falls back to the Tavily API. Requires `tavily_key` in `config.toml` or `$TAVILY_API_KEY`.
|
||||
- **Auto-approve**: Yes (auto-approved for all tool dispatch paths).
|
||||
- **Agent availability**: `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
### Reranking (optional)
|
||||
|
||||
`web_search` can use an external **reranker** to re-order the backend's result pool by relevance to the query before returning the top hits. Turnstone runs no reranker model itself; it POSTs to a Cohere/Jina-compatible `/rerank` endpoint (self-hosted [vLLM](https://docs.vllm.ai) / [TEI](https://github.com/huggingface/text-embeddings-inference) / llama.cpp, or hosted Cohere/Jina/Voyage).
|
||||
|
||||
**Disabled by default.** In the console **Models** tab, add a model definition whose `base_url` is a Cohere/Jina-compatible `/rerank` endpoint and whose capabilities include `{"supports_rerank": true}`, then select it under **Models → Roles → Reranker**. It's managed like every other model (write-only key, enable/disable, calibration). The reranker is purely this per-model definition — there is no global `rerank_url`-style endpoint setting.
|
||||
|
||||
The `rerank_web_search` toggle defaults on once a reranker is selected. If the endpoint is unreachable or errors, web_search falls back silently to the backend's native result order — reranking never makes a search fail.
|
||||
|
||||
When `rerank_bm25` is enabled, the candidate text for memory, tool, and skill retrieval (memory name/description/content and tool/skill names + descriptions) is also sent to the rerank endpoint — a self-hosted endpoint (vLLM/TEI/llama.cpp) keeps it on your infrastructure, a hosted provider (Cohere/Jina/Voyage) sends it off-box.
|
||||
|
||||
**Serving a Qwen3-Reranker with vLLM.** The model is instruction-aware, so vLLM **must** apply its chat template — pass `--chat-template` explicitly. Without it the bare query produces near-random scores and reranking actively *hurts* retrieval (verified: an irrelevant passage outscored the correct one):
|
||||
|
||||
```bash
|
||||
vllm serve /models/Qwen3-Reranker-0.6B \
|
||||
--runner pooling \
|
||||
--hf-overrides '{"architectures":["Qwen3ForSequenceClassification"],"classifier_from_token":["no","yes"],"is_original_qwen3_reranker":true}' \
|
||||
--chat-template /models/Qwen3-Reranker-0.6B/chat_template.jinja \
|
||||
--served-model-name qwen3-reranker --port 8000
|
||||
```
|
||||
|
||||
Then add a reranker model in the **Models** tab with `base_url` `http://vllm:8000/rerank` (model name `qwen3-reranker`) and select it under **Models → Roles → Reranker**.
|
||||
|
||||
For an endpoint that does *not* apply the model's template, set `rerank_instruction` instead — Turnstone then wraps each query as `<Instruct>: {instruction}` / `<Query>: {query}` (Qwen3's own default is `Given a web search query, retrieve relevant passages that answer the query`). Use the chat template **or** the instruction, not both (they double-wrap).
|
||||
|
||||
**Picking `rerank_bm25_threshold`.** The relevance floor that gates proactive memory injection is a probability in `[0, 1]`, but the right value differs per model (a sharp 0.6B reranker may want ~0.95; a broader 4B ~0.33). Calibrate it against your endpoint:
|
||||
|
||||
```bash
|
||||
turnstone-admin rerank-calibrate # probe the endpoint, recommend a floor
|
||||
turnstone-admin rerank-calibrate --apply # ...and write tools.rerank_bm25_threshold
|
||||
```
|
||||
|
||||
It reports the score scale, whether the endpoint cleanly separates relevant from irrelevant probes (a **"no clean separation"** result flags a mis-served or weak reranker), and the suggested floor. Leave the threshold at `0` to rerank-without-filtering.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
## Agent
|
||||
|
||||
The tool name uses the `_agent` suffix — bare `task` collides with
|
||||
Tool names use the `_agent` suffix — bare `plan` / `task` collide with
|
||||
chat-template channel names on some local models.
|
||||
|
||||
### task_agent
|
||||
@@ -358,9 +368,23 @@ Delegate a general-purpose task to an autonomous sub-agent.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `prompt` | string | yes | Complete task description for the sub-agent. |
|
||||
|
||||
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
|
||||
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, math, man, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: Top-level only.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
|
||||
---
|
||||
|
||||
### plan_agent
|
||||
|
||||
Plan before implementing -- an autonomous agent explores the codebase and writes a structured plan.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `prompt` | string | yes | What to plan -- the goal, constraints, and scope. |
|
||||
|
||||
- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-<ws_id>.md` (unique per workstream, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan.
|
||||
- **Auto-approve**: No -- requires user confirmation, plus post-execution review gate.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
|
||||
---
|
||||
|
||||
@@ -421,7 +445,7 @@ Provide either `username` for user-based targeting or `channel_type` +
|
||||
|
||||
- **What it does**: Sends a notification via the channel gateway's HTTP endpoint (`POST /v1/api/notify`). The server queries the `services` table for healthy channel gateways, authenticates with a service JWT (`aud: turnstone-channel`), and delivers to the first healthy gateway. On failure, retries up to 2 additional times with backoff (1s, 3s). Rate-limited to 5 notifications per turn (counter only increments on success).
|
||||
- **Auto-approve**: Yes — notifications are time-sensitive and auto-approved so the model can alert users urgently.
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
> See [Channel Integrations: Notifications](channels.md#notifications)
|
||||
> for the full delivery flow, service registry details, and security
|
||||
@@ -497,7 +521,7 @@ data.get("mergedAt") is not None
|
||||
- Duplicate names rejected within the same workstream.
|
||||
|
||||
- **Auto-approve**: `create` requires approval; `list` and `cancel` are auto-approved.
|
||||
- **Agent availability**: Main session only — not available to task sub-agents.
|
||||
- **Agent availability**: Main session only — not available to plan/task sub-agents.
|
||||
|
||||
> See [Watch Architecture](diagrams/png/18-watch-architecture.png) for the
|
||||
> full poll → evaluate → dispatch flow.
|
||||
@@ -530,30 +554,33 @@ pre-configure skills at workstream creation.
|
||||
|
||||
- **Auto-approve**: `load` requires approval (changes session behavior); `search`
|
||||
is auto-approved (read-only).
|
||||
- **Agent availability**: Main session only — not available to task sub-agents.
|
||||
- **Agent availability**: Main session only — not available to plan/task sub-agents.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Tool | Category | Auto-approve | task_agent | primary_key |
|
||||
|--------------|------------|--------------|------------|-------------|
|
||||
| `bash` | File Ops | No | Yes | `command` |
|
||||
| `read_file` | File Ops | Yes | Yes | `path` |
|
||||
| `write_file` | File Ops | No | Yes | `content` |
|
||||
| `edit_file` | File Ops | No | Yes | `old_string`|
|
||||
| `search` | File Ops | Yes | Yes | `query` |
|
||||
| `web_fetch` | Info | No | Yes | `url` |
|
||||
| `web_search` | Info | No | Yes | `query` |
|
||||
| `task_agent` | Agent | No | No | `prompt` |
|
||||
| `memory` | Memory | Yes | No | `name` |
|
||||
| `recall` | Memory | Yes | No | `query` |
|
||||
| `notify` | Notify | Yes | Yes | `message` |
|
||||
| `watch` | Monitor | No (create) | No | `command` |
|
||||
| `read_resource`| MCP | No | Yes | `uri` |
|
||||
| `use_prompt` | MCP | No | Yes | `name` |
|
||||
| `skill` | Skills | No (load) | No | `name` |
|
||||
| `tool_search`| Search | Yes | No | `query` |
|
||||
| Tool | Category | Auto-approve | agent | task_agent | primary_key |
|
||||
|--------------|------------|--------------|-------|------------|-------------|
|
||||
| `bash` | File Ops | No | No | Yes | `command` |
|
||||
| `read_file` | File Ops | Yes | Yes | Yes | `path` |
|
||||
| `write_file` | File Ops | No | No | Yes | `content` |
|
||||
| `edit_file` | File Ops | No | No | Yes | `old_string`|
|
||||
| `search` | File Ops | Yes | Yes | Yes | `query` |
|
||||
| `math` | Compute | No | Yes | Yes | `code` |
|
||||
| `man` | Info | Yes | Yes | Yes | `page` |
|
||||
| `web_fetch` | Info | No | Yes | Yes | `url` |
|
||||
| `web_search` | Info | No | Yes | Yes | `query` |
|
||||
| `task_agent` | Agent | No | No | No | `prompt` |
|
||||
| `plan_agent` | Agent | No | No | No | `goal` |
|
||||
| `memory` | Memory | Yes | No | No | `name` |
|
||||
| `recall` | Memory | Yes | No | No | `query` |
|
||||
| `notify` | Notify | Yes | Yes | Yes | `message` |
|
||||
| `watch` | Monitor | No (create) | No | No | `command` |
|
||||
| `read_resource`| MCP | No | Yes | Yes | `uri` |
|
||||
| `use_prompt` | MCP | No | Yes | Yes | `name` |
|
||||
| `skill` | Skills | No (load) | No | No | `name` |
|
||||
| `tool_search`| Search | Yes | No | No | `query` |
|
||||
|
||||
---
|
||||
|
||||
@@ -605,9 +632,8 @@ CLI flags override the config file:
|
||||
directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the built-in tools present in the current session
|
||||
(interactive sessions currently have 16; `BUILTIN_TOOL_NAMES` is the
|
||||
28-tool built-in union). These are always visible to the model.
|
||||
- **Always-on** -- the 19 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
These are always visible to the model.
|
||||
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
||||
the model searches for them.
|
||||
|
||||
@@ -621,10 +647,10 @@ CLI flags override the config file:
|
||||
|
||||
### Agent exemption
|
||||
|
||||
Task sub-agents do not use tool search. They operate on the scoped tool set
|
||||
(`TASK_AGENT_TOOLS`) with MCP tools merged in. Tool search is only active for
|
||||
the top-level session, where the model can interactively search for tools it
|
||||
needs.
|
||||
Plan and task sub-agents do not use tool search. They operate on scoped tool
|
||||
sets (`AGENT_TOOLS` for plan agents, `TASK_AGENT_TOOLS` for task agents) with
|
||||
MCP tools merged in. Tool search is only active for the top-level session,
|
||||
where the model can interactively search for tools it needs.
|
||||
|
||||
---
|
||||
|
||||
@@ -649,7 +675,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 16 built-in tools via
|
||||
4. **Merging**: MCP tools are appended after the 19 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
|
||||
@@ -675,6 +701,7 @@ gives per-tool-type granularity (e.g., all `use_prompt` calls).
|
||||
MCP tools are available to:
|
||||
- **Main session** — full access
|
||||
- **Task sub-agents** — via `self._task_tools` (merged list)
|
||||
- **Plan sub-agents** — via `self._agent_tools` (merged list)
|
||||
|
||||
### Naming convention
|
||||
|
||||
@@ -731,25 +758,34 @@ MCP tools (3):
|
||||
|
||||
### Dynamic tool refresh
|
||||
|
||||
MCP tool lists stay up-to-date without restart through two mechanisms:
|
||||
MCP tool lists stay up-to-date without restart through three mechanisms:
|
||||
|
||||
1. **Push notifications** -- MCP servers that declare `tools.listChanged: true` in
|
||||
their capabilities send `notifications/tools/list_changed` when their tool list
|
||||
changes. `MCPClientManager` registers a `message_handler` on each `ClientSession`
|
||||
that triggers an immediate refresh for that server.
|
||||
|
||||
2. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
|
||||
2. **Periodic timer** -- Servers that do *not* support push notifications are polled
|
||||
on a configurable interval (default 4 hours). The timer is staggered using a
|
||||
launch-time seed (`monotonic_ns ^ pid`) so cluster nodes don't all hit MCP
|
||||
servers simultaneously. Configure via `[mcp] refresh_interval` in `config.toml`
|
||||
or `--mcp-refresh-interval SECONDS` on the CLI. Set to `0` to disable.
|
||||
|
||||
3. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
|
||||
`/mcp refresh <server>` targets a single server. If a server has disconnected,
|
||||
manual refresh attempts reconnection. The console admin panel exposes the
|
||||
same controls (refresh / reconnect buttons per server) for cluster-wide
|
||||
fan-out.
|
||||
manual refresh attempts reconnection.
|
||||
|
||||
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
|
||||
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
|
||||
instances via registered listener callbacks. Each session rebuilds its `_tools`,
|
||||
`_task_tools`, and reconstructs its `ToolSearchManager` (if active),
|
||||
`_task_tools`, `_agent_tools`, and reconstructs its `ToolSearchManager` (if active),
|
||||
preserving the set of previously expanded (discovered) tools.
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
refresh_interval = 14400 # seconds (default 4h), 0 to disable
|
||||
```
|
||||
|
||||
```
|
||||
/mcp refresh
|
||||
MCP refresh complete:
|
||||
@@ -803,7 +839,7 @@ Use read_resource(uri='...') to access the resources listed above.
|
||||
|
||||
- **What it does**: Reads the resource from its MCP server via `MCPClientManager.read_resource_sync()`. Returns text content for text resources or base64-encoded data for binary resources. Output is truncated by the standard tool output limiter.
|
||||
- **Auto-approve**: No -- requires user confirmation (reads external data).
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
### Capability guards
|
||||
|
||||
@@ -845,7 +881,7 @@ the `initialize` handshake. Each prompt is stored with its prefixed name
|
||||
|
||||
- **What it does**: Invokes an MCP prompt by name via `MCPClientManager.get_prompt_sync()`, expanding it into messages. Returns the expanded prompt content formatted as `[role]: content` blocks joined with blank lines. The prompt catalog is listed in the system message so the model knows which prompts are available. Output is truncated by the standard tool output limiter.
|
||||
- **Auto-approve**: No -- requires user confirmation (invokes external prompt servers).
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
### Invocation
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
uv.lock
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 ]
|
||||
@@ -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
|
||||
@@ -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
@@ -1,829 +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_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 (A) is two columns and refused."""
|
||||
game = _game(tmp_path, clock)
|
||||
out = game.join("A")
|
||||
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()
|
||||
@@ -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}"
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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([]) == ""
|
||||
@@ -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)
|
||||
@@ -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.1–v0.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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
"A": "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
|
||||
@@ -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()
|
||||
@@ -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"] = "A" # 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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 `A`) 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
|
||||
"•",
|
||||
"⁂",
|
||||
"★",
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user