Compare commits

..

249 Commits

Author SHA1 Message Date
Patrick Buckley 2d174cd71c perf(webui): bound the transcript — containment, block flow, windowing
The remaining steady-state cost after the wedge-proofing pass was
structural: every row of an unbounded transcript participated in every
layout, and full re-renders rebuilt all of it.

CSS pair (shared scroller + the ui/static duplicate):
- The messages scroller is BLOCK flow, not a column flexbox — flex
  relayouts all items when the streaming row's height changes, O(rows)
  per token; block flow dirties only the tail. The old per-child
  flex-shrink pin (and the min-height:auto squish hazard it suppressed)
  goes with it; inter-row rhythm moves to a sibling margin.
- overflow-anchor: none — the pane owns bottom pinning, and native
  anchoring kept re-selecting an anchor inside the innerHTML-replaced
  live bubble every frame.
- content-visibility: auto with contain-intrinsic-size: auto estimates
  on .msg (80px) and .conv-batch (200px) rows, exempting the last two
  children so the live tail never toggles skip-state mid-stream. The
  `auto` keyword memoizes each row's rendered size, keeping scrollHeight
  and the bottom pin stable once painted.

Transcript windowing (interactive pane):
- Full re-renders paint the most recent 300 messages, cut FORWARD to a
  user-turn boundary so an assistant tool_calls message is never split
  from the tool results that anchor to it. Hidden content sits behind a
  "Load earlier messages" pager; each click grows the window a step and
  refetches, restoring the scroll anchor by scrollHeight delta (the
  rAF pin re-checks the near-bottom flag at fire time, so no
  suppression is needed). Rewind/edit turn math is tail-relative and
  unaffected — pinned as such.
- Live appends are bounded at the idle edge: past 900 rendered rows the
  oldest rows are trimmed (again to a turn boundary), only while pinned
  to the bottom — a scrolled-up user is reading the rows a trim would
  remove. Trimmed content stays in /history and returns through the
  pager; detached agent-card entries are swept.

The perf page gains ?window= (and the runner --perf-extra) so windowing
and containment effects can be isolated.

Measured (n=3000 history + 20-turn storm; baseline -> previous branch
-> this change): full replay 1060ms -> 238ms -> 28ms windowed / 107ms
with the window grown to the full transcript; longtasks during the run
6/1080ms -> 4/495ms -> none windowed / 4/205ms unwindowed; per-turn
live-storm cost at n=3000 now equals n=300 (~220ms harness floor) even
with all 25k nodes live — the transcript-size tax is gone. Known
degraded-mode cost: the chunk path at a fully-grown window measures
~1017ms vs the 833ms floor; the shipped windowed config sits at the
floor.
2026-07-02 00:33:35 -07:00
Patrick Buckley df573b7314 fix(webui): address review feedback on roster eviction and dead guards
- applyRosterSnapshot: null-prototype membership map (a ws id colliding
  with an Object.prototype property name would read as always-seen and
  dodge eviction) and a stable Object.keys snapshot for the eviction
  walk — current-key deletion during for...in is spec-safe, but the
  snapshot is self-evidently order-safe and skips inherited keys.
- _streamingRenderApply: drop the tautological typeof guards around the
  post-render decorators — both are module-local declarations, and the
  surrounding try/catch owns decoration fault tolerance.
2026-07-02 00:32:11 -07:00
Patrick Buckley 3c7a3c1375 fix(webui): wedge-proof the live-session pipeline and de-O(N) hot paths
Long sessions (5000+ messages, several compactions) degraded steadily
and could stop rendering entirely while the backend stayed healthy.
Four hard failure mechanisms, each sufficient on its own:

- Unguarded event pipeline: one throw escaping onmessage/handleEvent
  (e.g. renderMarkdown stack overflow on a few KB of nested "> ")
  stranded the streaming refs, so every later delta painted into the
  poisoned segment. stream_end now resets segment refs BEFORE the
  finalize render with a plain-text fallback (the coordinator pane's
  existing pattern); onmessage guards both parse and dispatch;
  renderMarkdown is depth-capped with throw-safe footnote-scope
  accounting; the streaming buffer is marked rendered only on success.

- Rebuild-vs-live races: clear_ui/replay_truncated re-renders wiped
  events painted in the snapshot->replaceChildren window (never
  redelivered) and left deltas writing into detached nodes. Rebuilds
  now quiesce the event stream behind a token-owned queue flushed
  after the render; streaming refs reset on every rebuild path
  including refetch FAILURE; a mid-stream replay_truncated defers its
  re-sync to the idle edge instead of dropping the repair.

- Ignored recovery floor: the global stream now handles node_snapshot
  and replay_truncated. Roster eviction (with a "Session ended" toast
  for open panes) happens only from the stream-ordered snapshot; the
  REST resync is merge-only and r.ok-gated so a mid-restart 503 body
  cannot read as an authoritative empty roster.

- Unbounded growth: _agentCards released on rebuild — deliberately NOT
  on transport-only reconnects, which must preserve the maps or the
  next child event builds a duplicate card; orphan grace timers
  cancelled on full reload/destroy; toast queue capped with duplicate
  coalescing; diff previews capped at 400 rendered lines (the
  spread-append could throw RangeError before the approval gate
  painted) with the omission notice below the scroll box; raw results
  clamped at 64KiB.

Per-event O(N) work removed from the hot paths: thinking-indicator
instance ref; near-bottom cached from a passive scroll listener and
re-checked at rAF pin time (a user scroll-up landing in the coalescing
window wins; ResizeObserver re-engages follow after layout changes);
rAF-coalesced outer and per-stream scroll pins; self-healing
call_id->row/stream lookup caches; verdict lookup scoped to the row's
batch; tracked retry holder; queue-controller Set replaces the
whole-transcript idle sweep; rail renders rAF-coalesced; coordinator
child_ws_state ticks routed to single-row updates (full render only on
terminal-boundary crossings) with observer unobserve on replace.

Also: the coordinator SSE-error 401 probe is un-deadened (raw fetch —
authFetch never resolves a 401 — with the body inspected so a
version_mismatch still takes auth.js's upgrade-reload path via the new
noteVersionMismatch export); the console cluster-SSE reconnect timer
is tracked across logout; the mermaid render chain is rejection-proof
per link and paints errors on the containers the failing link had
already claimed.

Measured with scripts/livepass.py --perf (n=3000 history + 20-turn
live storm): full replay 1060ms -> 238ms; re-render cycles 836-1071ms
-> ~94ms flat; chunk path now flat vs transcript size; worst longtask
1080ms -> ~500ms; agent-card retention across rebuilds 4 -> 0.

Known limit (needs a server-side event watermark on /history): a turn
completing inside the refetch window can paint twice after the quiesce
flush — rare, visible, and strictly better than the silent loss it
replaces.
2026-07-02 00:32:11 -07:00
Patrick Buckley 41e7d5b7d7 feat(livepass): add long-session perf harness (--perf)
New /perf/livepass.html mounts the real InteractivePane at production
scroll geometry and drives production-shaped SSE events through
handleEvent/replayHistory in real time (no virtual-time budget, no
forced reduced-motion — both corrupt the measurement), reporting:
replayHistory wall time at N messages, per-turn live-storm cost on top
of that transcript, tool_output_chunk throughput, busy/idle churn,
heap + node + agent-card counts across repeated replay cycles (the
detached-DOM leak probe), and longtask counts.

The --perf runner builds, serves, and launches headless Chrome with
--js-flags=--expose-gc and --enable-precise-memory-info so heap
numbers are real floors; the page POSTs its JSON report to
/perf/report. Reports carry a per-attempt run token the runner
validates, so a straggler POST from a killed prior attempt cannot be
misattributed to the next size, and the wait loop polls the Chrome
process so a sandbox startup failure bails to the --no-sandbox
fallback in seconds instead of burning the full timeout.
2026-07-02 00:32:11 -07:00
Patrick Buckley ca23f2876c fix(ci): refuse fork PRs in the vendor-js dispatch path
The workflow_dispatch input is an arbitrary PR number, and the job used
only headRefName to pick the checkout ref. For a fork PR that is a bare
branch name that can collide with a branch in this repo, so the job
(contents:write, ends in git push) would operate on that unrelated
branch. Resolve isCrossRepository alongside headRefName and fail loudly
unless the PR head lives in this repository.
2026-07-01 21:32:45 -07:00
Patrick Buckley a9898fdd6c fix(ci): gate workflow_run publishing to same-repo tag pushes
The publish and docker workflows trigger on workflow_run of CI, which
fires for every CI completion — including CI runs for pull requests
from forks — and always executes with this repo's secrets, tokens, and
the pypi environment. The only gate was CI success, so fork-PR CI runs
spawned publish jobs in the upstream context; actions/checkout v7's
fork-checkout refusal was the only thing that stopped one on 2026-06-30.
A fork PR whose head is an upstream-tagged commit would have passed the
tag check and reached the upload with valid OIDC.

Both workflows now require the triggering CI run to be a push event,
from this repository, with head_branch starting with 'v' — CI's push
trigger only matches main/stable/* branches and v* tags, so that is
necessarily a tag run (verified: tag-push runs report the tag name as
head_branch). Checkouts no longer persist the token while the tree's
build backend executes, and publishes are no longer cancellable
mid-upload (a half-uploaded release cannot be re-run cleanly because
PyPI rejects duplicate files).

vendor-js hardening in the same pass: gate on the immutable PR author
instead of github.actor, require a same-repo head before pushing to the
PR branch with contents:write, and pass github.head_ref through env
instead of interpolating it into the script body.
2026-07-01 21:32:45 -07:00
Patrick Buckley c71cc749d9 chore: bump version to 1.7.0a6 2026-07-01 21:07:52 -07:00
Patrick Buckley 2fb80cb88f fix(compaction): count fixed prompt overhead in the carry budget
Review finding on #751: the carry invariant omitted the system message
and tool definitions, which ride every request — at shipped defaults
reserve + 2 carries + margin lands exactly at the window, so any real
prompt overhead pushed the post-compaction send over it, and the
overflow backstop re-compacts WITHOUT the carries.

spare now subtracts system_tokens + tool_def_tokens (the same terms the
_estimated_prompt_tokens fallback counts), making
overhead + reserve + carries*budget + margin <= window hold by
construction. Invariant test pinned at shipped defaults with a 4k-token
synthetic prompt; a monotonicity test pins that the term is live; exact-
arithmetic tests isolate the overhead explicitly.
2026-07-01 21:06:31 -07:00
Patrick Buckley 2dd0688d45 fix(compaction): carry the plan and the ask across compaction verbatim
The definition review found the two control-relevant crossings paraphrased:
the model's wind-down spill (recorded on the cooperative advisory, then
handed to the summarizer with everything else) and the user's last message
(clipped to 400 chars in the continuation hint). Both now cross copied.

- carry_spill: when the model stopped because it was advised to wrap up,
  its final turn's text is shell-concatenated onto the summary under
  '## Wind-down (verbatim)', ahead of '## Continue'. The summarizer still
  reads the spill; its paraphrase is no longer the only survivor.
- _carry_budget_chars(carries): ~25% of the window per carry, sized so ALL
  concurrent carries fit the spare after the summary output reserve —
  spill + hint fire together at the end-of-turn site, and independent
  sizing stacked reserve + 2*(cw/4) + margin past the window at default
  config. Floored at 2000 chars; oversize content keeps head + tail.
- _truncate_block's marker reports the original size ('truncated — N chars
  total'), and a truncated carry adds one line telling the model the full
  text remains in history and recall can retrieve it.
- Summary turns carry source="compaction" (in-memory swap and checkpoint
  reconstruction); _find_turn_boundaries and _generate_title test the tag
  instead of the label string, so a user who literally types
  '[Conversation summary]' stays a real turn.
- The send-loop overflow backstop now passes my_generation, closing the
  compact-and-swap race every other compaction site already guards.

Tests: tests/test_compaction_crossing.py (tags on both paths, literal-label
boundary, budget arithmetic incl. the double-carry invariant at shipped
defaults, verbatim/truncated carries, spill semantics, forwarding); existing
suites updated for the tagged label turns and the new kwargs.
2026-07-01 21:06:31 -07:00
Patrick Buckley 848f123985 feat(recall): scope the recall tool to the compacted past
After a compaction, storage keeps the full transcript and the in-context
summary is a cache over it — recall is the model's re-derivation path back
into the originals. Un-scoped, its results duplicated the live context.

- search_history gains exclude_ws_id/exclude_after: the excluded ws's rows
  above the boundary (the live segment, already in context) are dropped in
  SQL via one shared fragment; rows at or below it — the summarized-away
  past — stay searchable. A never-compacted ws is excluded whole:
  everything is live. Other workstreams untouched.
- New get_compaction_checkpoint(ws_id) reads the latest marker's persisted
  watermark (distinct from get_compaction_watermark, which computes what a
  NEW compaction would use); the meta decoder is single-sourced with the
  resume slice (parse_checkpoint_watermark) so the two boundary consumers
  cannot drift.
- _exec_recall reads the boundary fresh at execution (a compaction that ran
  while the item was queued is respected) and labels own-conversation hits
  '(earlier in this conversation, compacted)'. Storage errors degrade to
  whole-ws exclusion — less information, never duplicates. Known limit
  (documented): a forked session excludes only its own ws, so inherited
  parent rows remain searchable — harmless duplication bounded by tenancy.
- NUDGE_COMPACTION_RESUME teaches the path: the summary is a digest, not
  the record, and recall can search the compacted portion.
- /history deliberately unchanged: a human browsing history has no context
  to duplicate.

Tests: tests/test_recall_compaction_scope.py — checkpoint reads (none /
marker / latest-wins / malformed-as-live), the exclusion matrix, the
composed tenancy+exclusion query with both filters dropping rows, exec
plumbing and labeling, the nudge line; cross-backend.
2026-07-01 21:05:57 -07:00
renovate[bot] 76241ab703 chore(deps): lock file maintenance 2026-07-01 19:47:04 -07:00
renovate[bot] 409875e296 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.26 2026-07-01 19:46:50 -07:00
Patrick Buckley 8e4f32c93a chore(ci): allow Renovate PRs through Claude Code review
Renovate opens PRs as a bot actor, which claude-code-action's default
human-actor check rejects — Renovate's dependency-bump PRs were never
getting reviewed.
2026-07-01 19:44:27 -07:00
Patrick Buckley 3e4c1931a1 fix(storage): scope conversation-history search by project tenancy
search_history / search_history_recent searched every workstream's rows
regardless of who asked. Pre-projects that matched the trusted-team
deployment shape; with private projects (062) it became a cross-tenant
read — the recall tool and /history returned private-project rows to
non-members.

Both methods take a keyword-only user_id (protocol, sqlite, postgresql)
scoped by one portable SQL predicate (HISTORY_VISIBILITY_SCOPE_SQL)
mirroring WorkstreamProjectVisibility: a row hides only when its
workstream links to an existing private project and the user is neither
the workstream creator, the project owner, nor a member. Applied in SQL
so limit/offset pagination stays honest; COALESCE guards the
NULL-creator row, which plain <> would leak.

The recall tool pins the scope identity at prepare time (the mcp_user_id
discipline) and fails loudly on an unpinned item; /history scopes to the
acting user; user_id=None (single-user CLI lanes) stays unscoped.

Tests: cross-backend visibility matrix, ws_visible parity pin,
marker-exclusion composition, LIKE-fallback path, prepare-pin plumbing.
2026-07-01 19:29:20 -07:00
renovate[bot] df7926215b chore(deps): update github actions 2026-07-01 19:26:18 -07:00
Patrick Buckley f583fb06db fix(projects): address PR review feedback — drop redundant asyncio import, precise failure-mode docs, format
The redundant function-local asyncio import in project_resources_endpoint
shadowed the module-level one. resolve_workstream_owner's docstring now
maps the failure modes precisely: a failed ROW lookup is fail-soft 404
(get_workstream_row degrades to None, pre-existing behaviour), while the
fail-closed 403 applies once a row is resolved and the project gate's
storage lookup fails — in-memory workstreams 403 on a gate blip,
not-loaded ones 404 at the row fetch first. Plus ruff-format on the
visibility test file (edited via script, so the local format hook never
saw it).
2026-07-01 18:01:50 -07:00
Patrick Buckley 4bca60c56c fix(projects): close review-found tenancy leaks + correctness regressions
Max-effort review findings on the visibility feature, worst first:

Leaks — the filter was sound where it ran, but several surfaces never
carried project_id to gate on:
- cluster_snapshot served the raw collector state with no filter at all;
  it now gets the same per-request tenancy treatment as its siblings
- console pseudo-node coordinator rows + emit_console_ws_created,
  the interactive-create ws_created event, and the poll-diff ws_created
  now carry project_id/user_id (parity with their filtered siblings —
  a missing field failed open, and a missing user_id over-hid the
  creator's own workstreams)
- the SSE snapshot's overview total/state histogram is re-derived from
  the filtered rows instead of leaking pre-filter counts

Correctness:
- saved list pages with OFFSET until it fills its 50-row window instead
  of filtering after the LIMIT (a caller's own rows at position 51+
  used to vanish behind other tenants' private rows); scan capped at 20
  pages, logged when hit
- an INHERITED project_id whose project was since deleted no longer
  400s coordinator child spawns — the dangling link is dropped; explicit
  unknown ids still 400, revoked membership still 403s
- the SSE filter keeps a per-connection unresolved map: a storage blip
  suppresses a row without pinning it hidden until reconnect (re-judged
  on later events, rate-limited); definitive verdicts settle as before
- bypass principals (service / admin.cluster.inspect) get payloads
  untouched — no row drops, no overview rewrite

Consistency and robustness:
- dashboard + saved-list visibility checks moved off the event loop
  (executor), matching every sibling site
- list_project_attachments chunks its IN() at 500 ids per statement
- ws_visible/ensure_project_attachable now share one _project_grants
  predicate so the tenancy rule can't diverge
- resolve_workstream_owner's docstring states the deliberate
  fail-closed trade for project-attached rows during DB outages
- the workstreams-for-project ordering test asserts strict order on a
  forced timestamp instead of a vacuous set fallback
2026-07-01 18:01:50 -07:00
Patrick Buckley 80b8997b88 fix(projects): full-suite findings — type-guard the visibility gate, bind acting user without breaking send stubs
ws_visible only treats real strings as project links (a test double or
corrupted value means no-project, not private-and-denied), the mgr-path
project_id is coerced likewise, and the HTTP send path binds the acting
user via a getattr-guarded bind_acting_user call inside the fresh-turn
closure instead of a send() kwarg — per-kind session stubs with explicit
send signatures keep working. Row-shape contract tests (interactive +
coordinator twins) grow the intentional project_id key.
2026-07-01 18:01:50 -07:00
Patrick Buckley bf9299de1a feat(projects): saved-list project column + per-project resources view
Dashboard saved-sessions lists now carry and render the workstream's
project: SavedWorkstreamInfo gains project_id (the saved projection was
extended in the visibility change), SavedColumns grows a PROJECT column
(name resolved through the shared projects data layer, searchable via
the filter haystack, re-rendered when the async project cache fills),
inserted on both the webui saved-workstreams and console saved-sessions
tables.

Manage → governance → Projects rows are now expandable (same
interaction contract as the Users tab's OIDC panel): a per-project
resources panel lists the project's workstreams (kind/state/updated),
referenced attachments (metadata + ws-scoped download link through the
console's node proxy), and the project-scoped memory count. Backed by
GET /v1/api/projects/{id}/resources (project.read + per-project ACL,
collection off the event loop) over two new storage queries —
list_workstreams_for_project (first consumer of idx_workstreams_project)
and list_project_attachments (conversation ref-list walk, metadata only,
first-referencing ws per blob, pruned blobs skipped).
2026-07-01 18:01:50 -07:00
Patrick Buckley fbfd170ca6 feat(projects): enforce private-project workstream visibility server-side
Workstreams attached to a private project were listed and reachable for
every authenticated user — only the scope tier was checked. Add a
tenancy predicate (WorkstreamProjectVisibility: private → project
owner/members, the workstream's own creator, service scope, or
admin.cluster.inspect; public/dangling/no project → unchanged
trusted-team visibility; membership itself is the grant — deliberately
NOT gated on the project.read capability, which guards the management
API) and apply it at every surface:

- listings: saved sessions (project_id + owner tail-appended to
  list_workstreams_with_history on both backends), active list, node
  dashboard, console cluster list (pre-pagination via a collector
  row_filter so totals stay honest), node detail
- console tier-1 SSE: per-connection snapshot filtering + a hidden-set
  for sparse follow-up events; ws_created project lookups run on the
  executor, membership changes take effect on reconnect
- row access: resolve_workstream_owner 403s private-project rows for
  non-members, covering every interactive ws-scoped verb via
  tenant_check (console coordinator lane stays on its privileged
  admin.coordinator gate)
- create: ensure_project_attachable gates explicit and parent-inherited
  project_id on both create validators (unknown project 400s instead of
  minting a dangling link)
2026-07-01 18:01:50 -07:00
Patrick Buckley 71c34839d9 fix(mcp): resolve oauth_user credentials for the acting user on shared workstreams
Per-user MCP credential resolution was bound once at session construction
to the persisted workstream owner, so on a shared workstream every sender
executed oauth_user tools under the creator's tokens (and saw the
creator's tool catalog). Bind the authenticated initiator of each turn
(send + retry paths) as the session's acting user: dispatch, catalog
merge, visibility gates, and consent flows now follow whoever is driving,
with the owner as fallback for CLI / eval / scheduled / internal turns.

Rebinding swaps the user-scoped tool/resource/prompt listeners (identity
is the (user_id, callback) pair), fire-and-forget primes the acting
user's pools, and rebuilds the merged tool list. Prepared tool items pin
the identity at prepare time so an item pending approval executes under
the user whose turn requested it, not whoever binds later. Queued
mid-turn interjections deliberately do not rebind (no mid-turn
credential switch).
2026-07-01 18:01:50 -07:00
Patrick Buckley f923351953 docs(hypothesis): harden the harness definition after peer review
Corrections: the middle-form re-separation names its true mechanism
(restarting specs or refusal-event predicates; within-run retries never
touch F), the standard-Borel aside admits belief-state coordinates, the
drift-slack display binds its variable, effect-record status gains a
`none` value (never launched) distinct from rolled_back and unknown,
and parsing is assigned to the inner readout R with the gate as pure
authorization.

Structure: the trusted principal as the provenance lattice's single
widening writer; two-rank control (authority vs plan) with a
rank-neutrality corollary; the narrow-only rule for learned checks;
pi's never-lower filter joins the deterministic core; gate TOCTOU and
cross-run serialization; a composition law for harness trees (four
correspondences) with delegation as monotone attenuation.

Appendix: new worked entries for resume (journal-before-dispatch),
parallel proposals (the batch gate), derived and durable state (the
provenance meet rule), and ambient authority (per-action capability).
Claims numbered C1-C8; two falsifiers added (certificate compression;
working-set probe anchored in streaming lower bounds).

Grounding: adds Ramadge-Wonham supervisory control, RL shielding, and
Dayan's successor representation; repairs the Positivity/Skolem gloss
and two citation characterizations. All 18 external citations verified
against their sources.
2026-07-01 18:01:07 -07:00
Patrick Buckley a7cab83dd1 fix(mcp): address pre-push review findings
A max-effort review of the branch before pushing surfaced six defects, several
introduced by this branch's own commits. All fixed:

[0]+[3] oauth priming (refined). Fully non-destructive priming never cleared a
genuinely-revoked grant — the dead token stayed "consented", its tools never
entered the catalog, and (bug) the PERMANENT branch returned before arming the
cooldown, so every session re-hit the AS with a dead refresh token. Root cause:
invalid_grant (PERMANENT) is a RELIABLE dead-grant signal (RFC 6749 §5.2), so
deferring its revoke was net-harmful. Renamed the flag revoke_on_dead_grant ->
revoke_ambiguous_escalation: priming now revokes genuinely-dead grants (permanent
/ expired-no-refresh) so the catalog isn't stranded cold behind a phantom token,
and defers ONLY the sustained-UNCLASSIFIABLE (ambiguous) escalation to lazy
dispatch — the case the "don't revoke an unused server's grant on a
misclassification" concern actually applies to. The cooldown is armed before the
ambiguous path, so the deferred case can't hammer the AS either.

[1] server.py. _public_server_status (operator refresh/reconnect endpoints)
didn't forward the new scope, so after per-user scoping every warm oauth_user
server rendered disconnected/empty there. Now passes aggregate=True (operator /
approve-scoped cluster view, matching the admin console).

[5] _is_dead_transport. The widened httpx.TimeoutException swept in
httpx.PoolTimeout — pool saturation, NOT a dead connection — so transient load
would evict a healthy session and trip the shared breaker for all users.
Narrowed to Connect/Read/WriteTimeout (kept NetworkError, RemoteProtocolError).

[8] _is_dead_transport. The exact-message "session terminated" fallback still
fired on a healthy session-owning server's protocol error with that message. The
SDK-synthesized code 32600 is the only deterministic signal (the message is
application-controlled), so match the code ALONE and drop the message fallback.

[11] cleanup. The dead-transport except block was triplicated across
call_tool_sync / read_resource_sync / get_prompt_sync — the exact drift this
branch had to repair. Extracted _record_and_evict_on_dead_transport.

Tests updated/added: prime revokes-permanent / defers-ambiguous (drives the real
resolver both ways); PoolTimeout-is-not-dead; exact-"Session terminated"-message
stays alive; _public_server_status aggregate. 836 test_mcp_* green, ruff + mypy
clean.
2026-06-30 19:30:20 -07:00
Patrick Buckley b28e8bac80 feat(mcp): admin-scoped aggregate view for oauth_user server status
Resolves the one regression the user-scoping in 0c28b0ce introduced: the admin
console reaches the read-scoped /mcp-status endpoint via the console proxy with
the ADMIN's forwarded identity, so per-user scoping made oauth_user servers show
as the admin's own (usually empty) pool instead of the cluster-health "in use by
anyone" aggregate.

Add an `aggregate` flag (default False) through get_all_server_status ->
get_server_status -> _oauth_user_server_status. When set, connected + a
representative catalog reflect ANY user's warm pool. internal_mcp_status gates it
on the admin.mcp permission: holders (who already see consent counts + server
config — the proxy forwards permissions via create_jwt, repopulated on validate)
get the aggregate; every other read-scoped caller stays strictly per-user, so the
cross-user catalog leak stays closed. Static-server status is unaffected.

Tests: manager-level aggregate-sees-any-user, and an endpoint-level gating test
asserting admin.mcp -> aggregate=True / read+approve-without-it -> aggregate=False.
2026-06-30 19:30:20 -07:00
Patrick Buckley 48f4c41442 fix(mcp): scope oauth_user server status to the requesting user
Follow-up to f585c47b (review finding #4). _oauth_user_server_status derived
connected + tools/resources/prompts counts from warm[0] — an arbitrary user's
pool entry — and get_all_server_status surfaced that to every read-scoped
caller of /v1/api/_internal/mcp-status, ignoring who was asking. So user B saw
user A's oauth_user server as connected with A's catalog size, over the wire
(connected + the three counts are in _READ_STATUS_PUBLIC_KEYS; user_pools /
auth_type are stripped). Before f585c47b these servers were absent from the
read map entirely.

Thread user_id through get_all_server_status -> get_server_status ->
_oauth_user_server_status; the warm-pool filter now matches uid == user_id, so
connected + counts reflect ONLY the requester's own pool. internal_mcp_status
passes _auth_user_id(request); an empty/absent principal (user_id falsy) sees
oauth_user servers as not-connected. Static-server status is unaffected (the
new param defaults to None and is ignored for them).

Note: the admin console (admin.mcp) reaches this same read endpoint via the
console proxy, which forwards the ADMIN's identity — so an admin now sees an
oauth_user server scoped to their OWN pool (typically not-connected) rather
than the prior any-user aggregate. Server-global health (circuit_open / error /
consecutive_failures) is unchanged, and the consented-users-count is a separate
aggregate. Restoring an aggregate in-use pill for admins (without re-leaking
per-user catalogs) would need a privilege-aware aggregate mode + admin.js
change — deferred.

Tests: updated TestOAuthUserServerStatus to the scoped signature, added the
cross-user isolation regression (user B sees neither A's connected flag nor A's
catalog size) and a no-user-context case.
2026-06-30 19:30:20 -07:00
Patrick Buckley 7f50fbefad fix(mcp/oauth): make session-start pool priming non-destructive
Follow-up to f585c47b (review finding #5/#6). f585c47b routed
_prime_user_pools through get_user_access_token_classified to refresh expired
tokens at session start (closing the chicken-and-egg where an expired token
stranded the pool). But that resolver also REVOKES a grant (delete_user_token
+ token_revoked audit) on a permanent-classified refresh failure — and priming
runs for EVERY consented server, so a single misclassified AS hiccup (e.g.
invalid_grant during a key-rotation window) could now delete a working grant
for a server the user isn't even using this session. The _prime_one comment
still claimed "priming can never revoke a live grant" — no longer true.

Add revoke_on_dead_grant: bool = True to get_user_access_token_classified. When
False, the four would-revoke sites return refresh_failed_transient with the
token left in place instead of deleting it. _prime_one passes False: priming
still refreshes+persists refreshable tokens (f585c47b's fix intact) but never
revokes — the authoritative revoke stays on the lazy-dispatch path, where the
user actually invokes the tool and a permanent failure means re-consent anyway.

Replaces the vacuous prime test (which fully stubbed the resolver, so its
"never revoke" assertion was meaningless) with a test that drives the REAL
resolver and pins both directions: same permanent failure, same code path,
revoke_on_dead_grant=False keeps the token / =True (lazy default) deletes it.
2026-06-30 19:30:20 -07:00
Patrick Buckley b8addd55c0 fix(mcp): complete dead-transport handling + harden oauth_user status
Follow-up to f585c47b. Three correctness gaps from a max-depth review of
that commit, all in the same dead-transport / session-corpse family it set
out to close.

1. read_resource_sync and get_prompt_sync were left on the old
   BrokenPipe/ConnectionReset/EOF-only eviction guard, so a dead
   streamable-http transport (McpError(CONNECTION_CLOSED), anyio
   ClosedResourceError, server-restarted session) reused the corpse session
   forever — the exact restart-hang call_tool_sync already fixes, just for
   resources and prompts. Both now route through _is_dead_transport and
   evict + trip the breaker like the tool-call path.

2. _is_dead_transport matched a bare "session terminated"/"session not
   found" substring, so a healthy session-owning MCP server (game/shell)
   rejecting a stale id with those words was misclassified as transport
   death — evicting the live session and opening the SHARED per-server
   breaker for every user after 3 such rejections. Now anchored on the
   SDK's deterministic synthesized code (32600, pinned as a named constant)
   with its exact message as a forward-compat fallback. The client never
   receives "session not found" for a real dead transport (the SDK discards
   the server's 404 body), so the tightening loses no coverage.

3. _is_dead_transport omitted httpx's read/write/close NetworkError leaves
   and the whole TimeoutException family (Read/Write/Pool timeouts are NOT
   builtin TimeoutError), so a stream that died on an idle read timeout —
   the dominant idle-death mode — fell through to "other" and the corpse
   was reused. Broadened to httpx.NetworkError | TimeoutException |
   RemoteProtocolError (LocalProtocolError, our own bug, stays excluded).

Also: _oauth_user_server_status iterated _user_pool_entries without a
list() snapshot, so a concurrent pool insert/evict on the mcp-loop thread
could raise "dictionary changed size during iteration" and 500 the status
endpoint. Snapshot like the sibling get_all_server_status does.

Adds TestIsDeadTransport (direct classifier unit tests, incl. the
healthy-"session not found"-is-not-dead and httpx-coverage regressions) and
resource/prompt eviction tests. All 8 behavior-change tests fail on the
pre-fix source and pass with the fix.
2026-06-30 19:30:20 -07:00
pow3rtool f585c47b7d mcp dead transport fix and token refresh 2026-06-30 15:22:23 -07:00
Patrick Buckley ee3a0297ea fix(compaction): don't classify a recognized rate-limit as context overflow
_stop_retrying calls _is_ctx_overflow with no exception-class gate of its own,
so a retryable 429 whose token-quota text contains an overflow phrase (e.g.
"... maximum number of tokens allowed per minute ...") was treated as a
deterministic overflow and made non-retryable.

Gate _is_ctx_overflow on "not a known backend class": an overflow is never a
recognized error (it arrives as BadRequestError/InternalServerError, neither in
_BACKEND_KNOWN_EXC_NAMES), so excluding known classes can't suppress a real
overflow while keeping a 429 retryable across every caller (the retry gates,
send-loop recovery, chunker, task_agent loop, formatter). _format_backend_error
drops its now-redundant inline class check.

Addresses Copilot review feedback on #740.
2026-06-30 03:47:12 -07:00
Patrick Buckley c6e5794125 fix(compaction): recover from context overflow on resume across providers
A session created under the openai-compatible provider and resumed under the
anthropic-compatible provider (same vLLM model) failed with an opaque
InternalError instead of recovering. Root cause: vLLM returns a context-window
overflow as HTTP 400 BadRequestError on /v1/chat/completions but HTTP 500
InternalServerError on /v1/messages, and the rehydrated resume payload overflowed
the window. The 500 was retried four times then surfaced as a bare class name.

- Detect overflow by message text, not exception class (_is_ctx_overflow),
  shared across the fatal-error formatter, both stream-retry gates, the send-loop
  recovery, the chunker, and the task_agent loop. Overflow is non-retryable
  (deterministic; no backoff). Phrasing is overflow-specific so a token-quota
  rate-limit isn't misclassified.
- Proactive pre-send compaction (Layer A): when already over the hard ceiling,
  compact once before the first stream so a resume that arrives over-window (or
  follows a switch to a smaller-context model, with no prior compaction) doesn't
  go out blind. Generation-guarded end to end so an orphaned or superseded send
  can never swap the live generation's history.
- Binary-subdivision chunker: an over-window summary batch is split in half and
  the partials merged (~log2(N) calls, not one per block); a lone over-window
  block is truncated progressively down to a floor before bailing irreducible.
- Cooperative cancellation honored through compaction; send() consumes its own
  generation's cancel signal on exit, so a stale cancel can't block a later
  idle /compact and a live cancel is never disarmed.
- _format_backend_error surfaces "Context window exceeded ..." instead of an
  opaque InternalServerError, and only for unrecognized classes.
- retry/rewind, the continuation hint, and title generation all exclude the
  synthetic [Conversation summary] turn so they can't target the label.
- task_agent salvages a sub-agent's partial work on any terminal error (not only
  overflow), re-raising only when there is nothing to salvage.
2026-06-30 03:47:12 -07:00
renovate[bot] 85b62860b2 chore(deps): update actions/checkout action to v7 2026-06-30 03:46:34 -07:00
renovate[bot] 74cf4e92aa chore(deps): pin dependencies 2026-06-30 01:38:15 -07:00
Patrick Buckley 6572b53c89 docs: refine HYPOTHESIS.md harness definition
- Add a plain-terms gloss of the claim (shell/plant split up front)
- Add a 'Converged-upon' grounding subsection: independent corroboration
  from capabilities, control theory, software architecture, and LM theory
- State provenance as a precondition of the reach-avoid certificate
  (CaMeL control/data-flow separation), not just an entry point to police
- Drop redundant 'none' from the effect-record status enum; normalize
  minor notation (A_bot, h->N)
- Fix stray backslash-escaped quotes that rendered literally
2026-06-28 21:59:44 -07:00
Patrick Buckley 7f1329d3b0 fix(memory): atomic single-statement upsert for memory save/update (#735)
* fix(memory): atomic single-statement upsert for memory save/update

save_structured_memory used "try INSERT -> catch IntegrityError ->
SELECT + UPDATE". On PostgreSQL a model saving the same key twice in a
turn logged a uq_smem_name_scope violation on the failing INSERT, and the
pattern threw + caught an exception on every update.

Replace it with one statement: a new StorageBackend.upsert_structured_memory
on both backends emitting INSERT ... ON CONFLICT (name, scope, scope_id)
DO UPDATE ... RETURNING.  It returns (row, was_update) -- the full saved
row and whether an existing row was updated -- like Django's
update_or_create; was_update is the supplied (fresh) memory_id differing
from the returned id.  save_structured_memory is a thin wrapper over it.

description / mem_type of None mean "leave unset": the column default
applies on insert and the stored value is kept on conflict; an explicit
value (including "" / "general") overwrites -- so clearing a description or
setting type back to "general" now persists, where the prior
"if mem_type != 'general'" / "if description" semantics silently dropped it.
The memory tool and the memories HTTP endpoint pass None for omitted fields
and read effective type/scope from the returned row; the HTTP endpoint
returns that row directly (one query, no follow-up SELECT).

Removes the now-unused update_structured_memory primitive and its dead
STRUCTURED_MEMORY_MUTABLE constant.  Adds cross-backend storage tests and a
session tool-path test (preserve-on-omit / overwrite-on-explicit), run on
PostgreSQL via --storage-backend -- the save-over-existing path was
previously SQLite-only.

* docs(memory): clarify upsert was_update precondition

Lead the upsert_structured_memory docstring with the behavioral contract
(callers MUST supply a fresh unique memory_id) rather than the internal
id-comparison mechanism, so a future caller can't reuse an existing id and
silently get was_update=False on a real update.
2026-06-28 20:24:20 -07:00
Patrick Buckley 5004858032 ci(claude): grant write permission so Claude reviews/replies can post
claude-code-review.yml granted pull-requests: read, so the Claude reviewer
ran green but its post step was permission-denied (permission_denials_count:
3) and posted no review on the PR. Bump to pull-requests: write so it can
post the review + inline comments.

claude.yml (the @claude responder) had the same read-only block and would
silently fail to post a reply; widen it to pull-requests + issues: write.

contents stays read -- no repo-push capability is granted. Both workflows
remain gated (the reviewer to same-repo PRs via head.repo.full_name ==
github.repository; the responder to @claude from OWNER/MEMBER/COLLABORATOR),
so write is scoped to already-trusted triggers.
2026-06-28 20:09:05 -07:00
Patrick Buckley de60127c45 fix(memory): don't recompose system prefix on memory write
Injected memories ride in the cached system block, so calling
_init_system_messages() on every memory save/update rebuilt the prompt
prefix and busted the provider prompt cache (a full system + history
re-write) -- for a memory the model already holds via the tool result.

memory(save) now only invalidates the per-turn search cache, so an
in-turn memory(search)/(list) still reflects the write; the new memory
folds into the prefix at the next natural recompose or the next session.

Also drop the redundant _init_system_messages() in the /reason handler:
reasoning effort rides in request kwargs (output_config / thinking), not
the composed prompt, so it recomposed to byte-identical output.

Add a chain-level test through the real _exec_memory -> no-recompose path
(asserts prefix unchanged, search cache invalidated, next recompose folds
the memory in). The prior memory tests either drove _init_system_messages
directly or patched it out, so this path was uncovered.
2026-06-28 18:31:11 -07:00
Patrick Buckley c7e0358aaf Add Claude Code GitHub Workflow (#733)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-28 17:00:27 -07:00
Patrick Buckley bbadd00ac0 chore: bump version to 1.7.0a5 2026-06-28 04:12:06 -07:00
Patrick Buckley 8dd356b7e6 fix(task-agent): keep sub-tool steps nested + preserve denial reasons
Address the Copilot review on #732 plus a task-agent sub-tool nesting
race surfaced alongside it.

Nesting (web UI):
- A sub-tool step whose task_agent row hasn't painted yet (the 4-wide
  tool pool's ordering window) buffers and nests when the row lands,
  instead of escaping to a top-level row that looks main-harness-issued.
- A row that never paints (id-correlation mismatch / aborted agent)
  escapes its buffered steps back to a visible top-level paint after a
  grace window, so steps are never buffered invisibly or leaked.
- The nested card survives the parent row's pending->resolved rebuild; a
  call_id reused across turns builds a fresh card rather than stealing the
  prior agent's steps.
- tool_info routes through the same nesting path (no duplicate top-level
  row); a namespaced sub-tool result no longer grafts onto an unrelated
  top-level row.

Denial reasons (backend):
- Preserve the specific denial reason a gate already stamped (operator
  feedback, or the matched policy pattern; web and CLI contracts) instead
  of clobbering it with a flat "Denied by user" -- in both the sub-agent
  and the main tool loop.

Verified with the livepass task_agent harness (race + orphan-escape
scenarios, headless) and unit tests.
2026-06-28 04:09:30 -07:00
Patrick Buckley 77cb76c006 feat(task-agent): recall sub-trajectory + per-agent read isolation
Final chunk of the task_agent modernization: rebuild a finished task
agent's card from /history (reload / reopen while the workstream is in
memory) and isolate each sub-agent's file-read tracking.

Recall: _project_agent_steps projects a sub-agent's trajectory into step
items (FIFO-per-call_id pairing via _iter_agent_tool_results, shared with
_cancel_ledger; output/arguments/count capped); _stash_agent_trajectory
keeps them on the UI in an LRU-bounded store; make_history_handler
attaches them as agent_steps to each task_agent tool_call, and
replayHistory/_replayAgentCard rebuild the collapsed card. In-memory only
(durable persistence deferred); a cold/evicted entry renders the flat
parent row ("not retained"), never a fabricated 0-step card.

Read isolation: _read_files (the blind-overwrite guard's memory) is now
per-sub-agent via the _active_read_files contextvar -- _exec_task copies
the parent's set on spawn and merges the agent's reads back on
completion, so a sibling in the 4-wide pool can't suppress another
agent's guard.

Also: _exec_task now self-reports the task_agent tool_result on every
path (the parent loop only reports error/denied results centrally) --
without it the live card never completed and a failed task recorded
is_error=False in the canonical trajectory. is_error flows from
_tool_error_flags to the recalled step; on_info suppression is per-thread
so a parallel sibling tool's progress isn't dropped.
2026-06-28 04:09:30 -07:00
Patrick Buckley ca7958329a feat(task-agent): nest sub-tool steps in an expandable card
Route a task agent's sub-tool events (tool_pending / approve_request,
tagged with parent_call_id) into a collapsible card under the task_agent
row, replacing the blue on_info turn-legs.

- conversation.js / interactive.js: buildAgentCardBody +
  _routeAgentItems / _ensureAgentCard nest steps by parent_call_id.
  Collapsed by default (a task agent can run 100+ steps and the parent
  fans out many in parallel); the label carries the live count + state.
  Auto-expand when a nested approval is pending so the blocking prompt
  can't hide behind the toggle.
- session.py / session_ui_base.py: on_agent_step paints auto-tool step
  rows; namespace child call_ids by parent so the 4-wide task pool can't
  collide on local sequential ids (call_0); suppress sub-agent on_info on
  the web pane (no call_id to nest by — the card carries steps + result).
- cli.py: on_agent_step prints a dim step leg (no card on the CLI, which
  keeps its on_info).
- livepass.py: task-agent card harness driving the real InteractivePane.
2026-06-28 04:09:30 -07:00
Patrick Buckley 65eaacb341 feat(task-agent): Turn-IR sub-harness + parent-tagged step events
Rebuild the task_agent sub-harness on the canonical Turn trajectory (build list[Turn], lower via dicts_from_turns at the wire boundary) instead of hand-rolled OpenAI dicts; the cancel-ledger helpers read Turns.

Tag each sub-tool's events with parent_call_id via a lock-guarded child registry stamped centrally in SessionUIBase._enqueue, so a later UI can nest a task agent's steps under its card. Getattr-guarded on the session side so CLI/eval/test UIs are unaffected.

Behaviour-preserving (same wire shape, same cancellation semantics); the parent tag is wire-invisible and unconsumed until the frontend card lands.
2026-06-28 04:09:30 -07:00
Patrick Buckley 9837214414 fix(compaction): persist checkpoint markers to bound resume rehydration (#731)
Compaction swapped a session's in-memory history for a summary but left the full
transcript in storage, so resume() reloaded all of it -- on a long session, or
one switched to a smaller-context model, the rehydrated context overflowed the
model window and deadlocked the first post-resume send.

Persist a `_source="compaction"` marker (summary + watermark) on compaction;
resume rehydrates [summary] + [rows after the watermark] instead of the full
transcript. Full history stays in storage for /history, export, and audit;
markers are filtered from display, search, and export, and rewind/retry
truncation is floored at the marker so the summary's backing is never deleted.
The watermark and search filters count real transcript rows only. No migration.
2026-06-27 23:36:24 -07:00
Patrick Buckley 2b0b1cf73e chore: bump version to 1.7.0a4 2026-06-27 17:20:21 -07:00
Patrick Buckley 7263b31536 fix(compaction): cap summary input budget to true capacity (review)
Address PR #730 review. _summary_input_budget_chars now caps the _MIN_SUMMARY_BUDGET_CHARS floor at the true input capacity (input_tokens), so output reserve + budgeted input + prompt always fit context_window; on a window too small to summarize it returns a sub-floor budget and _pack_blocks bails as irreducible instead of overflowing the summary call. After the half-window output-reserve bound this only affected sub-~2048-token windows, but it was a real edge.

Clarify the _CompactionIrreducibleError docstring: chunked compaction never drops or fabricates whole turns, but a single oversized block is still head/tail-truncated as summary input via _truncate_block.

Add test_budget_never_exceeds_true_input_capacity.
2026-06-27 17:06:18 -07:00
Patrick Buckley 6b6c220986 fix(compaction): chunk the summary call so it can't overflow
The compaction summary ran as a single model call sized by the per-message
token estimate, which disagreed with the head+tail-capped formatted text, so a
long history could overflow the summary call itself; the old prefix-fit also
silently dropped the most-recent messages.

Summarize the whole selection via _summarize_blocks: greedily pack the
formatted blocks into batches that each fit the summary call's own input budget
(_summary_input_budget_chars), summarize each, and recursively merge the
partials until they collapse to one. The common case (it all fits) stays a
single call. Bail to the existing False path when the input is irreducible
rather than fabricate a summary; a mid-chunk failure leaves messages untouched
(atomic swap only on full success).

Bound the summary output reserve to half the context window
(_summary_output_tokens), used by BOTH the input-budget sizing and the actual
call. compact_max_tokens defaults to the full window (32768); clamped only by
max_output_tokens it reserved the entire context for output, flooring the input
budget so compaction overflowed (or bailed as irreducible) at the
default/small-window config that needs it most. Large windows are unaffected
(compact_max_tokens stays binding).

Guard an empty summary (keep history instead of swapping in nothing and
reporting success). Fold tool-def tokens into the _last_usage-less estimate AND
the post-compaction usage anchor, so the compact-before-truncate budget doesn't
over-state free space by the tool-def count. Single-source the shared
compactor/merge prompt section (_COMPACT_OUTPUT_FORMAT) and the tool-def sizing
(_tool_def_chars/_tool_def_tokens). A just-resumed session (no _last_usage) now
counts tool-def tokens so it doesn't undercount and skip proactive compaction
until its first reply re-anchors the estimate.

Prepush review follow-ups: generation-guard the end-of-turn auto-compaction and
its resume turn so a force-cancel during the slow summary call can't compact or
persist under a new generation (matching the mid-turn and end-of-loop guards);
single-source the soft-threshold predicate (_over_soft) shared by the mid-turn
policy, _compaction_owed, and the end-of-turn check; add tests for the
pre-attempted-compaction guard and the recursion depth ceiling.
2026-06-27 17:06:18 -07:00
Patrick Buckley 65d1552ffa fix(session): provider-anchored context budget + cooperative compaction
Unify truncation and compaction on one provider-anchored fullness measure
(_estimated_prompt_tokens), closing the 80-100% dead zone where tool output
was truncated but compaction never fired. Make compaction cooperative: advise
the model to wrap up and record its plan, compact if it continues, auto-resume
after a cooperative stop, and compact-before-truncate (preserving the in-flight
tool-call turn). Floor auto_compact_pct at 0.1 (invalid 0 -> default 0.8).
2026-06-27 17:06:18 -07:00
Patrick Buckley 9f54a97cc6 fix(understone): rebalance early game and add inn turn refresh
The opening hours were a gold/HP death spiral: a fresh hero spent more gold
healing a fight than the kill paid, mid-tier foes out-damaged a starting HP
bar, the map gave no read on where harder foes spawned, and a spent day left
the player idle until the UTC rollover. This eases the on-ramp across both
shipped worlds.

Economy
- Healer drops from 2 to 1 gold per HP, so topping up no longer outruns income.
- Starting purse 20 -> 37: enough to buy the cheapest armor and one potion up
  front, a one-point DEF bump plus a heal cushion the player chooses to spend.

Combat
- Tier 2-4 common foes lose 1 ATK each, trimming the burst that could halve or
  end a fresh hero in a single bout. Rares and the boss are untouched.

Wayfinding
- The road glyph changes from "=" to a shaded path that reads as one continuous
  road in every orientation; "=" only looked right horizontally and broke into
  stacked dashes on vertical runs. Cinder's basalt path gets the same treatment
  with a crosshatch glyph free in its palette.

Rest
- Sleeping at the inn now rolls a spent adventurer into a fresh day's turns (it
  already fully heals). The top-up fires only at zero turns, so it never banks
  past the daily cap.

Verified: full suite green (+2 rest tests, ruff/mypy clean); the greedy balance
bot still clears the world 11/12 seeds at an unchanged pace on both packs.
2026-06-27 09:40:03 -07:00
Patrick Buckley 0e1972e6aa chore: bump version to 1.7.0a3 2026-06-27 01:22:33 -07:00
Patrick Buckley b1542ad62d fix(title): reliable titles on thinking models; defer utility temperature
Auto-title generation and manual refresh stopped producing titles on
reasoning models (the cluster serves qwen3.6). The title call capped
max_tokens at 200, so the model's think pass consumed the whole budget and
content came back empty (finish_reason=length) -> the title was skipped.
Both paths share _generate_title, so both broke.

Title path:
- Raise the title completion to 2048 tokens so reasoning finishes and the
  title text actually lands.
- Recover the title from content (never reasoning): reuse the canonical
  _strip_reasoning (handles <think>/<reasoning>, paired or unclosed) plus a
  backstop for the opener-absent </think> shape some templates emit, take
  the first non-empty line, and peel a "Title:" label and wrapping
  markdown/quote decoration. Internal punctuation is preserved. Cap at 80 to
  match the manual-alias bound.

Temperature:
- _utility_completion no longer hard-codes a temperature; it defaults to the
  session/registry value the main turn uses. Title (was 0.7/0.3), web-fetch
  extraction (was 0.2), and compaction all defer. Hard-coding a constant
  fought thinking/no-temp models and silently overrode an explicit [models.*]
  temperature; the provider still gates temperature per model.

Tests: title sanitization across think/reasoning variants, truncation, and a
trailing-prose case; utility-completion temperature deferral + explicit
override.
2026-06-27 01:21:56 -07:00
Patrick Buckley b2c93ce15a fix(composer): show server error text on a rejected send
The send POST's `!r.ok` guard threw a bare `send_http_<status>`, which both
send `.catch` handlers render verbatim — so a rejected send surfaced as
"Connection error: send_http_400" instead of the server's reason. Read the
`{error}` body and throw that, falling back to the status code when a wedged
proxy answers non-JSON (502/504 HTML) so it can't become an "Unexpected
token <" error. Applied to interactive and coordinator.

Also correct the queue-controller comments: a dequeue releases no
"server-side reservation" (queued messages are text-only and dequeue_message
just pops the entry), and onAfterDequeue is wired by coordinator too — not
omitted.
2026-06-27 00:09:44 -07:00
Patrick Buckley 5f0b5bb173 fix(composer): node-proxy-correct, reliable queued-message dismiss
The queued-message dismiss DELETE hardcoded /v1/api/workstreams without
the node-proxy prefix, so cancelling a queued message on a proxied
(remote-node) interactive workstream hit the console root, 404'd, and
the message was delivered anyway -- the dismiss silently did nothing.

composer_queue:
- Prefix getBase() onto the dequeue DELETE; interactive passes getBase
  (mirrors the attachment controller). Coordinator stays at base "".
- Never remove the card before the server confirms the cancel: removed
  -> drop the card; not_found (already drained) -> promote to a sent
  bubble + "already sent" notice; 404 (reaped session) -> terminal drop;
  error/timeout -> re-enable + "couldn't remove" notice.
- Bound the DELETE with a 15s AbortController (Promise.race fallback when
  AbortController is absent) so a wedged node can't freeze the card.
- a11y: aria-disabled (not the real disabled attribute) keeps keyboard
  focus on the dismiss control; in-flight state shown via aria-busy + CSS.

consumers (interactive, coordinator):
- Bound the send POST with the same 15s timeout so a pre-bind dismiss
  can't strand the card when the POST hangs.
- r.ok guard so a rejected send (4xx/5xx error body) surfaces as an
  error instead of being promoted as "delivered".
- Coordinator wires onNotice -> appendText.
2026-06-27 00:09:44 -07:00
Patrick Buckley f2e48166f4 fix(lowering): warn when operator context would fold onto an assistant turn
Operator-context system turns must follow a user/tool input turn — producers
maintain this via the user/tool drain seams plus the synthetic wake turn, so an
assistant predecessor is unreachable today. Add a fail-loud guard so a future
producer that breaks the invariant surfaces in logs instead of silently splicing
operator markup into the model's own prior output.

Logged, not raised: it degrades to a fold, since the nonce still gates operator
trust regardless of the host turn, so the harm is out-of-distribution voice
rather than a trust breach — disproportionate to crash a turn over.
2026-06-26 19:37:01 -07:00
Patrick Buckley 2c1ec9c230 fix(fence): guard detection_pattern against an empty tag set
Address PR review feedback:

- detection_pattern(()) with an empty tag set compiled to an overly-broad regex
  (the empty alternation matches any [start ...]/[end ...] run), which would turn
  the forgery scanner into a false-positive generator. Reject an empty or
  all-empty tag set up front. Not reachable from the sole caller today, but it is
  a public, security-relevant helper.
- Clarify build_operator_instruction_declaration's docstring: the trusted region
  is delimited by both the start and end markers (each carrying the nonce), not
  just the opening marker.
2026-06-26 19:37:01 -07:00
Patrick Buckley a318265946 fix(fence): bracket trust-fence markers instead of angle-bracket XML
Swap the trust-fence marker shape from <tag_nonce>...</tag_nonce> to
[start tag_nonce]...[end tag_nonce] for both the operator fold (system-reminder)
and the output-guard judge (tool_output). Angle-bracket markup pushed some local
models out of distribution and toward emitting their own turn-structure tokens:
chat templates built around rigid <...>-style structural tokens derail once a
few folded reminders accumulate. The start/end keywords carry no slash (no </ or
[/ closing-tag shape) and read as ordinary text.

Single-source the shape in fence.py (_OPEN_KW/_CLOSE_KW + detection_pattern) so
wrap, neutralize, the forgery/leak detector, and both trust declarations track
one definition. The nonce still rides both boundaries (unforgeable close); the
leak-vs-forgery split and the forge-in / break-out defang are preserved. The
fold is wire-only, so there is no migration; the legacy persisted-envelope
readers keep the old shape.

Add regression tests pinning each trust declaration to fence.wrap's emission so
a future keyword change fails loudly instead of silently desyncing the anchors.
2026-06-26 19:37:01 -07:00
Patrick Buckley 2169559d6e feat(projects): governed project containers — memory scope, grouping, manage UI (#724)
* feat(projects): governed project containers — memory scope, grouping, manage UI

A workstream can attach to a project: a first-class, shareable resource
container that owns a `project` memory scope, groups conversations, and is
managed from the console.

Storage / migration 062: projects + project_members tables, workstreams.
project_id, and the memory type default project→general; grants
project.{create,read,write,delete} (admin-default).

Recall + writes: project memory is recalled iff the workstream is attached AND
the user has access (owner ∨ member ∨ public-for-read), resolved once at session
construction; coordinators recall it too. New saves default to the project when
attached + writable; the save and delete paths are write-gated; deleting a
project purges its scoped memory; archived projects aren't recalled.

Access = RBAC capability ∧ per-project ACL (auth.resolve_project_access, a
single-fetch resolver); visibility changes, member management, and delete are
owner-only.

API: project CRUD routes on both the server and console; project_id threaded
through workstream creation, spawn inheritance, the cluster-create proxy, the
dashboard / snapshot / coordinator row builders, and the collector deltas.

UI: a project picker with an inline "+ New project" creator in every creation
box (console launcher + standalone dialog + dashboard); group-by-project in the
rail; a project badge in the composer and on dashboard rows; a console manage
tab (list + create/edit + members shelves). The admin Memories view gains
coordinator/project scope filters and human scope labels (name, not hex). The
memory tool schema documents the project scope and the attach-aware default.

* fix(projects): client refresh hardening, creator race guard, SDK project_id

Addresses PR #724 review feedback plus two bugs found while validating it.

- projects.js refreshProjects: a non-OK status (e.g. 403 when the caller
  lacks project.read) or a network/parse error no longer blanks the cache
  or masquerades as "no projects" -- the prior cache is preserved, the
  failure is recorded (new projectsError()) and warned. Honors the
  long-standing "a transient error can't blank the rail" docstring.
- projects.js _fp: the fingerprint separators were raw control bytes,
  which made git treat the whole file as binary (no reviewable diff).
  Rewritten as escape sequences instead of raw bytes -- behavior is
  byte-identical at runtime.
- project_creator.js: createProject() could reject unhandled (authFetch
  throws on network/401; r.json() throws on a non-JSON body), leaving the
  widget stuck busy/disabled. Added a .catch, plus a generation guard so a
  create whose widget was cancelled/reopened mid-flight drops its result
  instead of selecting a project the user backed out of.
- types.ts: add project_id to CreateWorkstreamRequest / WorkstreamInfo /
  DashboardWorkstream to match the server schemas (was SDK-invisible).
- test_project_api.py: move side-effecting HTTP calls out of asserts so
  the requests run even under python -O.

* fix(projects): JSON.stringify the cache fingerprint, drop control-byte separators

_fp joined fields/rows on raw NUL/SOH bytes, which made projects.js read as binary to git. Replace with a collision-proof, escape-free JSON.stringify encoding -- same change-detection semantics, zero embedded control characters.
2026-06-26 17:24:06 -07:00
Patrick Buckley c7d8acb6a5 fix(effect-status): harden effect_status decode + fix tests for typed synth
- Turn.effect_status also catches TypeError: a corrupt non-string meta value
  (e.g. a dict that survived into the column) would otherwise crash a consumer
  on access, since EffectStatus(non-str) raises TypeError, not ValueError.
  Degrade to None, mirroring the meta decoders (Copilot review).
- test_lowering: the wire-repair synth now carries the _effect_status side
  channel (stripped before the provider wire) — assert it.
- test_session_mcp_dispatch_error: the _capture stub swallows the new status
  kwarg via **_ so it stays signature-compatible with _report_tool_result.
2026-06-26 08:38:31 -07:00
Patrick Buckley b74a5e116b feat(effect-status): type tool dispositions, not just prose
The unknown / none / committed distinction the cancel and timeout paths
carry lived only in the result's free text — a deterministic reader (a
re-issue guard, owner-side compensation) couldn't recover it without
parsing prose. Promote it to a typed EffectStatus on the canonical Turn.

- EffectStatus (committed/none/unknown/partial/rolled_back) rides
  TurnMeta.extra["effect_status"] — wire-invisible like the other meta
  side channels: the model still reads the body, deterministic code reads
  the type.
- Persisted in the role-exclusive conversations.meta column (source_meta
  rides SYSTEM turns, effect_status rides TOOL turns), routed by role in
  reconstruct_turns. No migration; survives reload for the audit trail.
- Producer seam: _report_tool_result(status=) + a _tool_status dict popped
  at the fold, mirroring _tool_error_flags.
- Populated where the disposition is already determined: UNKNOWN at the six
  unobserved sites (bash / MCP-tool timeout, bash SIGKILL-cancel, cancel
  synthesis, wire-repair) and a precise none/partial/unknown on a cancelled
  task agent (shared _cancel_ledger so the typed status and the prose
  disposition can't disagree). Ordinary results stay unset.

Only the unknown/none split is load-bearing (HYPOTHESIS.md effect-record
appendix: unknown, never none); the full per-effect reversibility list
stays deferred. Thread A of the effect-record work; Thread B (per-tool
Smart-Approval floor + reversibility surfacing) follows.
2026-06-26 08:38:31 -07:00
Patrick Buckley c1ca742b54 fix(tools): timed-out side-effecting tools read UNKNOWN, not a flat failure
A bash command SIGKILL'd at its deadline and a timed-out MCP tool call are
killed / abandoned mid-flight, so their side effects are as unobserved as a
cancelled call's. Both read as a definitive "timed out after Ns", which invites
a blind re-run (a double-send) exactly as a dropped record invites an orphan.

Route both through a shared TIMEOUT_OUTCOME_CLAUSE so they read "Outcome
UNKNOWN ... do not assume it did not run, reconcile before re-issuing" — the
same "unknown, never none" discipline cancellation already follows
(HYPOTHESIS.md effect-record appendix). bash also keeps any partial stdout
captured before the kill, mirroring the cancel path.

Read-only timeouts (search, MCP resource/prompt reads) stay a plain failure:
an idempotent read has nothing to reconcile, so the reconcile advice would be
misleading there.
2026-06-26 07:32:35 -07:00
Patrick Buckley 16ac3f19b5 docs(hypothesis): gate-placement & effect-record appendix; scope incompressibility; split the two walls (#721)
* docs(hypothesis): gate-placement & effect-record appendix; scope incompressibility; split the two walls

Refinement + expansion pass on the harness hypothesis.

Appendix (new subsections):
- Gate placement (fail-closed, in practice): γ as a pure, effect-free
  parse-and-authorize; syntactic / user-authorization / structural-intent
  validation; semantic intent as a recursive plant call (a mini-harness),
  not a predicate in γ; "before any invocation" sharpened to "before any
  effect" — reads aren't free, the parser must not act, the output is an
  action too.
- Effect records (what ρ folds back): pins down the
  e = (tool_id, action_id, status, effects, time) shape the body referenced
  twice but never defined; committed/none/unknown trichotomy + a reversibility
  bit, framed explicitly as an open interface, not a result.

Corrections:
- Scope the incompressibility conjecture: split per-step drift by coordinate
  (the shell term is a low-complexity designed descent), so the incompressible
  part is the plant's, not all of W; add the coarse-functional counter-
  possibility (V* is one scalar hitting time, sometimes cheap) and state the
  claim conditionally. Walks back the earlier "the dynamics it certifies are
  the weights" overclaim.
- Split the second wall: the tape / space-O(L) picture follows from the
  autoregressive structure alone; the per-pass TC^0 bound is separate and
  weaker; flag that chaining them is a non-sequitur.

Smaller:
- Concrete justification for the standard-Borel assumption.
- Reading-table rows for the C/Y/A/E spaces and for H_ok/B.
- Daemon note: per-cycle hazard compounds, (1-q)^h over the horizon.
- Minimax: well-posedness caveat for sup over the adversary class Π.
- Note that H_cancel refines the body's deliberately coarse H\H_ok.

Notation (consistency linter clean):
- Brace the subscript A_{⊥} in the new table row (was unbraced — GitHub
  render hazard the linter guards against).
- Daemon cycle-count N → h, freeing N for the fundamental matrix.

* docs(hypothesis): address Copilot review — plain quotes + 'none' status value

- Effect-record status enum: add `none`, which the prose already treats as a
  distinct value ("unknown ... never none"; the committed/none/unknown
  trichotomy). Resolves the enum/prose inconsistency — `none` (no effect) is
  distinct from `rolled_back` (ran, then undone).
- Drop the two backslash-escaped quotes (the incompressibility walk-back and
  the minimax well-posedness caveat) for plain quotes, matching the rest of
  the document. GFM strips the backslash, so they rendered fine; the escapes
  were just unnecessary and inconsistent.
2026-06-26 05:24:32 -07:00
Patrick Buckley c0be383f99 refactor(doctor): replace turnstone-bootstrap with turnstone-doctor (#718)
* refactor(doctor): replace turnstone-bootstrap with turnstone-doctor

turnstone-bootstrap was an LLM setup wizard for Day-0; run.sh now owns install.
Repurpose its LLM/conversation plumbing into turnstone-doctor — a diagnose-only
tool for a running cluster.

- Preflight detects the install kind (docker-compose/systemd/pip/source) from
  config.toml + TURNSTONE_* env, with secret redaction.
- Self-configuring brain resolves the cluster's own model from config/env/storage
  read-only (no migrations, no create_all), falling back to interactive
  selection; the attempt itself is the LLM-backend health check.
- Deterministic version check: installed version, cluster drift via the console's
  authoritative /health, and latest upstream stable/experimental (offline-safe).
- Read-only diagnostic tools (read_file, compose/systemd/journal, http_health,
  check_llm_backend, node_health, finish) behind one secret-scrubbing chokepoint;
  no generic shell, so read-only is structural.
- node_health reaches a node the right way for the detected install kind
  (exec-into-container for compose, direct HTTP otherwise), overridable per node
  for mixed clusters.
- mTLS-aware: forwards [database] SSL params and reports node-mesh mTLS instead of
  mislabelling healthy nodes "unreachable".

init_storage gains a backward-compatible create_tables override for read-only
opens. Entry point turnstone-bootstrap -> turnstone-doctor; README/QUICKSTART/
architecture/docker docs, the bundled compose header, run.sh, and the CI smoke
updated. CHANGELOG deferred.

* fix(doctor): address Copilot + CodeQL review findings on #718

Validated all seven review findings (none false positives) and fixed:

- check_llm_backend now applies the same scheme / metadata-host guard as
  http_health (extracted to _assert_safe_http_url), so a model-supplied
  base_url can't be steered at the cloud metadata endpoint or a file:// URL.
- node_health no longer double-appends the default port when the operator
  passes host:port (regression: 10.0.0.5:8081 -> http://10.0.0.5:8081:8080).
- node_health install_type enum uses "git-source" to match the label the
  rest of the module and the prompt/report show the model (a schema-strict
  provider would otherwise reject the value the model is told to use).
- _read_api_creds takes base_url + api_key as a unit from the first config
  source that defines either field, then env-fills, instead of splicing the
  two across different config files into a pair that exists in no real config.
- _mask_secrets masks assignment-shaped content inside comment lines, so a
  commented-out real secret can't leak through read_file / the report; prose
  comments (no KEY=value shape) still pass through untouched.
- drop the mixed import styles CodeQL flagged in doctor.py and test_doctor.py.

Adds 5 tests; ruff + mypy clean; full doctor suite passes (129).
2026-06-26 04:57:20 -07:00
Patrick Buckley 4baf6f81c3 docs(hypothesis): clarity pass, GitHub-render fixes, consistency linter (#720)
* docs(hypothesis): clarity pass, GitHub-render fixes, consistency linter

Document (HYPOTHESIS.md):
- split the dense "Formal" definition into labeled subsections
- define the load-bearing terms: certificate (proven witness vs measured
  surrogate) and the controller / plant (= M_W) / shell triad
- corrections: three-way drift split (+ r_env), scope the success/safety
  collapse to absorbing refusal, unify tau*->tau_H and drop the orphaned bare tau
- calibrations: pin the incompressibility conjecture (still conjectural),
  mark the interlingua=certificate identity as figure, soften the two-walls trade
- GitHub math rendering: brace command-subscripts (_\bot -> _{\bot}, etc.) so the
  markdown emphasis parser stops breaking inline math; replace R_\# with R_{\sharp}
  (\# unescapes to a raw # in GitHub math)

Linter (lint_hypothesis.py):
- deterministic consistency checks A-G; G adds an orphan/redundant-declaration
  scan that catches the bare-tau failure mode
- residue guards so tau^star, unbraced _\cmd subscripts, and \# cannot return

* fix(hypothesis): make lint_hypothesis.py pass ruff under py311

- precompute the inline-$ count so no backslash sits inside an f-string
  expression (backslashes in f-strings are 3.12+; the project targets 3.11)
- split the one-line import (E401/I001); open HYPOTHESIS.md via a context manager (SIM115)
2026-06-26 03:57:36 -07:00
Patrick Buckley 4aaf6feac4 chore(cancel): address Copilot review nits
- console/server.py: replace a stale hard-coded `session_routes.py:852-854`
  comment reference (already drifted to make_close_handler's signature) with
  a by-name reference to make_close_handler's not-found path.
- test_cancel.py: rename test_marks_most_recent_action_unknown ->
  test_marks_in_flight_action_unknown; the disposition marks the first
  unanswered (in-flight) call, not the most recent — they merely coincide in
  this two-call case.
2026-06-26 03:28:06 -07:00
Patrick Buckley bc93b1f748 fix(cancel): address code-review findings before PR
The multi-stage review of this branch surfaced four major + two minor issues,
three of them in the new cancellation code. All fixed here (bug-3, the stale
generated TS SDK spec, stays deferred — it regenerates out-of-band).

- sec-1: cancelling a coordinator now auto-cascades to its children, but the
  cancel route allows the service-scope bypass while the removed stop_cascade
  gated the same destructive subtree-cancel at no-bypass — a service token
  without admin.coordinator could trigger the cascade. Re-assert the
  no-service-bypass gate inside _cascade_cancel_to_children, so a plain cancel
  by an under-privileged service token still cancels the coordinator's own
  turn but no longer cascades.
- bug-1: _cancelled_agent_disposition took the LAST issued tool call as the
  in-flight one. _run_agent executes a turn's calls sequentially, so the
  in-flight call is the FIRST unanswered one — taking the last inverted
  unknown/none on a multi-call turn (a SIGKILL'd bash mislabelled "not
  started", the never-run tail mislabelled UNKNOWN, inviting a re-run of the
  destructive call). Fixed to first-unanswered.
- perf-1: the per-child cancel fan-out was awaited inline before the cancel's
  200, so a cancel could block for tens of seconds on slow/unreachable
  children. Return the fan-out as a response BackgroundTask so it runs after
  the 200 (trigger, not drain).
- bug-2: the initial-send worker (_run_initial) cleared _worker_running
  unconditionally — the same clobber the session_worker guard just fixed.
  Apply the identity guard there too.
- sec-2: restore the per-child cascade audit row (coordinator.cancel_cascaded)
  the removed stop_cascade wrote; it had become log-only.
- q-1: extract the shared UNKNOWN-outcome clause (UNOBSERVED_OUTCOME_CLAUSE)
  so the wire-repair fallback and the session-layer synthesis can't drift.
2026-06-26 03:28:06 -07:00
Patrick Buckley 03f82521d9 fix(cancel): close workstream self-cancel gaps from the completeness review
Follow-up to the cancellation review — harden how cancel interacts with a
workstream's OWN turn and tools, not just its children and agents.

- wait_for_workstream: the wait loop holds no cancel handle and blocks on the
  child-event bus, so a cancelled coordinator parked in a wait stayed pinned
  for up to WAIT_MAX_TIMEOUT (600s). Add a cooperative check to the ~2s
  progress heartbeat — it raises GenerationCancelled, which propagates out of
  the otherwise cancel-blind wait (~2s abort).
- spawn_batch: stop creating the rest of the children once cancel is observed;
  already-spawned children stay recorded (they are live, durably parent-linked
  workstreams), the remainder are marked not-spawned.
- session worker: only clear _worker_running if this thread is still the
  current worker, so a late-finishing abandoned worker (force-cancel) can't
  clobber a live successor's flag — which would let a third send spawn a
  duplicate worker on the same session.
- bash silent-cancel: a SIGKILL'd silent command now records outcome-UNKNOWN
  (is_error, partial output kept) instead of a clean "Cancelled by user." that
  read as a successful empty result on replay.
- wire-repair: the last-resort orphan disposition now reads outcome-UNKNOWN,
  matching the cooperative-cancel message (unknown, never none).

Deferred: MCP / web_fetch / web_search remain uninterruptible mid-call,
bounded by tool_timeout; only bash is truly preemptible.
2026-06-26 03:28:06 -07:00
Patrick Buckley 776430d860 feat(cancel): honest cancellation dispositions + coordinator subtree propagation
A cancelled agent previously discarded its own ledger and reported a bare
"(task interrupted by user)" — fabricating the *outcome* (read downstream
as "nothing happened"), which invites a double-send as readily as a
dropped record causes an orphan. Make the fold-back honest, and propagate
an owner's cancel down the coordinator subtree.

- task_agent (single + parallel): on cancel, fold back a deterministic
  disposition built from the agent's in-memory ledger — actions completed,
  the in-flight action flagged outcome-UNKNOWN, and not-started calls —
  instead of the opaque interrupted string.
- coordinator cancel now auto-propagates to its direct children via a
  post_cancel hook on the shared cancel handler (cooperative fan-out; no
  blocking drain).
- synthesized cancelled tool results now read outcome-UNKNOWN rather than
  implying the call never ran.
- remove the now-redundant stop_cascade operator endpoint (handler, route,
  OpenAPI spec + schema, tests, docs); a coordinator cancel supersedes it.
2026-06-26 03:28:06 -07:00
Patrick Buckley fafc2d5617 fix(mcp): prune the refresh lock alongside backoff on the missing/decrypt path
Review follow-up (#717). The bug-1 fix made the transient keep-path retain the
per-(user, server) refresh lock for serialization, so the lock entry now lingers
after a transient failure. When the token then vanishes (missing) or goes
undecryptable, _no_token_result pruned only the backoff entry and left the lock
entry stranded, so mcp_oauth_refresh_locks could grow on that path. Drop both
sibling dicts in _no_token_result (removing the now-redundant explicit
_drop_refresh_lock on the in-lock decrypt return); the regression test asserts
both are pruned on the missing-after-transient path.
2026-06-25 23:13:30 -07:00
Patrick Buckley 800b561f56 fix(mcp): classify OAuth refresh failures so neither a blip revokes consent nor a dead grant strands the user
Follow-up to #714 (Entra OBO, #682). A refresh failure deleted the user token +
emitted token_revoked regardless of cause, so a transient AS/network blip during
a forced refresh (the live 401-retry path) permanently revoked consent
cluster-wide. Fixing only that, though, opens the dual failure: a genuinely-dead
grant the AS reports in a non-standard shape would now be kept forever and the
user stranded on a retryable error with no re-consent path. This classifies the
failure three ways so each is handled correctly.

Classification (_classify_refresh_failure): MCPOAuthRefreshFailed carries a
_RefreshFailureClass instead of a bool —
- PERMANENT (revoke + re-consent): an explicit dead-grant / re-consent signal —
  invalid_grant at any 4xx (400/401/403), invalid_scope, or an OIDC
  interaction-required code (interaction_required / login_required /
  consent_required / account_selection_required) the AS surfaces.
- TRANSIENT (keep, retry, never escalate): infrastructure (network, 5xx, 429,
  malformed body) and operator-fixable codes (invalid_client, invalid_request,
  unauthorized_client, unsupported_grant_type, temporarily_unavailable) —
  re-consenting the user can't fix a bad client_secret, and an outage must not
  revoke consent however long it lasts.
- AMBIGUOUS (keep, but escalate after a run): a 400/401 we can't map to a
  standard code. A one-off can't revoke, but an uninterrupted streak past a
  threshold escalates to re-consent so a dead grant in a non-standard shape
  can't strand the user. Infra transients reset the streak, so an outage never
  escalates.

Concurrency: do NOT drop the per-(user,server) refresh lock on the keep-the-token
path. Evicting it while the token is still live let a second concurrent caller
mint a fresh lock and refresh the same token in parallel; with refresh-token
rotation the second send reuses the consumed token, gets invalid_grant, and
spuriously revokes — the exact bug this commit prevents. The async-with still
releases the lock on return; the registry entry is pruned only when the token is
actually refreshed or revoked. Bit SQLite single-node hardest, where the pg
advisory lock is a no-op.

perf: a per-(user,server) cooldown short-circuits the token-endpoint round-trip
for a brief window after a transient failure, so a down AS isn't hit once per
tool call; self-heals when the window expires. Plus the lock-free in-flight key
set that collapses concurrent session-start pool primes (single mcp-loop thread).

dispatch/FE: the transient kind maps to a retryable mcp_refresh_unavailable
structured error (not mcp_consent_required); the FE titles it "Temporarily
unavailable" under a new soft "transient" category (amber, not the red hard-error
styling) in both stylesheets, with no wrong re-consent button.

tests: invalid_client kept (pins the discriminator on the error code, not the 4xx
status), single ambiguous 400 kept, 403 invalid_grant revokes, interaction_required
revokes, ambiguous streak escalates at the threshold, sustained 5xx never escalates
(outage safety), and the cooldown skips the second AS round-trip — all through the
real AS HTTP boundary.
2026-06-25 23:13:30 -07:00
copilot-swe-agent[bot] de271dc2f9 docs: remove Ollama from run.sh local backend list 2026-06-25 21:59:54 -07:00
Patrick Buckley 090f31c5c4 docs: drop Ollama from local-model lists (README + bootstrap wizard) 2026-06-25 21:38:49 -07:00
Patrick Buckley b939919560 fix(mcp): harden Entra OBO OAuth review follow-ups for #706
Follow-up review of the #706 on-behalf-of / Entra ID MCP changes (#682).

security (PKCE downgrade): the AS-metadata "assume S256 when
code_challenge_methods_supported is absent" relaxation applied to BOTH the
RFC 8414 oauth-authorization-server document and the OIDC openid-configuration
document. Per RFC 8414 an omitted field on the oauth-authorization-server
document means the AS does NOT support PKCE, so this was fail-open. The client
always sends code_challenge_method=S256, making this discovery check the only
pre-flight that the AS enforces PKCE. Track which document won discovery and
assume S256 only for the OIDC document; the RFC 8414 document now fails closed.
Also log which discovery profile (rfc8414 vs oidc) answered, for operators
debugging an enterprise AS.

bug (consent loss): session-start pool priming called the refreshing token
lookup for every cold oauth_user server. A near-expiry token triggered a
refresh, and a transient refresh failure (network/5xx/429) deletes the token
and emits token_revoked — so a blip during a cold-pool warm (e.g. after a
reboot) silently revoked consent across servers the user wasn't even using.
Priming now reads the token directly and skips missing/near-expiry tokens;
a refresh that may fail stays on the lazy dispatch path.

perf/UX (blocking redirect): the OAuth callback awaited prime_user_server
(default 20s timeout), holding the consent redirect on a slow/unreachable MCP
server. Replaced with fire-and-forget schedule_prime_user_server that schedules
onto the mcp-loop (GC-safe, no unreferenced request-loop task) and returns at
once.

perf: prime a user's pools concurrently under a bound instead of serially, so
one slow upstream can't stall the rest.

hygiene: log (not silently swallow) prime scheduling failures at session start;
add exc_info to the prime-failure warning; guard run_coroutine_threadsafe
against a closed mcp-loop.

tests: per-document S256 + OIDC-fallback discovery cases; pool priming
(non-destructive on near-expiry, skips connected) and bound-token rotation
reconnect.
2026-06-25 20:42:22 -07:00
github-actions[bot] 0ed8d19db5 chore: download vendored JS files 2026-06-25 19:42:08 -07:00
renovate[bot] 8e362986cc chore(deps): update dependency mermaid to v11.16.0 2026-06-25 19:42:08 -07:00
metaclassing 76e9d0a4f1 Address remaining blockers to entra id on behalf of flow for user impersonation to mcp servers (#706)
* This is a collection of little snippits to resolve all the OBO flow problems required to get this talking to entra id for on behalf of user impersonating to protected mcp servers. we make sure turnstone checks these mcp servers on startup, and address some of microsofts opinionated implementations of oauth2/oidc and metadata provided by the identity provider.

* minor token timeout bugfix

---------

Co-authored-by: root <root@pow3rtools>
2026-06-25 19:26:47 -07:00
renovate[bot] 1c746af36f chore(deps): update actions/checkout action to v7 2026-06-25 19:21:07 -07:00
renovate[bot] c99d1d42ba chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.24 2026-06-25 19:18:32 -07:00
Patrick Buckley ff5db73f97 chore(ui): refresh favicon to chevron mark across web entry points
Replace the amber gauge/needle favicon with a teal up-chevron and amber
dot on a dark-teal field. Applied identically to the console, coordinator,
and standalone UI entry points. Self-contained inline SVG data URI; no
network dependency.
2026-06-25 19:18:09 -07:00
renovate[bot] af98fe434a chore(deps): lock file maintenance 2026-06-25 19:17:12 -07:00
renovate[bot] c67b1ce9af chore(deps): update github actions 2026-06-25 19:16:47 -07:00
Claude e7e3135a37 docs(hypothesis): add "Appendix: model implementation" — cancellation as the worked pattern
Establishes the appendix pattern (locate a practical concern in the existing
formal objects; read off the discipline rather than inventing machinery) with
cancellation as the first and only worked example. Not the whole model.

Cancellation semantics, derived from objects already on the page:
- Cancel is a signal → lives in s (Markov). The gate closes on it: γ(s,y)=⊥ while
  live, which blocks pending actions and all future turns with no new machinery.
- In-flight (past γ) disposition is a trinary on the kind of Q_E: cancellable
  (propagate, true end-state), bounded (drain, real e), or opaque/unbounded
  (controller fabricates a synthetic "cancelled" e so the loop can halt).
- Load-bearing rule: ρ may fabricate the acknowledgment but not the outcome — an
  unobserved outcome is `unknown`, never `none` (double-send vs orphan, same bug
  opposite sign).
- New terminal H_cancel ⊆ H\H_ok: non-accepting but safe (outside B), postcondition
  "no action past γ after observed; in-flight drained or recorded unknown; ledger
  consistent."
- Cooperative not preemptive (observed at next γ check, not on send); recursive
  down the task-agent subtree (why task agents are the worst case).
- Compensation is the owner's job (saga, after H_cancel, reads child ledger) — the
  cancelled agent can't know if it's needed; it never observed the outcome.
- Design pressure: prefer bounded/instrumented Q_E over opaque, so cancellation and
  the ledger stay honest (a bash wrapper converts branch 3 -> branch 1).

Linter: balanced, no new collisions. Two ρ role-flags, both false positives
("authorized action" near ρ, correct usage).
2026-06-22 12:16:45 -07:00
Claude d1acded028 docs(hypothesis): round-sixteen (maxima re-run) — semantic-axis fixes the linter cannot see
The linter closed mechanical consistency; this review probes meaning, a separate
axis. Twelve findings, several real corrections, all folded.

Correctness:
- Stationarity overclaim: the supermartingale BOUND survives a nonstationary
  kernel under uniform conditional drift. Time-homogeneity is needed for V* as a
  fixed function, the resolvent/fundamental-matrix identities, and δ-calibration.
- Self-contradiction: "the certificate cannot be proven, only observed" contradicted
  the established "a proven inequality certifies" — reworded to "the architecture
  does not hand it to you; estimated unless separately certified."
- Citation: the TACL result is LOG-precision → logspace-uniform TC⁰ (verified);
  fixed/constant precision is a stronger restriction. Fixed in body and Grounding.

Modeling holes closed:
- Adversary class Π must respect rejection: γ(s,y)=⊥ ⇒ Q_E^α(s,⊥,·)=δ_e0, else the
  adversary resurrects refused side effects.
- R must be a syntactic/verified readout, not a semantic solver — otherwise the
  L-wall is void (compute could hide in R off the ≤L window).
- e must be an effect record (ledger outcome), not just API bytes, since only ρ
  writes external effects into s.
- The displayed M_W(c) freezes endpoint/version/sampler; config changes need a
  state-indexed M_{κ(s)} or K_C — the kernel can't silently depend on config in s.
- The final user-visible response/log is itself an effect: an authorized action
  through γ, or emitted only after an accepted halt.
- m_t must include a token counter and clock for the cap/timeout to be functions of it.

Residue (omissions a collision-linter can't catch):
- Another γ dropped from the K_C Dirac-special-case list.
- τ* mislabeled as "designed code" → the halt test (H) is the code; τ* is its
  emergent hitting time.
- H\H_ok relabeled "non-accepting" (safe refusals outside B; wrong/bad halts
  possibly in B), not uniformly "rejecting/fail-closed."
2026-06-22 12:16:45 -07:00
Claude d61baea8ae docs(hypothesis): round-fifteen — deterministic consistency lint; resolve G collision
Built and ran a static linter (no model): delimiter/emphasis balance, residue
regexes for everything prior rounds fixed, single-capital collision scan, a
definition check for recently-introduced symbols, and a γ/ρ role-neighborhood
scan. Result:
- All balance checks pass; all 10 residue regexes clean (no regression across
  14 rounds); all 12 introduced symbols defined; no display-only symbols.
- γ/ρ scan: one flag, a false positive (the symbol-table cell defines both).
- One real find: G was overloaded — the parser-stop update G(m_t,v) (added in
  round 14) collided with the Green/potential operator G. Renamed the stop-update
  to \mathsf{step}; the Green operator G is now unique.

This closes the consistency axis deterministically rather than by another review.
2026-06-22 12:16:45 -07:00
Claude 4ee148d9c0 docs(hypothesis): round-fourteen (maxima re-run) — round-13 residue, role/contradiction fixes, exact citation
Same prior-maxima full-tools review, re-run. Found mostly residue from round-13's
own edits plus longer-standing inconsistencies. Folded all; left the final
signature line alone (it is the author's call, and it is well-formed — see below).

Round-13 residue:
- 𝒴/𝒴_⊥ split half-committed: 𝒴 already includes ⊥, so R:𝒵→𝒴 and A_Y⊆𝒴 (drop _⊥).
- m_t was added to the inner triple with no dynamics: add m_{t+1}=G(m_t,v), define
  the stop set Stop and τ=inf{t:m_t∈Stop} in both display and prose.
- No-truncation special case had R=id, ill-typed on a triple: R(c,b,m)=c.
- Safety/success "exactly on safe refusals" overclaimed: they differ on any
  B-avoiding non-success run — also safe non-halting / endless safe retry, absent
  a.s. absorption into H∪B.

Role residue (γ does authorization/rejection; ρ does response/fold-back):
- "⊥ branch is what ρ rejects" → γ rejects it.
- "ρ validates response as well as the proposal" → ρ validates the response; γ
  gated the proposal.
- "fail-closed rejection at ρ" (falsification list) → at the gate γ.
- Symbol table still typed Q_E on authorized a → a∈𝒜_⊥ with the no-op; define e_0.

Longer-standing:
- Stochastic-controller contradiction: stochastic control falsifies the
  deterministic special case, not the broader K_C kernel model (round-9 K_C).
- Drift split r=r_shell+r_plant needs an additively separable V̂ or a declared
  attribution scheme.

Citation (verified via search, not the reviewer's say-so):
- TC⁰/log-precision → Merrill & Sabharwal, "The Parallelism Tradeoff", TACL 2023;
  caveat (added autoregressive steps escape it) → Merrill & Sabharwal, "The
  Expressive Power of Transformers with Chain of Thought", ICLR 2024.
2026-06-22 12:16:45 -07:00
Claude 7a403e5d91 docs(hypothesis): round-thirteen (prior-maxima full-tools audit) — consistency-debt cleanup
A cold reviewer given the complete prior-maxima changelog + full tools ran a
consistency audit of the file (it did not use tools for grounding — the gap was
internal). Found 15 real issues, all folded. No new design flaws; this is
accumulated editing debt from 12 rounds of surgical patches.

Half-applied fixes now propagated:
- Inner-kernel display still showed M_W(c)=Law(c_τ) and R:C→Y_⊥ despite the round-12
  triple; made z_t=(c_t,b_t,m_t) primary, R:Z→Y_⊥, M_W=Law(R(z_τ)).
- Append formula still used bare c·v; now suffix_{≤L}(c·v) in the display.
- Tuple still called B a "terminal set"; B is separate (τ_B fires mid-run).
- "halt/ready" survived at line 75 (fixed before only in tuple + table).

Collisions created by added notation:
- γ was both the authorization gate and the RL discount in (I-γP)^{-1}; discount → β.
- B was both the bad set and the dummy measurable set in the pushforward; dummy → A_Y.
- ρ over-credited as the disturbance-rejection margin; for side effects the margin
  is γ (consistent with round-12 irreversibility), ρ validates response/fold-back.

Real error in a prior round:
- The round-12 safety/success distinction collapses under absorbing refusal
  (Pr(τ_Hok<τ_B) requires reaching H_ok, so it is a success form). Split correctly:
  p_succ=Pr(τ_Hok<τ_F), F=B∪(H\Hok); p_safe=Pr(τ_B=∞); they differ on safe refusals.

Typing / hygiene:
- Q_E typed on S×A_⊥ (it is applied to ⊥); 𝒴 declared to include ⊥ (M_W, γ total).
- Controller list omitted γ and mis-listed the readout (specialization-only).
- Defined the previously-bare symbols D={s:E[τ_H]=∞}, μ, the drift r(s), and Π.
- Grounding "verify by measured drift" overstated; a proven inequality certifies,
  empirical drift only checks — reconciled with the body.
2026-06-22 12:16:45 -07:00
Claude afd66df4c0 docs(hypothesis): round-twelve (cold review, sandbox) — inner-state triple, authorization irreversibility, safety vs success
A cold no-priors review (given a local sandbox it did not use — the remaining
work is judgment, not computation). Mostly editorial/formal; its real catches
again concern round-10/11 additions. Folded the substantive ones, declined the
"extract a smaller core" restructure and the formalism padding.

Substantive:
- Inner kernel: replace round-11's awkward "read c_τ as the buffer" overload with
  a clean inner-state triple z_t=(c_t,b_t,m_t) — window, output buffer, parser/
  stop state — and M_W(c,·)=Law(R(z_τ)) from z_0=(c,∅,m_0). Strictly cleaner.
- Authorization is the irreversibility boundary: ρ can reject a bad tool RESPONSE
  but cannot undo an authorized action's side effects, so γ (not ρ) is the last
  line before irreversible effects. And the gate is bypassed if raw y reaches any
  sink (tool, logger, browser, remote) before γ.
- Safety ≠ success: p_ok=Pr(τ_Hok<τ_B) is the safety object (refusal permitted);
  the stricter success object races H_ok against all failure F=B∪(H\Hok). They
  differ exactly on safe refusals.

Precision:
- Foster–Lyapunov positive recurrence needs irreducibility/petite-set hypotheses;
  the absorbing-halt case needs only the weaker supermartingale hitting-time bound.
- Name an initial distribution s_0~μ_0. Fix residual "halt/ready" in the table
  (round 11 fixed only the tuple).
2026-06-22 12:16:45 -07:00
Claude 79cf8dae0b docs(hypothesis): round-eleven (confirmatory cold review) — fix B/terminal partition, fail-closed, output buffer
A JSON-constrained cold review largely validated round 10; its new catches
cluster in round-10's newly-added material.

Fixes (the real ones):
- Terminal-set partition was wrong (a round-10 error): B is NOT a terminal
  component — τ_B can fire mid-run. H now splits into accepting (H_ok) and
  rejecting/fail-closed (H\H_ok); B is a separate unsafe set for reach-avoid.
- Fail-closed generalized: rejection need not be terminal (reject-then-retry is
  valid) — define it as "no unauthorized side effect + land in a safe non-bad
  set," with terminal rejection one case. ρ must also validate the tool RESPONSE
  e (adversarial/malformed Q_E output), not only the model proposal at γ.
- Sliding-window truncation (round-10) loses transcript: the readout R reads a
  separate output buffer, not the truncated c_τ alone.

Precision:
- Deterministic maps are measurable transforms inside the pushforward, not
  literally "outside the integral."
- Absorbing halt H vs the separate (non-absorbing) daemon "ready" recurrence.
- "Syntactic soundness is free" qualified: relative to a formal schema and a
  correct validator.
- State-ablation falsifies Markovity but cannot establish it (necessary, not
  sufficient). Added a readout-typing diagnostic.
2026-06-22 12:16:45 -07:00
Claude 23a79a6c64 docs(hypothesis): round-ten (no-priors review) — authorization gate, minimax fix, raw-vs-correct halting
A cold no-tools external review (lower trust on world-facts, but its catches are
math-internal and correct) found two real bugs plus rigor gaps.

Bugs fixed:
- Verification-after-side-effect (the important one): the kernel ran e~Q_E(s,y)
  then ρ verified, so a tool call's side effect landed before authorization. Add
  a deterministic authorization gate γ:S×Y→A_⊥ between model and environment;
  Q_E now acts on the authorized action γ(s,y); ρ becomes ρ(s,y,a,e). Fail-closed
  is now a property (γ=⊥ ⇒ no-op env ⇒ fold to H\H_ok), not a name.
- Minimax drift display had a free y (introduced round 9): it integrated only
  over e while y~M_W(π(s)). Now integrates over both y and e, adversary as a
  policy α(s,y) over environment kernels, on the authorized action.

Reframing / rigor:
- Raw halting is cheap: a budget counter k gives V=k as a trivial halting
  certificate, so "no certificate by construction" overstated. The missing
  guarantee is correct/safe/successful halting (H_ok, B, p_ok).
- Standard Borel spaces (not merely measurable); define H, H_ok (⊆H), B (∩H_ok=∅)
  and hitting times τ_A up front; add 𝒜 to the tuple.
- Inner kernel: truncate c·v to suffix_{≤L} at the window edge; M_W is a
  probability kernel only via EOS/max-token/timeout/⊥ (else sub-probability +
  cemetery).
- Drift: weaker bound δ≤δ̄<ε gives E[τ]≤V̂/(ε-δ̄); distinguish δ_ν (distributional)
  from δ_sup (worst-case).
- Injection enters π's inputs (retrieval/pages/tool metadata), not only post-model
  Q_E; B needs a side-effect ledger in S. Architectural invariants stated
  (model sees only C; outputs are proposals; γ gates side effects; terminals
  partitioned). Complexity/LBA material marked heuristic, not definitional.
  An LLM-judge verifier is a learned kernel, not deterministic ρ.
2026-06-22 12:16:45 -07:00
Claude 2c943d2634 docs(hypothesis): round-nine (fresh review) — stochastic controller, minimax policy, reach-avoid
A cold external review (same priors, no path-dependence) surfaced three real gaps
the iterative chain missed, plus precision items. Folded in:

Substantive:
- Stochastic controller: the deterministic π,ρ,H are the Dirac special case of a
  controller kernel K_C(s,dc) (routing, sampled retries, ensembles, learned
  routers). Deterministic is the case worth wanting (localizes randomness); the
  split widens, not breaks, under stochastic control.
- Minimax type fix: the adversary chooses a POLICY/kernel, not the realized
  sample. Display is now sup over α of ∫ V(ρ(s,y,e)) Q_E^α(s,y,de), not sup over
  the post-probability e.
- Reach-avoid security: add a bad set B; injection steers toward B (wrong
  acceptance, exfiltration, unauthorized tool use, privilege escalation,
  irreversible effects), so security is reach-avoid p_ok=Pr(τ_{H_ok}<τ_B) with a
  barrier certificate for B, not liveness. B and H_ok added to the tuple.
- Unconditional V*_ok is infinite under any positive pre-acceptance failure
  probability ⇒ the workable object is p_ok (or the regenerative time on restart).

Precision / hygiene:
- Compiler claim scoped to a specific data-flow analysis (not a whole compiler);
  add integrability/optional-stopping conditions to the hitting-time bound.
- Formal hygiene: spaces measurable, τ/τ* stopping times, H absorbing.
- Mid-generation tool calls interleave the loops — clean nesting is an
  idealization needing a finer state machine.
- Soften SSM ("different", not "tighter"); demote "manifold" to informal
  shorthand in the formal section; gloss "all undefined behavior" as "no complete
  formal source-language semantics."

Not changed: V* incompressibility (already labeled conjectural in Grounding).
2026-06-22 12:16:45 -07:00
Claude 6c32b7e3cf docs(hypothesis): round-eight micro-edits — precise compiler claim, well-posedness wording
The review's verdict was "Merge." These are its two correct non-blocking nits;
its third nit (stop adding theorems/caveats) is heeded — nothing else changed.

- Grounding: "the compiler's V is free" → "a classical monotone data-flow
  analysis gets its V for free." A whole compiler does not get termination for
  free; the specific lattice-based analysis does (Kildall).
- Asserted: the Koopman/certificate co-determination "holds only under" →
  "is well-posed only under" the spectral assumptions — avoids asserting truth
  ("holds") for a claim explicitly labeled as not-a-theorem.

Deliberately NOT changed: D → D_H (prose already marks D harness-relative;
subscripting one formula while D stays bare elsewhere would add asymmetry, not
remove it), and no further theorem additions or caveats per the review's note
that more caveating now costs clarity without adding rigor.
2026-06-22 12:16:45 -07:00
Claude 480acd262b docs(hypothesis): round-seven micro-patch — harness-relative reachable, attribution, grounding
The review's verdict was "mergeable"; these are its three optional items plus the
delta-attribution nit.

- δ attribution: sampled-state coverage is an evaluation-protocol property, not a
  weights property. Attribute the noise floor / residual risk to the trained
  weights, the environment, AND the evaluation distribution.
- reachable(L) is harness-relative too (same reason U_H(L) is): rename to
  reachable_H(L) and note the divergent set D is likewise relative to H.
- Split the dense frontier paragraph in two: (1) the SR / fundamental-matrix /
  potential-operator identity with its caveats; (2) the speculative interlingua/
  certificate thesis. No content change.
- Grounding: add the absorbing-chain fundamental matrix (Kemeny & Snell 1960),
  the general-state potential/Green operator (Revuz 1984), and Koopman (Koopman
  1931; Lyapunov-from-eigenfunctions, Mauroy & Mezić 2016) to Proven; mark the
  Koopman/certificate co-determination (spectral-assumption-dependent) and the
  interlingua/certificate identification as Asserted.
2026-06-22 12:16:45 -07:00
Claude 08edb14588 docs(hypothesis): round-six fixes — harness-relative U_H(L), Neumann caveat, predicate split, Koopman hedge
- U(L) is harness-relative: tools and decompositions change membership, so rename
  to U_H(L) and note the shell's verified tools / decompositions determine what
  can be paged or outsourced.
- Countable fundamental matrix: lead with the Neumann series N=Σ Q_tr^n, scope
  countable to convergence, and write (I-Q_tr)^{-1} only when the inverse exists;
  general-state version is the same series read as the potential (Green) operator.
- Distinguish failure modes for V*_ok: infinite under a formal success predicate
  vs undefined if no predicate has been specified.
- Soften the delta "floors" line: mu(D), sampled-coverage, and Var[tau*] drive
  the empirical noise floor / residual risk, they are not literal floors of the
  drift slack.
- Hedge the Koopman bridge (the last frontier thread): the eigenbasis claim
  presumes a diagonalizable, point-spectrum operator — mixing dynamics carry
  continuous spectrum and admit no eigenbasis — and the linearizes/certificate-
  decomposes coincidence holds only for a V in the span of those eigenfunctions.
2026-06-22 12:16:45 -07:00
Claude 3f1be4f963 docs(hypothesis): round-five fixes — finite-mean hitting, potential operator, absorbing failure
Address the round-five review's three precision points (plus the adaptive-adversary
refinement).

- Absorption is finite expected hitting time, not positive recurrence: replace
  "positive-recurrent to H" with "reached in finite expected time," domain
  {s : E_s[τ_H] < ∞}. Positive recurrence stays reserved for the daemon/
  ready-state case (where it is used correctly).
- The fundamental matrix N=(I-Q_tr)^{-1}=Σ Q_tr^n is the finite/countable object;
  the formal model lives on general measurable spaces, so add the general-state
  potential (Green) operator G=Σ Q_tr^n with G·1=V* where the series converges.
  Q_tr now stated as the sub-stochastic kernel restricted to H^c.
- V*_ok is taken on the process where H\H_ok (halting wrong, refusing, failing
  closed) is absorbing failure — so a run that fails closed before acceptance
  has infinite accepting hitting time unless the spec restarts it. This is the
  mechanism by which a U(L) task sends V*_ok → ∞.
- Adaptive adversary: nonstationary Q_{E,n} → time-ordered product; an adaptive
  adversary → controlled / game-value operator (not merely time-indexed).
2026-06-22 12:16:45 -07:00
Claude 509f6e29a3 docs(hypothesis): pre-emptive round-four fixes — halting vs success, fundamental matrix
Fold in the two seams flagged after round three, before the next review pass.

- Limit section now states explicitly that its V*=E[τ*|s] certifies *halting*
  (reaching H at all), not correct halting; defers V*_ok (expected time to an
  accepting H_ok ⊆ H) to the second wall. Removes the latent inconsistency
  between the limit section (plain H) and the U(L) refinement (H_ok).
- Frontier section: the discounted successor-representation resolvent
  (I-γP)^{-1} presumes a discount γ and fixed P the stopped formulation lacks.
  Replace with the correct undiscounted/absorbing object — the fundamental
  matrix N=(I-Q_tr)^{-1}, Q_tr the sub-stochastic transient block — whose row
  sums N·1 are exactly V*. Converts analogy-dressed-as-identity into a true
  identity for the doc's own kernel.
- Mark the "one object seen twice" identity as holding only in the stationary
  regime: under the adversarial Q_{E,n} the resolvent/fundamental matrix become
  a time-ordered product, so identity in the stationary case, analogy beyond.
2026-06-22 12:16:45 -07:00
Claude 78f4b644b4 docs(hypothesis): round-three review fixes — V*_ok vs V*, pushforward readout, tool discharge
Address the round-three review. The substantive one is the V* correction.

- Successful halting vs raw halting (the real conceptual fix): a U(L) task does
  NOT make V*=E[τ_H|s] undefined — the chain can still hit H by failing closed,
  refusing, or returning a wrong answer. Split H from the accepting set H_ok and
  define V*_ok=E[τ_{H_ok}|s]; U(L) blows up V*_ok, not V*. Restate the domain as
  dom_{<∞}(V*_ok) ⊆ reachable(L)\D.
- Tools compute, not just store: the L-wall binds *model-mediated* work; work
  discharged to a verified external tool (solver, interpreter, compiler) runs
  off-context. U(L) now excludes tool-dischargeable work explicitly.
- Readout typing: use the pushforward M_W(c,·)=R_# Law(c_τ) (equivalently the
  conditional law); make R total, R: C → Y_⊥, with the ⊥ branch handled by the
  fail-closed ρ.
- Adversary/history: a history-conditioning adversary needs that history in s,
  else the object is a Markov game requiring further augmentation, not a chain.
- Hedge the LBA claim: "in the variable-L, fixed-precision idealization, the
  model-mediated inner computation behaves like a linear-bounded automaton."
2026-06-22 12:16:45 -07:00
Claude 7c84cc5353 docs(hypothesis): round-two review fixes — readout typing, S vs C, adversarial kernel
Address the three follow-up points on the first review patch.

- Reconcile the model kernel's two types: M_W(c,dy) maps into 𝒴, while the
  transformer line writes M_W(c)=Law(c_τ) over contexts. Add the readout R:
  𝒴 is either c_τ itself (𝒴=𝒞) or a deterministic readout R(c_τ), with
  M_W(c,dy)=Law(R(c_τ)∈dy).
- Separate harness state 𝒮 from model-visible context 𝒞: the L wall binds 𝒞
  (the L×d residual stream), not 𝒮. External stores (files, DBs, vector stores,
  durable memory) are shell-supplied memory that extends addressable storage but
  not the per-pass resident set — every read still routes through the ≤L window.
  Retype U(L) accordingly: not data exceeding L (pageable) but irreducible
  per-step working set exceeding L (not pageable).
- Make the time-homogeneity assumption explicit at the formal kernel: the
  displayed T is the fixed-kernel case; nonstationary/adversarial environments
  replace Q_E with a time-indexed kernel Q_{E,n} / admissible family, which the
  minimax certificate downstream quantifies over.
2026-06-22 12:16:45 -07:00
Claude 853bb27b3a docs(hypothesis): apply peer-review fixes — typing, Lyapunov status, δ as risk metric
Address the accepted points from an external peer review while preserving the
controller/plant thesis and the document's voice (layer, don't flatten).

- Claim: replace the ill-typed `T = ρ ∘ (M_W ∘ π, E)` with the integral
  transition kernel over (𝒴,ℰ); add explicit informal/formal split; demote the
  residual-stream implementation from definitional to a kept specialization
  (M_W as a general learned kernel); weaken "fixpoint searches" to hitting-time
  processes with fixpoint as one mode.
- Reading-it: note s is Markov only after state augmentation; mark controller
  determinism as conditional on versioned code/config/endpoint/interfaces.
- The limit: rephrase "carries no descent function by construction" to "supplies
  no certificate automatically" (a certificate is sufficient, not provided for
  free); label V* incompressibility as conjecture, not theorem.
- δ: "measure" → "estimate"; demote empirical δ from certificate to calibrated
  risk metric (confounds: bad V̂, coverage, sup not attained, nonstationarity,
  non-Markov); certificate only once statistically bounded.
- Cash-out: split "soundness is free" into syntactic soundness (free) vs
  semantic adequacy (empirical).
- Qualify the single-pass TC^0 claim (fixed-depth/fixed-precision; log-depth
  changes it) in both body and Grounding.
- Add an operational falsification program (state-ablation, determinism audit,
  drift calibration, adversarial-environment, boundary-control ablation).
2026-06-22 12:16:45 -07:00
Patrick Buckley a5f10b1c8c docs(readme): render the headline formula as a code block (PyPI-safe) 2026-06-22 01:10:20 -07:00
Patrick Buckley a7ab0a3a09 docs(hypothesis): add closing sign-off 2026-06-22 01:10:20 -07:00
Patrick Buckley 437c8bc7e2 docs(hypothesis): ground the doc + add the working-memory (L) bound and the interlingua frontier
Citations with a proven-vs-asserted split; the orthogonal context-length
tape bound (TC^0 single pass, the U(L) non-haltable region); and a flagged
frontier coda on V* and the semantic interlingua as one object.
2026-06-22 01:10:20 -07:00
Patrick Buckley 482a11c537 docs: add HYPOTHESIS.md — what is a harness?
A one-formula definition of a harness — a deterministic controller in
closed loop with a stochastic learned plant — and the certificate it
provably can't carry. The headline equation sits at the top of the
README and links through to the full doc.
2026-06-22 01:10:20 -07:00
Patrick Buckley e3af600a90 feat(deploy): vllm-litellm example — 3-model co-resident shape + HF loader (#688)
* feat(deploy): vllm-litellm example — 3-model co-resident shape + HF loader

Update the unified-memory inference example to the validated GB10 Spark shape:
qwen3.6-27B-FP8 (reasoning) + gemma-4-12B-it (perception) + Qwen3-Reranker-4B,
all co-resident on one GPU behind LiteLLM, loaded by HF id into a mounted
HF_HOME cache.

- qwen: MTP spec-decode + runai_streamer (weight load ~166s->1s) + full 256K at
  util 0.50 (default KV)
- gemma on the OpenAI lane (audio), reranker direct on :8002/rerank
- sequential startup + page-cache-drop guidance; runai_streamer kept on the big
  model only (its buffers break small models' KV budgets)
- README: HF-id loader, DGX Spark (validated) + AMD Strix Halo (ROCm) setup,
  tuning notes, troubleshooting
- wheel-check ALLOW entries for the example files (supersedes #687)

* docs(deploy): clarify AMD edits are compose literals (Copilot review)

In the Strix Halo guidance, --max-model-len and --load-format runai_streamer are
hard-coded in docker-compose.yml's vllm-qwen command, not .env vars — say where
to edit them.
2026-06-21 20:30:52 -07:00
Patrick Buckley f97c6351bb feat(deploy): add vLLM + LiteLLM unified-memory inference example (#686)
* feat(deploy): add vLLM + LiteLLM unified-memory inference example

A docker-compose stack co-residing a reasoning model (Qwen 3.6 27B) and a
perception model (Gemma 4 12B) on one unified-memory accelerator (NVIDIA DGX
Spark / AMD Strix Halo) behind a LiteLLM gateway serving both the Anthropic
/v1/messages and OpenAI /v1/chat/completions routes.

- qwen on the Anthropic lane (vLLM native /v1/messages), full 256K context
- gemma on the OpenAI lane (required for audio input_audio perception)
- sequential startup + page-cache drop for reliable KV provisioning on one card
- README: DGX Spark (validated) + AMD Strix Halo (ROCm) setup + troubleshooting

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-21 15:41:15 -07:00
Patrick Buckley 2b3b212971 Add altair + vl-convert-python viz stack (#685)
* feat(deps): add altair + vl-convert-python viz stack

The standard, ui://-ready visualization stack: one Vega-Lite spec renders to
static SVG via vl-convert (a bundled Rust renderer — no browser, GDAL, or
chromium) and drops into vega-embed for interactive ui:// panels. The first
consumer is the civic-records choropleth map; future ui:// surfaces build on
the same stack.

The dependency closure is fully permissive (BSD-3 + the OFL font + MIT/ISC JS) —
clean for Apache-2.0 and commercial use. Adds a mypy override for the untyped
vl_convert wheel.

* fix(deps): bump pydantic-settings to 2.14.2 (GHSA-4xgf-cpjx-pc3j)

Clears the pip-audit --strict advisory on the transitive pydantic-settings
2.14.1. Pinned as an explicit security floor in [project.dependencies]
(matching the starlette/cryptography CVE-floor convention) even though it is
transitive-only, so the floor is documented and survives re-resolution.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* chore: regenerate uv.lock to pass lock check

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-21 15:39:41 -07:00
Patrick Buckley 73f4fb5933 test: address Copilot review on the leaked-thread guard
- The guard snapshotted live threads by `Thread.ident`, but idents are
  recycled after a thread exits — a new leaked thread reusing an exited
  thread's ident would be mistaken for pre-existing and missed (false
  negative). Snapshot the Thread OBJECTS and compare by identity instead.
- Fix the `serve` fixture docstring: the factory returns the ephemeral
  port, not the server.
2026-06-17 18:07:45 -07:00
Patrick Buckley a7d8895287 test: eliminate leaked-thread test pollution + guard against it
Background daemons, event loops, and test servers that outlived their test
bled into later tests' captured output — an intermittent "I/O operation on
closed file" heisenbug, and the same class behind a past multi-day CI-hang
investigation.

- conftest: a fail-on-leak autouse guard (`_no_leaked_threads`) snapshots
  threads at setup and fails any test that leaves one running past teardown,
  with an `allow_thread_leak` opt-out — so the next leak is caught in minutes,
  not days. Plus `logging.raiseExceptions = False` to mute the benign
  logging-vs-capture-teardown race, and shared loop/server teardown helpers
  (`stop_loop_thread`, `serve_until_exit`).

- collector (PRODUCT FIX): the node-discovery loop slept uninterruptibly, so
  `ClusterCollector.stop()` couldn't join the `console-discovery` thread until
  the full interval elapsed — a real shutdown hang in production (up to
  `discovery_interval`). It now sleeps on an interruptible Event that `stop()`
  sets and `start()` clears.

- test fixtures: docker_healthcheck's HTTP servers, the MCP background event
  loops (shutdown_default_executor + close), and the FastMCP uvicorn upstreams
  (timeout_graceful_shutdown=0 + force_exit) now tear down cleanly instead of
  leaking.

Full non-live suite: 7456 passed, 0 closed-file errors, 0 leaked threads, and
~1.5 min faster (the leaks were dragging it).
2026-06-17 18:07:45 -07:00
Patrick Buckley cc0fa53077 feat(coordinator): port Regenerate/Edit title to coordinators
Coordinators carry LLM/auto titles like interactive workstreams but had no
way to regenerate or rename them. Port the interactive "Refresh title" (LLM
regenerate) + "Edit title" (manual alias) dropdown actions by lifting the
two handlers — the last shared verbs that weren't yet lifted — and opting
coordinators in.

- session_routes.py: add make_refresh_title_handler / make_set_title_handler
  factories (cfg pattern, mirroring make_close_handler). set_title resolves
  the workstream BEFORE the alias write and 404s when the kind has no
  tenant_check storage gate and the in-memory manager doesn't own it:
  set_workstream_alias is a global, kind-unscoped UPDATE, so this prevents
  an operator renaming a workstream the coord manager doesn't own (e.g. an
  interactive ws via the coord route) and the silent-200 on a bogus id.
- server.py: re-point the interactive bundle to the lifted handlers; drop
  the standalone refresh_workstream_title / set_workstream_title.
- console/server.py: wire refresh_title / set_title into the coord bundle
  (gated by the existing admin.coordinator operator check).
- shell.js: enable titleVerbs on the coordinator pane's tab menu; the
  base-aware lane posts to the console-origin coord routes.

Tests: coord refresh/set-title (regenerate, operator-gate, 404 unknown,
alias store + broadcast, empty, conflict, cross-kind reject); interactive
title tests re-pointed to the lifted handlers for lift-parity; shell.js
coord-menu assertion.
2026-06-17 16:04:18 -07:00
Patrick Buckley d81f8abde5 fix(coordinator): address Copilot review on title persistence
Three points from the PR #676 Copilot review:

- _coord_display_name ran on a lifecycle-event path and called
  get_workstream_display_name → get_storage(), which auto-initializes a
  SQLite .turnstone.db in the CWD when storage isn't initialized yet —
  a stray-file footgun on early-startup / unit-test paths. Add
  is_storage_initialized() to the storage registry and skip the DB read
  (fall back to ws.name) when storage isn't up. (Copilot's "skip when
  ws.name is non-synthetic" suggestion would have broken alias > title >
  name, so guard on init state instead.)

- Document, on SessionUIBase, that on_aux_usage (storage/metrics, no
  _ws_lock state) and on_rename (queue/locked fan-out) are safe to call
  from a concurrent auxiliary thread — the title-gen thread now runs
  during streaming, and these are the only two UI hooks it touches. No
  behavior change: the methods were already thread-safe (the same path
  task_agent sub-agents use); the contract just didn't say so. Add a
  matching note at the title-trigger site.

- Note in _coordinator_rows that the secondary `title` field is
  best-effort for a live coord outside the limit=200 window (the
  user-visible `name` stays correct via the uncapped bulk lookup, and
  the window is unreachable in practice — live coords are max_active-
  bounded and sort to the top of updated DESC).
2026-06-17 16:03:59 -07:00
Patrick Buckley 1860d14a65 fix(coordinator): persist + eagerly generate workstream titles
Coordinator workstream LLM titles were written to workstreams.title but
never read back, and were rarely generated in the first place:

- Read path: the dashboard's `_coordinator_rows` builder hardcoded
  title="" and used the synthetic `ws.name`, so a generated title (or a
  user alias) reverted to `ws-xxxx` on every refresh. Interactive rows
  resolve via get_workstream_display_name, so the gap was coord-only.
- Write path: the auto-title trigger only fired on a tool-call-free
  assistant turn, which coordinators (near-constant tool use) seldom
  reach — so the title almost never generated.

Read path:
- Project `title` + `alias` in list_workstreams (appended after user_id so
  existing positional fallbacks stay valid). `_coordinator_rows` resolves
  the display name (alias > title > name) for both lanes — live names via
  the bulk get_workstream_display_names (exact ids, no row cap), persisted
  rows from their own _mapping.
- Seed the console pseudo-node fan-out with the resolved display name so a
  rehydrated coordinator shows its title in the live tree immediately
  (one bulk lookup instead of an N+1 over mgr.list_all()).

Write path:
- Fire auto-title right after the user turn is recorded in send(), gated on
  a real (non-wake, non-empty) user message, instead of waiting for the
  terminal tool-call-free turn. Applies to interactive + coordinator.
- Snapshot self.messages in _generate_title since it can now run
  concurrently with the streaming turn.
2026-06-17 16:03:59 -07:00
Patrick Buckley 381057e6ed fix(audio): omni STT transcode + thinking-off, with streaming
Speech-to-text against an omni chat model (e.g. Gemma-4 on vLLM) was
broken end to end:

- The browser records webm/opus, but the omni chat lane only decodes
  wav/mp3 (it sniffs the bytes), so every clip came back 400 "Invalid
  or unsupported audio file". Transcode the upload to 16 kHz mono WAV
  with ffmpeg first, hardened against the untrusted blob:
  -protocol_whitelist pipe (no file:/http: SSRF), -vn, and a duration cap.
- The chat STT path calls the raw client and so bypasses the provider's
  request shaping. It now forces enable_thinking=false (via the model's
  thinking_param): leaving reasoning on costs ~11x latency and returns
  empty content on some clips. The prompt precedes the audio part (the
  order Gemma documents for transcription) and max_tokens is capped.

Add a streaming variant: POST .../speech-to-text/stream returns the
transcript as plain-text deltas and the composer fills them in live
(~0.3s to first word). The blocking stream is driven from one worker
thread that owns and closes the upstream connection.

Drop the gemma skip_special_tokens server-compat workaround: the vLLM
bug it patched is fixed upstream, and a stale shim can corrupt output.

The node image now installs ffmpeg; rebuild to run this live.
2026-06-16 19:49:31 -07:00
Patrick Buckley 3e88d2395c fix(tls): stub backoff via a _sleep seam, not the global asyncio.sleep
The test-postgres failure on test_init_retries_exhausted_raises surfaced the
root cause: sleeps held 2275x 0.1 instead of [1.0, 2.0]. Those 0.1s came from
a concurrent background poller doing asyncio.sleep(0.1) on anyio's shared
(persistent) event loop — the tls retry tests patched the *global*
asyncio.sleep, which intercepted that poller too.

- Before: the stub didn't yield, so the poller busy-looped and monopolized
  the loop -> the test hung (the CI-only "after 92%" hang on 3.12+).
- The earlier "make the stub yield" change converted the hang into this
  flood (the poller spins instead of blocking), which is what exposed it.

Fix: route init()'s backoff through TLSClient._sleep so the tests stub that
method in isolation and never touch the global asyncio.sleep. Tasks sharing
the loop are no longer affected; schedule assertions are unchanged.

The deeper fragility this exploited — a leaked, un-cancelled background poller
surviving on the shared test loop — is left as a follow-up.
2026-06-16 17:10:12 -07:00
Patrick Buckley 8ba669cf57 fix(deadline): prefer a ready result over a same-window deadline/cancel
run_with_deadline checked the deadline/cancel before reading the result
queue, so a call that completed in the same scheduling window could be
reported as a spurious timeout. Drain the queue first.

Also from review:
- test_validate_regex_pattern stubs run_with_deadline, so the probe regex
  never runs — use a benign pattern instead of a real backtracking literal
  (the literal tripped a ReDoS scanner).
- output_guard_judge docstring: reference IntentJudge._parse_verdict instead
  of brittle judge.py line numbers.

The _runner BaseException catch is intentional and kept: it relays (not
swallows) whatever fn() raises to the caller via the queue; narrowing to
Exception would let a BaseException escape the worker so the caller never
gets a value, degrading the no-hang guarantee.
2026-06-16 17:10:12 -07:00
Patrick Buckley bde960f725 ci: cap the suite jobs at 20 minutes
A hung run otherwise rides GitHub's 6-hour default with -v streaming the
whole time (the source of the multi-GB job logs). Cap test and test-postgres
at 20 minutes so a flaky hang fails fast instead of bleeding hours.
2026-06-16 17:10:12 -07:00
Patrick Buckley 05fd08ed1f test(tls): yield in the asyncio.sleep stub (suspected CI-hang fix)
CI hung on test_init_retries_transient_failure (the new -v output named it:
its nodeid printed, no PASSED, the job rode to cancellation). It is the first
retry test that actually awaits the stubbed asyncio.sleep — the earlier tests
raise before sleeping — which points straight at the stub.

The stub returned without ever suspending, so the retry run completed in one
event-loop step with no checkpoint; that is fragile under the async test
runner and is the suspected cause (3.12/3.13/3.14 only — never reproduced on
3.11 or locally). Capture the real asyncio.sleep before patching and await
sleep(0) in the stub so it still yields, keeping the no-real-delay behavior
and the backoff-schedule assertions. Same fix in the discovery-failure test.
2026-06-16 17:10:12 -07:00
Patrick Buckley 0b4f77db33 fix(judge): daemon-thread call deadlines; raise local-model timeouts
The judges and the regex ReDoS probe ran a blocking call on a
ThreadPoolExecutor and abandoned the worker with shutdown(wait=False) on
timeout or cancel. concurrent.futures joins every executor worker from an
atexit hook regardless of wait=False, so a wedged call could pin
interpreter exit — and hang the test suite at shutdown.

Add turnstone/core/deadline.py::run_with_deadline: run a blocking callable
on a daemon thread bounded by a wall-clock timeout and an optional cancel
event. A daemon worker is never joined at exit, so abandoning one is safe.

Migrate three sites onto it:
- OutputGuardJudge.evaluate()
- IntentJudge._evaluate_single / _run_judge — this also removes
  _ExecutorPoisonedError and the executor-restart dance: per-call daemon
  threads can't poison a shared single-slot pool, so a timeout now returns
  None and the caller delivers one fallback verdict.
- console/server.py _validate_regex_pattern (regex ReDoS probe)

Also:
- Double the default judge LLM timeouts for slower local models:
  judge.timeout 60->120s and judge.output_guard_llm_timeout 30->60s
  (settings registry, JudgeConfig dataclass, --judge-timeout CLI default,
  class docstring, docs). Correct a stale doc that described the per-turn
  timeout as a total budget across turns.
- Raise the regex probe bound 0.5->3.0s so a legitimately complex pattern
  isn't false-flagged as catastrophic backtracking.
- CI: run pytest with -v instead of -q so a hang names the offending test
  instead of riding the job timeout.
- Tests: cover deadline.py and the regex validator; move test_judge.py off
  fixed sleeps onto the existing _wait_for helper.
2026-06-16 17:10:12 -07:00
Patrick Buckley fd5b710437 chore: bump version to 1.7.0a2 2026-06-16 04:39:26 -07:00
Patrick Buckley e562d04e8b fix(deps): enforce cryptography + starlette security floors
main's lockfile was already on the patched versions (cryptography 49.0.0,
starlette 1.3.1) via renovate, but the pyproject floors (>=42, >=1.0.1) still
permitted a regression to vulnerable versions. Raise the floors to match
stable/1.6's v1.6.7 security fix:
- cryptography >=48.0.1 (GHSA-537c-gmf6-5ccf — bundled OpenSSL vulnerable <48.0.1)
- starlette >=1.3.1 (CVE-2026-54282 host spoof + CVE-2026-54283 url-encoded form DoS)
2026-06-16 04:39:26 -07:00
Patrick Buckley f714e49e02 fix(voice): default blank provider to openai in the admin audio gate
Copilot review: _audioModelEligible gated stt/tts on md.provider, but a
blank/unset provider was treated as not-audio-capable and excluded — an
asymmetry with the backend, where _provider_carries_audio and
ModelConfig.provider both default to "openai". Default the provider to "openai"
before the check so a provider-less model isn't wrongly dropped from the
voice-role dropdowns.
2026-06-16 03:41:51 -07:00
Patrick Buckley f4bab9fe16 refactor(attachments): retire the vestigial reservation scaffolding
The by-ref change replaced the send_id reservation model with the per-node
upload buffer (peek-then-drain at write time), but the surrounding narration was
never swept and a no-op stub was retained to make the send handler "read like"
the old flow — which is what made a recent diagnosis assume reservations still
existed.

- Delete the no-op _release_reservation_on_fail() and its 5 call sites in the
  send handler (behaviour-preserving — it did nothing).
- Rename ordered_reserved / reserved_set -> ordered_taken / taken_set (the values
  are the "taken" subset from resolve_staged_attachments, not reservations).
- Sweep the stale "reserve/reservation" wording across the create/send
  docstrings, the API schemas/specs, and the SDK docstrings to the staged-buffer
  vocabulary (resolve / attach / drain). The canonical docs in attachment_buffer
  and attachments already stated the reservation token is gone.

No behaviour change; no tests exercised the removed scaffolding (the
migration-060 test correctly pins the reserved_at column removal and stays).
2026-06-16 03:41:51 -07:00
Patrick Buckley b8a8b04042 fix(attachments): drain create-time staged uploads synchronously
A create-time attachment is dispatched on the first turn, but the buffer drain
runs at write time inside the async dispatch worker (_append_user_turn). The
freshly-opened pane calls rehydrate() before the worker drains, so it painted
the image as a still-pending composer chip ("thumbnail in the text input box").

The inlined first-turn dispatch is the only consumer of those staged uploads and
always commits at create, so drain them from the buffer synchronously right
after resolving them — both the interactive and coordinator post-install paths.
The worker's own per-id discard then no-ops.
2026-06-16 03:41:51 -07:00
Patrick Buckley d6f9e6f7d3 fix(voice): gate audio roles to OpenAI-SDK providers
An omni model registered via the anthropic-compatible lane (vLLM Messages API)
was offered for the STT role because it carries supports_audio_input — but the
Anthropic SDK client has no .chat.completions and the Messages API has no audio
content block, so the mic failed with a cryptic
"'Anthropic' object has no attribute 'chat'".

Audio (input_audio) only rides the OpenAI-SDK surface, so gate all audio roles
to OpenAI-SDK providers (openai / openai-compatible / google / xai):
- model_supports_role returns False for anthropic(-compatible), so the mic
  won't draw and the STT/TTS dropdowns won't offer those models.
- transcribe() raises a clear AudioUnavailableError naming the provider instead
  of the opaque AttributeError (defence in depth).
- admin _audioModelEligible mirrors the gate — voice roles only; reranker hits a
  /rerank endpoint, not audio, so it stays un-gated.

To use an omni model's audio, register it as openai-compatible (the input_audio
path); the anthropic-compatible lane is text/vision only.
2026-06-16 03:41:51 -07:00
Patrick Buckley 531913ec03 refactor(attachments): address branch self-review
- DRY the launcher create body: the multipart (meta + file parts) vs JSON
  framing was duplicated in _createCoordinator and _createInteractive — extract
  _createWorkstreamFetchOpts so the create wire shape lives in one place.
- Correct the proxy comment: the forwarded owner uid comes from the
  authenticated ws_body (as on the JSON path), not the caller's meta; the proxy
  token source is console-proxy, not console.
2026-06-16 03:41:51 -07:00
Patrick Buckley 2568ea5691 feat(voice): let omni models serve speech-to-text via the chat path
The mic is an STT control — it records and transcribes to editable text in the
composer. STT eligibility required the dedicated /audio/transcriptions endpoint
(supports_transcription / a whisper-style name), so an omni chat model
(supports_audio_input, e.g. Gemma) couldn't back it: it has no transcription
endpoint, it ingests audio via chat.

- model_supports_role accepts supports_audio_input for the STT role, so an omni
  alias resolves as STT and the mic draws for it.
- transcribe() branches: a whisper-style alias keeps /audio/transcriptions; an
  omni alias transcribes via chat input_audio + an instruction prompt — the
  audio.stt_prompt override, else a default that emits only the transcript.
  Audio attachments on an omni-STT setup transcribe the same way.
- admin _audioModelEligible mirrors the eligibility so omni models show in the
  STT dropdown; the role description notes the two backends.
2026-06-16 03:41:51 -07:00
Patrick Buckley 0171a9dd18 fix(attachments): normalize EXIF orientation so thumbnails and models see upright images
Phone photos store landscape pixels plus an EXIF orientation tag. Browsers honour
the tag for <img>, but Pillow (our thumbnails) and many vision-model image
decoders do not — so the thumbnail rendered rotated AND the model literally
perceived the photo sideways (noticed earlier as model "hallucinations", before
thumbnails made the rotation visible).

Normalize on read, at both surfaces:
- new core/images.normalize_image_orientation: bakes the rotation into the pixels
  and re-encodes (preserving format); images with no / identity orientation pass
  through untouched (pristine original, no per-send cost).
- make_thumbnail applies exif_transpose — after the decompression-bomb pixel gate,
  which now also covers the transpose decode.
- attachment_to_content_part runs image bytes through the normalizer before
  base64, so the primary model and the perception model both get upright pixels.

Because normalization is on read (not at upload), it fixes already-stored uploads
too.
2026-06-16 03:41:51 -07:00
Patrick Buckley 793c5518cc fix(attachments): surface the perception role in admin (roles tab + settings filter)
The universal perception fallback (perception.model_alias) shipped backend-only
— session.py + perception.py + settings_registry.py — so its admin UI was never
wired. Operators had no way to assign it from the Models → Roles sub-tab, and the
raw setting leaked into the Settings tab.

- Add a Perception row to MODEL_ROLES (no capability filter — it spans
  image/PDF/audio; the description tells operators to enable supports_vision /
  supports_audio_input on the target model, which is what makes the audio
  fallback engage when no STT role is set).
- Derive the Settings role-key skip-set from MODEL_ROLES instead of a
  hand-maintained list, so perception is filtered out and no future role can
  drift back in (stt/tts/reranker had leaked the same way).
- Add an optional per-role disabledLabel so the blank dropdown option reads
  correctly for non-voice roles (perception, reranker) instead of "voice off".
- Refresh the stale STT description that claimed "no audio-capable session
  fallback" — audio attachments now fall back to perception.
2026-06-16 03:41:51 -07:00
Patrick Buckley 9c15bb035c fix(attachments): forward create-time attachments for console interactive sessions
The console creates interactive sessions by proxying to the owning node via
/v1/api/cluster/workstreams/new, which only forwarded JSON — so a file staged in
the launcher was blocked with "Attachments aren't supported for interactive
sessions yet". The node create endpoint already accepts multipart (meta JSON +
file parts) on interactive_endpoint_config; only the proxy lacked it.

Teach create_workstream to accept multipart: parse meta + files (same caps as
the node), pick the node exactly as before (auto / pool / pinned), and forward
the files instead of re-serialising JSON. _createInteractive sends multipart
when files are staged (mirroring _createCoordinator) and the launcher gate is
removed. The files-need-a-task guard already ensures an initial turn to
dispatch them on.
2026-06-16 03:41:51 -07:00
Patrick Buckley f7500261e2 fix(attachments): base-prefix interactive pane attachment requests
A console interactive pane is node-proxied — every request rides the pane's
transport base ("/node/{id}"). The attachment controller hardcoded bare
/v1/api/workstreams/... paths, so upload / list / delete / preview landed on
the console's OWN coord route, which resolves ws_id via coord_mgr.get() and
404s as "coordinator not found". The standalone server (base="") was
unaffected, which masked the bug.

Thread the pane base through: createAttachmentController and
buildAttachmentPreview take an optional getBase / base, and the interactive
pane wires this._base into both. Coordinator panes and the standalone server
pass "" and stay origin-mounted as before.
2026-06-16 03:41:51 -07:00
Patrick Buckley 8e05c10b78 fix(attachments): address PR review feedback (Copilot + code-quality)
- TextDecoder in the text-preview stream now flushes on completion/cancel, so a multibyte UTF-8 char split across a chunk boundary isn't dropped (Copilot).

- send() clears self._wire_part_cache in a finally so the per-send memo (which can hold large rasterized PDF page-images) is released at send end instead of retained on an idle session until the next send (Copilot + fix-review).

- Make the implicit byte-string concatenation in _minimal_pdf explicit (+) in test_pdf.py and test_thumbnails.py so it can't read as a missing comma (CodeQL / github-code-quality).
2026-06-16 00:48:14 -07:00
Patrick Buckley ed5c104a88 fix(attachments): address fix-review nits (ftyp scan, text-preview, cache doc)
A review of the fix commits surfaced three refinements:

- ftyp audio sniff: scan the whole ftyp box (its declared length) for an audio brand instead of a fixed 6-slot window, so a real .m4a with the brand listed late still passes — while a pure-video file (no audio brand) still rejects.

- text-preview: accumulate body chunks until >=240 chars before cancelling the stream, instead of assuming the first chunk is large (flush boundaries can split a large body into small early chunks).

- _resolve_attachments: correct the cache comment — the memo is refreshed per send and the wire resolver only runs during a send, so a stale value is never observed between sends.
2026-06-16 00:48:14 -07:00
Patrick Buckley dd80ca3655 chore(attachments): hygiene sweep — dead code, stale comments, SDK type, pdf nit
- Remove the unused PerceptionUnavailableError (never raised/caught/imported).

- Reword the now-shipped 'Phase 3' placeholder comments on the Anthropic + OpenAI-Responses audio paths to describe the live upstream STT/perception fallback (these placeholders are defensive, not pending work).

- Clarify the no-vision image fall-through comment (fires when perception is unconfigured OR can't see, not only the former).

- Type AttachmentInfo.kind as the image|text|pdf|audio union in the TS SDK.

- extract_pdf_text: append the truncation marker only when there's actual text, so a scanned PDF over the page cap returns '' (-> placeholder) instead of a content-free document part.
2026-06-16 00:48:14 -07:00
Patrick Buckley f7cba67c2c test(attachments): handler-level coverage for /thumbnail + the served-blob gate
The /thumbnail endpoint and the _resolve_served_blob ownership/404-leak gate it shares with /content had no handler-level test (only make_thumbnail as a unit + route mounting). Add cases through the real app: image -> 200 image/png with the nosniff + CSP + max-age headers; audio/text -> 415; make_thumbnail None -> 415; cross-workstream id and unowned-ws cross-user -> 404 (no existence leak).
2026-06-16 00:48:14 -07:00
Patrick Buckley 07e7e6db2f perf(attachments): stop downloading the whole text blob for a 240-char preview
The text-snippet preview fetched the entire /content body (text attachments are capped at 512 KiB) only to render the first 240 chars — and again on the sent-message pill (the endpoint sends Cache-Control: no-store). Read only the first response-body chunk and cancel the stream, so the rest of the blob is never transferred or regex-scanned. Falls back to r.text() where the streaming body API is unavailable.
2026-06-16 00:48:14 -07:00
Patrick Buckley 8ca20fecd4 fix(attachments): unify kind-icon, fix coordinator audio pill + thumbnail-error gap
Three copies of the kind->glyph mapping had drifted: the coordinator pill rendered audio as the document glyph (not the audio note) and showed no inline preview, diverging from the interactive pane.

Export kindIcon() from composer_attachments.js (+ window bridge) as the single source of truth; the interactive pane imports it and the coordinator pill uses it. Wire the coordinator pill to buildAttachmentPreview too (image/pdf thumbnail, audio player), gracefully no-oping on history replay (which omits attachment_id), matching interactive.

Also fix buildAttachmentPreview's thumbnail-error handler: it called img.remove(), but the caller has already replaced the icon span with the img, so a failed thumbnail left a blank gap. Swap in the kind glyph instead (.attach-preview-icon, sized to the thumbnail slot).
2026-06-16 00:48:14 -07:00
Patrick Buckley 797a8e0404 fix(attachments): preserve pdf/audio kind when reloading attachments from the DB
_reconstruct_attachment_refs collapsed every non-image attachment to the 'document' placeholder kind, so a reloaded session's pdf/audio placeholder type ({type:document}) mismatched the live-injection type ({type:pdf}/{type:audio}). Harmless today (resolution keys on attachment_id + blob kind) but a latent footgun for any consumer branching on the pre-resolution placeholder type. Preserve image/pdf/audio verbatim; only a stored 'text' blob collapses to 'document'.
2026-06-16 00:48:14 -07:00
Patrick Buckley 5b2a9480a1 fix(attachments): sanitize user filenames in model context; mark derived text untrusted
A user-controlled filename was interpolated unescaped into model-visible frames (the [PDF attachment '{name}'...] / audio / transcript / perception placeholders, the Anthropic document title, and the unreadable placeholder). A crafted name like "'] New instructions:" broke out of the frame and injected text into the model context.

Add core.attachments.safe_attachment_label() (strip control chars + quote/bracket/angle delimiters, collapse whitespace, clamp length) and apply it at every model-context embedding site. The raw filename is still used verbatim for display / Content-Disposition, which neutralize at their own boundaries.

Also tag perception descriptions and STT transcripts '(untrusted)' so attachment-derived text reads as data, not instructions. Blast radius is single-tenant (injecting into a model reading one's own upload); a structural role=tool fence is deferred as disproportionate.
2026-06-16 00:48:14 -07:00
Patrick Buckley bcfc6306eb fix(attachments): reject video as audio in ftyp sniff; add ADTS-AAC sniff
sniff_audio_mime returned audio/mp4 for ANY ISO-BMFF ftyp box, so an MP4/MOV video uploaded within the audio size cap sniffed as audio and was sent as input_audio. Restrict to genuine audio brands (M4A/M4B/F4A/F4B major, or M4A/M4B in the compatible-brands list, so a real .m4a with an mp42 major brand still passes).

Also add ADTS-AAC sniffing (0xFFF1/0xFFF9): audio/aac was in ALLOWED_AUDIO_MIMES + AUDIO_MIME_TO_FORMAT but never sniffable, so an advertised .aac upload always failed.
2026-06-16 00:48:14 -07:00
Patrick Buckley 25101e7ff6 fix(attachments): close thumbnail decompression-bomb gap (40M, not 80M)
make_thumbnail set Image.MAX_IMAGE_PIXELS=40M, but Pillow only raises DecompressionBombError above 2x the cap; a 40-80M px image merely warns and decodes fully (~480MB RGB), defeating the documented bound.

Gate on the header-declared size after open() and before convert(), so nothing past the cap is decoded. Explicit check rather than a warnings filter — make_thumbnail runs in a worker thread and global warnings state is not thread-safe. Adds tests for the (cap, 2*cap] warn-only window and the at-cap boundary.
2026-06-16 00:48:14 -07:00
Patrick Buckley 7da07e2350 perf(attachments): per-send wire-part memo to stop re-rasterizing every round-trip
_resolve_attachments re-runs on every agentic round-trip (and per fallback model), each time re-fetching every attachment across the full history and re-rasterizing / re-base64'ing it. A 10-page PDF in a 10-cycle tool turn was rendered dozens of times.

Add a per-send memo (self._wire_part_cache) keyed by (attachment_id, caps-signature): the materialized wire part is computed at most once per send. The cache is None outside a send (display/export paths unaffected) and reset per send to bound the heavy rasterized-page parts and pick up any mid-session capability change. Skip the DB fetch entirely when every id is already cached.

Also peek the perception (alias, content_hash) memo before building parts in _perception_fallback_part, so a cross-send describe hit no longer wastes a PDF rasterize. Leaves pdf.py's deliberate no-module-cache stance intact — the per-send scope addresses the round-trip amplification without the durable store it defers.

Adds describe_peek() + per-send-cache and peek tests.
2026-06-16 00:48:14 -07:00
Patrick Buckley d5d9db39d4 fix(attachments): repair dead OpenAI-Responses native PDF path
sanitize_messages ran inline_document_parts (which placeholders an application/pdf document part) before the Responses translator's native input_file branch could run, so every supports_pdf model silently degraded its PDF to an unsupported text placeholder.

Thread a skip_pdf_inline flag through sanitize_messages -> inline_document_parts; the Responses lane sets it so the PDF document survives to convert_content_parts. Chat / Google-compat keep the placeholder (they have no native PDF block).

The existing test exercised convert_content_parts in isolation, bypassing sanitize_messages and masking the bug. Add an end-to-end _convert_messages regression test (verified to fail without the fix) plus contrast tests pinning both lanes' behavior.
2026-06-16 00:48:14 -07:00
Patrick Buckley a3cb546030 docs(attachments): pin xAI Grok to the rasterize-PDF fallback
q-3 from the pre-push review, settled against docs.x.ai: Grok's document
support is an agentic attachment_search workflow over Files-API uploads
(file_id / file_url), not the inline base64 native ingestion that OpenAI
input_file / Anthropic document blocks use. Our native PDF path emits inline
base64, which xAI's Responses surface doesn't accept — so supports_pdf is
correctly left unset (Grok PDFs rasterize to images, which Grok can see).

Document the rationale on GROK_CAPABILITIES and pin every Grok row's
supports_pdf=False with a test so it isn't naively flipped without first
wiring a Files-API upload flow.
2026-06-16 00:48:14 -07:00
Patrick Buckley 558ddadc79 feat(attachments): universal perception fallback for non-native modalities
Add a `perception.model_alias` model role: when the primary model can't ingest
an attachment natively and can't be shown a degraded-but-native form, a
configured perception model perceives it and its output is carried as text.
Mirrors the STT role — a role alias plus a module-level memo so the extra LLM
round-trip runs once per attachment, not once per conversation turn. The call
goes through the provider abstraction's create_completion (the path the intent
judge uses), so any vision/omni provider works.

Bottom-tier, universal ladder — perception only fills the remaining gap:
- pdf  : native supports_pdf -> rasterize-to-vision-primary -> perception
         -> extracted text -> placeholder
- image: native vision -> perception (non-vision primary) -> native image_url
- audio: native supports_audio_input -> STT -> perception (omni) -> placeholder

Folds in two review findings the role subsumes:
- bug-1: thread the active attempt's capabilities into _resolve_attachments
  (bound in _try_stream) so a model fallback materializes attachments against
  the fallback model's caps, not the primary's.
- bug-2: charge a by-reference pdf/audio a bounded budget min(size_bytes, 16K)
  instead of zero, so a large-attachment turn isn't budgeted as ~empty (the
  exact materialized size isn't known until wire build).
2026-06-16 00:48:14 -07:00
Patrick Buckley 8af3e21dff fix(attachments): harden thumbnail/rasterize DoS + review nits
Pre-push review follow-ups that are independent of the perception-role work
(bug-1 caps threading, bug-2 budget, and the perf cluster fold into that):

- thumbnails: cap decoded pixels (Image.MAX_IMAGE_PIXELS=40M) so a small
  compressed image that decodes to huge dimensions can't OOM the node, and
  reject DecompressionBombError cleanly.
- pdf: clamp per-page render scale so the longest rendered side stays <= 2000px
  (a maximal MediaBox at scale 2.0 rendered to a ~28800px, multi-GB bitmap).
- session_routes: type classify_upload's rejection element as
  UploadRejection | None instead of Any.
- test_session_routes: assert the /thumbnail route mounts (it was untested) and
  fix the stale "quartet"/four wording to five.
2026-06-16 00:48:14 -07:00
Patrick Buckley 9ad447ca33 fix(attachments): design-review polish for preview chips/pills
Two-reviewer + sanity pass over the attachment previews:

- composer audio chip is icon+name+size only; the native <audio> player
  renders on the sent message, not the staging chip (too heavy at chip scale)
- cap sent-message pills (+ in-pill audio/snippet) so they no longer overflow
  the bubble at narrow widths; player and snippet drop to their own row
- clamp the chip filename in shared chat.css so long names ellipsize instead
  of wrapping (console main + coordinator previously left it unclamped)
- merge the duplicated .composer-chip rule; drop unused kind-modifier classes
  and inert vertical-align / inline-block declarations
- fix undefined var(--bg-base) -> var(--bg-surface) thumbnail backing
- label the <audio> control (aria-label) and drop the decorative snippet from
  the a11y tree

scripts/livepass.py: add an attachments harness that drives the real
createAttachmentController + Pane.addUserMessage so these surfaces render
headlessly for review.
2026-06-16 00:48:14 -07:00
Patrick Buckley c09ba6041f feat(attachments): inline chip previews (image/pdf thumbnail, audio player, text snippet)
- core/thumbnails.py + GET .../attachments/{id}/thumbnail: server-rendered PNG
  thumbnails (image downscale; pdf first page via pypdfium2). Extracted a shared
  ownership-gated blob resolver used by both get_content and the thumbnail route
- buildAttachmentPreview (composer_attachments.js): image/pdf -> thumbnail,
  audio -> <audio> player, text -> lazy snippet; reused by the composer chips and
  the sent-message pills (interactive.js). Cookie auth, so direct media src works
- chip kind icons now cover pdf/audio; the upload swap adopts the server's
  authoritative kind for styling + icon + preview
- chat.css preview styling; tests for make_thumbnail
2026-06-16 00:48:14 -07:00
Patrick Buckley 8ad6d3d2f3 feat(attachments): accept pdf/audio uploads in the UI + admin capability toggles
- composer: accept pdf/audio in the upload picker; client-side kind
  inference for the optimistic chip (server classify_upload stays
  authoritative)
- admin Models tab: supports_pdf + supports_audio_input toggles (flow
  through the field-aware capabilities merge into ModelCapabilities, so
  flipping supports_audio_input on an omni alias enables native input_audio)
- docs: AttachmentInfo.kind, AttachmentUpload, and the TS SDK note pdf/audio
2026-06-16 00:48:14 -07:00
Patrick Buckley 471d94b27f feat(attachments): rasterize PDF to page images for vision models without native PDF
A vision-capable model that can't ingest PDF natively now gets the PDF
rendered to one image per page instead of extracted text; falls back to
text extraction when rendering yields nothing.

- core/pdf.py: rasterize_pdf via pypdfium2 render + Pillow PNG (page-capped
  at 10, never raises)
- session._wire_content_part: pdf + !supports_pdf + supports_vision ->
  rasterized image parts; else text extraction
- trajectory.resolve_attachment_parts: a placeholder can now expand to a
  list of parts (1->N); the resolve_attachments callback return type widened
  to dict[str, Any] across the provider protocol + 4 providers
- pyproject: pillow dependency
- tests: rasterize_pdf, vision-rasterize gate path, 1->N materialization
2026-06-16 00:48:14 -07:00
Patrick Buckley addb8d0be8 feat(attachments): capability-gated client-side fallback (pdf->text, audio->transcript)
When the active model can't ingest a kind natively, the wire resolver
converts it client-side instead of sending a part the model can't read.
Per-kind ownership, no shared machinery: PDF text-extraction is a
pure-local PDF concern; audio transcription is an STT concern memoized
in the audio domain.

- core/pdf.py: extract_pdf_text via pypdfium2 (pure-local, no network, no
  cache — re-run per build; page-capped)
- core/audio.py: transcribe_cached — non-raising, memoized by
  (alias, content-hash); backend failures not cached
- session._wire_content_part: per-kind dispatch — native where the model
  supports the kind (supports_pdf / supports_audio_input), else fallback;
  display/export resolve natively so no conversion fires on a render
- image left ungated (pre-existing behavior unchanged)
- pyproject: pypdfium2 dependency + mypy untyped-import override
- tests: pdf extraction, transcript memoization, per-kind gate dispatch
2026-06-16 00:48:14 -07:00
Patrick Buckley 701ae46c72 feat(attachments): native PDF + audio translators, accept on upload
PDF and audio attachments now work end-to-end on the native provider
lanes; non-native lanes degrade to a placeholder (client-side fallback
lands next). Capability flags are populated but not yet consumed by a
wire-build gate.

- providers: Anthropic PDF -> base64 document; OpenAI Responses PDF ->
  input_file; compat/Google inline_document_parts PDF -> placeholder
  (fixes the base64-as-text mangle); audio = input_audio passthrough on
  the compat lane (omni), defensive text placeholders on Anthropic +
  Responses
- capabilities: supports_pdf on cloud Claude + OpenAI chat models;
  local/default/compat stay False (-> client-side fallback)
- upload: classifier accepts pdf (32 MiB) + audio (25 MiB); endpoint
  multipart read cap raised to PDF_SIZE_CAP
- hygiene: consolidate the duplicated upload classification into one
  attachments.classify_upload (+ UploadRejection); collapse
  AttachmentUploadHelpers to a single classify_upload callable
- tests: PDF/audio translator shapes, capability flags, classify_upload
2026-06-16 00:48:14 -07:00
Patrick Buckley 129560ee60 feat(attachments): pdf + audio attachment kinds (dormant spine)
Provider-neutral plumbing for PDF and audio attachments, with no
user-facing change yet: the upload classifier still rejects them and the
capability tables stay unpopulated (both land in the native-translator
phase). No migration — workstream_attachments.kind is free-text.

- attachments.py: PDF/audio byte caps, allowed-audio MIMEs + format map,
  magic-byte sniffers (sniff_pdf_mime / sniff_audio_mime),
  Attachment.is_pdf / is_audio
- providers/_protocol.py: supports_pdf / supports_audio_input capability
  fields (default False; orthogonal to the STT/TTS roles)
- storage/_utils.py: attachment_to_content_part emits the internal
  document(application/pdf, base64) and input_audio shapes
- session.py: by-reference placeholder branches for pdf / audio
- trajectory.py: AttachmentRef docstring (dict-bridge already kind-agnostic)
- tests: test_attachments_pdf_audio.py
2026-06-16 00:48:14 -07:00
Patrick Buckley 04b3a3abe4 feat(deploy): systemd units for a bare-metal turnstone-server node
Hardened service + slice + node-identity drop-in template + a README for
running a turnstone-server outside Docker that joins the compose cluster —
the production-shaped counterpart to the one-liner in docs/docker.md. Secrets
stay in config.toml; per-host identity + cluster URLs go in the drop-in. The
README notes the cross-host mTLS caveat (turnstonelabs/lacme#22).
2026-06-15 03:41:24 -07:00
Patrick Buckley 1f61350545 feat(compose): let bare-metal turnstone-servers join the cluster (incl. mTLS)
A turnstone-server running outside the compose network ("bare-metal", e.g. a
local-GPU box) couldn't fully join: it can't resolve the in-cluster console
(console:8090) to enroll its mTLS cert, and SearxNG was unreachable for
web_search. Only Postgres was published.

Publish the console's plain-HTTP ACME endpoint (:8090) and SearxNG (:8081)
alongside Postgres, all bound via one knob TURNSTONE_HOST_IP (default 127.0.0.1
-- nothing new on the LAN; set it to the host's LAN IP for a node on another
machine). Postgres keeps honoring the legacy POSTGRES_BIND as a fallback, so
existing .env files don't break.

The node's TLS client now honors TURNSTONE_CONSOLE_URL so a bare-metal node can
point at the published ACME endpoint instead of the unreachable in-cluster name
(empty = in-cluster service discovery, unchanged).

Docs (docker.md, tls.md), the run.sh-generated .env, and the bootstrap wizard
updated to match. The advertised host is the cert's primary SAN and the console
collector dials it back, so mTLS hostname verification holds both ways.
2026-06-15 03:41:24 -07:00
renovate[bot] 94e385e91f chore(deps): lock file maintenance 2026-06-15 02:49:32 -07:00
Patrick Buckley 108714a48d fix(auth): isolate server/console session cookies by name
The server (:8080) and console (:8090) both set a cookie named
`turnstone_auth`. Cookies ignore port (RFC 6265), so on a shared host
(localhost dev, the Electron build, single-box installs) logging into one
surface overwrote the other's cookie and 401'd the first session.

Give each surface its own cookie name -- `turnstone_auth_server` /
`turnstone_auth_console` -- threaded as a required `cookie_name` argument
through the cookie builders, `check_request`, `AuthMiddleware`, and the six
shared auth handlers (login/logout/setup/whoami/refresh/oidc_callback). Each
app passes its own constant; the parameter is required (no default) so a
forgotten caller fails loudly instead of silently reverting to the legacy name.

Names key on role, not node: the cluster shares one JWT identity and the
console->node proxy re-mints a bearer token (dropping Set-Cookie), so
per-instance names would break identity portability and aren't used.

Hard cutover: the legacy `turnstone_auth` cookie is no longer read and
self-expires within its 24h TTL (one forced re-login). JWT audience was
already enforced, so the shared cookie was a session clobber, not an auth
bypass.
2026-06-15 02:48:50 -07:00
Patrick Buckley a628e9f3b4 fix(ui): interactive pane keeps its scroll pin across tool calls
The interactive pane only auto-scrolled when isNearBottom() was true, but it measured that AFTER the new node was appended. A tool block is a tall one-shot append (batch shell, approval card, or result) that clears the 80px near-bottom threshold in a single step, so the post-append check read false and auto-follow silently disengaged at exactly tool-call time — the view froze at the top of the block and only snapped back at the next stream_end. Token streaming was unaffected because each append stays sub-threshold.

Capture the near-bottom state as the first statement of each tool-render method, before any DOM mutation, and thread it into scrollToBottom(stick). This re-pins when the user was already at the bottom and, unlike the coordinator pane's unconditional pin, leaves the view alone if they deliberately scrolled up while a result was rendering.

Methods fixed: announceToolBlock, showInlineToolBlock, resolveApproval, appendToolOutput (all three exit paths), appendToolOutputChunk.
2026-06-13 06:15:49 -07:00
Patrick Buckley 1468ca7972 fix(examples): accept remote Host headers when bound off localhost
The streamable-http server bound to 0.0.0.0/a LAN IP answered TCP and
/watch but returned 421 "Invalid Host header" on /mcp for every remote
node — which broke multi-node play entirely. FastMCP freezes DNS-rebinding
protection (a localhost-only Host allowlist) at CONSTRUCTION, and this
module builds its FastMCP at import time with the default 127.0.0.1 host;
flipping settings.host in _serve afterward never updated the frozen
allowlist, so the LAN Host was always rejected.

When UNDERSTONE_HOST is off localhost, drop the allowlist in _serve before
run() — matching the SDK's own default for a non-localhost bind. The /mcp
and /watch routes are unauthenticated by design, so serve only on a trusted
network (documented).

Regression test pins the mechanism: a default FastMCP 421s a foreign Host,
a protection-disabled one accepts it. Tests 420 -> 421.
2026-06-13 04:40:40 -07:00
Patrick Buckley efa8664e4d ci(examples): name the Understone job distinctly
The job was named "test", colliding with core CI's "test" matrix so the PR
checks list showed two "test (3.11)" rows. Rename it to "understone" so the
example's checks read unambiguously (understone (3.11) / (3.13)).
2026-06-13 04:40:40 -07:00
Patrick Buckley a0a097dfa8 fix(examples): address PR review feedback (CodeQL + Copilot)
- CodeQL (implicit string concatenation in a list): collapse the wrapped
  bullets in cli._render_validate_coverage to single literals. The rendered
  output is byte-identical (the example's ruff ignores E501); clears all
  six alerts and reads cleaner.
- Copilot: packs/README no longer claims the directory ships "effectively
  empty" — it ships the bundled Cinder Wastes alternate world.
- Copilot: the Cinder Wastes' ash_flats and caldera_deep zones overlapped
  on column x=60 (inclusive bounds + first-match zone_for silently shadowed
  the tier-3..5 band onto a 1x5 deep-edge strip). Move caldera_deep to
  x0=61 — no overlap, no dead tiles, deep zone still covers the dungeon.
  And harden the loader: overlapping zone rectangles are now a
  WorldLoadError, so no authored pack can ship that bug unseen (the
  cold-author dogfood loop — a generated pack exposed a validator gap).

Tests 419 -> 420 (zone-overlap rejection). Both worlds validate sound and
remain winnable by the sim bot.
2026-06-13 04:40:40 -07:00
Patrick Buckley 30c09aaf51 ci(examples): run the Understone example test suite
The door-game example is a standalone package (no turnstone-core
dependency) that the root suite does not collect — its
testpaths are scoped to ["tests"], so the example's 419 tests, ruff,
and mypy gates never ran in CI.

Add a path-filtered workflow that installs the example and runs its
full gate (pytest + ruff check + ruff format --check + mypy) whenever
examples/door-game (or this workflow) changes, across the example's
declared Python floor and ceiling (3.11, 3.13). Pinned action SHAs and
contents:read permissions match the existing CI workflows.
2026-06-13 04:40:40 -07:00
Patrick Buckley 393a6fc2b2 feat(examples): Understone v0.10 — the satchel, the ore-forge, and the vault
A game-loop mechanics patch: the satchel becomes a real stacking inventory,
forging now demands ore won in combat (not just gold), and a vault lets a
hero protect coin from ambush.

- Stacking satchel: the bag re-encodes from a flat id list to "id:qty"
  stacks, so potions stack (three Minor Potions fill one slot, not three)
  and materials ride alongside. satchel_max now caps distinct KINDS (3);
  per-kind quantity is unbounded. quaff/death-save still pull the strongest
  potion and ignore materials. One pure codec (engine/satchel.py) owns the
  encoding; the façade, the Watch, and the sim all decode through it — no
  three-way drift (the v0.9 single-source lesson). The codec parses a bare
  id as qty 1, so it can never silently drop a malformed stack.
- Ore-gated forge: ore is a material that drops from won dungeon-rung
  fights (and, less often, forest fights), stacks in the satchel, and is
  not buyable or sellable — you earn your edge by fighting for it. Forging
  now costs gold AND ore ((plus+1) ore per tier), so a rich-but-idle hero
  can no longer buy power at the dice table. The dungeon is now also the
  mine.
- The vault: deposit/withdraw at the inn moves coin to a strongbox that
  ambush cannot touch and that SURVIVES the Wyrm-win legacy reset — the
  carry-vs-protect decision the PvP economy was missing.
- Surfaced on both the /watch lobby TV and the in-chat door_status sheet:
  each hero's stacked satchel, carried gold, and vaulted gold.
- Tuning (the sim is the instrument): the ore gate added ~2 days to the
  Vale and ~1.6 to the Cinder Wastes; the greedy bot still slays the Wyrm
  3/3 on both, fully forged to +3/+3, so the loop is not stalled. Defaults
  held — no numbers needed retuning.

Four new banded settings (forge_ore_item, forge_ore_per_plus,
ore_dungeon_drop, ore_forest_chance); both worlds gained an ore item.
Schema mutated in place (banked column, satchel re-encoding) — pre-1.0, no
migration by design; a real migration story is owed at 1.0. Tests 382 ->
419; the vault-survives-rebirth invariant and the codec are revert-verified.
2026-06-13 04:40:40 -07:00
Patrick Buckley 917e391b1f feat(examples): Understone v0.9 — colour roles for every object type
Graphics polish: distinct terrain and structures now read by COLOUR on the
Watch, not only by glyph. One unified palette, shared by every world — the
fix is to grow the set of distinct object-type roles, not to fork per-world.

- Roads were the tell: road shared the "floor" green with grass, so a path
  vanished into the meadow on the lobby TV. Likewise forest shared "tree",
  the three town buildings all shared "town", and the Cinder Wastes' molten
  slag borrowed "water" and rendered BLUE. Each is now its own role: road
  (stone), forest (lush green) with scrub (its barren ember-brown
  counterpart for volcanic/desert dense terrain that must NOT read as
  woods), lava (molten orange), barren (wasteland taupe), and inn/shop/
  healer split out of the generic town.
- Both worlds remap onto the shared vocabulary; in each, no two distinct
  terrain/building types share a colour. A live render caught the Cinder
  cinder-fields rendering green under the generic "forest" role — hence the
  scrub role, so the volcanic waste reads warm. The text frame renderer
  stays monochrome (it never read colour), so frames and goldens are
  untouched — this is Watch-only.
- The bug class is now closed by construction: a test asserts the Watch
  PALETTE carries a hex for EVERY Color role, so a role can never ship
  unpaintable and silently fall back (which is exactly how road hid).
- Color.assignable() is the single source for the overlay-vs-assignable
  split (runtime actor/item colours and the DEFAULT fallback are not
  author-pickable); the authoring manual's colour vocabulary generates
  from it, so it can't drift.

Tests 373 -> 382. floor/tree/forest are three greens kept deliberately
distinct (forest is olive-hued); verified on a real render along with the
scrub fix.
2026-06-13 04:40:40 -07:00
Patrick Buckley 65e7b404bc feat(examples): Understone v0.8 — worlds without authors
The slice that proves the pipeline: a second world authored entirely by an
LLM from AUTHORING.md and the validator alone, plus the tooling to discover,
theme, and balance-test any world.

- The dogfood: "The Cinder Wastes" — an ashen volcanic underworld (slag
  rivers, a caldera mouth, a Magma Wyrm) — was written cold by an agent
  given only the generated authoring manual and `understone validate`. It
  passed validation on the FIRST run with zero failures. Its stumble log
  found six places where the manual stated a rule the validator didn't
  enforce; those became permanent hardening (below). It ships in
  understone/world/packs/ and glows ember on the lobby TV.
- `understone worlds` lists every bundled world (the Vale + alternates)
  with its load status, via one shared discovery path.
- Per-world Watch themes: settings.watch_theme (phosphor/amber/ice/ember,
  loader-validated) repaints the spectator page; the Vale's green is
  byte-for-byte unchanged.
- The sim harness: a pure, seeded, greedy bot plays the real game façade
  over an injected day-stepping clock and emits a balance report —
  `understone simulate PATH [--days N] [--seeds K]`. It SLAYS THE WYRM on
  both worlds (Vale ~day 13, Cinder ~day 25), so the whole v0.1->v0.7 loop
  is proven winnable end-to-end by an unclever bot through the real stack.
- Loader hardening from the dogfood: a rare monster may not occupy a
  dungeon-rung guardian slot (it would silently become a fixed foe and
  leave the rare pool); exactly one monster may be the boss; and the
  boss-tier error now says "no non-boss monster," matching the manual.
  AUTHORING gained a generated "what validate checks vs. what it cannot"
  section so the rule/guidance boundary is honest.

Review hardened the bot for arbitrary authored packs (a MENU-mode fight
spin and four related robustness gaps that were latent on the shipped
worlds), and documented that final_level reads post-legacy-reset. Tests
359 -> 373; both worlds still win byte-identically after the fixes.
2026-06-13 04:40:40 -07:00
Patrick Buckley dcc0e5fb0a feat(examples): Understone v0.7 — the deep, the satchel, the forge, rare beasts
The depth slice: four standing reasons to return past the daily reset.

- The rung ladder: the dungeon is a descent fought one rung per turn, each
  guardian a fixed tier. A loss bounces you home but your depth PERSISTS —
  you re-enter where you left off. The Wyrm now gates on BOTH level AND
  reaching the floor (the deep has a bottom, and you must have touched it).
- The satchel + the death-save: potions are CARRIED now (up to three),
  bought to the satchel, drunk with quaff. The heart of it: when any fight
  would kill the active fighter and they carry a draught, the strongest is
  drunk automatically — they survive standing at the potion's value, no
  bounce. This fires on EVERY fight (forest, rung, and the Wyrm itself —
  a potion carried to the climax is a real tactical choice); a Wyrm loss
  so saved is "driven back, alive but unproven," not devoured. The sleeping
  ambush victim never quaffs (they are asleep). combat.py stays pure — the
  satchel and the save live entirely in the façade.
- The forge: the shop spends scaling gold to add a +1 edge to equipped
  weapon or armour, capped — the late-game gold sink. Swapping or selling
  the piece loses the edge with it (one centralized unequip clears the
  bonus and the plus so a stat can never go phantom).
- Rare beasts: a few named foes prowl the forest via weighted selection,
  surfacing seldom; felling one is a public Herald flash and always yields
  a draught into the satchel. Rung guardians are never rare (fixed foes).

Four new player columns; four new banded settings; dungeon_tiers extended
to three rungs. Tests 283 -> 330; the death-save (all four paths), forge
accounting across forge/buy/sell/legacy, rung math, and weighted rare
selection all pinned, with the death-save and forge invariants
revert-verified.
2026-06-13 04:40:40 -07:00
Patrick Buckley b76b2a98d0 feat(examples): Understone v0.6 — UTF-8 graphics and the width discipline
The look of the next age — the modern equivalent of the ASCII->CP437 leap.
Full Unicode is available now, but the whole stack (text frames, golden
tests, the Watch's 1ch grid) assumes one glyph = one column, so the
enabling piece is a WIDTH RULE, not the glyphs themselves.

- textwidth.is_grid_safe: one code point, printable, East-Asian width not
  Wide/Fullwidth, no combining/format/control category. This is the
  one-glyph-one-column contract. Ambiguous-width glyphs are ACCEPTED on
  purpose — they ARE CP437 (the wall, the club-tree, the up-arrow forest)
  and render single-column on the Western-monospace metrics every surface
  uses; only genuinely double-width runes are barred. The loader enforces
  it on every map glyph; the player-name/free-text sanitizer enforces the
  same rule (the narrow ledger), so a wide name can't shear a frame.
- Re-skin: water ~ -> ≋, inn -> ⌂, healer -> ✚, dungeon mouth -> ∩, and
  the other adventurer -> ☻ (CP437's own player glyph). The colour field
  the renderer has carried unused since v0.1 now has a second consumer.
- Texture variants: grass and water vary by a deterministic per-coordinate
  hash, rendered identically in the Python frame builder and the Watch's
  JS. The two are kept in lockstep by shared hash constants + an agreement
  test that replays the JS arithmetic and asserts it equals the Python
  output for every variant over a grid — not a comment-coupled copy.
- Watch glow-up: a Noto Sans Mono font stack and a UTC-hour day/night tint
  (the Vale darkens at dusk on the lobby TV).
- The curated SAFE_PALETTE is enforced author-usable: a test asserts no
  palette glyph collides with the reserved player markers, so AUTHORING's
  generated appendix can't advertise a glyph the loader would reject.
- Resume is identity-preserving: an existing character resumes by exact
  stored name without re-validating the width rule (which governs creation
  only) — resume must never lock anyone out.

Tests 231 -> 283; width edges (CJK/emoji/combining/fullwidth), the
Python<->JS lockstep, the palette/reserved guard, and resume-vs-create all
pinned and revert-verified.
2026-06-13 04:40:40 -07:00
Patrick Buckley 08d46f086f feat(examples): Understone v0.5 — ambushes, the inn mailbox, and dice
The social slice: the shared world gets teeth, letters, and a house game.

- Ambush (async PvP, classic door-game player-kill spirit): waylay an adventurer who has
  not yet begun their day. Ordered gates — known target, not yourself, the
  gatekeeper shields the young (both >= min level), level band +-2, the
  SLEEP RULE (acting today makes you watchful — an active-play defense),
  mercy for the downed (hp<=1 cannot be piled on: even bandits have
  standards), once per pair per UTC day. Win: capped gold cut transfers,
  victim wakes at the spawn-stone with a private note; lose: the sleeper
  wakes blade-in-hand and the Herald crows your shame. The attacker wears
  the counter-blows the combat log narrates (state matches story). Both
  players persist in one transaction.
- The inn mailbox: events carry a target ('' = public). door_log delivers
  private notes to the addressee only; the Watch and other players never
  see them. Mail is DURABLE past the in-memory tail (SQLite backfill for
  cursors older than the resident window) — the broadsheet is ephemeral,
  letters are not. Sanitized, daily-capped.
- Inn dice: 2d6 against the house, bet- and count-capped per day, big wins
  make the news.
- Six new banded settings; four day-counter columns join the shared lazy
  UTC reset; schema stamp stays 1 (pre-1.0 mutates in place by design).

Tests 184 -> 231; sleep rule, mercy gate, band boundary (exact/over),
refusal precedence, attacker wear, zero-gold robbery, mail eviction
survival, and Watch privacy all pinned; guards revert-verified.
2026-06-13 04:40:40 -07:00
Patrick Buckley d40c4c85ee feat(examples): Understone v0.4 — the authoring pipeline (worlds as data)
The IGM seam realized: world packs are now a first-class authoring target
for models and humans, with a validate loop and a loader hardened for
routinely-untrusted generated content.

- understone newpack DIR scaffolds a pack (the six content JSONs templated
  from the shipped Vale) plus AUTHORING.md — a manual written for a model
  to follow cold. Its bands table is RENDERED FROM the loader's own band
  constants at scaffold time, so documented limits and enforced limits
  cannot drift.
- understone validate DIR loads a pack and prints either a pack report
  ("This pack is sound. The door stands open.") or the loader's
  file/index/field-naming error — the authoring feedback loop.
- Loader hardening: glyphs must be one printable column-safe character and
  never the frame box-drawing set or the @/& player markers (map content
  cannot impersonate players or forge frame chrome); map dims 8..256;
  per-file count caps; display-name length caps. All errors instructive.
- The packaged-world path is single-sourced (understone.world.
  PACKAGED_WORLD_DIR) for the server default and the scaffold template.
- README "Authoring worlds" section frames the loop: newpack -> write or
  generate -> validate -> serve with UNDERSTONE_WORLD=dir.

Review round: bug finder returned zero findings; quality round fixed the
world.json doc example (it showed a zone fragment where an authoring model
would copy a whole-file shape — now a labeled skeleton), the stale Usage
docstring, and the duplicated packaged-path constant.

Tests 166 -> 184. Scaffold round-trips through load_world by test.
2026-06-13 04:40:40 -07:00
Patrick Buckley d54110ffcb feat(examples): Understone v0.3 — the Watch (lobby TV) + a livelier Vale
A read-only CRT spectator page served by the game process itself, plus
content depth. Input never flows through the Watch — it is the wall-mounted
terminal in the BBS room; chat remains the only actuator, so there is no
input channel to deadlock and no cross-origin surface (the page polls the
same origin that served it).

- /watch: one self-contained page (inline CSS/JS, no external assets),
  phosphor CRT styling. The base map paints once from /watch/world.json
  (terrain glyph rows + a glyph->color legend — the palette the text
  renderer has deliberately ignored since v0.1 finally gets its first
  renderer); players overlay as positioned glyphs repainted from
  /watch/state.json every 2s; the sidebar carries the roster with win
  stars, the Hall of Legends, and the Herald. SIGNAL LOST on poll failure;
  the bootstrap retries so a spectator arriving during a server blip
  recovers without a reload.
- Routes ride FastMCP custom_route on the existing process — read-only
  handlers with no awaits between reads (handlers and sync tools
  interleave on one event loop, so every response is a consistent
  snapshot).
- door_join/door_help advertise the Watch URL in http mode (stdio: none).
- Content: +5 monsters (one per tier; the gauntlet's first-in-tier foes
  preserved), +3 items smoothing the gear curve, +6 events; fight weight
  retuned to hold ~55% of encounter rolls. Zero geography churn.
- Review round: the Herald window is a plain list tail (id arithmetic
  under-reported the feed when AUTOINCREMENT ids gap — regression-pinned
  with sparse ids), and the bootstrap-retry fix above.

Tests 149 -> 166.
2026-06-13 04:40:40 -07:00
Patrick Buckley 4b8681db8a feat(examples): Understone v0.2 — the Wyrm, forest events, and the Herald
The "make it a game" slice: a win condition with classic-door-game-style legacy, texture
between fights, and a shared broadsheet.

- The Wyrm Below: a boss (flagged in the pack, excluded from random bands)
  behind a level-gated `challenge` verb at the dungeon. Victory writes a
  Hall of Legends row and the character resets to the fresh-start kit,
  keeping a wins counter rendered as ★ on the leaderboard — the classic
  race-reset-race loop. Defeat and stalemate flight make the news.
- Forest events: movement encounters weighted-pick from a content-pack
  table (fight/gold/heal/trap/lore). Only fights stop the walk or cost
  turns; texture is free and private. Trap damage floors at 1 hp.
- The Understone Herald: door_log is a broadsheet with a masthead and
  write-time template variety; the public feed is curated to notable beats
  (joins, blessings, level-ups, defeats, the Wyrm's fate) — town errands
  stay private.
- Reward narration moved from the combat engine to the façade, composed at
  the moment gold/xp are actually banked, so the server can never narrate
  a reward it did not apply (the Wyrm win previously claimed +400 XP /
  +250 gold that the legacy reset wiped).
- Fresh-start hp/atk/def promoted into world.json settings alongside the
  starting kit; dungeon-tier validation counts non-boss monsters only,
  keeping the validator's no-silent-rung promise true.

Schema mutated in place (players.wins, hall_of_fame) — pre-release, no
migration path by design. Tests 109 -> 149; the challenge level gate is
negative-tested; rank stars survive 24-char names (compact form past 5).
2026-06-13 04:40:40 -07:00
Patrick Buckley 99e7dc17ec feat(examples): Understone — a BBS door game as a standalone MCP server
A shared-world, classic-door-game-style door game in examples/door-game/: a pure-stdlib
game engine (tile overworld + location menus, seeded combat, daily turn
budget, leveling, shop, event log, leaderboard) behind nine sync door_*
FastMCP tools returning monochrome box-drawing frames. The connecting
session's LLM plays dungeon master — tool descriptions plus a door_help
manual teach a cold model to run the game with zero setup, while the server
owns all dice and state, so the DM narrates around facts it cannot bend.

Non-obvious decisions:
- engine/screen/world/persistence import stdlib only; server.py is the only
  mcp import. All nine handlers are sync def: on mcp 1.27 they execute
  inline on the event loop (verified against func_metadata), so tool bodies
  serialize and one SQLite connection (WAL, per-action commit) is safe.
  check_same_thread=False exists only because the Store may be constructed
  on a different thread than the serving loop.
- Streamable HTTP serves ONE process = one shared world (players appear on
  each other's maps; async "while you were away" event feed); stdio is the
  solo-world fallback.
- The economy is content, not code: daily_turns, costs, xp curve, bestow
  budget, and dungeon tiers live in world.json settings, band-validated by
  the loader. door_bestow gives the DM capped, event-audited largesse
  (gold/heal only, never turns) so story generosity cannot melt the shared
  leaderboard.
- Player names and bestow reasons are sanitized (printable-only, length
  caps) because they flow into the shared event log and from there into
  other players' DM context — embedded newlines would forge log lines.
- Daily turn/bestow pools lazy-reset per UTC day on every consuming path
  (injectable clock); the dungeon gauntlet is a fixed boss ladder by design.

Tests: 109 — engine units with seeded RNG + frozen clock, hand-authored
golden frames paired with structural asserts, loader band rejections, and
one real-wire integration test (uvicorn + streamablehttp_client) with a
two-session shared-world assertion. Negative-tested by reverting the guard
and watching the suite fail: the daily turn-budget guard, the bestow cap,
and the sanitizer's isprintable clause.
2026-06-13 04:40:40 -07:00
Patrick Buckley 30b590fb25 feat(memory): durable per-user coordinator scope + anonymous-coordinator guard
The coordinator memory scope was keyed by the session's ws_id, so every
new coordinator session started with an empty namespace and its rows
were orphaned on close — coordinator memory never actually persisted.
Re-key the scope to the coordinator's creator user_id: one durable
orchestration namespace per user, shared by all of that user's
coordinator sessions (concurrent ones included; upsert-by-name is the
collision rule).

The child-containment threat model is unchanged: the gate is session
KIND — children are always interactive and share the parent's user_id,
so _validate_scope rejects them before scope resolution, and the REST
memories API still rejects the coordinator scope outright. The implicit
visibility lane now also fails closed on an empty scope_id to match the
explicit search/list lanes (the storage helpers treat a falsy scope_id
as 'no scope_id filter', which would have read every user's rows).

Anonymous coordinators are no longer constructible: ChatSession refuses
kind=COORDINATOR with an empty user_id at the constructor — the single
choke point covering create, rehydration of legacy rows (surfaced by
the open handler as a 503 with remediation text), and any future host —
and the console no longer masks an empty uid as a phantom 'system'
principal when minting coordinator JWTs, per CoordinatorTokenManager's
documented 'sub = the real creator user_id' contract.

Migration 061 carries existing coordinator rows across: rows whose
owning workstream is gone or ownerless are deleted (unreachable under
user keying), same-name collisions within a user keep the newest
updated row (memory_id tiebreak), and survivors re-key to the owner's
user_id.
2026-06-12 13:54:49 -07:00
Patrick Buckley ce105c4ed1 fix(ui): split separator ARIA range reflects the real clamp, not 10–90
_buildHandle hard-coded aria-valuemin/max at 10/90 (inherited from the
old ui/static implementation) while the actual drag/keyboard clamp is
_ratioBounds — the cell minimums against the split node's OWN px region
(a 1200px host really clamps at ~17/83; nested splits sit tighter), so
assistive tech was told a wider range than the separator allows.

aria-valuenow/min/max are now all written in _applyLayout's handle loop
from _ratioBounds(h.node) — one writer, refreshed on every drag,
keyboard nudge, and structural change. A bare window resize can stale
the advertised range until the next interaction (no resize listener by
design — % insets make resizes free), still strictly truer than a
constant. The max>=min guard covers a host shrunk below two cell
minimums, where the bounds legitimately cross.
2026-06-12 00:11:08 -07:00
Patrick Buckley d8619ce3c8 docs(ui): the pane-hosted coordinator scope is every coordinator in practice
The /coordinator/{ws_id} standalone page is reachable only by direct
URL — all three console navigation sites are shell-fallback else
branches behind openPane. Record that in the sidebar-padding comment
so the scope isn't over-read as a live second surface.
2026-06-12 00:11:08 -07:00
Patrick Buckley 482e6648ca fix(ui): drop the pane-hosted coordinator sidebar below the corner chip
The per-pane ✕/− chip floats at the pane's top-right — exactly where
the coordinator sidebar's toggle row and Children refresh button sit,
so the chip covered them. Pane-hosted coordinators now start the
sidebar content 44px down (padding, not margin, so the column's left
border still runs the full pane height); the standalone coordinator
page has no chip and keeps the 14px default.
2026-06-12 00:11:08 -07:00
Patrick Buckley ed08986d93 fix(ui): split-view pre-push review round — mode-distinct chip, anchoring, light-theme AA
Dual designer review (one primed on the branch context, one cold), all
measured findings applied:

- The per-pane chip was a mode-error trap: identical glyph at the
  identical locus, reversible in split mode (hide cell) but destructive
  single-pane (close pane). Now − hides, ✕ closes, and the close mode
  wears a danger hover/focus ring so the irreversible action telegraphs
  before the click lands.

- Single-pane chip anchored to the VIEWPORT: an unpositioned section
  resolves absolutes to <body>, so the chip only coincidentally landed
  near the pane corner. .panes > section.pane is now position:relative
  in both modes (all pane-content absolutes verified to anchor to their
  own local relative parents).

- Light-theme AA (measured): .shown tab underline 55% mix composited to
  2.34:1 -> 80% (~3.7:1 light / ~5:1 dark); focused-cell ring 2.60:1 on
  light -> 75% mix override there (dark keeps 55% at 3.75:1).

- Chip: border --hair-2 measured ~1.3:1 (invisible) -> --ink-4; 22px
  target under WCAG 2.5.8's 24px floor -> 28px; right offset clears the
  message scrollbar gutter; light resting glyph one ink step up.

- Focus bar inset 1px from cell sides (no doubled-accent stripe where
  it butted a separator at the T-junction); greyscale font smoothing on
  the tail glyphs (subpixel RGB fringed the box-drawing characters).

Rejected with rationale: aria-pressed on the split buttons (they are
one-shot verbs — splitting again nests — not mode toggles).
2026-06-12 00:11:08 -07:00
Patrick Buckley 44c11efb53 feat(ui): split-view follow-ups — per-pane ✕, child-opens-beside, close-on-ws_closed
Four refinements from first live use:

- Per-pane ✕ chip, top-right of every visible pane. Split mode: hide
  that cell (closeCell — the tab stays, the sibling absorbs the space).
  Single-pane: close the pane outright (withheld from the unclosable
  Dashboard). The click decides at click time; the label tracks the
  mode. Manager-injected into the pane section — content untouched.

- Coordinator child links open BESIDE the coordinator (openPaneBeside:
  split right of the focused cell, seeded with the child pane) instead
  of replacing it — the parent stays on screen. Degrades to the plain
  focused-cell swap when the split is denied (cap / narrow viewport).
  splitFocused() gained an optional explicit-fill parameter for this.

- Tier-1 ws_closed now CLOSES the open interactive pane (tab gone, a
  split cell collapses) — the coordinator-closes-its-child flow,
  matching the standalone's pane-auto-close. The dead-banner lane
  stays for streams that die without a ws_closed (node crash/network),
  where the session may still be revivable.

- Paint bug: the focused-cell ring was an inset box-shadow on the
  section, which paints in the element's own background layer — UNDER
  opaque children touching the edges, so the status bar / composer
  strip occluded it. The ring now rides a click-transparent ::after
  overlay above pane content; the 2px top bar sits above the ring line.

The livepass shell surface's demo panes grew a .ws-status-bar footer so
the occlusion bug class stays visible to future passes.
2026-06-12 00:11:08 -07:00
Patrick Buckley f8f7152d63 feat(ui): split view returns to the L-shell — PaneManager layout tree
Revives the split-pane feature retired with ui/static (step 6), rebuilt
on PaneManager: an optional binary layout tree (null = the one-pane-per-
tab behaviour, unchanged) renders visible panes as %-inset cells — no
reparenting, so live stream DOM, scroll state and media survive layout
changes. Tabs stay global: the active tab is the focused cell, a
backgrounded tab swaps into it, clicking inside a visible pane focuses
its cell, .shown marks visible-unfocused tabs. Separators resize by
pointer-capture drag and arrow keys (role=separator + aria-value*); the
tree persists in the working-set blob and rehydrate prunes leaves whose
pane did not restore. Limits: 6 cells, 200x150 cell minimums, denials
toast the manager's reason.

Affordance: Split right / Split down / Unsplit buttons in the tab-bar
tail replace the redundant [+] (the permanent Dashboard tab is the
launcher) — deliberately no contextmenu override this time. The dead
TS_APP.focusLauncher seam goes with it.

Measured chrome: the focused cell wears a 2px accent top bar (no thin
tinted ring clears 3:1 in both themes) plus a 55%-mix inset ring;
separators rest at --ink-4 with solid-accent hover/drag/focus; .shown
tabs carry an accent underline; the tail cluster is fenced and lifted
to --ink-3.

scripts/livepass.py grows a third surface: shell/livepass.html boots
the real shell.js + pane.js and drives ?split=right|down|three|none
(+ &theme=light), stamping SPLIT-READY-<cells> / SPLIT-FAILED-<reason>.
2026-06-12 00:11:08 -07:00
Patrick Buckley 3e5f2c3870 test: zero out the suite's warning noise
121 warnings -> 0. Two upstream deprecations get narrowly-scoped
filterwarnings entries (the mcp streamablehttp_client rename — adoption
deliberately rides the v2 migration since the new entry point's call
shape changes again there; the starlette httpx TestClient notice). The
one real RuntimeWarning is fixed at the source: tests that mock
asyncio.run_coroutine_threadsafe handed real coroutines to a stub that
never awaited them, GC-firing 'coroutine was never awaited' inside
whatever unrelated test ran later (the same cross-test bleed mechanism
as the CI closed-stream spew — per-test filterwarnings markers cannot
catch it, which is why two such markers existed and still leaked). A
shared _dispatch_stub now closes real coroutines before returning the
canned future; the obsolete markers are removed.
2026-06-11 20:42:34 -07:00
Patrick Buckley 1497c392e4 chore: cap mcp <2 ahead of the v2 breaking rewrite
mcp 2.0.0a1 shipped 2026-06-11 (stable targeted ~2026-07-27). v2 removes
streamablehttp_client, changes the transport tuple arity, and renames
mcp.types fields to snake_case — all of which our client imports. The
maintainers' release note asks downstream packages to add an upper
bound now (their worked example is this exact constraint). Floor stays
at 1.27: nothing newer adds anything our surface needs, and the #2147
shutdown busy-loop we wrap remains unfixed at every released version.
Resolution is unchanged (1.27.2); lockfile re-pinned metadata only.
2026-06-11 20:42:34 -07:00
renovate[bot] af3cfc509d chore(deps): update docker images to v0.11.21 2026-06-11 20:42:10 -07:00
Patrick Buckley 7ef04e576a fix(providers): require base_url for anthropic-compatible
Copilot review on #661: empty base_url let the SDK fall back to
https://api.anthropic.com, sending compat-shaped requests to the
commercial API. The lane is local-only by definition, and the /v1-strip
edge case already established fail-loudly-over-silent-prod-retarget;
apply the same principle to the empty case. create_client raises an
actionable ValueError; the admin Detect path surfaces it as a clean
error string via probe_model_endpoint's existing handler.
2026-06-11 20:27:29 -07:00
Patrick Buckley 12bd848c68 feat(providers): anthropic-compatible lane for local /v1/messages servers
Add provider id "anthropic-compatible": the existing AnthropicProvider
pointed at Anthropic-compatible local servers (vLLM /v1/messages),
mirroring the openai/openai-compatible split. Registry-only — configured
via the admin Models tab or [models.*] toml, not exposed on the bare
--provider flag, so the CLI/server prod-URL defaults are unreachable for
the lane and real-Anthropic behavior is untouched.

Lane behavior (live-verified against vLLM 0.22.1rc1 + DeepSeek-V4-Flash):
- Capability defaults replace the Claude static table: token_param
  max_tokens, thinking_mode none, web_search/tool_search/vision off,
  reasoning replay on. vLLM rejects Anthropic server-side tool types
  (tools require input_schema) and ignores the thinking request param,
  so neither is sent; thinking blocks still stream back and round-trip
  through the native lane verbatim.
- Reasoning toggles via server_compat extra_body chat_template_kwargs
  (first-class vLLM request field; request-level keys beat server
  defaults). _build_thinking_and_kwargs forwards non-internal
  extra_params as SDK extra_body; thinking_budget_tokens stays internal.
- No temperature force: thinking_mode none skips the Claude-only
  temperature=1.0 requirement.

Admin UI: provider option + URL placeholder (base_url without /v1 — the
SDK appends /v1/messages); the server-compat section shows only the
extra-body field for the lane. thinking_mode round-trips through the
form dropdown for every provider except anthropic-compatible, where it
stays in the raw capabilities JSON — the edit-load lift and save restore
use the same predicate so stored overrides are never silently dropped.

Docs: architecture.md gains the lane subsection incl. verified quirks
(thinking param dropped by vLLM; stop_sequences cut inside thinking and
report end_turn; usage has no cache fields; images need a multimodal
model; mid-conversation system turns are per-model opt-in).

Negative-tested: removing the _INTERNAL_EXTRA_PARAMS exclusion fails
test_internal_keys_not_leaked; the live test drives a streamed turn with
the chat_template_kwargs toggle and asserts no reasoning deltas.
2026-06-11 20:27:29 -07:00
Patrick Buckley 3f5ee333fb fix(mcp): close the shutdown drain race + close the owned loop
Review feedback: (1) gating the drain on a main-thread truthiness check
of _background_tasks could skip cancellation when a spawn queued via
call_soon_threadsafe had not reached the set yet — submit whenever the
loop is RUNNING and snapshot on the loop, where FIFO callback order
guarantees earlier-queued spawns have landed; (2) shutdown stopped the
loop thread but never closed the loop or cleared _loop/_thread, leaking
selector resources for embedders that cycle managers — close + clear
when we own the thread and it actually stopped (loud warning when it
does not); unowned loops (tests wiring _loop directly) stay untouched;
(3) the bare await-in-suppress drain loops become
asyncio.gather(return_exceptions=True) in both the shutdown drain and
the test fixture.
2026-06-11 18:15:58 -07:00
Patrick Buckley 6c48af1900 fix(mcp): track fire-and-forget background tasks; harden loop teardown
The post-reconnect catalog refresh was scheduled as a bare
asyncio.create_task: no strong reference (the task could be GC'd
mid-flight, so the refresh might silently never run) and no exception
retrieval (failures surfaced as "Task exception was never retrieved"
at GC time — in CI, onto an already-closed pytest capture stream, the
"I/O operation on closed file" spew; a suspected contributor to the
flaky 60-minute CI hangs via cross-test loop/task state bleed).

- _spawn_background(coro, label): tracked-task set + done-callback
  that retrieves and logs failures at warning; discard runs LAST so
  set-emptiness means "done AND reported"
- shutdown() drains tracked tasks FIRST, so stack teardown can't race
  an in-flight refresh; same run_coroutine_threadsafe idiom and
  timeouts as the existing close steps
- running_loop_mgr fixture: cancel-pending -> drain -> stop ->
  join(5) with a loud assert -> loop.close() (was stop + silent
  join(2), never closed)
- the false-property test ("swallows refresh failure" — nothing
  swallowed it) now waits for completion and asserts the logged
  warning via the patched module logger (structlog; caplog cannot
  observe it), polling inside the patch context
2026-06-11 18:15:58 -07:00
Patrick Buckley 5ff726dd7a fix(storage): enforce orphan-ness inside the purge DELETE + chunk IN-lists
Review feedback on the purge's race window: the pre-SELECT re-verify
left a statement-to-statement gap where a concurrent registration could
still lose rows — and the pre-counted refcount release could underflow
when it didn't. Orphan-ness now rides the DELETE itself (correlated
NOT EXISTS) with refcounts released from its RETURNING, so refs are
released for exactly the rows that were deleted. Input is de-duplicated,
IN-lists chunk at the storage layer's 500 convention, and the scan's
per-workstream ref-count loop is now one anti-join pass.
2026-06-11 14:00:42 -07:00
Patrick Buckley 06bb375916 feat(admin): orphan-conversations maintenance verb — scan + purge
Conversation rows whose workstreams row is gone (historical unregistered
writers; the delete-during-inflight race re-creating rows after
delete_workstream) are invisible cruft that also pins attachment
refcounts. Add a turnstone-admin verb: default = read-only scan report
(ws_id, rows, attachment refs, first/last); --delete [--yes] purges.

- shared find/purge logic in storage/_utils; protocol + both backends
  in lockstep (thin wrappers)
- purge re-verifies orphan-ness in-transaction: a ws_id re-registered
  between scan and purge is skipped, never deleted
- releases the deleted rows' attachment refcounts through the
  delete_workstream GC path and sweeps workstream_config/overrides
- summary reports actual purge results, including the skipped clause
2026-06-11 14:00:42 -07:00
Patrick Buckley ef7fdb3a26 fix(ui): re-home MCP consent badge on the Manage Connections row (#657)
* fix(ui): re-home MCP consent badge on the Manage Connections row

The L-shell renovation retired the standalone settings gear (#settings-btn).
The MCP pending-consent badge anchored to that gear via _refreshConsentBadge,
which null-guarded silently — so since the renovation pending consent requests
had no indicator (the badge was invisible).

Re-home the badge on the rail's Manage row where the MCP/connections surface
lives in both deployments:

- rail.js gains a generic setRowBadge(tabKey, count, label?) hook + a `badge`
  builder: a small ⚠-glyph + count chip (never colour alone) using the DS warn
  tokens. mountManage registers row + owning-group-head refs and re-applies live
  counts across a (re)mount. When the owning group is collapsed, the count also
  mirrors onto the group head so a hidden row never hides the signal. rail.js
  stays agnostic — it owns the mechanism, the caller owns the meaning.
- shell.js (the ESM bridge) re-exports setRowBadge on window.TS_SHELL so the
  classic ui/static/app.js subsystem can drive it without importing the module.
- The standalone consent subsystem keeps its shell-level ownership: _refresh-
  ConsentBadge now drives setRowBadge on the Connections tab, fed by both the
  loadPendingConsents hydrate/poll load and live onConsentDetected notifications.
- The shared interactive pane host bridges onConsentDetected to the new
  window.TS_APP.onConsentDetected seam (undefined on the console, so the console
  pane stays a no-op there); panes only notify.
- The dead colour-only gear badge CSS (.settings-consent-badge, red dot) is
  removed; the new chip lives in shell.css as token-only .rail-badge so it
  flips themes by construction.

Console MCP tab (Extensions > mcp) and standalone Connections tab
(Extensions > connections) both badge correctly. Pins extended in
test_shell_js.py + test_app_js.py.

* fix(ui): drop the unused head ref from the rail badge row map

Review feedback: _rowEls stored each row's group-head element but every
head consumer resolves it through _groupEls; keeping the duplicate DOM
ref made the remount state shape harder to reason about.
2026-06-11 14:00:06 -07:00
Patrick Buckley d9f5093a17 test(console): make dedupe-pin slice bounds reformat-tolerant
Review feedback: the next-case end markers were exact-indentation
string finds that raised a bare ValueError when unmatched. Use
whitespace-tolerant regexes with actionable assertion messages, and
bound the history-replay window structurally (next role branch, with
a generous fallback) instead of a fixed 600 chars.
2026-06-11 13:49:35 -07:00
Patrick Buckley b11565a1f6 fix(ui): single-path Enter activation + hls.js teardown on player error
Review feedback: (1) the Enter keydown re-dispatched through btn.click(),
relying on the disabled-guard to suppress the browser's own
Enter-to-click — preventDefault + direct activation makes the keyboard
path provably single-fire; (2) the branch-scoped Hls instance was
unreachable from the media error handler, leaking its listeners and
loader timers when the player node was replaced with the retry UI —
hoist the ref and destroy it before replacement.
2026-06-11 13:48:28 -07:00
Patrick Buckley 8b41b32174 fix(ui): lift media player activation into the shared interactive pane
The interactive Pane renders media embeds (buildMediaEmbed / buildPlayButton),
but the Play activation — _loadHls / _isHlsUrl / _activatePlayer and the
click/keydown delegate — stayed behind in the standalone ui/static/app.js as
DOCUMENT-level listeners. The console L-shell mounts the same interactive.js
module but never loads ui/static/app.js, so the Play button was dead in
console-hosted interactive panes.

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

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

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

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

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

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

Touches are best-effort through the facade, which already swallows
storage errors, so a failed touch never breaks composition or a tool
call.
2026-06-11 01:12:28 -07:00
Patrick Buckley a137bffa25 chore: bump version to 1.7.0a1 2026-06-10 22:21:43 -07:00
Patrick Buckley 23007e3ac5 chore: bump version to 1.6.0 2026-06-10 22:21:03 -07:00
Patrick Buckley f44886a55f docs: 1.6.0 changelog + release-track policy (#653)
* docs: 1.6.0 changelog — roll up the 1.5→1.6 line for stable

Replaces [Unreleased] with the 1.6.0 section: 320 main-only commits
since the stable/1.5 divergence grouped into theme bullets (license,
trajectory/migration-060, web search, rerank/memory, approvals/judge,
L-shell, shelf, SSE, providers, cluster ops, security). Breaking
changes aggregated up top; migration-060 backup callout reshaped from
discussion #631 for the stable audience.

* docs: add the stable/1.6 track to the changelog preamble

* docs: retire the stable/1.4 track — current + one prior policy

Changelog preamble down to three tracks with the policy stated;
1.4 retirement noted in the 1.6.0 Removed section (final release
v1.4.0; tags/artifacts remain, BUSL-1.1 as shipped). releasing.md
track table, policy bullet, and examples brought up to the 1.6.0
promote cycle — the doc was still describing the 1.4-stable era.
2026-06-10 22:19:58 -07:00
Patrick Buckley 3803feb008 fix(console): uniform not_found wait-entry keys + ws_ids param precision
PR #652 review follow-ups:
- not_found snapshot entries now carry the full key set (updated/name
  empty) so results[ws_id] is shape-uniform across states; pinned by a
  key-set assertion in the sentinel test
- ws_ids param text now distinguishes malformed (fails before any
  waiting) from well-formed-but-unobservable (first-tick abort) at
  unchanged length — per-param descriptions stay lean by policy
2026-06-10 22:03:08 -07:00
Patrick Buckley d0e9aa3dbe fix(console): coordinator ws-ref validation, did-you-mean recovery, wait fail-fast
Field incident: the coordinator LLM hand-copied a child ws_id and
collapsed its aaa run to a, producing a 30-char id. inspect said "not
found", wait called it "denied", neither offered recovery, and the model
concluded the child was dead and dropped the lane — silent report
degradation while the child kept working.

- validate model-supplied ws_id args at the tool boundary
  (send/close/cancel/delete/inspect/wait): full 32-hex ids pass through
  at unchanged storage cost; a child's exact legacy id still resolves;
  anything else fails fast with a did-you-mean (capped Levenshtein <=3
  over the coord's own children) plus a child roster. Near-misses never
  auto-resolve; display names are not addresses (mutable, non-unique) —
  a name ref errors with a pointer at the right id
- wait_for_workstream: rename per-entry state "denied" -> "not_found"
  with an honest sentinel; malformed refs error before any waiting
  (invalid_ws_ids); a well-formed id that is foreign, missing, or
  hard-deleted mid-wait aborts the wait on the tick that observes it
  instead of burning the timeout (mode=all was unsatisfiable) or riding
  along to complete=True (silent lane loss); mode=all completes only
  when every id is real-terminal; entries carry the child display name
- one not-found payload across all verbs: foreign and nonexistent stay
  byte-identical (no existence oracle), hints reference only the coord's
  own children, echoed refs clipped in error strings; invalid_ws_ids and
  not_found share one per-ref shape with the roster hoisted top-level
- inspect ownership now requires user_id parity via _row_in_own_subtree,
  matching the wait/mutating gates (#506) — closes the forged-parent
  cross-tenant read
- session exec serializes the structured recovery payload (results +
  did_you_mean + children) on unresolvable-id wait errors instead of
  collapsing to the bare error string
- tool JSON descriptions + coordinator docs updated to the new contract;
  incident regression test pins the captured aaa-collapse ids
2026-06-10 22:03:08 -07:00
Patrick Buckley 81a3eaecce chore: relicense BUSL-1.1 → Apache 2.0 for 1.6.0 (#651)
* chore: relicense BUSL-1.1 -> Apache 2.0 for 1.6.0

Flips every license artifact in the tree; 1.5.x and earlier remain
BUSL-1.1 per their release-time LICENSE files. Contributor consent
record: #548 (rationale: #546).

- LICENSE: canonical Apache 2.0 text
- NOTICE: new; copyright line + pointer to THIRD-PARTY-NOTICES
- pyproject.toml: SPDX expression + explicit license-files trio
- Dockerfile: COPY the license trio (hatchling needs them at build)
- THIRD-PARTY-NOTICES: BUSL line reworded; bundled-version drift
  fixed (KaTeX 0.17.0, Mermaid 11.15.0, hls.js 1.6.16)
- README badge + License section, CONTRIBUTING inbound-license line,
  TS SDK package(+lock), example pyproject
- docs/pgbouncer.md: drop stray ':' introduced in #353

* docs: add CONTRIBUTORS.md

* chore: drop LICENSE leading blank line

The apache.org LICENSE-2.0.txt begins with a newline; the SPDX
canonical text and GitHub license templates do not. Use the
conventional form — detection is whitespace-normalized either way.
2026-06-10 21:03:16 -07:00
Patrick Buckley 9b75a12848 fix(server): re-author --skip-permissions CLI flag wiring
Independent re-implementation of the --skip-permissions argparse flag,
OR-ed with the tools.skip_permissions config-store setting at both
consumption sites. Written from the flag's pre-existing spec (the
--help epilog and compose.yaml, which referenced it before #450
existed).

Replaces reverted #450 so that 1.6.0 ships no non-consented
contributions under Apache 2.0. Provenance record in #548.
2026-06-10 20:37:44 -07:00
Patrick Buckley 695a335744 Revert "fix(server): accept --skip-permissions CLI flag (#450)"
This reverts commit 2cdf87b115.
2026-06-10 20:37:44 -07:00
Patrick Buckley 9e0c86e78a fix(console): persona-btn radius onto the r-sm token
Copilot round: the 3px literal (carried from the hatch .seg segments)
disagreed with the shared :focus-visible rule, which restates
border-radius as var(--r-sm) — so the corner radius popped on keyboard
focus. One token, no jump.
2026-06-10 20:11:45 -07:00
Patrick Buckley c15dcee1f5 ui(console): launcher persona toggle wears its kind — amber coord / cyan int
The dashboard launcher's Coordinator|Interactive radiogroup styled its
active option as a neutral panel highlight — two faint text links that
said nothing about WHAT was being chosen. The active option now takes
the kind vocabulary the rest of the shell already speaks (.ptag.coord
amber / .ptag.int cyan): 15% kind tint, kind-colored label, and a kind
LED dot, in a recessed .seg-style track. Colour is never alone — the
LED + label weight carry the state, and the JS contract (classList
toggle on .active, aria-checked, roving tabindex) is untouched.
2026-06-10 20:11:45 -07:00
Patrick Buckley b3c3acc5d1 fix(scripts): livepass dialog-tier riders + loud open-failure + dock-displacement probe
Designer-review round on the scroll fix found the harness's dialog-tier
gate silently green: confirm-dialog (and install/coord-delete) markup
lives OUTSIDE #admin-layout, so the fragment extraction never embedded
it — ?open=confirm threw at showConfirmModal and screenshot a normal,
dialog-less page. build() now injects every hatch dialog the fragment
does not already contain, and a driven ?open= that ends with no open
dialog stamps OPEN-FAILED-<state> into the title instead of passing.

Also upstreams the review's probe states: &focuslast=1 focuses the last
shelf-body control (the displaced-dock regression class — only .sh-body
may scroll; head/foot must stay pinned) and &scrolled=bottom shows the
24px scroll tail.
2026-06-10 18:51:31 -07:00
Patrick Buckley 6bb47cad2d fix(console): manage-pane scroll regressions — interior scroller, clip the hatch-host, anchor hidden inputs
The L-shell height-pins the admin chain and .hatch-host clipped it, so no
box below the pane could scroll: tabs taller than the pane were cut dead,
and the overflow:hidden host doubled as a hidden scroll container that
focus-into-view silently scrolled — visually-hidden toggle/cap/radio
inputs escape the .sh-body scroller (abspos under an unpositioned label),
overhang the shelf, and a Tab keypress shoved the docked hatch off its
head with no scrollbar to recover by.

- .admin-content becomes the manage pane's interior scroller (the #main
  precedent); switchAdminTab resets it on real tab changes only
- .hatch-host: overflow hidden -> clip — paint clipping without a scroll
  container, so focus can never displace the dock
- position:relative anchors on the three hidden-input labels
  (toggle-switch, .sh-body .cap, segmented-option); .settings-toggle
  already carried one
- livepass: the console harness wraps the fragment in the REAL L-shell
  chain (its bespoke height pin is exactly how this bug class stayed
  invisible to the screenshot gates) and gains a ?tall=1/&scrolled=1
  scroll state
2026-06-10 18:51:31 -07:00
Patrick Buckley 160062235a chore: bump version to 1.6.0rc2 2026-06-10 13:35:47 -07:00
Patrick Buckley b991dc2e83 fix: CI lint pin + copilot-thread hardening
The wiring-lint test used percent-formatted regex patterns — UP031 under
the ruff 0.15.6 the CI pre-commit pins (the older venv binary let it
through; checked repo-wide against the exact pin now). f-strings with
doubled quantifier braces, plus one over-long fixture line in the
livepass generator split.

Copilot threads, both validated rather than blindly applied:
- closeShelf's scrim-ownership scan now skips detached entries. The
  thread's throw scenario doesn't occur on the real removal path (a pane
  close detaches an ANCESTOR, so _hostOf still resolves inside the
  detached subtree) — but a detached shelf is genuinely not a scrim
  owner, so the guard is correct beyond being defensive.
- toast.js drops the popover attribute via removeAttribute instead of
  the null assignment. The claim that null leaves popover="null" is
  refuted — the IDL is nullable and null removes the attribute (verified
  empirically in headless Chrome) — but removeAttribute reads correct
  without requiring that spec knowledge.
2026-06-10 13:31:53 -07:00
Patrick Buckley 9102f858a4 chore(scripts): commit the livepass harness generator
The livepass harness — the headless-render rig that verified every
converted modal surface and click-drives submits (the dead-Save bug
class) — lived as ad hoc files in /tmp and got wiped once already.
The durable piece is the GENERATOR: the markup is extracted fresh from
the index files at build time (a committed snapshot would drift) and
the stylesheets/scripts are symlinked so edits are live on refresh.

scripts/livepass.py builds both harnesses into /tmp/livepass/ (ui:
all six dialog-tier surfaces incl. the real cards.js batch controller
drive; console: the admin-pane fragment hosting the shelves, with
schedule/model/policy/confirm/token fixtures and the model-save click
drive that flips document.title to PUT-OK-<n>). --serve included;
the chrome screenshot incantation and the ?open= registry are in the
module docstring. Governance fixtures (roles/HR/OGP/memory/skill) are
documented seams for when those surfaces need driving.
2026-06-10 13:31:53 -07:00
Patrick Buckley 8b5af71603 fix(console): wire the model shelf's Save button — and lint the whole class
The models conversion dropped the legacy onclick= from the submit button
and wired detect/recalibrate/capgrid/thinking in the boot IIFE — but never
the submit itself. submitCreateModel existed with nothing calling it: Save
clicked dead with no error, exactly what a live test surfaced. None of the
gates could see it — the markup lint checks anatomy not wiring, the review
finders verified the submit function's internals, and the livepass renders
never clicked Save.

Audited every id-bearing button inside every dialog.hatch across both apps
for click wiring: model-create-submit was the only true positive (the
batch confirm buttons wire through cards.js's $() prefix helper and
new-ws-submit wires 106 lines from its getElementById — audit false
alarms). The new test_every_hatch_button_is_wired pins the class: direct
getElementById wiring or wiring through the assigned variable, with the
two prefix-built cards.js ids allowlisted; verified it fails against the
pre-fix tree. Livepass now drives the actual click: Save → busy → one PUT
→ shelf closes + toast.
2026-06-10 13:31:53 -07:00
Patrick Buckley f40404bfb9 fix: review-gate round — croniter calendar guard, results teardown, hidden war
Sixteen-finder review (4 dimensions x 4 subsystem slices) + adversarial
verify: 16/16 findings confirmed, all fixed.

Majors:
- The schedule preview (and create/update via _compute_next_run) 500'd on
  syntactically-valid-but-impossible cron dates: croniter.is_valid passes
  '0 0 30 2 *' but get_next raises CroniterBadDateError. One _next_cron_runs
  helper now owns construction + the guard for both paths; the preview
  answers its 200/valid:false contract, and next[] is one shape (the cron
  branch now carries the UTC offset the 'at' branch always had).
- The batch-delete results view tore down (exit delete mode, refresh the
  stale list) only via the footer Close — header ✕ / Escape / backdrop left
  deleted rows on screen and the mode stuck. The teardown moved onto the
  dialog's onClose (gated by a resultsShown flag so a pre-delete cancel
  keeps the selection), and Close just closes.
- The model shelf's Server-compatibility section was permanently invisible:
  the one hidden-attr element still toggled via style.display, which cannot
  beat .hatch [hidden] !important — openai-compatible operators could never
  reach server type / API surface / extra-body. Now .hidden like its
  siblings.

Minors: shelves prune detached entries when a pane closes mid-edit (state
map + Escape-listener leak); the capabilities autofill gains the
_schPreviewSeq stale-response guard; the rename dialog focuses its input
before select() (select() does not move focus per spec — Enter landed on
the ✕); .mcp-install-source-label becomes the fourth protected label
component; nine write-only shelf-handle vars dropped; one alert region
gets one name; orphaned .modal-col-heading CSS, the stale toast z-index
rationale, a dangling divider comment, and a comment chasing the renamed
_submitRoleShelf all cleaned. Regression tests pin the Feb-30 preview,
the create-path guard, and the uniform next[] shape.
2026-06-10 13:31:53 -07:00
Patrick Buckley 472f12e14a fix(ui): phase-3 design-review round — wrong-target rename, error hygiene, focus
Dual review of the dialog-tier work (primed + cold), adjudicated:

- Menu-launched dialogs lost focus return: openPopupMenu's close() removed
  the focused item before the action ran, so dialogs captured <body> as
  their opener and the close-restore no-op'd. The menu now hands focus to
  its return target before invoking the action — repairs every
  menu→dialog flow in both apps.
- Rename targeted the wrong workstream: submitEditTitle re-read the
  ACTIVE pane's id, so renaming a background tab via its context menu
  renamed whichever pane was focused. The dialog now pins its target at
  open (pre-existing bug, carried from the legacy overlay).
- Batch-failure rows printed raw HTML error pages verbatim (proxy 502s,
  gateway timeouts): bodies are stripped of style/script content and
  markup before display, with an HTTP-status fallback.
- The two single confirms behaved differently in flight — delete-ws
  closed optimistically while revoke held under the busy lock. Unified on
  hold-open-with-busy: failures keep the user's context for retry.
- Batch results: failures announce through the live sh-alert (previously
  dead markup), a clean run flips the chrome to the success kind (red
  head over '3 deleted, 0 failed' disagreed with the de-dangered foot),
  and focus lands on Close after the state swap.
- Ghost-button boundaries measured ~1.2:1 on the foot strip (WCAG
  1.4.11): ink-mix borders routed through a variable so kind variants
  keep their own border colors; reduced-motion busy gains a static ' …'
  cue; dark label-hint opacity brought above the compound 4.5:1 line.
- Revoke voice unified ('Revoke connection', no '?'); batch alertdialogs
  gain aria-describedby; dead _settingsTrap machinery removed.
2026-06-10 13:31:53 -07:00
Patrick Buckley 1dda974b3e test(ui): align the app.js pins with the dialog-tier revoke confirm
The revoke confirm is no longer a role=dialog/aria-modal overlay with
page-local CSS — it's a hatch dialog-tier alertdialog whose chrome lives
in /shared/hatch.css.  The markup pin follows the new shape and the
stylesheet pin list drops the retired #revoke-mcp-overlay rule.
2026-06-10 13:31:53 -07:00
Patrick Buckley e2693764bf feat(ui): batch-delete confirms onto the dialog tier — both hosts, one builder
The shared cards.js multi-select controller serves the ui Saved
Workstreams AND the console Saved Coordinators, so the builder and its
two host markups convert as one unit: #ws-delete-dialog and
#coord-delete-dialog are md danger dialogs whose list renders inside the
sh-body (the only scroll region — the 200px inner list cap dies).

Foot grammar: [N selected meta] [Cancel data-close, autofocus] [red
filled "Delete N workstreams"] — the count moves out of the body prose
into the meta and the action label.  The fan-out brackets with setBusy
(LED pulse + action lock replace the disabled/'Deleting...' swap); the
results view swaps the action to a neutral Close and hides Cancel (a
Cancel beside a Close is the redundant dismissal pair the foot grammar
forbids).  The dormant error region becomes the sh-alert.

The controller's hand-rolled focus trap, prevFocus bookkeeping and
overlay display toggles die — hatch.js owns trap/Escape/backdrop/focus
restore; the post-results Close still hands focus to the section toggle
the bar collapse just rebuilt.  The ws-delete-modal-* CSS family leaves
cards.css (only the row treatment survives); the dead window wrappers
(cancelWsDelete/confirmWsDelete + coord twins) and the ui keydown
handler's last legacy-overlay branch go with it.
2026-06-10 13:31:53 -07:00
Patrick Buckley c592486046 feat(ui): standalone app onto the hatch dialog tier — new-ws, rename, confirms
The ui app has no admin pane host and new-ws is a launcher invokable from
anywhere, so every surface lands on the document-modal DIALOG tier (no
shelves).  hatch.css gains the one md dialog width (560) the v1 design
specified; the ui index links hatch.css/hatch.js alongside the other
shared assets.

- New workstream: md create dialog, WS-NEW plate; the fork path keeps its
  title/semantics (WS-FORK plate, skill + attach rows hidden via the
  hidden attribute).  Submit brackets with setBusy; errors land in the
  sh-alert with the scroll-into-view rule.
- Rename: a styled prompt() — single field, Enter submits, no plate.
- Delete-workstream + revoke-MCP mirror the console confirm exactly:
  danger chrome, prose body, Cancel autofocus, red filled action.

The four hand-rolled focus traps, the Escape/overlay-click dispatch and
the body-overflow lock die (native dialog + hatch.js own all of it); the
global-shortcut handler defers on dialog:modal instead of the overlay-ID
list.  The forked legacy modal CSS leaves style.css (~270 lines).  The
markup-shape lint now scans both index files, the asset assertion and the
parse-time-bridge guard extend to the ui app.
2026-06-10 13:31:53 -07:00
Patrick Buckley c1a0417f10 fix(console): design-review consistency round — plates, verbs, hints, focus
Cosmetic/consistency findings from the dual shelf review, adjudicated;
accepted items:

- One designation-plate grammar: 2-4 char domain code + closed suffix
  vocabulary. SKILL-GH → SKL-IMPORT, PP-* → PPO-*, ROLE-MAP → USR-ROLES,
  ROLE-* → ROL-*; HR-/OGP- stay (established judge domain terms).
- Create titles say "New <thing>" (Add model / Add MCP server renamed;
  "New prompt" was creating a prompt POLICY); "Edit model" gains the
  "— {alias}" suffix every other edit surface carries.
- Shelf primaries uniform single-word Create/Save — the skill shelf's
  "Create skill" / "Save config" / "Save changes" collapse.
- label-hint dialect normalized to bare lowercase (wrapping parens
  dropped); the em-dash unit form "— UTC" is a different species, kept.
- Placeholder-only format instructions promoted to label-hints (a11y
  3.3.2): HR confidence "0.0–1.0", intent "supports {arg_snippet}". The
  install dialog's dynamic required asterisk pairs with required on the
  control + aria-hidden on the glyph.
- The mcp and schedule adjacent toggle pairs wrap in toggle-stack like
  the model shelf; the sch-enabled-row hidden toggle rides inside.
- memory-detail foot grammar: Delete demoted from err-filled to a quiet
  destructive text action (.sh-btn--quiet-danger, doubled class so it
  beats the later-loaded hatch.css quiet color), Close stays rightmost.
- First-focus: user-roles lands on its first role toggle once rendered,
  channel on the type select, builtin-role edit on the first enabled
  control, mcp-install autofocuses its primary.
- The watch-cancel confirm's action reads "Stop watch" — no more
  Cancel-beside-Cancel.
- A locked skill opens as data-kind=inspect (cyan read-out chrome) and
  flips to edit on the in-place unlock re-render.
- mcp-detail healthy node dot pairs with a dim "connected" text token,
  mirroring the error-text sibling (state was color-alone).
- etm-scan section converts to the hidden attribute (last
  style.display straggler on the skill shelf).
2026-06-10 13:31:53 -07:00
Patrick Buckley 360ed48f62 fix(ui): design-review round — busy lock holes, broken auth cards, contrast
Dual design review of the shelf stack (one primed on intent, one cold)
adjudicated; accepted findings:

- The busy lock held the door for the mouse only: Enter on the focused
  primary re-fired submits, scrim clicks closed a shelf mid-flight, and
  the dialog tier's native Escape (cancel event) dismissed a busy confirm.
  One capture-phase guard + a scrim busy check + cancel interception close
  all three for every surface; setBusy now announces aria-busy. Contract
  assertions added to the busy test (it previously claimed scrim coverage
  it didn't assert).
- The MCP auth radio cards were destroyed by the .sh-body label cadence —
  the exact specificity war the toggle-switch/cap exceptions guard
  against, missed for .segmented-option. Restated at 0,2,1.
- Light-theme off-state toggle tracks measured ~1.2:1 (WCAG 1.4.11 wants
  3:1): real ink fill on light, recessed look kept on dark.
- Failed submits on tall shelves rendered out of view — _showModalError
  scrolls the alert into view. user-roles gains the missing busy bracket
  and in-shelf errors instead of toast-only.
- Toasts render under the dialog tier's top layer: promote the toast to a
  manual popover only while a modal dialog is open (popovers stack above
  later dialogs); attribute dropped after so the everyday fade survives.
- Ambient glow now follows the kind accent (cyan/red surfaces no longer
  bloom amber); skill editor's content column pins sticky so the SKILL.md
  pane stays visible while the meta column scrolls; MCP-detail stacking
  re-keyed from viewport to @container pane; dark-body micro-text and the
  origin badge brought up to the contrast floor; quiet foot actions
  (Validate regex, Detect) lifted from body-copy gray.
2026-06-10 13:31:53 -07:00
Patrick Buckley 69fb8c3140 fix(console): help popover consumes its Escape — layered shelf dismissal
With the popover and a shelf both open, Escape closed both at once
(admin.js's popover listener and hatch.js's shelf listener each fired).
The popover listener registers at parse time — always ahead of hatch.js's
first openShelf — so stopImmediatePropagation makes Escape peel one layer
at a time: popover first, shelf on the next press.
2026-06-10 13:31:53 -07:00
Patrick Buckley d896108ae0 chore(console): tear down the legacy modal machinery — the shelf sweep is total
With the skill editor converted, nothing renders through the legacy
overlay system any more. Grep-driven deletion:

- admin.js: _modalFocusTrap/_installTrap/_removeTrap, the govOverlays
  dispatch table, and the global Escape handler die. The handler's one
  still-live block — closing an open settings-help popover — is
  extracted into its own small keydown listener (the settings panels
  and the shelf form help buttons share that component).
- governance.js: the orphaned template trap/trigger let declarations.
- style.css: the .admin-modal box rules (incl. -wide/-skill), every
  .admin-modal-prefixed half of the doubled toggle/segmented/perm/
  user-roles selectors (the unscoped twins keep serving the settings
  panels and shelf bodies), .admin-details (the shelf uses
  details.rawhatch), .modal-buttons/.modal-cancel/.modal-submit,
  .modal-section-divider, hr.toggle-group-divider, the now-empty
  #…-template-overlay ID rule, and their reduced-motion entries.
  Stale comment pointers re-aim at the live rules (.sh-alert,
  .sh-body label).

Kept with reason: .modal-columns/.modal-col* — _openMcpDetail still
builds the MCP-detail shelf body with them (their narrow-viewport
stacking rules survive in a rebuilt media block).

Livepass re-verified after the teardown: zero console errors across
create/edit/locked, and the unlock-confirm-over-shelf capture is
pixel-identical to the pre-teardown one.
2026-06-10 13:31:53 -07:00
Patrick Buckley ecef600c0b feat(console): skill editor onto the xl shelf — create/edit merge, lock in the head
The last legacy modal pair (create-template + edit-template, ~430 lines
per mode) collapses into one pane-scoped 920px shelf: a hidden skl-id
decides POST vs PUT, the duplicated ctm-/etm- field sets merge into one
skl-/sklc- set, and the skill-spec two-column grid transplants whole into
the scrolling body (content-area rule re-scoped under .sh-body so the
shelf's font:inherit/min-height cadence doesn't flatten it; the mobile
breakpoint becomes a pane container query to match the shelf's own
bottom-sheet degradation).

Everything judgment-bearing carries over: paste-to-parse (now cancelled
via the shelf's onClose so Escape/scrim dismissals abort the inflight
parse too), live {{variable}} detection (re-dressed as match-strip chips
under the textarea, count mirrored into the foot meta when provenance
isn't occupying it), pending-resource rows in create, server-backed
resources + security scan + re-scan in edit, and the runtime-config
field set that stays editable on readonly skills (must keep matching
SKILL_RUNTIME_CONFIG_FIELDS). The readonly lock affordance moves into
the head strip left of the designation plate as a ghost icon button;
origin/locked provenance renders in the foot meta lane (installed/
customized chip + source URL + 'locked — unlock to edit'); the unlock
confirm stacks above the shelf via the native top layer and the
post-unlock re-render mutates the open shelf in place. Progressive
disclosure keeps its <details> semantics on rawhatch chrome, with the
shared-DOM state leaks (disabled spec fields, expanded details) re-armed
on every create open.

Mode-exclusive blocks keep their ctm-/etm- ids (pending vs server
resources, scan) — only true duplicates merged. Inline onclick wiring
moves to a one-shot _skillShelfWire; submits go busy via the shelf LED
lock. The legacy overlay markup and the .skill-lock-btn / .skill-vars-*
rules are deleted; trap machinery teardown follows separately.

Livepass-verified (stubbed authFetch, headless Chrome): create, edit
populated, locked view (disabled spec + editable runtime + scan +
readonly resources), unlock confirm stacked over the shelf, paste
auto-fill, variable chips, pending-resource add/remove.
2026-06-10 13:31:53 -07:00
Patrick Buckley 14f159ccdf chore(console): drop the policy pilot's orphaned trap declarations
_cpTrapHandler/_epTrapHandler lost their last readers when the policy
surface moved onto the shelf (f609911b) — declaration-only since.
2026-06-10 13:31:53 -07:00
Patrick Buckley ddcf337883 feat(console): memory detail onto the inspect shelf
The memory-detail read-out leaves its body-level overlay for a pane-scoped
inspect shelf (#memory-detail-shelf, cyan, lg) inside #admin-layout. The
detail-grid + content population carries over verbatim; the foot keeps the
Delete action (now a danger sh-btn) wired after the record loads, beside Close.
showMemoryDetailModal/hideMemoryDetailModal keep their names — the row renderer
calls them — and run through window.TurnstoneHatch; the post-delete close check
keys off the dialog's .open. Legacy overlay markup, its _installTrap/Escape
dispatch entries, the trap/trigger lets, and its style.css overlay ID-list row
are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley e54bdd1bc6 feat(console): prompt-policy + heuristic-rule + output-guard onto the shelf
The three governance create+edit pairs collapse onto pane-scoped lg shelves
inside #admin-layout. Each merges into ONE shelf with a hidden id (plus a
builtin flag on the judge surfaces) deciding the write: PUT for a DB row, an
override-POST for a built-in's first edit, a plain POST for a new row.
Title/tag/data-kind/submit-label flip between create and edit; edit-only chrome
(the prompt-policy Enabled toggle) sits in a hidden-toggled row.

The heuristic-rule and output-guard editors lift out of the Judge tab panel to
sit as direct children of the hatch-host (the shelf's inert containment needs
that); the two built-in-edit entry points share a single populate-and-open
helper. The settings-help-popover buttons carry over verbatim — their document-
delegated toggle is independent of the container. The output-guard "Validate
regex" button moves to the foot as a quiet action while its result strip stays
in the body. Submits go busy via the shelf LED lock; the regex flags input and
the is-credential toggle move onto the field grid. Legacy overlays, their
_installTrap/Escape dispatch entries, the trap-handler/trigger lets, and their
rows in the style.css overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley e6cf2c9346 feat(console): roles + user-roles + github-import onto the shelf
The governance role surfaces leave their legacy overlays. Create + edit role
collapse into ONE pane-scoped shelf (#role-shelf inside #admin-layout): a hidden
role-id decides POST vs PUT, and title/tag/data-kind/submit-label flip between
"New role"/ROLE-NEW/create/Create and "Edit role — name"/ROLE-EDIT/edit/Save.
The slug-name row is create-only (hidden attr on edit); the display name carries
over and is disabled for builtin rows. The permission checkbox grid renders with
one "role" prefix for both modes — the builtin baseline-vs-rendered diff that
produces {grant, revoke} (and round-trips unknown perms untouched) is preserved
verbatim. Submit goes busy via the shelf LED lock instead of the disable dance.

User-roles becomes an edit shelf carrying its toggle-list population; github-
import a create shelf with the URL as an sh-mono field and the hint folded into
a label-hint span. The public show/hide/submit names the toolbars and row
renderers call are kept — only the bodies are rewired through window.TurnstoneHatch
(handler-time, never at parse time). Legacy overlays, their _installTrap/Escape
dispatch entries, the trap-handler/trigger lets, and their rows in the style.css
overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley f2d76bbec6 feat(console): the MCP family onto the shelf — create/edit merge, detail, import, install
The four MCP surfaces leave their legacy overlays. The add/edit server editor
collapses into ONE lg pane-scoped shelf (#mcp-shelf): a hidden mcp-edit-id
decides POST vs PUT, and the title/tag/data-kind/submit-label flip between
"Add MCP server"/MCP-NEW/create/Create and "Edit MCP server — name"/MCP-EDIT/
edit/Save. The transport-conditional stdio/http field groups and the OAuth
subfield block toggle on the hidden attribute instead of style.display (which
.hatch [hidden] enforces); the multitenant-auth segmented radio control carries
over verbatim. The transport/auth onchange and submit move out of inline markup
into _mcpWire (which also installs the audience-autofill listener once).

mcp-import becomes a create shelf with the JSON paste as an sh-mono textarea;
mcp-detail an inspect shelf carrying its setSafeHtml two-column population. The
registry install flow moves onto the document-modal dialog tier per the confirm
precedent: a STATIC #mcp-install-dialog whose summary/source/fields containers
_showInstallMcpModal still populates — the dynamic overlay-shell construction is
gone. _doRegistryInstall is shared by the one-click card path and the dialog
submit, so its busy lock and inline-vs-toast error branch now key off the
dialog's .open. Submits go busy via the LED lock; legacy trap/Escape/ID-list
entries and the trap-handler lets are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley 4adf223dd9 feat(console): user/token/channel/runs onto the shelf — the admin.js simple forms
The four admin.js leaf surfaces follow the schedules pilot onto pane-scoped
shelves inside #admin-layout: create-user, create-token, link-channel and the
read-only schedule run history. Each keeps the public show/hide/submit names the
toolbars and row renderers already call (showCreateUserModal, showScheduleRuns,
…) — only the bodies are rewired. Open/close run through window.TurnstoneHatch
(handler-time, never at parse time); submit goes busy via the shelf LED lock
instead of the disable/relabel button dance; errors land in the sh-alert.

The token shelf hands its issued secret to the already-converted token-created
dialog unchanged. Schedule runs is an inspect shelf (cyan, lg width, Close-only
foot) and carries its setSafeHtml run-table population verbatim. The channel
type→placeholder onchange moves out of inline markup into _channelWire. Legacy
overlays, their _installTrap/Escape dispatch entries, the trap-handler lets, and
their rows in the style.css overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley 0e0532eee5 feat(console): policies pilot + the dialog tier — confirm/token-created go native
Tool policies join the shelf: one pane-scoped editor for create+edit with a
priority-neighbor read-out computed from the loaded policy list ('evaluates
after deny-rm (900) · before default-ask (0)' — policies run highest-first),
so where a priority lands answers itself while typing. Live tool-pattern
match chips are deferred until a cluster tool-registry endpoint exists.

The reusable confirm and the show-once token dialog move onto the
document-modal hatch tier (native showModal): the confirm keeps its
showConfirmModal(title, message, actionLabel, callback) contract for all
14 call sites, gains the danger chrome (red LED/hairline/title + err-filled
action), and deliberately moves autofocus from the action button to Cancel
— Enter on a fresh destructive confirm no longer fires the action. The
token dialog gets the success chrome + show-once callout, with copy wired
to the primary. Nested confirm-over-shelf now stacks via the top layer;
the z-index 650 special case and four more overlay-ID/dispatch entries die.

Also fixes a real war the livepass caught: display rules on form chrome
(label.toggle-switch's inline-flex) defeat the hidden attribute — hatch
containers now enforce [hidden] with display:none !important.
2026-06-10 13:31:53 -07:00
Patrick Buckley eba53edd36 feat(console): models pilot — capabilities JSON becomes an LED tile matrix
The model editor moves onto a lg shelf and the hand-written capabilities
JSON requirement dies. Nine LED tiles (tools/streaming/vision/web-search/
temperature/effort/STT/TTS/reranker) display merge(dataclass defaults,
known-model table, explicit overrides) with SPARSE-OVERRIDE persistence:
only keys saved in the row or toggled by the operator are written back, so
known models keep tracking future capability-table updates instead of
being pinned at save time. The known-model lookup that previously dumped
the whole table into the textarea becomes the tile BASELINE refresher with
a provenance banner ('Loaded from the built-in table for X'); the raw JSON
survives as a collapsed advanced hatch holding everything the tiles don't
manage (thinking_display, max_output_tokens, …). supports_rerank is now a
tile — no more hand-JSON to flag a reranker — and drives the Re-calibrate
button + calibration chip (now a foot read-out next to quiet Detect).

Everything regression-prone carries over: server_compat extraction,
write-only api_key sentinel, reranker calibration field re-merge (raw-typed
keys still win), thinking-mode representability guard, detect/calibrate
flows. Inline onclick/onchange wiring moves to the boot IIFE; busy locks
the shelf LED instead of disabling the button. Legacy overlay markup, the
trap/Escape dispatch entries, and the CSS ID-list entry are deleted.

Livepass-verified (stubbed authFetch, headless Chrome): create + edit,
baseline banner, tile extraction, toggle-switch inside .sh-body.
2026-06-10 13:31:53 -07:00
Patrick Buckley 425c38dc0e feat(console): schedules pilot — the cron DSL becomes a builder on the shelf
First production surface on the service-hatch shelf. Create + edit collapse
into ONE pane-scoped dialog (#schedule-shelf inside #admin-layout, now the
.hatch-host): a segmented Runs control (Daily/Weekly/Monthly/Interval/Once/
Cron) compiles to schedule_type/cron_expr/at_time — nobody types cron unless
they choose Cron mode, which keeps the raw input as the escape hatch with
the same live read-out. The NEXT RUNS read-out previews the next three
firings through the server's croniter (debounced POST /schedules/preview),
and the foot strip always shows the compiled expression for verification.

Storage is untouched: on edit the saved expression is reverse-parsed back
into the friendly mode when its shape matches (_cronToScheduleMode), else
the editor opens in Cron mode. Notify-row/select-populate helpers carry
over verbatim; submit goes busy via the shelf LED lock instead of button
text swapping. The legacy create/edit overlays, their show/hide/toggle
globals, hand-rolled trap wiring, and their entries in the overlay ID list
and _installTrap/Escape dispatch tables are deleted (-242 lines of markup).
Toggle-switch gets its .sh-body exception in hatch.css (the same
specificity war .admin-modal fights), + .sh-mono utility.

Verified against a stubbed-authFetch livepass harness in headless Chrome:
create/edit/light/busy, weekly reverse-parse round-trip, preview rendering.
2026-06-10 13:31:53 -07:00
Patrick Buckley 5b0255f468 feat(ui): service-hatch container system — pane-scoped shelf + modal dialog tier
One chrome vocabulary (machined head/foot strips on --code-bg, kind LED,
designation plate, center-fading hairline), two mounting points:

- .hatch--shelf: pane-scoped, NON-modal (dialog.show()). Mounts inside the
  pane's .hatch-host, docks right, dims only that pane via a lazy sibling
  .pane-scrim, and contains focus with inert on the pane's other children —
  rail/tabs/other panes stay live. Split panes work by construction; the
  bottom-sheet degradation is an @container query on the pane, not a
  viewport media query, so a narrow split degrades too. Controller-owned
  Escape defers to any document-modal dialog stacked above.
- .hatch--dialog: document-modal (showModal()) confirm/show-once tier;
  top layer stacks it above any shelf with no z-index ladder.

Smart-input primitives ship alongside (seg, chips, readout, capgrid,
match-strip, rawhatch, autofill) for the Phase-1 surfaces. data-busy locks
the container while a submit is in flight (LED pulses, dismissal refused).
Light-theme micro-text gets the .tab-menu-key one-step-up contrast pass.
Classic scripts reach the ESM controller via the window.TurnstoneHatch
bridge (toast.js pattern, handler-time only); invariants pinned in
tests/test_hatch_js.py incl. a markup-shape lint over dialog.hatch.
2026-06-10 13:31:53 -07:00
Patrick Buckley f086e38e37 feat(console): schedule preview endpoint — next-3-runs read-out backend
POST /v1/api/admin/schedules/preview validates {schedule_type, cron_expr,
at_time} with the same _validate_schedule_fields the CRUD path uses and
returns the next three croniter firings. Pure compute, no storage touch;
invalid input answers 200 {valid:false, error} because the schedule
editor renders it live while the user types. Registered ahead of the
{task_id} routes so the literal segment wins.
2026-06-10 13:31:53 -07:00
Patrick Buckley 8f415f9c68 docs(judge): say llm_fallback, not 'heuristic fallback', for cancelled items
Review feedback: the cancel-path docstrings described undone items as
degrading to 'heuristic fallback verdicts', but the emitted and
persisted tier is llm_fallback (heuristic content relabeled). Aligned
all eight occurrences — including the pre-existing _deliver_fallbacks
docstring — so docs, logs, and audit rows use one vocabulary.
2026-06-09 20:00:55 -07:00
Patrick Buckley b24b029c4d refactor(storage): single-source the bulk-insert race rationale
Review follow-up: the ON CONFLICT rationale lived verbatim in three
places (protocol docstring + both backend comments). Keep the prose in
the protocol — the contract's home — and point the backends at it.
Also recommend cancel_on_approval=true in docs for deployments where
the judge shares one local inference backend with the session model.
2026-06-09 20:00:55 -07:00
Patrick Buckley c75afd704b docs(judge): document verdict lifecycle — run-to-completion, superseded rows, replay parity 2026-06-09 20:00:55 -07:00
Patrick Buckley fb282fab73 fix(judge): honor the cancel_on_approval=False run-to-completion contract
judge.cancel_on_approval=False (the default) promises the daemon
evaluates every tool call to completion so all verdicts are available
for later review. Two sites conspired to break that: the approval
gate's finally set the cancel event unconditionally the moment a
decision landed, and _evaluate_single's poll loop honors the event
regardless of config — so every item the sequential judge hadn't
reached degraded to a heuristic llm_fallback row. On a 22-call
parallel batch, approving after the third verdict silently downgraded
the other 19; the elaborate late-verdict machinery in
on_intent_verdict was effectively dead code.

Make the event a pure abort signal whose firing policy lives with the
caller: the gate fires it only when cancel_on_approval is enabled,
while generation supersede (next batch) and close() keep firing it
unconditionally, bounding a stale daemon to one batch of real work.
_run_judge drops its own config second-guessing — a fired event always
fast-forwards the remainder to fallbacks (every call still gets
exactly one verdict), and the fallback reason no longer claims 'user
approval' for supersede/close aborts.
2026-06-09 20:00:55 -07:00
Patrick Buckley d0cede5e13 fix(storage): per-row conflict tolerance on bulk verdict insert
The async judge daemon can UPSERT a fallback row — reusing a heuristic
verdict_id from the batch approve_tools is about to bulk-insert —
before the bulk write runs. With a plain INSERT, that single PK
collision aborted the entire statement, and the caller's best-effort
try/except silently discarded every heuristic row in the batch.

Insert ON CONFLICT (verdict_id) DO NOTHING on both backends: siblings
survive a mid-batch collision, and the colliding row keeps the
daemon's llm_fallback tier upgrade instead of regressing to the
heuristic stamp (the documented preferred outcome). Regression test
runs against both storage backends via --storage-backend.
2026-06-09 20:00:55 -07:00
Patrick Buckley effdb8f365 fix(judge): persist superseded late verdicts for the audit trail
ChatSession._on_verdict guards on judge-generation identity so a stale
verdict can't ride a reused call_id into the Smart-Approvals cache —
but it dropped those verdicts entirely, before persistence. Every
ruling the sequential judge delivered after the next turn began left
intent_verdicts claiming the judge never answered.

Route superseded verdicts to a new persist-only hook
(SessionUIBase.on_superseded_intent_verdict): the row lands with
user_decision="superseded" while every live surface stays untouched
(no SSE, no replay cache, no pending-decision park). The hook is
duck-typed; display-only UIs (CLI/eval) don't define it and keep the
plain drop. upsert_intent_verdict already excludes user_decision from
its on-conflict SET, so a superseded fallback upgrading its heuristic
row in place cannot clobber a decision already stamped there.
2026-06-09 20:00:55 -07:00
Patrick Buckley 461d01f72a fix(ui): ship risk-none intent verdicts on history replay
The /history decoration layer suppressed intent-verdict rows with
risk_level="none" from the wire payload, on the assumption the client
filtered them anyway. It never did: buildConvVerdict renders a badge
for every verdict it receives, so the live SSE path painted all judge
verdicts while rehydration silently dropped the benign majority — a
22-call parallel batch came back from a restart showing only the 3
flagged calls.

Ship every stored row and let the client render replay exactly as it
rendered the live stream. The output-guard chip pair (showOutputWarning
+ merge-on-clean) already suppresses consistently on BOTH sides and is
unchanged.
2026-06-09 20:00:55 -07:00
Patrick Buckley 9fefe830d1 fix(tls): validate init() retry params
attempts < 1 made init() return successfully without fetching CA or
cert — a silent no-op leaving the client uninitialized. Fail fast with
ValueError instead; negative base_delay rejected on the same guard.
2026-06-09 18:37:46 -07:00
Patrick Buckley a3ff07a86d fix(tls): mTLS-aware container healthcheck + boot-time init retry
A whole-stack restart races every node against the console for the CA
fetch (compose re-enforces depends_on ordering only on `up`): losers
logged one warning and served plain HTTP for their lifetime, while
winners served mTLS that the plain-HTTP container healthcheck could
never probe — leaving "healthy" plaintext nodes and "unhealthy"
working ones.

- TLSClient.init() grows attempts/base_delay retry (server passes 6
  attempts, ~31 s backoff) absorbing the boot race; per-attempt CA-fetch
  failures log warning + debug traceback instead of error tracebacks.
- healthcheck.py falls back to HTTPS when the plain probe fails,
  presenting the node's own cert as the client cert with the cluster CA
  pinned; dials localhost because the internal CA issues DNS SANs only.
  Default plain-HTTP deployments are unchanged.
- The server writes boot PEMs under a fixed root (TURNSTONE_TLS_PEM_DIR,
  default <tmpdir>/turnstone-tls) so the probe can find them; boot
  clears stale dirs and refuses a symlinked/foreign-owned root; renewal
  rewrites the PEM dir so the probe's client cert never outlives the
  served cert.
- /health reports tls: "active"|"fallback" (absent when TLS is
  disabled) so a silently downgraded node is observable.
2026-06-09 18:37:46 -07:00
Patrick Buckley 319b73d046 chore: bump version to 1.6.0rc1 2026-06-09 13:39:19 -07:00
Patrick Buckley e1b99bcec6 fix(ui): popup-menu ArrowUp entry point + tabs-only tablist
Review feedback (Copilot), both confirmed against source:

- openPopupMenu: when no menu item has focus (a click on a separator or
  the menu surface moves focus off the items without closing), ArrowUp's
  unguarded modulo landed on the second-to-last item ((-1-1+n)%n == n-2).
  Guarded to enter at the bottom; ArrowDown's (-1+1)%n already entered at
  the top. Pre-existing in the tab dropdown this helper was extracted
  from — the shared chrome means one fix covers both menus.
- The burger and the [+] tail were focusable non-tab children inside the
  element PaneManager stamps role=tablist (the [+] violation pre-existed;
  the burger doubled it). The tabs now live in their own .tabstrip, which
  becomes the tablist PaneManager owns; burger and tail sit outside it in
  .tabbar. The strip is also the mobile horizontal scroller, so burger +
  [+] stay pinned while tabs scroll.

Verified: 289 static-suite tests, 28 harness self-tests, and both
real-page boot harnesses green; mobile render unchanged.
2026-06-09 13:37:35 -07:00
Patrick Buckley b1c526a170 style: ruff-format the appended drawer test guard 2026-06-09 13:37:35 -07:00
Patrick Buckley 221e804ef3 test(ui): de-modulize the renderer harness sources
test_renderer_js drives renderer.js behaviorally through node via
vm.runInThisContext — script semantics, which choke on the import/
export syntax renderer.js and utils.js now carry (all 68 tests failed
at harness setup). The harness now evaluates _demodulize()d source:
imports drop (the shared vm context resolves cross-file bindings as
globals, exactly like the pre-module classic scripts) and export
keywords peel off.

Deliberately NOT switched to dynamic import(): the mermaid harness
pokes renderer-internal state (_mermaidState = 'ready') that script
evaluation exposes but a real module would encapsulate. Module
semantics are covered by test_shell_js's .mjs parse sweep; these
tests pin renderer behavior.
2026-06-09 13:37:35 -07:00
Patrick Buckley 539ed3ccbf test(ui): pin the collapse/drawer/popup-menu behavior + breakpoint pair
Review follow-ups: the new rail collapse and mobile drawer had no
committed guards (the repo pattern is per-step string assertions in
test_shell_js.py) and openPopupMenu — now load-bearing for both the
tab dropdown and the footer user menu — was unpinned.

- test_rail_collapse_glyph_strip: persistence key, toggle +
  aria-controls, class-flip seam, cpill-label/manage-glyph companions,
  52px desktop-scoped CSS.
- test_mobile_drawer_off_canvas: burger, scrim, rail-open flip,
  pane-activation auto-close, off-canvas translateX + visibility:hidden.
- test_popup_menu_shared_helper: the export + both consumers (the user
  menu's prefer-up path included).
- shell.css: the 769/768 media blocks are a matched pair CSS cannot
  express as a shared token — both now carry a cross-referencing
  change-both comment.
2026-06-09 13:37:35 -07:00
Patrick Buckley 1d2046b5bb fix(ui): defer saved-table construction to boot — parse-time bridge use
Review finding (critical): the ESM migration made cards.js a deferred
module, but both classic app.js bundles built their saved-list tables at
TOP LEVEL — const COORD_COLUMNS = [SavedColumns.name(), ...] and
const _coordTable/_wsTable = createSavedTable({...}) execute at parse
time, before the window bridges exist. ReferenceError aborted each
bundle before it could define TS_APP.boot, so neither deployment booted.
(The earlier consumer audit caught bare top-level CALLS and IIFE bodies
but excluded declarations — missing initializers with side effects.)

Construction moves into _initSavedCoordTable()/_initSavedWsTable(),
called first from each boot path (substrate modules have evaluated by
then). The two typeof-undefined guards become null-checks (a let
binding passes typeof). Verified end-to-end with real-page load
harnesses: both index.html script chains (real app/admin/governance +
module substrate, network mocked) boot to a mounted shell with zero
uncaught errors — console over loopback HTTP (the coordinator dynamic
import needs real URL resolution), standalone over file://.
2026-06-09 13:37:35 -07:00
Patrick Buckley 3bfb40f60f refactor(ui): retire dead split-pane remnants + dedupe popup-menu chrome
Dead-code removal, all provable (no JS creator / no reachable caller):

- interactive.js drops the !this._embedded branches: focus tracking, the
  right-click context menu, and the header with split/close buttons all
  referenced shell globals (setFocusedPane, splitPane, splitRoot,
  showPaneContextMenu, countLeaves, closePane) that exist nowhere since
  the step-6 fork collapse — reaching them was a guaranteed
  ReferenceError. The embedded flag goes with them (every pane is
  L-shell-hosted; pane--embedded is now unconditional), as do the
  call-less updateWsName() and the host adapter's getWsName seam.
- ui/static/style.css drops the orphaned split-pane/tab-bar vocabulary:
  .ws-tab*, #new-tab-btn, #split-btn, .split-handle, the pane-header/
  action-button block, the unused dropdown-in keyframe, and the dead
  entries in the reduced-motion list.
- ui/static/app.js drops the retired settings-gear menu remnants (state
  vars for a builder that no longer exists + an always-false Escape
  guard) and fixes a real crash: hideNewWsModal() focused the removed
  #new-tab-btn unguarded, throwing a TypeError on every create/fork
  modal close; focus now returns to the shell's [+] new-session button.
- pane.js exports openPopupMenu — items, positioning (flip + clamp),
  dismissal, aria-expanded mirroring, and arrow-key roving in one place.
  The tab-action dropdown delegates to it, and shell.js's footer user
  menu replaces its hand-rolled duplicate (which lacked Tab-close and
  arrow roving — it inherits both).

test_interactive_pane_js.py: the embedded-gate pins flip to retired-
symbol pins (the gate is gone, not gated).
2026-06-09 13:37:35 -07:00
Patrick Buckley 2320c6d13c refactor(ui): migrate the shared_static substrate to ES modules
utils/toast/kb/cards/auth/renderer/composer/composer_attachments/
composer_queue/status_bar convert from classic scripts (implicit globals,
IIFE wrappers) to ES modules with explicit exports. Parse-time
cross-dependencies become real imports (auth/kb/cards -> utils,
auth/cards -> toast, cards -> auth, renderer -> utils), which deletes the
implicit script-order contract those files relied on. utils stays
import-free (bottom of the graph); its two upward calls (setMarkdown ->
renderer, export -> toast/auth) late-bind through window at call time to
avoid import cycles.

Each module installs a transitional window bridge for the still-classic
bundles (console app/admin/governance, ui app, inline onclick=), which
only touch the globals at boot/event time — verified by a column-0 /
IIFE-body audit of all four consumers, and including the audit-missed
initLogin() that both app.js boot paths call. theme.js stays classic:
deferring it would flash the wrong theme before first paint. Vendored
katex/hljs/mermaid stay classic and lazily typeof-guarded.

interactive.js and shell.js drop their bare-global reads for real imports
(authFetch, showToast, Composer, StatusBar, queue/attachment controllers,
streaming renderer, setMarkdown). The three HTML entries load the
substrate as module tags (same positions, same version_html stamping);
classic admin/governance/app still parse first, modules evaluate before
shell.js calls TS_APP.boot().

Tests: auth/kb/utils move from test_app_js's classic node-check sweep to
test_shell_js's module-semantics sweep, which now covers all 15 shared
modules (sink scan excludes renderer.js, the sanctioned HTML producer;
the no-var ratchet covers the var-free subset). The const-reassign guard
re-includes the converted files plus the shell modules.
2026-06-09 13:37:35 -07:00
Patrick Buckley f6f7d1fff7 feat(ui): collapsible glyph rail + mobile off-canvas drawer
The L-shell rail gains its two deferred responsive modes:

- Desktop collapse (user preference, localStorage turnstone_interface.rail):
  the rail shrinks to a 52px glyph-only strip — live Tier-1 state glyphs
  remain the navigation, cluster pills stack as glyph+count, Manage becomes
  one gear row opening the Admin pane, children flatten to peer glyphs.
  Title attrs (now set unconditionally) carry the names; aria-labels were
  already complete.
- Mobile drawer (max-width 768px): the rail leaves the grid and overlays
  off-canvas at full width behind a scrim. Burger in the tab bar opens it
  (focus moves into the rail); Escape (focus returns), scrim tap, or any
  pane activation closes it. Closed drawer is visibility:hidden so its
  buttons leave the Tab order. The collapse preference lies dormant here.
- Tab titles render in an ellipsizing span capped at 240px (48vw mobile)
  instead of growing the tab unbounded; tab bar scrolls horizontally on
  mobile.
2026-06-09 13:37:35 -07:00
Patrick Buckley 3fc65577f5 fix(ui): tab-menu verbs follow the live node when the controller is dead
A dead interactive controller's base goes stale once its node loses or
re-homes the ws, but menuBase() returned it first — so the close/delete
404-as-success lanes could silently drop a tab whose session is alive on
the node it re-homed to. Mirror the revive path: when isDead(), lead with
the live Tier-1 node and fall back to the stale base only when the ws is
gone cluster-wide (its 404 then correctly reads as "already closed").
2026-06-09 11:57:18 -07:00
Patrick Buckley 4b1536be2c fix(ui): ws lifecycle round 2 — dead-session revive + proxied tab-menu verbs
Two reported console bugs, one shared root: a pane can outlive its
session, and nothing brought the two back together.

Reconnect: an interactive pane whose stream died (ws closed/evicted
elsewhere, node restart, re-home) could never reconnect while its tab
existed — openPane() on an existing pane was focus-only, the
controller's connect() is one-shot, and its 5s recovery loop re-dialed
the SAME node forever (infinite 404 polling through the console proxy).
The only workaround was closing the tab before resuming.

- createInteractivePane now tracks terminal failure: 3 consecutive
  CLOSED recovery beats -> give up (stream closed, timers + any pending
  history load invalidated, status bar "Disconnected", opts.onDead
  fired once). host.onStreamOpen (new hook) resets the counter;
  isDead()/markDead()/base join the controller surface; onLogin
  ignores a dead controller — revive owns recovery, so a deliberately
  closed session is never resurrected by a timer.
- PaneManager.openPane fires pane.onReopen(extra) when it targets an
  ALREADY-OPEN pane — the explicit-intent signal (saved-list resume,
  rail row, child link) that activate() can't carry (hooks no-op on the
  active pane, and onActivate also fires on plain tab switches).
  getPane() added for cross-cutting lifecycle signals.
- The shell paints a click-to-reconnect banner on give-up — and
  immediately on Tier-1 ws_closed via the new
  TS_SHELL.notifySessionClosed seam (the console keeps the tab, unlike
  the standalone's auto-close, so the conversation stays readable).
  Reopen/banner-click revives: tear down the dead controller,
  re-resolve through the origin-first POST /open lane, rebuild.  The
  forced resolve skips BOTH beginConnect fast paths (a stale Tier-1 row
  must not bypass /open) while a live node leads the hint chain (an
  origin-first /open then reuses a genuinely-live session instead of
  loading a duplicate on the old meta node).  The standalone lane POSTs
  its local /open on revive too — /events 404s on an unloaded ws.
- Coordinator parity: the factory exposes reconnect() (acts only on a
  missing/CLOSED stream; OPEN is healthy, CONNECTING is already being
  worked) and the pane's onReopen drives it — the saved-list resume
  POSTs /open before openPane, so a fresh stream is all it needs.

Tab menu: a node-proxied interactive pane's dropdown gated every verb
on classic globals that only exist in ui/static/app.js, so the console
got a nearly-empty menu whose one surviving verb (Export) hit the
console origin and 404'd. convTabMenu gains a base-aware fallback lane:
verbs POST against the pane's OWN transport base (controller's exact
base -> persisted node hint -> live Tier-1 node; a verb is omitted
while no base is resolvable — never aimed at the wrong origin).
Close/Delete confirm first (window.confirm, the coordinator precedent)
and treat 404 as intent-satisfied (nothing left to stop/delete -> drop
the tab). exportWorkstreamDownload takes the base. The standalone
keeps its globals lane (incl. Fork) byte-identical, and an empty verb
section no longer renders a leading separator.

Verified: 189 JS-pin tests; two headless-Chrome live-DOM harnesses
driving the real modules — console 16/16 (connect -> ws_closed ->
banner -> reopen revives on a new node with the fresh hint -> give-up
stops retrying -> live-node-led resolve), standalone 10/10 (globals
menu intact, revive POSTs /open exactly once, no cluster resolve).
2026-06-09 11:57:18 -07:00
Patrick Buckley 91c4afb2d0 chore: sync uv.lock with anthropic>=0.108 floor + docstring nit
CI lock-check failed: the Fable 5 commit raised the anthropic floor to
>=0.108 in pyproject.toml but uv.lock still recorded >=0.39 / resolved
0.107.1. Regenerate the lock: anthropic 0.107.1 -> 0.108.0, specifier
0.39 -> 0.108 (no transitive changes).

Also address the Copilot review nit: the operator-instruction trust
declaration docstring wrote the fence marker as <system-reminder_<nonce>>;
align it to the emitted and project-standard <system-reminder_{nonce}>
notation.
2026-06-09 10:50:58 -07:00
Patrick Buckley 114ada791b feat(providers): add Claude Fable 5 to the Anthropic provider
- claude-fable-5 capability entry: 1M context / 128K output, adaptive
  thinking (summarized display), effort low..max incl. xhigh, no
  sampling params, web + tool search, vision, reasoning replay, native
  mid-conversation system messages
- document the Fable 5 wire quirk at the capability table: an explicit
  thinking={"type": "disabled"} is a 400 on this model; the adaptive
  branch never emits "disabled", so adaptive-or-omitted is preserved
- widen the native mid-conversation-system comments from opus-4-8-only
  to opus-4-8 + fable-5 (protocol, provider, tool_advisory, prompts,
  session)
- raise the anthropic SDK floor 0.39 -> 0.108: 0.39 predates every
  named kwarg the provider sends (output_config 0.77, top-level
  cache_control 0.83, mid-conversation system blocks 0.105); 0.108
  adds claude-fable-5
- tests: capability assertions for claude-fable-5 + dated-variant
  prefix match
2026-06-09 10:50:58 -07:00
Patrick Buckley ab6d95da24 fix(ui): clear browser-console warnings (invalid pattern regex + favicon 404)
The pattern attribute on the MCP server-name and model-alias inputs used an unescaped hyphen in its character class. Browsers compile the HTML pattern attribute with the RegExp `v` flag, under which a literal `-` must be escaped — the class failed to compile, so the browser silently dropped the constraint and disabled client-side validation (Firefox). Escaping the hyphen leaves the matched set unchanged and consistent with the server-side ^[a-zA-Z0-9._-]+$ validators.

Add an inline-SVG data: URI favicon to the console, coordinator, and ui entry points so page loads no longer 404 on /favicon.ico. A data URI needs no new static route and survives the /node/{id} proxy path rewrite.
2026-06-09 10:30:50 -07:00
356 changed files with 68819 additions and 17786 deletions
+32 -18
View File
@@ -14,8 +14,8 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
- run: pip install pre-commit
@@ -25,8 +25,8 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
- run: pip install mypy
@@ -35,12 +35,15 @@ jobs:
test:
runs-on: ubuntu-latest
# Cap a hung run at 20 min instead of riding GitHub's 6-hour default
# (a flaky-hang run otherwise streams -v output for hours).
timeout-minutes: 20
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
# Node is required by tests/test_renderer_js.py — without
@@ -51,7 +54,10 @@ jobs:
with:
node-version: "24"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
# -v lists each test id as it starts (pytest prints the nodeid at
# logstart), so a hang names the culprit on the last line instead of
# riding the job timeout with only a trail of "..." dots.
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -v
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
@@ -60,6 +66,7 @@ jobs:
test-postgres:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:18
@@ -75,23 +82,23 @@ jobs:
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
- run: pytest tests/ -m "not live" --storage-backend=postgresql -v
env:
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
wheel-completeness:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
- run: pip install build
@@ -106,9 +113,16 @@ jobs:
| grep -v '\.py$' | grep -v '\.dist-info' | grep -v '\.pyc' | grep -v '^File$' \
| sort)
# Files intentionally excluded from the wheel (one per line)
# Files intentionally excluded from the wheel (one per line).
# The vllm-litellm/ deploy example ships in the repo, not the wheel
# (you clone the repo to run it; the package doesn't reference it).
ALLOW="
turnstone/core/storage/migrations/script.py.mako
turnstone/deploy/vllm-litellm/.env.example
turnstone/deploy/vllm-litellm/README.md
turnstone/deploy/vllm-litellm/docker-compose.yml
turnstone/deploy/vllm-litellm/gemma.Dockerfile
turnstone/deploy/vllm-litellm/litellm-config.yaml
"
MISSING=$(comm -23 <(echo "$SOURCE") <(echo "$WHEEL") \
@@ -132,12 +146,12 @@ jobs:
/tmp/smoke/bin/turnstone-console --help
/tmp/smoke/bin/turnstone-admin --help
/tmp/smoke/bin/turnstone-channel --help
/tmp/smoke/bin/turnstone-bootstrap --help
/tmp/smoke/bin/turnstone-doctor --help
lock-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
uv-version: "0.9.18"
@@ -146,11 +160,11 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
uv-version: "0.9.18"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
- run: uv sync --frozen --all-extras
@@ -174,7 +188,7 @@ jobs:
run:
working-directory: sdk/typescript
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
+46
View File
@@ -0,0 +1,46 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
if: github.event.pull_request.head.repo.full_name == github.repository
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # post the review + inline comments
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@6c0083bb7289c31716797a039b6367b3079cc46e # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: 'renovate[bot]' # let Renovate PRs get reviewed
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
+63
View File
@@ -0,0 +1,63 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) || (
github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) || (
github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@claude') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)
) || (
github.event_name == 'issues' &&
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # post comments/reviews when @-mentioned on a PR
issues: write # post comments when @-mentioned on an issue
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@6c0083bb7289c31716797a039b6367b3079cc46e # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
+15 -4
View File
@@ -7,7 +7,9 @@ on:
concurrency:
group: docker-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
# Never cancel mid-push: an interrupted multi-tag push can leave the
# registry with a partial tag set (e.g. :latest moved, :stable not).
cancel-in-progress: false
permissions:
contents: read
@@ -19,15 +21,24 @@ env:
jobs:
docker:
# Same gate as publish.yml: workflow_run fires for every CI completion
# (including fork and same-repo PR runs) with this repo's token and
# packages:write. Only same-repo tag pushes may publish images; CI's
# push trigger matches main/stable/* and v* tags, so a head_branch
# starting with "v" is necessarily a tag run.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
# The docker build only reads the tree; keep the token out of it.
persist-credentials: false
- name: Resolve release tag
id: tag
@@ -72,7 +83,7 @@ jobs:
- name: Build and push
if: steps.tag.outputs.skip == 'false'
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: true
+19 -5
View File
@@ -7,7 +7,9 @@ on:
concurrency:
group: publish-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
# Never cancel a publish mid-upload: a half-uploaded release (sdist up,
# wheel missing) cannot be re-run cleanly because PyPI rejects duplicates.
cancel-in-progress: false
permissions:
contents: write
@@ -15,14 +17,26 @@ permissions:
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
# workflow_run fires for EVERY CI completion — including CI runs for
# pull_requests from forks — and always executes here with this repo's
# secrets, tokens, and the pypi environment. Gate to same-repo tag
# pushes only: CI's push trigger matches branches main/stable/* and
# tags v*, so a head_branch starting with "v" is necessarily a tag run.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
# python -m build executes the tree's build backend; don't leave
# the contents:write token sitting in .git/config while it runs.
persist-credentials: false
- name: Resolve release tag
id: tag
@@ -36,7 +50,7 @@ jobs:
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
if: steps.tag.outputs.skip == 'false'
with:
python-version: "3.14"
@@ -49,7 +63,7 @@ jobs:
- name: Create GitHub Release
if: steps.tag.outputs.skip == 'false'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with:
tag_name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
+42
View File
@@ -0,0 +1,42 @@
name: Understone example
# The door-game example is a standalone package with no dependency on
# turnstone core, and the root test suite does not collect it
# (testpaths=["tests"]). Without this workflow its suite never runs in CI.
# Path-filtered so it only runs when the example (or this workflow) changes.
on:
push:
branches: [main, "stable/*"]
paths:
- "examples/door-game/**"
- ".github/workflows/understone-example.yml"
pull_request:
branches: [main, "stable/*"]
paths:
- "examples/door-game/**"
- ".github/workflows/understone-example.yml"
permissions:
contents: read
jobs:
understone:
runs-on: ubuntu-latest
defaults:
run:
working-directory: examples/door-game
strategy:
matrix:
# Floor and ceiling of the example's requires-python (>=3.11).
python-version: ["3.11", "3.13"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[test,dev]"
- run: pytest tests/ -q
- run: ruff check .
- run: ruff format --check .
- run: mypy understone/
+26 -5
View File
@@ -25,22 +25,43 @@ permissions:
jobs:
vendor-js:
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
# Same-repo PRs only: this job checks out the PR head and pushes to it
# with contents:write, so it must never act on a fork's branch.
# Gate on the PR author (immutable), not github.actor (names whoever
# caused the latest event, which can be someone else re-running it).
if: >-
(github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'renovate[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Resolve PR head ref
id: ref
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Branch names may contain shell metacharacters; pass via env,
# never interpolate ${{ }} into the script body.
HEAD_REF: ${{ github.head_ref }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
# The dispatch input is an arbitrary PR number; refuse fork PRs.
# A fork's headRefName is a bare branch name that may collide
# with a branch in this repo, and checkout+push would then hit
# that unrelated branch ("same-repo PRs only" applies here too).
pr_json=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefName,isCrossRepository)
if [[ "$(jq -r '.isCrossRepository' <<< "$pr_json")" != "false" ]]; then
echo "::error::PR #${PR_NUMBER} head is not a branch in this repository; refusing to complete it."
exit 1
fi
ref=$(jq -r '.headRefName' <<< "$pr_json")
else
ref="${{ github.head_ref }}"
ref="$HEAD_REF"
fi
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ steps.ref.outputs.head_ref }}
+200 -58
View File
@@ -6,81 +6,223 @@ 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:
Three release tracks are maintained — the current stable, one prior
stable, and the experimental line:
- **`stable/1.4`** — patch-only (`v1.4.x`)
- **`stable/1.5`** — patch-only (`v1.5.x`)
- **`stable/1.6`** — patch-only (`v1.6.x`)
- **`main`** — experimental (next major)
## [Unreleased]
## [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
- **Self-hosted SearxNG web search** — the `web_search` tool's backend for
local/vLLM models is now a bundled [SearxNG](https://searxng.org) service
(`searxng` in both compose stacks; internal docker network only, JSON API
enabled, rate limiter off). Two new settings configure it: `tools.searxng_url`
(default `http://searxng:8080`, env `TURNSTONE_SEARXNG_URL`) and
`tools.searxng_engines` (env `TURNSTONE_SEARXNG_ENGINES`). Commercial providers
(Anthropic, OpenAI) continue to use their own native server-side search and
never touch SearxNG; the `mcp:server:tool` backend is unchanged. A persistent
`searxng-cache` volume keeps its favicon/internal cache across restarts, and
Caddy can serve SearxNG's own web UI on a dedicated port (dev stack:
`https://localhost:8444`, localhost-only; production: opt-in). See
[docs/docker.md](docs/docker.md) for the AGPL-3.0 §13 note that applies to
operators who expose the bundled SearxNG publicly.
- **`turnstone-admin` reads `config.toml`** — the admin CLI now honors
the same `[database]` section that `turnstone-server` does, with the
same precedence (`CLI / config.toml > TURNSTONE_DB_* env > defaults`).
Operators with DB credentials in `config.toml` no longer need to
re-export `TURNSTONE_DB_URL` before every admin invocation. Newly
plumbed through to `init_storage`: `pool_size`, `sslmode`,
`sslrootcert`, `sslcert`, `sslkey` — previously the admin CLI
silently dropped these. A new `--config PATH` flag mirrors the
one already on `turnstone-server`.
- **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, otherwise
no client (the `web_search` tool is dropped for models without native search).
- **`web_search` tool: `topic``category`** *(BREAKING)*the LLM-facing
parameter is renamed and its values are now `general` (default), `news`, `it`
(code/tech), or `science`, mapped to SearxNG categories so the model can target
the right corpus. The Tavily-era `finance` topic (no SearxNG equivalent) is gone.
(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 self-hosted SearxNG service (see Added). Removed: the
`tools.tavily_api_key` setting, the `$TAVILY_API_KEY` env var, the
`[api].tavily_key` config key, and the `ddg` install extra (the `ddgs`
dependency). Migration: use the bundled SearxNG (it ships in the compose stacks
by default) or point `TURNSTONE_SEARXNG_URL` at an existing instance. No
database migration required.
- **`man`, `math`, and `plan_agent` built-in tools removed** — `man` and
`math` duplicated capabilities already available through `bash`; `plan_agent`
is better expressed as a `task_agent` running a planning skill. Removing
them simplifies the tool surface and cuts per-call token cost. This release
also removes: the `math` sandbox executor (`turnstone.core.sandbox`) and the
`[sandbox]` extra's role for it; the read-only `AGENT_TOOLS` sub-agent tool
set and the `agent` tool-metadata key; the plan-review protocol
(`/v1/api/plan` endpoint, `plan_review`/`plan_resolved` SSE events, the
`on_plan_review` SDK/UI hook); and the `model.plan_alias` /
`model.plan_effort` ConfigStore settings (and the corresponding
`[model].plan_model` / `[model].plan_effort` config.toml knobs).
**Breaking change** on the experimental 1.6 line. Interactive built-in tool
count moves from 19 → 16; `TASK_AGENT_TOOLS` from 13 → 11.
- **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
- **Permissive `config.toml` now warns** — `turnstone.core.config.load_config`
logs a single warning when the resolved config file is group- or
world-readable (any bit in `0o077`). DB password and TLS key paths
live in `[database]`; operators usually want the file at `0600`.
- **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]
+1 -1
View File
@@ -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 [Business Source License 1.1](LICENSE).
project's [Apache License 2.0](LICENSE).
+13
View File
@@ -0,0 +1,13 @@
# 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)
+5 -3
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.11.19 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
@@ -17,8 +17,10 @@ RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
# ripgrep is the preferred backend for the search tool — natively bounds
# per-line, per-file, and per-filesize so pathological inputs (minified
# bundles, training-data JSONL with multi-MB single records) can't OOM us.
# ffmpeg transcodes omni STT uploads (browser webm/opus) to the 16 kHz mono
# WAV the omni chat-audio lane decodes.
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 git curl jq man-db manpages procps file ripgrep \
libpq5 git curl jq man-db manpages procps file ripgrep ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
@@ -33,7 +35,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 ./
COPY pyproject.toml uv.lock README.md LICENSE NOTICE THIRD-PARTY-NOTICES ./
RUN uv sync --frozen --no-install-project --no-dev \
--no-compile --extra all
+207
View File
@@ -0,0 +1,207 @@
# What is a harness?
*A hypothesis — not a theorem. The honest answer is a claim about **shape**: an object you can write down that says what a harness is and, just as precisely, the guarantee it cannot carry for free.*
Most descriptions of an agent framework are a feature list. This is an attempt at a definition.
---
## The claim
*Informal.* A harness is a **stopped, deterministically-controlled Markov process on task-state, closed around a stopped autoregressive process on context-space, driven by a learned model kernel** — a deterministic controller in closed loop with a stochastic learned plant.
*In plain terms.* The **harness** is the whole governed loop: a deterministic **shell** you write — build the prompt, authorize an action, fold the response back into state — wrapped around a black-box stochastic model kernel (the **plant**, $M_W$) and the environment its actions touch, looped until it halts in $H$. The shell is deterministic, $M_W$ is not, and everything below makes that split precise.
*Formal — the objects.* A harness is a tuple $\mathcal{H} = (\mathcal{S}, \mathcal{C}, \mathcal{Y}, \mathcal{A}, \mathcal{E}, \pi, M_W, \gamma, Q_E, \rho, H, H_{\mathrm{ok}}, B)$ over **standard Borel** spaces (concretely: the *controlled* state is standard Borel by construction — token sequences, finite config maps, bounded counters and ledgers, finite tuples of real vectors — and the model/environment coordinates are inherited as such whenever they serialize to a Polish space; the assumption is roomier than it looks — even a belief-state coordinate valued in $\mathcal{P}(X)$ survives, since $\mathcal{P}(X)$ is Polish for Polish $X$ — and fails only for a genuinely non-separable coordinate, an uncountable product $\sigma$-algebra being the canonical hazard, which this construction avoids): a deterministic lowering $\pi:\mathcal{S}\to\mathcal{C}$; a stochastic model-run kernel $M_W(c, dy)$ into a readout space $\mathcal{Y}$ (which includes the parse-failure $\bot$, so $M_W$ and $\gamma$ are total over it); a deterministic **authorization gate** $\gamma:\mathcal{S}\times\mathcal{Y}\to\mathcal{A}_{\bot}$ that validates the model's parsed readout into an authorized action in $\mathcal{A}$ or rejects it as $\bot$ (parsing itself lives inside $M_W$ — realized as the readout $R$ of the specialization below); a stochastic environment/tool kernel $Q_E:\mathcal{S}\times\mathcal{A}_{\bot}\rightsquigarrow\mathcal{E}$ on the authorized action (rejection included, with $Q_E(s,\bot,\cdot)=\delta_{e_0}$ for a distinguished no-op response $e_0\in\mathcal{E}$); and a deterministic verify-and-fold-back map $\rho:\mathcal{S}\times\mathcal{Y}\times\mathcal{A}_{\bot}\times\mathcal{E}\to\mathcal{S}$.
*Terminal structure.* The terminal set is an absorbing halt set $H\subseteq\mathcal{S}$ (the daemon "ready-state" recurrence of the note below is a separate, non-absorbing object) with accepting subset $H_{\mathrm{ok}}\subseteq H$; separately, a bad set $B\subseteq\mathcal{S}$ ($B\cap H_{\mathrm{ok}}=\varnothing$) marks the unsafe states for reach-avoid, possibly entered before any halt; hitting times are $\tau_A=\inf\{n\ge 0:s_n\in A\}$, and $\tau_H$ is a stopping time for the natural filtration.
*The outer kernel.* The induced outer transition kernel, for $s\notin H$, is
$$T(s, A) = \int_{\mathcal{Y}}\!\int_{\mathcal{E}} \mathbf{1}_A\!\big(\rho(s, y, \gamma(s,y), e)\big)\; Q_E\big(s, \gamma(s,y), de\big)\; M_W(\pi(s), dy), \qquad T(s,A)=\mathbf{1}_A(s)\ \text{ for } s\in H,$$
and the harness runs $s_{n+1} \sim T(s_n)$ from an initial $s_0 \sim \mu_0$ until $\tau_H = \inf\{n : s_n \in H\}$. Because $\pi, \gamma, \rho, H$ are deterministic they contribute no integration variable of their own — they appear as measurable transformations inside the integrand (the pushforward), not literally outside it — so the controller injects no randomness, and every coin is inherited from $M_W$ and $Q_E$. (The earlier shorthand $T = \rho \circ (M_W \circ \pi, E)$ is suggestive but ill-typed — $M_W$ returns a *law*, while $\rho$ consumes a *sample* together with the prior state $s$; the integral is what the shorthand meant.)
*Fail-closed.* The gate $\gamma$ is what makes **fail-closed** a property, not just a name: model output is an *untrusted proposal*, and $\gamma(s,y)=\bot$ forces a no-op environment response ($Q_E(s,\bot,\cdot)=\delta_{e_0}$) — so a malformed or unauthorized tool call is rejected *before* it can act, not validated after its side effects have landed. Fail-closed is then the property that a rejected proposal causes *no unauthorized side effect* and lands in a **safe, non-bad** set ($\rho(s,y,\bot,e_0)\notin B$): a non-accepting terminal $H\setminus H_{\mathrm{ok}}$ in the strict case, or a safe non-terminal state when the spec retries. And $\rho$ must validate the tool *response* $e$, not only the proposal that $\gamma$ already gated: a malformed or adversarial response $e$ is caught at fold-back, not just at the gate. But response-validation has a hard limit: $\rho$ can reject a bad tool *response*, yet it cannot undo side effects an *authorized* action already caused — so $\gamma$, not $\rho$, is the last line before irreversible effects, and anything irreversible must be gated at authorization. The boundary is also only real if raw model output reaches *no* sink — tool, logger, browser, or remote call — before $\gamma$; any pre-authorization escape bypasses the gate. The user-visible final response and any logging are themselves effects, and the rule binds *model-authored* bytes: they reach a sink either as an authorized action through $\gamma$, or only after an accepted halt in $H_{\mathrm{ok}}$. Shell-*templated* text — a refusal notice, a cancellation report reading the ledger — is controller output, outside $\gamma$'s jurisdiction, and may accompany any halt (a template that *interpolates* model-authored fragments inherits the model's label — the appendix's meet rule — and those bytes are gated like any others); the invariant is that raw model text never reaches a sink ungated, not that failed runs die silent.
*The harness invariants.* These are the invariants that make $\mathcal{H}$ a *harness* and not merely a controlled Markov process with a learned kernel inside: the model sees only $\mathcal{C}$, never full $\mathcal{S}$; its outputs are proposals, not actions; a deterministic capability boundary $\gamma$ gates every side effect; and the *terminal* set $H$ splits into accepting ($H_{\mathrm{ok}}$) and non-accepting ($H\setminus H_{\mathrm{ok}}$ — safe refusals outside $B$, and wrong or bad halts possibly in $B$), while the bad set $B$ is a *separate* unsafe set — possibly absorbing, possibly entered mid-run before any halt — against which $\tau_B$ is measured for reach-avoid. Two notes keep the invariants honest. They are *signature*, not strength: a $\gamma$ that authorizes everything still satisfies the tuple, as a trivial group satisfies the group axioms — the definition admits degenerate harnesses, and fail-closed, provenance isolation, and the certificates below are properties a particular harness *earns*, not gifts of the signature. And the first invariant has a sharper, two-sided form: $\pi$ is the *only* channel from state to model — the confidentiality floor lives at what $\pi$ must never lower (credentials, other principals' data) — exactly as $\gamma$ is the only channel from model output to effect, where the injection bounds live; exfiltration is therefore cut at either chokepoint, never lowered or never emitted (the gate refusing the read whose URL is the payload is the emission-side cut). One chokepoint out of the state, one into the world; a bypass of either is the same bug with the sign flipped.
*Beyond the stationary kernel.* This displayed $T$ is the time-homogeneous, fixed-kernel case; for nonstationary or adversarial environments, replace $Q_E$ with a time-indexed kernel $Q_{E,n}$ — or an admissible family of kernels, or an adversary's policy — over which the robust certificate (the minimax form under *The limit*) quantifies. If that adversary conditions on history rather than only the current $(s, y)$, the history must itself live in $s$ — otherwise the object is a Markov *game* requiring further augmentation, not a Markov chain. And nonstationarity is not the environment's monopoly: a provider retraining or re-serving under a fixed endpoint name is a nonstationary $M_{W,n}$ — the table places model version *in* $s$ precisely so a version bump is a visible state change — and any measured surrogate (the $\delta$ of *The limit*) is calibrated against one kernel and dies with the bump; the dashboard must be keyed to the kernel it measured.
*The inner kernel.* $M_W$ is itself a stopped process, and for a decoder-only transformer it is implemented as
$$M_W(c, \cdot) = \mathrm{Law}\big(R(z_{\tau})\big), \quad z_t = (c_t, b_t, m_t), \quad v \sim K_W(c_t, \cdot), \quad K_W(c, v) = (U \circ \Phi_W \circ \mathrm{Emb})(c)[v], \quad c_{t+1} = \mathrm{suffix}_{\le L}(c_t\!\cdot\! v),\ \ b_{t+1} = b_t\!\cdot\! v,\ \ m_{t+1} = \mathsf{step}(m_t, v),\ \ \tau=\inf\{t:m_t\in\mathrm{Stop}\}.$$
with the layer stack $\Phi_W$ on the residual stream as the (loosely) "manifold" core — formally just the learned high-dimensional residual-stream transformation, with manifold-proper reserved for the frontier. The inner state $z_t=(c_t,b_t,m_t)$ separates the model-visible window $c_t$ (the $\le L$ slice that slides) from the untruncated output buffer $b_t$ (the transcript the readout actually consumes, so truncation never loses it) and the parser/stop state $m_t$ (parser state, a token counter, and a clock, so the cap and timeout are functions of it), updated $m_{t+1}=\mathsf{step}(m_t,v)$, whose stop set $\mathrm{Stop}$ — EOS emitted, max-token cap, timeout, or parse-failure $\bot$ — forces $\tau=\inf\{t:m_t\in\mathrm{Stop}\}$ finite, making $M_W$ a genuine *probability* kernel rather than a sub-probability one completed by a cemetery output. (One honesty note on the clock: a token-count cap is a deterministic function of the run, but a *wall-clock* timeout imports infrastructure noise — server load, batching, congestion — into the kernel's coin; legitimate, a kernel may carry any randomness, but it makes the displayed $M_W$ the model *plus its serving substrate*, and the determinism audit under *How this could be wrong* must hold the clock fixed along with the samples.) The readout is total, $R : \mathcal{Z} \to \mathcal{Y}$ — a parsed tool-call, answer, or transcript, returning the parse-failure $\bot\in\mathcal{Y}$ when parsing fails; crucially $R$ is a *syntactic, verified* readout (parsing and extraction), not a semantic solver, or the $L$-wall below is void — arbitrary computation could hide in $R$ off the $\le L$ window — so $M_W(c, \cdot) = R_{\sharp}\,\mathrm{Law}(z_{\tau})$, the pushforward of the stopped-state law along $R$ (equivalently $M_W(c, A_Y) = \Pr[R(z_{\tau}) \in A_Y \mid z_0 = (c,\varnothing,m_0)]$ for a measurable $A_Y\subseteq\mathcal{Y}$); the no-truncation special case takes $\mathcal{Y}=\mathcal{C}$ with $R(c,b,m)=c$ (the window is the whole transcript), reading $c_{\tau}$ directly. The $\bot$ branch is exactly what $\gamma$ rejects fail-closed. This is a **specialization, not part of the definition**: a harness wrapped around a black-box API is still a harness, and $M_W$ may be any learned kernel. Where the weights are open, the geometry of $\Phi_W$ is where the substrate's continuity lives, and several downstream claims lean on it — but the definition does not.
Two stopped processes, nested: **deterministic control over stochastic dynamics over a learned kernel.** Both loops are hitting-time processes; *some* harnesses additionally read the halt set as a fixpoint or acceptance condition — iterative refinement to self-consistency is the genuine fixpoint case, while EOS, length, and tool-call syntax are not convergence. Neither loop settles because you asked it to. (The clean inner-then-outer nesting assumes tool calls fall *between* model runs; streaming or mid-generation tool calls interleave the two loops and need a finer state machine — the nesting is then an idealization.)
## Reading it
| Symbol | Is |
|---|---|
| $\mathcal{H}$ | the harness — the whole controlled system, *not* the model |
| $s \in \mathcal{S}$ | task-state: IR / dialect stack, tool results, plan, counters, **and every mutable interface variable** (model/tool versions, permissions, retrieved context) — only Markov *after* that augmentation |
| $\mathcal{C},\ \mathcal{Y},\ \mathcal{A},\ \mathcal{E}$ | the **context / readout / action / effect spaces** — model-visible context $\mathcal{C}$, model readout $\mathcal{Y}$ (incl. the parse-failure $\bot$), authorized actions $\mathcal{A}$ (with $\mathcal{A}_\bot = \mathcal{A}\cup\{\bot\}$), and tool/environment effects $\mathcal{E}$ |
| $\pi : \mathcal{S} \to \mathcal{C}$ | **lowering** — prompt construction, dialect lowering, effective-program selection (deterministic) |
| $M_W(c, dy)$ | the **model-run kernel** (inner solver) — a stopped autoregressive process; $\Phi_W$ is the residual-stream ("manifold") core in the transformer case |
| $Q_E(s, a, de)$ | the **environment/tool kernel** on the authorized action $a\in\mathcal{A}_{\bot}$ (with $Q_E(s,\bot,\cdot)=\delta_{e_0}$, the no-op $e_0$) — tool effects, API responses, the world (possibly adversarial) |
| $\gamma,\ \rho$ | the deterministic **authorization gate** $\gamma:\mathcal{S}\times\mathcal{Y}\to\mathcal{A}_{\bot}$ (untrusted proposal → authorized action or $\bot$) and the **fail-closed verify-and-fold-back** $\rho:\mathcal{S}\times\mathcal{Y}\times\mathcal{A}_{\bot}\times\mathcal{E}\to\mathcal{S}$ |
| $H,\ \tau_H$ | the **halt set** (absorbing) and the outer **halting time** — a hitting-time process, not a single pass |
| $H_{\mathrm{ok}},\ B$ | the **accepting halts** $H_{\mathrm{ok}}\subseteq H$ (correct, successful terminals) and the **bad set** $B$ — unsafe states for reach-avoid ($B\cap H_{\mathrm{ok}}=\varnothing$), *separate* from $H$ and possibly entered mid-run before any halt |
The structural fact that earns the word *controller*: $\pi$, $\gamma$, $\rho$, and the halt test are **deterministic** (and the readout $R$ too, where the transformer specialization is in play), so $\mathcal{H}$ injects no randomness of its own. Every coin is inherited from $M_W$ and $Q_E$. This split — deterministic code around a stochastic oracle — wears two names. In control-theory terms it is **controller vs. plant**: the controller is those deterministic maps; the **plant** is the learned kernel $M_W$, *plant* in its exact sense — the element with its own dynamics you steer but do not author. In engineering terms it is **shell vs. plant**: the **shell** is the entire deterministic outer harness — the control logic *plus* the external memory and tools it administers (the files, databases, vector stores below) — of which the controller is just the control-logic slice. So *shell : plant :: the part you write : the part you don't*; $M_W$ is the only thing on the right, while the environment $Q_E$ is the world the actions meet — a disturbance into the loop, not the plant. (A reader from reinforcement learning or classical control will make the opposite assignment — environment as plant, policy as controller; the inversion is deliberate: in harness engineering the element you are trying to make behave is the model, and the world is what pushes back on the attempt.) This determinism is *conditional* — on versioned code, configuration, model endpoint, and tool interfaces, and on *single-run sequencing*: concurrent runs sharing authorization state re-open a gap the per-run object cannot see (taken up under *Gate placement* in the appendix); any retry, timeout, race, or randomized routing that escapes that conditioning must be modeled explicitly as part of $Q_E$ or the controller, not waved away. The displayed $M_W(c)$ likewise freezes endpoint, version, and sampler; a routing or config change is a state-indexed kernel $M_{\kappa(s)}$ or folds into $K_C$ — the kernel must not silently depend on config the table places in $s$. More generally, control may itself be stochastic — a controller kernel $K_C(s, dc)$ over routing, sampled retries, ensemble votes, learned routers — of which the deterministic $\pi, \gamma, \rho, H$ are the Dirac special case. That case is the one worth wanting: it localizes every coin to $M_W$ and $Q_E$ and keeps the controller/plant split clean. Where control is genuinely stochastic the split does not break, it widens — fold $K_C$ into the kernel and the certificate quantifies over its randomness too. But the guarantees do not soften uniformly, and the component-to-guarantee map is worth stating because it says exactly what may be learned without loss. A learned $\pi$ — retrieval, reranking, summarization inside the lowering — costs only *semantic adequacy*, under one factorization: $\pi$ splits into a deterministic **never-lower filter** — the redaction that keeps credentials and other principals' data out of $\mathcal{C}$ — composed with learned selection, and only the selection may soften, or the confidentiality floor of the invariants note becomes a probability. With the filter Dirac, no-unauthorized-effect is $\gamma$'s property alone, and the reach-avoid certificate survives too, so long as the provenance partition of *The limit* holds. A learned $\gamma$ or $\rho$ costs the thing itself — authorization and ledger integrity are exactly the properties that must stay Dirac, or "no unauthorized effect" and "the ledger is what happened" become probabilities. So the minimal deterministic core is $\{\gamma, \rho, H\}$ plus $\pi$'s never-lower filter: the rest of $\pi$ may soften into a kernel and the harness bends without breaking — fortunate, because every deployed $\pi$ already has learned kernels inside it.
## Why this shape
$$f(x) \;\longrightarrow\; x = f(x;\,W) \;\longrightarrow\; f(x)$$
Classical software, inverted into latent geometry, then re-wrapped in classical software. The harness **re-imposes the determinism the model dissolved**: $\pi, \gamma, \rho$, and the halt test ($H$) are ordinary designed code — a controller — whose primitive operand happens to be a stochastic oracle. That closure is why a compiler is the right mental model (staged deterministic software ports cleanly) and exactly why the analogy breaks (a compiler's primitive operation was never a coin). **The harness is the half you can reason about classically, sitting on top of the half you cannot.**
## The limit, stated honestly
**Raw halting is cheap; correct halting is not.** A **certificate** is a *witness*: a checkable object — here a Lyapunov/drift function $V \ge 0$ — that *provably* satisfies a condition entailing the guarantee, through a standard supermartingale / optional-stopping theorem (the target picks the condition: drift toward $H$ for halting, a barrier for safety, reach-avoid for success). It is not the property, only an object cheap to check and hard to produce. One word then carries two senses, and the seam between them is what this section is about: the **proven** certificate, a $V$ whose bound actually holds; and the **measured** surrogate you fall back on when the architecture exhibits none — a candidate $\hat V$ with a sampled slack $\delta$, a *calibrated risk metric, not a certificate* until that bound is proven (or held to a high-confidence worst case). The gap between the two is the whole honest-limit argument. A deterministic budget — augment $s$ with a counter $k$ decremented each outer step, halting at $k=0$ — makes $V(s)=k$ a trivial Lyapunov certificate for *halting*, so the architecture does not lack a halting guarantee by construction. What it lacks for free is a certificate of *correct, safe, successful* halting under the learned dynamics. The un-budgeted halting object is still worth stating, since it shows where even the easy guarantee comes from: a certificate would be *sufficient* for almost-sure halting with bounded expected runtime — a $V \ge 0$ with
$$\mathbb{E}[\,V(s_{n+1}) \mid s_n\,] \le V(s_n) - \varepsilon \quad\text{off the halt set}$$
bounds $\mathbb{E}[\tau_H] \le V(s_0)/\varepsilon$ under the usual integrability and optional-stopping conditions. Nothing in the harness hands you such a $V$ the way a compiler's structure does: a specific compiler analysis gets its $V$ for free where a finite-height lattice *is* a well-founded descent — termination by construction *for that analysis*, not for a whole compiler — and the harness has no analogous built-in descent for its model/environment loop.
But the relevant $V$ is not *absent* — and this is the subtlety the blunt phrasing erased. The minimal certificate exists and is **forced**: it is the expected halting time itself,
$$V^\star(s) = \mathbb{E}[\,\tau_H \mid s_0 = s\,],$$
finite wherever $H$ is reached in finite expected time — the domain $\{s : \mathbb{E}_s[\tau_H] < \infty\}$ — though note this $V^\star$ certifies *halting* (reaching the terminal set $H$ at all), not *correct* halting; the stronger object, the expected time to an accepting $H_{\mathrm{ok}} \subseteq H$, is $V^\star_{\mathrm{ok}}$, taken up at the second wall below. So the honest claim splits in two: the architecture provides no certificate *for free*, and the one that exists is — **conjecturally, not as a theorem** — a functional of $W$ and the environment that does not compress below model scale. The conjecture needs scoping, because the per-step drift splits by coordinate (made precise below) and the shell's contribution is an exact, designed descent of low description complexity *by construction* — so whatever is incompressible is not the shell's part but the **plant's**, the contribution $M_W$ supplies. And even there it is conjecture with a live counter-possibility, not foregone hardness: $V^\star$ is a *coarse* functional — one scalar, an expected hitting time, not the full output law — and coarse functionals of complicated kernels are sometimes cheap (absorbing chains with sparse transition structure have tractable expected hitting times over enormous state spaces). So the honest form is conditional: *if* the plant's contribution to the drift admits no certificate of description length materially below $|W|$, then ours is as hard as the dynamics — but that antecedent is the unproven part, and the flat phrasing of an earlier draft ("the dynamics it certifies *are* the weights") overstated it by treating a coarse hitting-time functional as if it carried the whole distribution. The compiler's certificate is structurally trivial; ours is *plausibly* as hard as the plant dynamics, though whether useful compressed certificates exist — for the coarse hitting-time functional, or for structured sub-tasks — is open. This is the quantitative form of *you can borrow how LLVM is built — not, in general, why it is correct.*
So you never compute $V^\star$. You pick a candidate $\hat V$ and **estimate its drift slack**
$$\delta = \sup_{s \notin H}\Big(\mathbb{E}[\,\hat V(s_{1}) \mid s_0 = s\,] - \hat V(s) + \varepsilon\Big).$$
The status of $\delta$ has to be stated carefully, because it is easy to oversell. If you can establish a *high-confidence upper bound* on the true worst-case slack and it is $\le 0$, optional stopping hands you a real, conservative certificate, $\mathbb{E}[\tau_H] \le \hat V(s_0)/\varepsilon$. But an *empirical* $\delta$ estimated from sampled states is **not** a certificate: a measured $\delta > 0$ may mean the candidate $\hat V$ is poor, the sampled distribution missed rare failures, the supremum was never attained in-sample, the process is non-stationary, or the state abstraction is not Markov. So $\delta$ is **the number on the dashboard** — a *calibrated risk metric*, the evaluable surrogate for a guarantee the geometry will not give you, and a genuine bound only once it is statistically controlled against rare-event and adversarial tests. A weaker result is still useful: a true bound $\delta \le \bar\delta < \varepsilon$ (rather than $\le 0$) leaves descent intact with effective slack $\varepsilon - \bar\delta$ and $\mathbb{E}_s[\tau_H] \le \hat V(s)/(\varepsilon - \bar\delta)$. And the empirical quantity is distributional, not a supremum — write $\delta_{\nu}$ for drift averaged over a sampled $\nu$, reserving $\delta_{\sup}$ for the worst-case bound; only $\delta_{\sup}$ certifies. Its empirical noise floor and residual risk are driven by the measure $\mu(D)$ of the divergent region $D=\{s:\mathbb{E}_s[\tau_H]=\infty\}$ (states from which $H$ is not reached in finite expected time, under the reference/sampling measure $\mu$), the coverage of the sampled state distribution, and the hitting-time variance $\mathrm{Var}[\tau_H]$ — properties of the trained weights, the environment, and the evaluation distribution, knowable only a posteriori.
> For an agent *meant* to run forever — a coordinator, a daemon — halting is the wrong target, and $V^\star = \infty$ is the spec, not a pathology. The same drift theory then certifies **recurrence to a ready-state** instead of absorption to a halt-set. The object changes; the missing certificate does not. Safety changes shape too: it is no longer the one-shot $\Pr_s(\tau_B=\infty)$ but a *per-cycle* hazard that compounds — if each ready-state-to-ready-state cycle touches $B$ with probability $q$, survival over $N$ cycles is $\approx (1-q)^N$, so a reassuring per-cycle $0.9999$ is $\approx 0.37$ over ten thousand cycles. The reach-avoid certificate for a daemon is therefore a bound on $q$ against the intended horizon — the safety twin of the regenerative expected time that replaces $V^\star_{\mathrm{ok}}$ for restarting specs.
And the consolation rests in part on an assumption the world violates — though less of it than it first seems. The supermartingale *bound* itself survives a nonstationary kernel, provided the conditional drift holds uniformly at every step; what genuinely needs a **time-homogeneous kernel** is $V^\star$ as a fixed function, the resolvent / fundamental-matrix identities, and the sampled-$\delta$ calibration (which assumes the very kernel it was measured on). But the environment $E$ is *part of* $T$, and the world is not stationary — worse, it can be **adversarial**, an attacker choosing the tool-output *policy* — a kernel over what tools return, not the realized draw — so as to break your descent. The drift condition then stops being a fixpoint question and becomes a **minimax** one,
$$\sup_{\alpha \in \Pi}\ \int_{\mathcal{Y}}\!\int_{\mathcal{E}} V\big(\rho(s, y, \gamma(s,y), e)\big)\, Q_E^{\alpha(s,y)}\big(s, \gamma(s,y), de\big)\; M_W(\pi(s), dy) \;\le\; V(s) - \varepsilon,$$
a descent that must hold in expectation over the model's own output $y$ *and* even when the adversary picks the worst admissible environment policy $\alpha(s,y)$ from the class $\Pi$ of policies the environment genuinely permits — every $\alpha\in\Pi$ must still respect rejection, $\gamma(s,y)=\bot \Rightarrow Q_E^{\alpha}(s,\bot,\cdot)=\delta_{e_0}$, or the adversary resurrects side effects the gate refused. Well-posedness is a frontier caveat of its own: for $\sup_{\alpha\in\Pi}$ to be *attained* rather than merely defined, $\Pi$ needs structure — measurability of $\alpha\mapsto Q_E^{\alpha}$, compactness of the per-state admissible set, or a measurable-selection theorem furnishing a worst-case $\alpha$ — and "respects rejection" is a *constraint* on $\Pi$, not that existence argument; on a general state space the sup may have no maximizer, in which case the certificate quantifies over a maximizing sequence rather than a single adversary. A $V$ that certifies halting against a benign world is defeated by an adversarial one, and the measured $\delta$ bounds only the $Q_E$ you *sampled*, never the policy an attacker will choose.
**This is the formal home of prompt injection** — not "the model did something bad," but the environment optimized to bend your dynamics. And the target is not merely non-halting: injection steers toward a **bad set** $B$ — wrong acceptance, data exfiltration, unauthorized tool use, privilege escalation, irreversible side effects — so security is a **reach-avoid** problem, not a liveness one.
Here two reliability objects must be kept apart, because under absorbing refusal every naive intermediate collapses into one of them:
$$p_{\mathrm{succ}}(s) = \Pr_s\big(\tau_{H_{\mathrm{ok}}} < \tau_F\big), \quad F = B \cup (H \setminus H_{\mathrm{ok}}), \qquad\qquad p_{\mathrm{safe}}(s) = \Pr_s\big(\tau_B = \infty\big).$$
**Success** is reaching a correct halt before *any* failure — a safe refusal counts *against* it. **Safety** is never entering the bad set at all — a safe refusal *satisfies* it. These genuinely differ on any run that avoids $B$ without reaching $H_{\mathrm{ok}}$ ($p_{\mathrm{succ}}$ scores $0$, $p_{\mathrm{safe}}$ scores $1$): safe refusals, and — absent almost-sure absorption into $H\cup B$ — safe non-halting or endless safe retry. The tempting middle form $\Pr_s(\tau_{H_{\mathrm{ok}}} < \tau_B)$ is *not* a third object, by a two-line case analysis: for it to differ from $p_{\mathrm{succ}}$, a run would need $\tau_F < \tau_{H_{\mathrm{ok}}} < \tau_B$ — a non-accepting terminal hit strictly before success, then success anyway — which forces *exiting* $H \setminus H_{\mathrm{ok}}$, impossible while $H$ is absorbing. Note what does **not** re-separate them: within-run fail-closed retries (the non-terminal fail-closed of the definition) never touch $F$ at all — the rejected proposal lands in a safe *non-terminal* state — so a refuse-retry-succeed run scores $1$ on both forms, and the coincidence survives any amount of retrying. The middle form becomes a genuine third object only when the two hitting times can genuinely part ways: under **restarting specs**, where an owner re-launches out of a refusal terminal and the absorbency of $H \setminus H_{\mathrm{ok}}$ is deliberately dropped (the regenerative reading the daemon note above already contemplates) — no bookkeeping needed, since hitting times record *visits*, not occupancy, so the relaunched run's $\tau_F$ is already finite — or under a failure set that counts refusal *events* accumulated in $s$, $F' = B \cup (H \setminus H_{\mathrm{ok}}) \cup \{\mathsf{refusals} \ge 1\}$, which separates the forms even within a single run. In the restart case a run may halt refused, restart, and still reach $H_{\mathrm{ok}}$ before $B$: the middle form credits it; $p_{\mathrm{succ}}$, measured against the refusal it passed through, does not. Safety is certified by a barrier / avoidance certificate for $B$; success needs that plus the reach part — a hitting-time drift toward $H_{\mathrm{ok}}$. Fail-closed control is the disturbance-rejection margin for both, but split by reversibility: the gate $\gamma$ caps how far an adversarial world reaches into *side effects* and widens the gap to $B$ (it is the margin for the irreversible part), while $\rho$ validates the response and folds back, rejecting bad state after the action has run — which cannot undo an authorized side effect. In this language, security is robustness of the reach-avoid certificate.
And injection is not confined to the post-model kernel $Q_E$: poisoned retrieval, prompt-injected pages, and malicious tool metadata enter through $\pi$'s *inputs*, before generation — so the adversary lives wherever untrusted content enters the state/context-construction pipeline, which is why input provenance and the gate $\gamma$ both matter, not post-hoc verification alone. And provenance is a *precondition* of the certificate, not just an entry point to police: partition $s$ into a **control-determining** part — plan, intent, what is authorized next, the coordinates $\pi$ lowers and $\gamma$ checks — and a **data** part — tool values, retrieved text, the bytes of $e$. Reach-avoid presupposes untrusted effects touch only the latter; let $\rho$ fold attacker-controlled $e$ into the control part and the structural-intent check validates against a plan the adversary already bent, collapsing $\gamma$ to the strength of $\rho$'s validation. So the claim is conditional — reach-avoid *given* control flow provenance-isolated from untrusted data, the isolation that makes provable security possible (the content of CaMeL's control/data-flow separation, untrusted data filling typed values but never the program), a structural property the harness supplies and $\rho$ cannot recover after the fact. The partition then forces a question the isolation rule alone cannot answer: *something* must be permitted to write the control-determining part mid-run — or no plan could be steered, no approval granted, no scope widened — and naming that something is part of the object. It is the **trusted principal**: the owner of the run. An approval request is an ordinary authorized action through $\gamma$ into $Q_E$ — ask-the-owner is a tool call to the one counterparty you trust — and its response is the *single* class of $e$ that $\rho$ may fold into control coordinates; every other $e$ folds into data. This is not an exception eroding the partition but the partition completed: a provenance *lattice* with exactly one writer at the top, which is what trusted means — and the appendix's gate-placement entry derives the matching rule for *learned* verdicts, which may never stand in this writer's stead. One distinction keeps the lattice from outlawing the loop it governs. Control-determining is not one rank but two: **authority** — grants, scopes, budgets, what the principal has permitted — which only the top writer widens; and the **plan**, which the model rewrites at every fold of $y$, because replanning *is* the harness. The plan is a *middle* rank: written through the gated fold of the model's own output — the channel the minimax descent above already prices — never directly by an effect, and never a source of widened authority. The rank is also the field's live design axis: pin plan-writes to the top-derived rank — the plan fixed from the trusted query before any untrusted read, which is CaMeL's move — and provable security follows exactly there; let the middle rank replan interactively and you pay the adversarial price the certificate quantifies. A corollary with teeth: a dedicated planning component is rank-neutral — its writes land in the same middle rank as the model replanning inline — so it changes no guarantee and lives or dies on measured capability alone; in general, sub-components that only write middle-rank state are priced by evals, not by the certificate, which prices only rank crossings, gates, and $\Pi$. (For $B$ to capture irreversible side effects rather than only states, the side-effect ledger must itself live in $\mathcal{S}$, and the response $e$ must be an *effect record* carrying the ledger outcome — not just API bytes — since only $\rho$ writes external effects into $s$.)
There is a **second wall, orthogonal to the first.** It binds not the full harness state $\mathcal{S}$ but the **model-visible working memory** $\mathcal{C} = \mathcal{V}^{\le L}$ — bounded by the context length $L$. That bound is *not* the incompressibility of $V^\star$ (a fact about the parameters $W$ — the **dictionary**, fixed at training); it is a fact about the inner kernel's **working memory** (the $L\times d$ residual stream — the **desk**). $\mathcal{S}$ itself may be far richer — files, databases, vector stores, durable memory, queues — but that is *external* memory the shell supplies, and the distinction is the point: every external read still passes *through* the $\le L$ window to touch computation, so external stores extend addressable storage without extending the per-pass resident set. The shell can page; the plant cannot grow its desk. (What follows is heuristic, not definition-level: the complexity claims turn on depth, precision, and architecture, and belong with the frontier, not the core.) The tape picture comes from the autoregressive structure alone and needs no complexity theorem: each step reads a bounded window and writes one token, so **the context window is the tape, the autoregressive loop is the read/write head**, and — in the variable-$L$, fixed-precision idealization — the model-mediated inner computation behaves like a linear-bounded automaton, its reachable fixpoints capped by space-$O(L)$ computability (chain-of-thought is register-spilling onto that tape). Separately, and more weakly, there is a *per-pass* expressivity bound: under the standard fixed-depth, log-precision theoretical model a single forward pass is in constant-depth $\mathsf{TC}^0$ — *suggestive* for deployed models, not literal (real models use fixed-point precision and depth that grows with scale, and log-depth variants escape parts of it). These are different resources — the first bounds the *space* the loop addresses, the second the *depth* of one step — and only the space bound carries the $L$-wall; chaining them (one pass buys bounded depth, *therefore* the loop is space-$O(L)$) would be a non-sequitur, since per-step depth says nothing about the length of the tape the loop runs on. This is a *second* obstruction beside divergence, and it concerns *success*, not raw halting. Split the terminal set: let $H$ be any halt state (including fail-closed refusal) and $H_{\mathrm{ok}} \subseteq H$ the successful, accepting halts, with $V^\star_{\mathrm{ok}}(s) = \mathbb{E}[\tau_{H_{\mathrm{ok}}} \mid s_0 = s]$ taken on the process where $H \setminus H_{\mathrm{ok}}$ — halting wrong, refusing, failing closed — is *absorbing failure*, so a run that fails closed before acceptance has infinite accepting hitting time unless the spec explicitly restarts it — hence unconditional $V^\star_{\mathrm{ok}}$ is infinite whenever pre-acceptance failure has positive probability, which is why the workable reliability object is the success probability $p_{\mathrm{succ}}$ (above) or, for restarting specs, the regenerative expected time. Then $U_{\mathcal{H}}(L)$ — harness-relative, since the shell's decompositions and verified tools determine what can be paged or outsourced — is the set of tasks whose **irreducible per-step model-mediated working set** exceeds $L$ — not tasks whose *data* exceeds $L$ (those the shell can page), and not work that can be **discharged to a verified external tool** (a solver, interpreter, or compiler computes off-context). For a task in $U_{\mathcal{H}}(L)$ the raw chain may still hit $H$ — by failing closed, refusing, or returning a wrong answer — so $V^\star = \mathbb{E}[\tau_H \mid s]$ stays perfectly well-defined; what blows up is $V^\star_{\mathrm{ok}}$, the expected time to a *correct* halt, which is infinite under a formal success predicate, or undefined if no such predicate has been specified. The honest statement is about the finite-success domain, and it is *schematic* — a shape written in set notation, not a theorem, since $\mathrm{reachable}_{\mathcal{H}}(L)$ is exactly as informal as the working-set notion behind $U_{\mathcal{H}}(L)$: $\mathrm{dom}_{<\infty}(V^\star_{\mathrm{ok}}) \subseteq \mathrm{reachable}_{\mathcal{H}}(L) \setminus D$ — both the reachable set and the divergent set $D$ relative to $\mathcal{H}$. The two walls **trade***directionally, not as a literal exchange rate*: parametric memory $|W|$ and working memory $L$ press on the same budget along the pretraining-vs-inference-scaling axis, with no clean unit-for-unit substitution of one for the other. And the bound is inherent to *finite working memory*, not attention specifically: state-space models embody it differently (a fixed-size recurrent state rather than an $L$-window), and real attention's usable tape is shorter than $L$ (lost-in-the-middle).
## Where it cashes out
This is not ornament; the decomposition is load-bearing in the design.
- **$\pi$ is a progressively-lowered dialect stack** — raw input → intent → plan → tool-call → the neutral wire IR — each level a deterministic pass with its own verifier — *pass* and *verifier* meaning the shell's transformation and checking: the **content** entering at the plan level is plant-authored, middle-rank state (the two-rank note of *The limit*), which is exactly why that level carries a verifier at all. The per-step drift $r(s)=\mathbb{E}[\hat V(s_{n+1})\mid s]-\hat V(s)$ splits by coordinate, $r = r_{\text{shell}} + r_{\text{plant}} + r_{\text{env}}$ — presuming an additively separable $\hat V$, or a declared scheme attributing each step's drift to shell, plant, and environment coordinates: the shell term is an *exact, designed* descent — but per lowering pass, not per outer step: each pass strictly narrows the admissible-meaning set, a well-founded descent we build by hand, while the outer loop *revisits* — retry, replan, rewind are planned ascents of any reasonable $\hat V$, which the run-level certificate must absorb (a retry budget inside $\hat V$ is the standard device), so the shell's descent is well-founded in the nested, lexicographic sense rather than monotone along the run; the plant term ($M_W$) is the irreducible residue, and the environment term ($Q_E$) is the one an adversary controls — the very quantity the minimax descent must bound, which the old two-way split folded out of sight. **Syntactic soundness is free; semantic adequacy is not.** Relative to a formal schema and a correct validator, schemas, types, and boundary checks go into the shell at zero probabilistic cost; whether the lowered task still *means* what the user intended stays empirical, because natural language supplies no source-language standard to check against.
- **$\rho$ is fail-closed verification** — validate at every boundary, never let malformed state flow downstream. The discipline transfers from compilers in *form*; the *teeth* do not, because a harness has no source-language standard — natural language is, in effect, all undefined behavior — there is no complete formal source-language semantics to check against. And $\rho$ must be *deterministic*: if verification is itself an LLM judge, that is another learned kernel call — it belongs in $M_W$, not in $\rho$. Where $\rho$ *repairs* rather than rejects — canonicalizing malformed input into valid shape — remember that repair is an authorization decision in disguise: each repair rule converts a reject into an accept on bytes the adversary chose, so it must be deterministic, meaning-narrowing, and its output re-validated as if it had arrived that way, or the repair pass is a bypass of the very boundary it serves.
- **$\delta$, $\mu(D)$, $\mathrm{Var}[\tau_H]$ are what you measure** — not derive. You instrument the certificate precisely because the architecture does not hand it to you — you estimate it unless it is separately certified. And the meter is attack surface: if $\hat V$ is itself computed by a learned judge — a model scoring "progress" — the instrument is a kernel draw with the plant's own adversarial exposure, and an environment optimized to bend your dynamics will bend your *measurement* of them first; an injected page persuading the judge that work is advancing is precisely a divergence hidden from the dashboard built to catch it. The rule that put the LLM judge in $M_W$, not $\rho$, applies to instrumentation too: a learned $\hat V$ is part of the measured system, never a neutral meter.
## How this could be wrong
It is a hypothesis; here is what would falsify it. If the controller cannot in practice be kept deterministic — if real reliability demands stochastic control the plant can't absorb — the clean *deterministic* split is a fiction (the broader $K_C$ kernel model still holds, but loses its payoff: localizing every coin to the plant). If the drift slack $\delta$ turns out *not* to track real-world failure, the whole "measure the certificate you can't prove" program is empty. And if harnesses are simply better described some other way — not as nested stopped chains at all — then this is a pretty equation that merely happens to fit, an elegance we would be right to distrust.
First, handles — the load-bearing claims numbered, so the tests have addresses. **C1**: the harness is faithfully modeled as nested stopped Markov processes — the tuple, the outer $T$, the inner $M_W$. **C2**: the controller injects no randomness — every coin localizes to $M_W$ and $Q_E$. **C3**: fail-closed is a *gate* property — no effect crosses unvalidated, and rejection is a true no-op. **C4**: no certificate of correct halting comes free, and the measured slack $\delta$ is a calibrated risk metric, never a certificate. **C5** (conjecture): the minimal certificate $V^\star$ admits no representation materially below model scale. **C6**: two orthogonal walls — divergence ($\mu(D)$) and the $L$-bounded per-pass working set. **C7**: security is reach-avoid, certifiable only conditional on provenance isolation with a single trusted writer. **C8** (figure): certificate and interlingua are one object — already demoted by its own section, and exempt below accordingly.
Each claim is operational, not merely rhetorical:
- **State-ablation (C1 — the Markov claim).** Drop a variable from $s$ and check whether next-step transition statistics move. If they do, the abstraction was not Markov, and $s$ must be augmented until it is. (Passing is necessary, not sufficient — the test can falsify Markovity, not establish it.) The same probe pointed at $\pi$ tests lowering *sufficiency*: drop a coordinate from $c$ rather than $s$ and watch task success rather than transition statistics — context compaction lives or dies by exactly this.
- **Controller-determinism audit (C2).** Re-run with model samples and tool outputs *held fixed*. Any residual variance is randomness the harness itself injected — clock reads are the classic leak (timestamps folded into $s$, wall-clock timeouts, cache expiries) — and must be folded into $Q_E$ or the controller, or the determinism claim is false.
- **Drift calibration (C4).** Test whether $\hat V$-drift actually predicts failure, retry count, latency, or non-halting. One uncorrelated candidate kills that candidate, not the program; the program is empty only if candidates from the natural families — plan depth, open-obligation counts, budget burn, judge scores — *systematically* fail to track failure.
- **Adversarial-environment test (C7).** Replace sampled $E$ with worst-case tool outputs, prompt-injected documents, poisoned tool metadata, malformed responses. The minimax descent must survive these, not merely the benign draw.
- **Boundary-control ablation (C3, C7).** Compare prompt-only defenses against deterministic tool-call validation, capability checks, sandboxing, and fail-closed rejection at the gate $\gamma$. The hypothesis predicts the latter class dominates; if prompt-only defenses match it, the controller/plant security story is wrong.
- **Readout-typing check (C1, C3).** Verify that $M_W$'s codomain is exactly what $\gamma$ consumes — especially under window truncation, where the final context need not hold the full transcript, so the output buffer and the gate's input must still agree.
- **Certificate-compression search (C5).** The conjecture falsifies constructively: exhibit a $\hat V$ of description length far below $|W|$ whose worst-case slack is provably $\le 0$ over a nontrivial task domain. The text concedes the live counter-possibility — coarse hitting-time functionals of complicated kernels are sometimes cheap — so C5 stands only until someone cashes it.
- **Working-set probe (C6).** Fix the shell and scale a task family's irreducible per-step working set past $L$, on tasks the shell can neither page nor discharge to a verified tool — anchoring "irreducible" in families with proven streaming or communication-complexity lower bounds, so the floor is someone else's theorem and a solved family cannot retreat to reducible-after-all. C6 predicts success collapses at the wall rather than degrading smoothly; a family solved reliably past it, without new shell decompositions, falsifies the second obstruction.
## Where this points (the frontier — least falsifiable, so flagged)
If $V^\star$ is incompressible only in *token* coordinates, the right change of coordinates might compress it — and that change of coordinates is a representation of meaning itself. Cost-to-go and representation co-determine each other: where the Koopman operator is diagonalizable — a point-spectrum idealization, since mixing dynamics carry continuous spectrum and admit no eigenbasis — the eigenbasis that linearizes the dynamics is also the one in which the certificate decomposes, and even then only for a $V$ in the span of those eigenfunctions; in reinforcement learning the discounted successor representation (Dayan 1993) is the resolvent $(I-\beta P)^{-1}$ — discount $\beta$, not the gate $\gamma$ — with $V$ a *linear readout* of it — and in the undiscounted, absorbing case that actually matches a stopped harness the same role is played, in the finite setting — and countable settings where the Neumann series converges — by the **fundamental matrix** $N = \sum_{n \ge 0} Q_{\mathrm{tr}}^{\,n}$ (written $(I - Q_{\mathrm{tr}})^{-1}$ when the inverse exists), where $Q_{\mathrm{tr}}$ is the sub-stochastic kernel restricted to $H^c$ (transitions before absorption at $H$) and the row sums $N\mathbf{1}$ *are* $V^\star$ on the finite-mean hitting domain; on general state spaces the same series is read as the potential (Green) operator $G$, with $G\mathbf{1} = V^\star$ wherever it converges. Each of these is a clean identity only for a fixed, time-homogeneous kernel — under a nonstationary $Q_{E,n}$ the resolvent and fundamental matrix dissolve into a time-ordered product, and under an *adaptive* adversary into a controlled / game-value operator, so what is identity in the stationary regime is analogy beyond it.
With that caveat, **the interlingua and the certificate are one object seen twice** — and the reason neither can be written in closed form is the same "all undefined behavior": no canonical lowering of meaning, hence no finite header-file for either. The only representation of both is $W$ — a band-limited, lossy compression of a scale-free meaning-space, sharp where the record is thick and blurred where it thinned. That a finite object renders an infinite one *lossily but honestly* — declaring its resolution, and where it is unsure — is not a lie; it is the most an $f(\cdot\,;W)$ can do. **The search for $V$ and the search for the interlingua are not two programs. They are one** — and the day either is written in closed form, so is the other, or we will have proven why neither can be. Read this as *figure*, not a lurking theorem: the only precise version would need the Koopman eigenbasis to fall on the very coordinates that lower meaning, and the mixing-spectrum caveat above already concedes that eigenbasis does not exist — which guts it. It is the least-defensible claim in this document, and it should announce that rather than imply a rigor it has not got.
---
*The formula is the architecture; the corollary is why the architecture is hard. Both on the page — nothing hidden behind a tidy composition.*
## Grounding
Borrowed theorems are real; the framings are not — keep them separate. Some framings are nonetheless *corroborated* — independently reached from another field — a third grade, weaker than proof and noted last.
**Proven (citable).** FosterLyapunov drift ⇒ positive recurrence + $\mathbb{E}[\tau]\le V(s_0)/\varepsilon$ (Foster 1953; Meyn & Tweedie, *Markov Chains and Stochastic Stability*, 1993) — positive recurrence needs the usual irreducibility/petite-set hypotheses, while the absorbing-halt case used here needs only the weaker supermartingale optional-stopping hitting-time bound. The minimal $V$ is the expected hitting time, by first-step analysis + optional stopping (Norris, *Markov Chains*, 1997). For an absorbing chain that expected hitting time is the row sum of the fundamental matrix $N=\sum_{n\ge0}Q_{\mathrm{tr}}^{\,n}$ (Kemeny & Snell, *Finite Markov Chains*, 1960), with the general-state analogue the potential (Green) operator (Revuz, *Markov Chains*, 1984). Koopman's linear-operator view of nonlinear dynamics is classical (Koopman 1931), and Lyapunov functions can be assembled from its eigenfunctions when the spectrum is suitable (Mauroy & Mezić, 2016). You certify a candidate $\hat V$ by a *proven* drift inequality rather than by deriving $V^\star$, and estimate it empirically only where a proof is out of reach — the empirical drift checks, it does not certify (neural-Lyapunov: Chang, Roohi & Gao, *Neural Lyapunov Control*, NeurIPS 2019, arXiv:2005.00611). A classical monotone data-flow analysis gets its $V$ for free because a finite-height lattice is a well-founded descent (Kildall, POPL 1973). The gate-a-plant architecture itself is classical: supervisory control theory synthesizes a deterministic supervisor that disables controllable events of a plant it does not author, with the supremal controllable sublanguage as the largest admissible behavior (Ramadge & Wonham, SIAM J. Control and Optimization, 1987) — $\gamma$ is that supervisor, with a learned stochastic plant on general state spaces. The successor representation is Dayan (*Improving Generalization for Temporal Difference Learning: The Successor Representation*, Neural Computation 1993). Dialect-stack architecture: MLIR (Lattner et al., CGO 2021, arXiv:2002.11054); learned pass-ordering: MLGO (Trofin et al., arXiv:2101.04808). Single-pass low-depth expressivity: log-precision transformers are simulable by constant-depth logspace-uniform threshold circuits ($\mathsf{TC}^0$) (Merrill & Sabharwal, *The Parallelism Tradeoff: Limitations of Log-Precision Transformers*, TACL 2023) — fixed/constant precision is a stronger restriction, added autoregressive steps escape it (Merrill & Sabharwal, *The Expressive Power of Transformers with Chain of Thought*, ICLR 2024), and growing precision changes the picture, so the bound is suggestive for deployed models, not literal.
**Asserted (ours — not theorems).** That the harness is best modeled as nested stopped chains; that $V^\star$ is incompressible (no compression theorem); that "no lattice for $f(\cdot\,;W)$" means none is *known*, not that none exists; and everything under *Where this points* — including the Koopman/certificate co-determination, which is well-posed only under the spectral assumptions noted there, and the interlingua/certificate identification; and the design rules read off the objects rather than proven from them — the single-trusted-writer completion of the provenance partition, the narrow-only rule for learned checks, the composition law of the appendix. These organize the design; they are not results.
**Converged-upon (independently arrived at, from other framings).** The *Asserted* claims above are ours but not ours alone; several are reached independently, from starting points unconnected to this framing — which is the corroboration a definition earns: not a chorus of agreement (the systems below often disagree on method and goal), but that work approaching from capabilities, reinforcement learning, control theory, software architecture, and language-modeling theory each lands on a piece of the same object. That the **deterministic controller, not the model, carries the guarantee** is reached from four directions — capability and information-flow control (CaMeL: Debenedetti et al., *Defeating Prompt Injections by Design*, arXiv:2503.18813, securing the agent even when the underlying model is susceptible); reinforcement learning (shielding: Alshiekh et al., *Safe Reinforcement Learning via Shielding*, AAAI 2018, arXiv:1708.08611 — a deterministic reactive shield filtering a learned policy's actions against a temporal-logic specification); control theory (*Stable Agentic Control*, arXiv:2605.03034, enforcing finite action catalogs at the tool-output interface under a Lyapunov input-to-state-stability certificate against adversarial disturbance); and software architecture (the plan-then-execute / control-flow-integrity line, e.g. Beurer-Kellner et al., *Design Patterns for Securing LLM Agents against Prompt Injections*, arXiv:2506.08837). The **certified-vs-measured split** is reached from the construction side (CaMeL's provable security) and, independently, from the destruction side (guardrail-evasion results — *Bypassing Prompt Injection and Jailbreak Detection in LLM Guardrails*, arXiv:2504.11168, the v1 title — later versions retitle it; *No Free Lunch with Guardrails*, arXiv:2504.00441), with verification-oriented work stating it as the motivating gap (*Towards Verifiably Safe Tool Use for LLM Agents*, arXiv:2601.08012; VeriGuard, arXiv:2510.05156): a learned safeguard raises the odds of detection but cannot guarantee safety against a persistent attacker. The **inner readout as a composition of Markov kernels** is independently formalized in language-modeling theory — the autoregressive step as kernel composition in the category $\mathsf{Stoch}$ (*A Markov Categorical Framework for Language Modeling*, arXiv:2507.19247), and the broader "LLMs as Markov chains" line — though that work models the inner kernel alone and never closes it into an agentic loop, which is exactly the seam this definition adds. That **provenance shrinks the admissible adversary** is reached by datamarking / spotlighting (Hines et al., arXiv:2403.14720, 2024) and by CaMeL's data/control-flow separation; and a systematization of prompt injection against agentic coding assistants reaches the same verdict from the attack side — mitigation must be *architectural*, not model-level (*Prompt Injection Attacks on Agentic Coding Assistants*, arXiv:2601.17548); the sharper open problem this object is built to answer — formally specify the trust boundaries, then verify implementations respect them — is our phrasing of where that verdict points, not the paper's. Two convergences are weaker, and flagged. The **reach-avoid hitting-time certificate** is the independently developed reach-avoid supermartingale (RASM, arXiv:2210.05308, AAAI 2023) and stochastic Lyapunovbarrier apparatus, and its *hardness* is corroborated — expected-stopping-time problems for Markov chains are inter-reducible with the Positivity problem, a relative of the Skolem problem (Chatterjee & Doyen, *Stochastic Processes with Expected Stopping Time*, arXiv:2104.07278) — but this supports generic hardness only, not the specific incompressibility-at-$|W|$ conjecture, which remains ours and unproven. And **injection as an adversarial policy** is corroborated as a minimax game in the *detection* setting (DataSentinel: Liu et al., *A Game-Theoretic Detection of Prompt Injection Attacks*, arXiv:2504.11358) and as adversarial-disturbance robustness (*Stable Agentic Control*, above) — but no prior work assembles it as reach-avoid over the tool-output kernel with the gate as the irreversibility margin; here the relation is adjacency, not convergence.
---
## Appendix: model implementation
The definition is deliberately abstract: $\pi, \gamma, Q_E, \rho$ are *roles*, not code, and a deployed harness forces concerns the abstract object is silent on. This appendix does not re-derive the implementation; it establishes a **pattern** — take a hard practical concern, locate it in the objects already defined, and read off the discipline they imply rather than inventing new machinery. Cancellation is the worked example, chosen because it is where the silence bites hardest and because the answer falls entirely out of objects already on the page.
**Cancellation.** An owner stops a running agent mid-flight — worst across a task-agent tree. The naive reading is "stop and undo," but the irreversibility point forbids it: $\gamma$ is the last line before irreversible effects, and $\rho$ can reject a response but cannot undo an authorized action. So cancellation is not *making it not have happened*; it is a disciplined stop with a defined disposition for what is already irreversible.
A cancel is a signal, so by the Markov requirement it lives in $s$. The gate then closes on it: while the cancel flag is live, $\gamma(s,y)=\bot$ for every proposal. That is the entire "block the pending actions" requirement — they hit the gate already built and bounce into the no-op, with no new blocking machinery — and it forecloses all *future* turns at once, since $\pi$ lowers nothing new that $\gamma$ will pass. After the signal is observed, **no action crosses $\gamma$.**
The hard half is the action already *past* $\gamma$, executing in $Q_E$, whose effect is landing or has landed. Here the disposition is a trinary on the kind of $Q_E$ you authorized. If the tool is **cancellable**, propagate the cancel into it; it aborts and reports a true end-state (committed, rolled-back, or partial), and $\rho$ folds the real disposition. If it is **bounded** — drainable in acceptable time — simply wait and record the real $e$. If it is **opaque and unbounded** — a bash invocation that may itself be a harness, an environment you hold no handle into — you cannot stop the effect, only your *wait* for it: the controller fabricates $e$, a synthetic "cancelled" response, and folds it through $\rho$ so the loop can reach a terminal.
That synthetic result is the subtle case, and the load-bearing rule is this: $\rho$ may fabricate the *acknowledgment* but must not fabricate the *outcome*. A synthetic "cancelled, no effect" entry reads downstream as *the action did not happen* — and will cause a double-send exactly as readily as a dropped record causes an orphan. Same bug, opposite sign. An outcome you did not observe is $\mathsf{unknown}$, never $\mathsf{none}$: the cancelled agent never saw whether bash sent the email, and the ledger must say exactly that. (This is why $e$ must be an effect record and the ledger must live in $s$ — the fabricated entry is still a ledger write, and its value is what a later reader acts on.)
The run halts into $H_{\mathrm{cancel}} \subseteq H \setminus H_{\mathrm{ok}}$ — a distinguished terminal, non-accepting but *safe* (outside $B$), refining the deliberately coarse $H \setminus H_{\mathrm{ok}}$ of the definition (the body leaves that set unenumerated; the appendix is where its subclasses earn names) — with a specific postcondition: no action crossed $\gamma$ after the cancel was observed, every in-flight action was drained to its real disposition or recorded $\mathsf{unknown}$, and the ledger is consistent. It is worth separating from refusal and from a wrong answer precisely because that guarantee is its own.
Cancellation must be **cooperative, not preemptive.** The owner writes the cancel into the child's $s$; the child observes it at its next $\gamma$ check. The guarantee is therefore "no new action after the cancel is *observed*," not "after it is *sent*" — a child may authorize one more action in the gap, which simply drains like any other in-flight. Preemptive cancellation — killing the child mid-$Q_E$ — is exactly what manufactures $\mathsf{unknown}$ state at scale, because it destroys the record of whether the action landed. And the propagation is **recursive**: cancel flows down the subtree, each level closes its gate at its next check and drains, and the owner's cancel "completes" only when the subtree has drained. A single agent's drain is its own in-flight action; a tree's is the whole subtree reaching safe points cooperatively — the irreversibility problem stacked on a distributed-coordination one, which is why task agents are the worst case.
Compensation lives **outside** the cancelled agent. A completed-but-unwanted effect cannot be undone by the agent that caused it — its gate is closed — so a compensating, saga-style action is the *owner's* job, issued after $H_{\mathrm{cancel}}$ and reading the child's ledger to decide what to reverse or annotate. It must be the owner's, because the cancelled child cannot even know whether compensation is needed: it never observed the outcome. The owner inherits the $\mathsf{unknown}$ and any still-live orphan process, and reconciliation is its responsibility.
Finally, the part that shapes the tool rather than the document. Opaque unbounded $Q_E$ is uncancellable because authorization happened at the wrong **granularity** — an unbounded environment crossed $\gamma$ on a single approval. The discipline the objects imply is therefore not "handle uncancellable tools better" but: *the gate should prefer bounded, instrumented $Q_E$ over opaque ones, so that cancellation and the ledger stay honest.* A bash invocation behind a wrapper that tracks its process tree and effects converts the third branch into the first. Sometimes opaque is the only option, and then $\mathsf{unknown}$ and owner-inherited orphans are the honest floor — but where the choice exists, that is the pressure cancellation semantics put on tooling.
**Resume (involuntary stop).** Cancellation's twin, without the courtesy of a signal: a process crash, a lost node, a partition mid-$Q_E$. Nothing new is needed to say what recovery *is*. A crash is not a halt — $H$ is a property of the state, and the run never reached it; the chain merely stopped being *computed*, and resume computes it further, re-entering $T$ at the last durable $s$ (not the body's *restarting spec*, which exits a refusal terminal — here no terminal was ever reached). That sentence is the Markov requirement cashing out operationally: re-entry is sound exactly when $s$ was the whole state, so anything load-bearing that lived only in process memory — an in-flight buffer, a lock held in RAM, a plan revision not yet folded — is a state-ablation failure (*How this could be wrong*) discovered at the worst possible time. Durability of $s$ is not an implementation nicety; it is what the Markov claim *means* when the machine dies.
The sharp part is an ordering the ledger's own trichotomy forces. The formal transition is atomic — $s_{n+1} = \rho(s, y, a, e)$ in one piece — and a crash lands *inside* it, so resume is really a statement about the implementation's refinement of that atom into micro-steps: authorize, journal, dispatch, collect, fold. The discipline is that every crash point must resume to one of exactly two honest readings — not-yet-dispatched ($\mathsf{none}$, safely retriable) or dispatched-unconfirmed ($\mathsf{unknown}$, the cancellation entry's third branch) — and **journal-before-dispatch** is what makes the boundary between them observable: on $\gamma$'s authorization the shell journals an open $(\mathsf{action\_id}, \mathsf{pending})$ entry into durable $s$ before $Q_E$ sees the action — the write is the shell's step bookkeeping, so $\gamma$ itself stays effect-free. Journal *after* dispatch and a crash in the gap leaves no record at all — resume reads silence as $\mathsf{none}$ and re-sends, the double-send bug again, produced by a power cut instead of a synthetic entry. Write-ahead intent is not imported from database lore; it is forced by "did not confirm" is not "did not happen."
The same pressure lands on tooling from a second direction. The $\mathsf{action\_id}$ the record already carries is an idempotency key wherever the tool will accept one: re-dispatch after resume becomes safe, and $\mathsf{unknown}$ becomes *queryable* — ask the tool what it did with this key — rather than terminal. The disposition trinary returns with new labels: idempotent-or-queryable $Q_E$ resumes cleanly, bounded $Q_E$ drains, opaque $Q_E$ leaves $\mathsf{unknown}$ and owner-inherited orphans, the honest floor again. The wrapper that made bash cancellable makes it resumable; it was the same wrapper all along. And if durable $s$ itself is lost there is nothing to re-enter: the run collapses to a single $\mathsf{unknown}$ in its owner's ledger — degraded accounting, but never silent.
**Gate placement (fail-closed, in practice).** The natural implementation question is whether fail-closed means tool-call parsing and validation must happen before any tool invocation. It does — with the division of labor the definition already fixed: *parsing* lives in the inner readout $R$, the syntactic, verified extraction into $\mathcal{Y}$ (what the readout-typing falsifier checks), and *authorization* lives in $\gamma$, which is a *gate* — validation is not merely *prior to* invocation, it is what *authorizes* it. The model emits text; $R$ has already extracted it into a typed proposal; $\gamma$ validates that proposal against $s$, and only a survivor becomes an authorized action that $Q_E$ may execute. The teeth are in $\gamma$ being the *sole* route from model text to execution: no path to a side effect that does not pass the gate. And the validation is not a fixed checklist but **any deterministic predicate over $s$ and $y$** — that domain is the point, since the gate sees all of the state and the full proposal, so anything computable from them is a legitimate authorization condition. Three kinds matter. *Syntactic* — well-formed, schema-conformant, the tool exists, arguments typed. *User authorization* — does the principal this run acts for hold the right to *this* operation on *this* resource in *this* context: a function of the auth scope, principal, and session carried in $s$ and the resource and operation named in $y$, and *dynamic* rather than a static capability table, since the same caller may be permitted now and not once a budget is spent or a lock held. *Structural intent* — does the call cohere with the plan and the lowered task already in $s$: a consistency check, not a mind-reading one.
That last kind marks the seam where the gate stops being able to stay pure, and it is the same seam the rest of this document is built around. The *structural* slice of intent — does the action cohere with the plan in $s$ — is a deterministic predicate over $s$ and $y$, effect-free, and belongs in $\gamma$ without reservation. But whether an action matches what the user *actually meant*, in the full semantic sense, is exactly the thing the definition says cannot be checked: natural language is all undefined behavior, with no source-language standard to validate against. So a semantic intent check is a *learned* check, and an LLM judging "is this what they wanted" is a **stochastic kernel** — putting it inside $\gamma$ breaks the property the gate exists to hold, by the same move flagged for the fold-back verifier: a learned judge is a kernel, and belongs in $M_W$, not in a deterministic map. Semantic intent therefore does not live *in* the gate; it is a plant call — a separate authorize-the-proposal pass through $M_W$ whose output $\gamma$ then deterministically gates — or it is drift you measure, never a guarantee you hold. That nested call is not a new kind of thing: it is a mini-harness inside the gate's decision — a judge $M_W$, its own syntactic readout, its own deterministic gate — so its failure case answers itself, the inner gate fail-closing on an unparseable or low-confidence judgment exactly as the outer one does, because it *is* one. The object is **closed under this construction**: semantic gating is added by recursion, not by a new primitive. One constraint on the recursion is load-bearing enough to be a rule, because it is where this entry meets the provenance partition of the body: the judge's verdict is derived, through a learned kernel, from the very content an adversary may have bent, so folding it into authorization is exactly the fold the partition forbids — *unless the verdict can only cost capability*. **A learned check may narrow the deterministic admissible set; it must never widen it.** Judge-as-veto is safe by construction: attacker influence over the judge can at worst manufacture a denial, a liveness cost the certificate already prices. Judge-as-approver — a verdict granting what the deterministic checks alone would refuse, or standing in for the trusted principal's confirmation — lowers the certified floor to those deterministic checks alone; if avoiding $B$ depended on the deny the judge now withholds on the adversary's behalf, the certificate is gone. Only the trusted principal widens authorization; learned kernels only narrow it. (The recursion already obeys this: the mini-harness's inner gate fail-closes to $\bot$ — a deny — which is why the construction was safe to add at all.) The cost is real and worth stating — a judge pass is another full model call, with its latency and tokens — so it is a decision about *which* actions warrant it, not a free wrapper for all of them. The gate widens to every deterministic predicate over $s$ and $y$; it does not widen to the one predicate the document says is not deterministically checkable.
But "before any invocation" has to be read as *before any effect*, which is sharper than it sounds — and the reason is the irreversibility point above: you validate before execution because execution is what you cannot take back, so the real invariant is **no effect crosses $\gamma$ unvalidated**. That catches three cases the naive reading misses. *Reads are not free*: a read-only call is still an injection vector (it pulls attacker-controlled content into context) or an exfiltration vector (a request whose URL is the payload), so the gate authorizes the *call* regardless of whether it mutates. *Validation must not act*: a "validator" that resolves a call by hitting an API, expanding a template that fires a webhook, or evaluating an argument that runs code has collapsed validation into invocation, and the effect has already happened *inside* $\gamma$ — so $\gamma$ itself must be **effect-free**, pure and total over the proposal and the current $s$, with no network and no execution; if deciding validity *requires* a side effect, that side effect is itself an action and must go through the gate, recursively. *The output is an action too*: the user-visible response and any logging are effects — for model-authored text, emitted either as an authorized action through $\gamma$ or only after an accepted halt (shell-templated status on any halt is the controller speaking, not the model) — streaming raw tokens to a sink before $\gamma$ has cleared them is the same bug from the other end.
So the property, tightest: $\gamma$ is a **pure, effect-free authorization that every model-proposed action — tool call, read, write, or final output — must pass before any effect occurs**, with "before" enforced structurally by the gate being the only route from model text to $Q_E$. The two failure modes to design against are a path from model output to a sink that bypasses the gate, and a $\gamma$ that is not effect-free, so that "validating" a call already rang the bell. And the boundary, so the property does not overpromise: $\gamma$ guarantees *no unauthorized effect* — pure code ordering, fully in your control — but not that an *authorized* effect is safe or correct; that is the plant's problem, and the reason $\rho$ and the reach-avoid certificate exist. Fail-closed is the floor — nothing executes that did not pass the gate — not the ceiling.
There is a third failure mode beside those two, and it is not a code path but a credential. A tool process that holds standing authority — an environment full of long-lived secrets, a database connection with every grant, an agent identity the network trusts — does not need the model's proposal to act, and against it $\gamma$'s $\bot$ is a decision with nothing to enforce it. The gate *decides*; something must make the decision *binding*, and "no path from model output to a sink that bypasses the gate" must be read to include the non-code paths: ambient authority is a bypass provisioned before the run began. The discipline is **per-action capability**: the authorized action *carries* its grant — a scoped, short-lived credential minted at authorization, valid for this $\mathsf{action\_id}$, this resource, this operation — so that a tool holds, at any moment, exactly the authority of the actions the gate has passed it and nothing standing. In the language of the minimax certificate this is enforcement as $\Pi$-shaping: sandboxing, capability scoping, and network policy do not make the gate smarter — they shrink the class $\Pi$ of environment policies an adversary can choose from, so the worst case the certificate must survive gets structurally smaller. A gate in front of an omnipotent tool is a suggestion; the objects compose into a guarantee only when $Q_E$'s reachable effects are no larger than what crossed $\gamma$.
And one more boundary, because "fully in your control" above is a *single-run* statement. $\gamma$ authorizes against the $s$ it read; the effect lands later, against a world that may have moved — the gate cannot freeze the world between authorization and commit, so the honest property is *no effect unauthorized relative to the $s$ at authorization time*, and closing that gap requires the tool itself to bind check to commit (compare-and-swap in $Q_E$), which relocates part of the enforcement past the gate and weakens "$\gamma$ is the last line" to "$\gamma$ plus a commit guard" for exactly the effects that need it. The same seam opens *between* runs: the dynamic authorization state the gate reads — budgets, quotas, locks — is, once shared, no single run's coordinate, and two children of a coordinator can each pass $\gamma$ against snapshots that jointly overdraw a budget neither exceeded alone. The cancellation entry's observed-not-sent gap ("a child may authorize one more action in the gap") is this phenomenon wearing one hat; the general statement is that cross-run authorization state needs its own serialization discipline — the ledger as the serialization point is the natural choice — and the per-run certificate is silent about it. TOCTOU is not a counterexample to the formalism; it is what the formalism says when you admit $s$ is a *view*.
**Parallel proposals (the batch gate).** Models emit several tool calls in one turn, and the outer chain assumed one action per step. The repair is formally cheap: a batch is a single action in $\mathcal{A}$ that happens to be a set, $Q_E$ runs its elements concurrently, the interleaving's nondeterminism folds into $Q_E$ exactly as the determinism audit requires, and $\rho$ folds one effect record per element — $e$ is then a finite set of records — each keyed by its own $\mathsf{action\_id}$ — the record interface already supports partial outcomes (one element $\mathsf{committed}$, its sibling $\mathsf{unknown}$). One discipline survives the cheapness: **individually admissible actions can be jointly inadmissible.** Read-the-secret and post-to-the-web each pass a per-call check; the pair is an exfiltration channel — and two calls that each fit a budget jointly overdraw it, the cross-run overdraw of the previous entry reappearing *inside* one turn whenever elements are authorized independently. Since $\gamma$'s domain is any deterministic predicate over $s$ and $y$, joint authorization was licensed all along; the content here is only that the gate must take it — authorize the *set*, atomically, against one snapshot, with interaction predicates (source-to-sink flow between capability classes, summed resources) and not merely element predicates. The cost note is the judge's, transposed: full powerset reasoning is combinatorial, so a real gate checks declared interactions rather than every subset — a tractability trade to make explicitly, not by forgetting the batch was a set.
**Effect records (what $\rho$ folds back).** The fold-back $\rho$ and the cancellation ledger both turn on the response $e$ being an *effect record* rather than raw API bytes — said twice in the body and pinned down nowhere, though it is the interface that makes both tractable. The minimal shape is small: roughly
$$e = (\mathsf{tool\_id},\ \mathsf{action\_id},\ \mathsf{status},\ \mathsf{effects},\ \mathsf{time}), \quad \mathsf{status}\in\{\mathsf{committed},\mathsf{rolled\_back},\mathsf{partial},\mathsf{none},\mathsf{unknown}\}, \quad \mathsf{effects}=[(\mathsf{resource},\mathsf{op},\mathsf{reversible})].$$
Each field is forced by something the body already needs. The $\mathsf{action\_id}$ lets $\rho$ match a response to the in-flight action $\gamma$ authorized, and lets the ledger say which actions are still open — without it the $\mathsf{unknown}$/orphan accounting has nothing to key on. The $\mathsf{status}$ must carry $\mathsf{unknown}$ as a value *distinct* from $\mathsf{committed}$ and from $\mathsf{none}$, because that distinction is the whole content of the cancellation ledger: "did not confirm" is not "did not happen" ($\mathsf{none}$ is *never launched* — the record of the distinguished no-op $e_0$ a $\gamma$-rejection forces, which is how a bounce at the gate enters the ledger at all — distinct in turn from $\mathsf{rolled\_back}$, which launched and was undone: conflating those erases the difference between a gate that held and a compensation that worked). The $\mathsf{reversible}$ bit on each effect is what lets the gate know which effects are irreversible — the predicate the gate-placement entry leans on ("anything irreversible must be gated at authorization") but cannot evaluate unless the record carries it (a bit is the minimal honest form, not the final one: real effects are reversible *until* — an unsend window, a force-push until someone fetched, a row until the backup rotates — so the mark wants to be a $(\mathsf{reversible\_until}, \mathsf{cost})$ pair, a refinement the open-interface caveat below already licenses). And $\rho$ writes the record into $s$ (the ledger lives in the state), which is what lets the next step's $\gamma$, and any owner-side compensation, read it at all. The exact fields are an **open interface, not a result**: bash, HTTP, a filesystem, and a database expose effects at wildly different granularity, and a record uniform across them is a real design problem this document does not resolve — it fixes only what the record must *support* (match by $\mathsf{action\_id}$, the $\mathsf{committed}$/$\mathsf{none}$/$\mathsf{unknown}$ trichotomy, and a reversibility mark), since without those three $\rho$ and the cancellation semantics lose their grip.
**Derived and durable state (compaction and memory).** Two mechanisms let data re-enter the context long after it arrived: compaction, which replaces transcript with a summary when the conversation outgrows what $\pi$ can lower, and memory, which persists records across sessions. Both are transformations of state that produce state, and both therefore raise a question the body's partition answers only if one more closure property is stated: **provenance is a property of the information, not of its position in the pipeline — a transformation's output inherits the meet, in the trusted-writer lattice, of its inputs' labels.** Without that closure, compaction is a laundering channel: a summary of a session that contained an injected page can assert "the user asked to export the database," and the structural-intent check then validates future proposals against a plan the adversary bent — not through $\gamma$, not through $\rho$'s fold of a single $e$, but through the summarizer, which is a learned kernel (it lives in $M_W$, by the standing rule) and so cannot be trusted to preserve a partition it does not know exists. The discipline: summaries of data are data; the control-determining coordinates — plan, grants, what is authorized next — cross a compaction *verbatim* (copied, not paraphrased) or by re-confirmation from the trusted principal — never through the *summarizer*; the model rewrites the plan at plan steps, through the gated fold the body prices, and compaction is not one of them. Memory obeys the same closure twice, at write and at retrieval: the label rides the stored record across sessions, or a poisoned memory is an injection with an arbitrarily long fuse — and retrieval, being learned ($\pi$'s selection factor — adequacy-only behind the never-lower filter), decides what comes back but never what it is trusted *as*. The same test applies at birth: tool catalogs and server-supplied tool descriptions are third-party durable data that arrive dressed as instructions, and the lattice files them on the data side of $s_0$.
One more read-off, this time from irreversibility. *Destructive* compaction — dropping the original transcript once the summary is written — is a side effect against your own state that no later step can undo, and the gate-placement rule ("anything irreversible must be gated at authorization") does not exempt self-directed effects. The granularity preference then says what it said about bash: prefer the instrumented form — originals kept content-addressed, the summary an index and a cache rather than an authority, re-derivable when the $\pi$-sufficiency probe (*How this could be wrong*) says the summary dropped what mattered. A summary you can audit against its source is a lowering; a summary that replaced its source is a fait accompli.
**Composition (harness trees).** The cancellation entry already walked a tree — cancel flowing down, drains flowing up — and "a bash invocation that may itself be a harness" has hovered since the disposition trinary; what is missing is only the statement that makes both ordinary. From the parent's seat, a child harness *is* a $Q_E$ component: spawning it is an action authorized by $\gamma$ like any other, and the entire child run — its own $\pi, \gamma, \rho$, its own coins, its own halt — is one environment draw whose response $e$ is the child's terminal ledger. The law is four correspondences. The child's halting time is the parent's per-step *cost*: a parent certificate consumes a bound on $\mathbb{E}[\tau_H^{\mathrm{child}}]$ — the budget handed down at spawn, which the child's own budget-counter certificate discharges — or the parent's drift is uncontrolled however good its own $\hat V$. The child's ledger is the parent's *effect record*: the child's $e$ carries the $\mathsf{committed}/\mathsf{none}/\mathsf{unknown}$ accounting upward — which is what already let the cancellation entry make compensation the owner's job; the interface was this all along. And the child's non-accepting halts are the parent's *partial failures*: a refused child folds back as a response the parent routes around, not an exception that unwinds it. And the child's admissible effects are the parent's *$\Pi$-restriction*: the spawn grant bounds what the child can reach — the ledger reports what *happened*, the grant bounds what *could* — which is how safety composes without the parent ever reading the child's gate; the attenuation below is this correspondence stated as a rule. Read this way, the gate-granularity discipline and the tree are one preference: an instrumented child — budgeted, ledgered, cancellable — *is* the bounded, cancellable $Q_E$ the trinary prefers, and an opaque bash invocation is an un-annotated child you declined to instrument. Nesting adds no primitive on the environment side either: the parent never sees the child's gate and does not need to — it gates the spawn, prices the budget, folds the ledger, and the child's internal guarantees surface only as the shape of $e$. Nothing fixes one level: the tree recurses, budgets subdivide, ledgers concatenate upward, and the cooperative drain of cancellation is this law read under a cancel signal.
The tree leaves one seat unassigned: who plays trusted principal for a *child*? The parent — but with derived authority, not original, and the derivation is the narrow-only rule read along the spawn edge: **authority attenuates monotonically down the tree.** A spawn may grant the child any subset of the parent's own grants and nothing outside them; budgets subdivide, scopes narrow, and no edge widens. When a child asks-the-owner, the parent may answer from authority it already holds — that is attenuation working as designed — but a request beyond the parent's grants routes *up*, ultimately to the root principal, because a parent improvising an answer it was never granted is a learned kernel widening authorization: precisely what the gate-placement rule forbids a judge, and being a parent confers no exemption. The corollary is worth one sentence: a fully autonomous run is one whose root principal is unreachable, so the tree's only widening channel is closed and authorization is frozen at launch — not a limitation of the formalism but the honest price of the word *autonomous*.
The pattern generalizes, and that is the point of the appendix. Nothing here added a primitive: the cancel is a signal in $s$, the gate closes by the rule it already follows, the in-flight disposition is forced by irreversibility, $H_{\mathrm{cancel}}$ is a subclass of an existing terminal set, and compensation is an ordinary owner-issued action — and the later entries kept the promise: resume re-enters $T$ at a persisted $s$, the batch gate was always in $\gamma$'s domain, provenance closure is the lattice's meet, attenuation is narrow-only read along an edge, and per-action capability is the gate's decision made enforceable. Every practical concern that earns a place here should resolve the same way — not new machinery, but the discipline the existing objects already imply, made explicit. Cancellation and resume, gate placement and the batch gate, effect records and the state derived from them, composition and delegation — those are the worked instances; the rest of the model is the same exercise.
---
*The ramblings of Claude and Patrick.*
+187 -48
View File
@@ -1,62 +1,201 @@
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Parameters
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
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
1. Definitions.
For information about alternative licensing arrangements for the Licensed Work,
please contact buckleypm@gmail.com.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
Notice
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
Business Source License 1.1
"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.
Terms
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
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.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
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.
"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.
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.
"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).
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.
"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.
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.
"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."
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.
"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.
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).
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.
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.
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.
+7
View File
@@ -0,0 +1,7 @@
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.
+81 -66
View File
@@ -1,92 +1,107 @@
# Bootstrap Wizard
# Quickstart
Interactive, AI-guided setup for Turnstone deployments. Instead of manually
editing `.env` files and reading deployment docs, the wizard walks you through
every decision conversationally and generates all the config files for you.
Install Turnstone, then diagnose it with `turnstone-doctor` if anything looks off.
## Quick Start
## Install
The one-line installer autodetects your distro (Ubuntu/Debian, Fedora/RHEL,
Arch, and WSL), installs git + Docker if missing, generates secrets, picks free
ports, and starts the stack:
```bash
turnstone-bootstrap
curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
```
That's it — no flags, no arguments. The wizard prompts for everything.
Re-running is safe — it updates the checkout and keeps your existing `.env`.
When it finishes it prints the dashboard URL and how to create the first admin
user.
## How It Works
**Other ways to install**
1. **Pick a model** — Choose OpenAI, Anthropic, or a local/vLLM endpoint to
power the wizard. Local endpoints auto-detect available models.
2. **Answer questions** — The AI walks you through deployment mode, LLM
provider, database, authentication, ports, and optional features.
3. **Review generated files** — Each file is previewed before writing. You
confirm or reject every write.
4. **Start the stack** — The wizard prints the exact `docker compose` command
and a `setup.sh` script to create your first admin user, roles, and policies.
- **Already have Docker?** Clone the repo and `docker compose up` for the full
local cluster, or `docker compose -f turnstone/deploy/compose.yaml up` for the
released single-node stack. See [docs/docker.md](docs/docker.md).
- **Python package:** `pip install turnstone` (add `--pre` for the experimental
track), then run `turnstone-server` / `turnstone-console` directly. See the
[README](README.md#quickstart).
## What Gets Generated
## Diagnose: `turnstone-doctor`
| File | Purpose |
`turnstone-doctor` is an LLM-backed assistant that inspects a **running**
Turnstone install and helps you troubleshoot it. It is **read-only** — it
investigates and tells you the exact commands to fix things, but never changes
your system. (Installation is the installer's job, not the doctor's.)
```bash
# From a host that has the turnstone package installed:
turnstone-doctor
# For a Docker install from run.sh (no package on the host), run it with pipx:
pipx run --spec turnstone turnstone-doctor --dir ~/turnstone
```
### What it does
1. **Preflight** — detects how Turnstone is installed here (docker-compose,
systemd/bare-metal, pip, or a source checkout) by probing for `config.toml`
files, `TURNSTONE_*` environment variables, compose files, and systemd units.
2. **Self-configures its LLM** — it powers its own brain from your cluster's
*own* model configuration (env / `config.toml` / the database). Whether that
works is the first diagnostic: success means your LLM backend is healthy; if
it can't, that's surfaced as finding #1 and it falls back to asking you for a
provider and key so it can still help.
3. **Version check** — reports the installed version, version drift across your
cluster's nodes, and the latest upstream stable/experimental releases.
4. **Interactive diagnosis** — it reads logs, `/health`, `docker compose ps`,
`systemctl`, config, and ports to pin down problems like a node not joining
the console, an unreachable database, a down model backend, port conflicts,
or a JWT-secret mismatch — then hands you the precise remediation commands.
### Flags
| Flag | Purpose |
|------|---------|
| `.env` | All environment variables for `compose.yaml` |
| `setup.sh` | Post-start script: creates admin user, roles, tool policies, prompt templates via the API |
| `docker-compose.override.yaml` | Only if customizations beyond env vars are needed |
| `--dir PATH` | Install directory to inspect (default: current directory) |
| `--report` | Print the deterministic preflight report and exit — no LLM key needed |
| `--offline` | Skip the upstream GitHub version check |
## Requirements
`--report` is the fastest way to get a health snapshot (and to share one when
asking for help) — it never needs an API key:
- **Python 3.11+** with turnstone installed (`pip install turnstone`)
- **An LLM API key** — for the wizard itself (OpenAI, Anthropic, or a local
model). This can differ from the LLM your deployment will use.
- **Docker & Docker Compose** — needed to run the stack. The wizard detects
whether Docker is installed and gives platform-specific install instructions
if it's missing. You can still generate config files without Docker.
## Deployment Modes
- **Single-node production** — `docker compose up` against the bundled
`turnstone/deploy/compose.yaml`: 1 server + console + channel + PostgreSQL,
pulled from ghcr.io. Good for most deployments.
- **Local multi-node cluster** — clone the repo and run `docker compose up` at
the root for a 10-node fleet + console + Caddy + channel, built locally.
See [docs/docker.md](docs/docker.md) for both.
## Example Session
```bash
turnstone-doctor --report --dir ~/turnstone
```
```
$ turnstone-bootstrap
## Install profile
- Detected kind(s): docker-compose (primary: docker-compose)
- Docker daemon reachable: yes
- Compose files:
/home/you/turnstone/compose.yaml
- Database: backend=postgresql, url=postgresql+psycopg://turnstone:****@postgres:5432/turnstone
- Candidate health URLs: http://localhost:8080/health, http://localhost:8090/health
Turnstone Bootstrap Wizard v1.5.0
────────────────────────────────────────────────
## Versions
- Installed (this tool): 1.7.0a2
- Cluster nodes: 10 reporting; versions ['1.7.0a2']
- Version drift across nodes: no
- Upstream: stable 1.6.9, experimental 1.7.0a2
Which provider for this wizard?
[1] OpenAI
[2] Anthropic
[3] OpenAI-compatible (local/vLLM)
> 3
Base URL [http://localhost:8000/v1]:
API key (press Enter for 'none'):
Querying http://localhost:8000/v1 for available models...
Found model: Qwen/Qwen3-32B
Connected to Qwen/Qwen3-32B. Handing off to AI assistant...
> (AI walks you through the rest interactively)
## LLM backend (ok)
- resolved Qwen/Qwen3-32B via openai-compatible @ http://host.docker.internal:8000/v1
```
Secrets (JWT secret, database password, API keys) are always redacted in the
report and in anything the doctor reads.
## Tips
- **Re-run safely** — running the wizard again detects your existing `.env`
and offers to update it rather than overwriting.
- **Duplicate writes are skipped** — if the LLM tries to write the same file
twice with identical content, it's silently ignored.
- **Type `quit` to exit** at any time during the conversation.
- **Ctrl+C** is handled gracefully — press once to interrupt, twice to exit.
- **Type `quit`** to exit the conversation; **Ctrl+C** interrupts (twice to quit).
- **Point it at the right install** with `--dir` when you run it from elsewhere.
- **(Re)installing or adding nodes?** Use the installer (`run.sh`), not the doctor.
## See Also
- [Docker Deployment](docs/docker.md) — manual compose setup and profiles
- [Docker Deployment](docs/docker.md) — compose stacks, ports, and bare-metal nodes
- [Security](docs/security.md) — auth architecture and token types
- [Governance](docs/governance.md) — roles, policies, and templates
+13 -5
View File
@@ -3,7 +3,7 @@
[![CI](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml/badge.svg)](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/turnstone)](https://pypi.org/project/turnstone/)
[![Python](https://img.shields.io/pypi/pyversions/turnstone)](https://pypi.org/project/turnstone/)
[![License](https://img.shields.io/badge/license-BSL--1.1-blue)](LICENSE)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/Nh3bWMacaq)
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.
@@ -14,6 +14,14 @@ Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) (*Arenaria interpres*) — a shorebird that flips stones to discover what's hiding underneath.
**What is a harness?**
```
: s_{n+1} ~ T(s_n) for n < τ*, T = ρ ∘ (M_W ∘ π, E)
```
[**the hypothesis →**](HYPOTHESIS.md)
### Release Tracks
| Track | Install | Docker | Description |
@@ -27,7 +35,7 @@ See [docs/releasing.md](docs/releasing.md) for the full release process.
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
- **Local-first & private** — runs entirely on hardware you control, with no telemetry and no phone-home. Point it at local models (vLLM, llama.cpp, Ollama) or commercial APIs you hold the keys to — your prompts and data never transit a third party you didn't choose.
- **Local-first & private** — runs entirely on hardware you control, with no telemetry and no phone-home. Point it at local models (vLLM, llama.cpp) 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
@@ -86,7 +94,7 @@ LLM; add model backends from the console UI.
For production (released images from ghcr.io, real secrets required), use the
bundled stack: `docker compose -f turnstone/deploy/compose.yaml up`.
See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration.
See [QUICKSTART.md](QUICKSTART.md) for the install + troubleshooting walkthrough and [docs/docker.md](docs/docker.md) for Docker configuration.
### Programmatic (SDK)
@@ -117,7 +125,7 @@ Built-in tools for shell, files, search, web, memory, notifications, and autonom
| `turnstone-channel` | Channel gateway (Discord and Slack adapters) |
| `turnstone-admin` | User/token management CLI |
| `turnstone-eval` | Eval harness for prompt/tool optimization |
| `turnstone-bootstrap` | LLM-guided setup wizard |
| `turnstone-doctor` | LLM-backed cluster diagnostics |
### Diagrams
@@ -169,4 +177,4 @@ Questions, ideas, or want to show what you're building? Join us on Discord:
## License
[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.
[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.
+4 -4
View File
@@ -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 BUSL-1.1 license does not apply to these components.
Turnstone Apache-2.0 license does not apply to these components.
================================================================================
KaTeX 0.16.38
KaTeX 0.17.0
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.13.0
Mermaid 11.15.0
https://mermaid.js.org/
https://github.com/mermaid-js/mermaid
@@ -98,7 +98,7 @@ SOFTWARE.
================================================================================
hls.js 1.6.15
hls.js 1.6.16
https://github.com/video-dev/hls.js
Copyright 2017 Dailymotion
+44 -16
View File
@@ -29,10 +29,11 @@
# 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):
# Join a bare-metal host: a turnstone-server running OUTSIDE compose (e.g. to use
# a local GPU) can join this cluster. Postgres, the console's ACME endpoint, and
# SearxNG are published on 127.0.0.1 so a node on THIS machine reaches them via
# localhost. Keep secrets in ~/.config/turnstone/config.toml (chmod 0600 — the
# loader warns otherwise):
# [auth]
# jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
# [database]
@@ -41,10 +42,19 @@
# [api]
# base_url = "http://localhost:8000/v1"
# api_key = "dummy"
# [tls] # only if the cluster runs mTLS
# enabled = true
# then run (node identity isn't a secret, so it stays on the command line):
# TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
# TURNSTONE_NODE_ID=host-1 \
# TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
# TURNSTONE_CONSOLE_URL=http://localhost:8090 \
# TURNSTONE_SEARXNG_URL=http://localhost:8081 \
# turnstone-server --host 0.0.0.0 --port 8080
# It registers in Postgres and the console reaches it back via host.docker.internal.
# The node registers in Postgres, auto-enrolls its mTLS cert from the console's
# ACME endpoint (when the cluster runs mTLS), and the console collector reaches
# it back via host.docker.internal. To join from ANOTHER machine, set
# TURNSTONE_HOST_IP to this host's LAN IP and use it in the URLs above (and the
# node's TURNSTONE_ADVERTISE_URL = the NODE host's IP) — see docs/docker.md.
# =============================================================================
name: turnstone
@@ -93,13 +103,14 @@ services:
# INSECURE dev default — override POSTGRES_PASSWORD in .env for real use.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-turnstone}
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.
# Published so a bare-metal turnstone-server can join the cluster (see "Join
# a bare-metal host" in the header). Bound to 127.0.0.1 by default (same-host
# nodes only); set TURNSTONE_HOST_IP to this host's LAN IP to let another
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose a
# database with the insecure default password to your network. (The legacy
# POSTGRES_BIND is still honored as a fallback when TURNSTONE_HOST_IP is unset.)
ports:
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
- "${TURNSTONE_HOST_IP:-${POSTGRES_BIND:-127.0.0.1}}:${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
@@ -120,10 +131,12 @@ services:
# 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.
# Browsers must reach the dashboard through Caddy (https://localhost:8443): a
# plain HTTP/1.1 origin caps the browser at 6 connections, which starves the
# dashboard's per-pane SSE streams, whereas Caddy serves HTTP/2 (multiplexed)
# and proxies to console:8090 internally. The console's :8090 is published
# below ONLY so bare-metal nodes can reach the plain-HTTP ACME enrollment
# endpoint — don't point a browser at it.
#
# The single `build:` here produces the turnstone:local image every other
# service reuses. extra_hosts lets the console reach a bare-metal server
@@ -138,6 +151,14 @@ services:
- turnstone-console
- --host=0.0.0.0
- --port=8090
# Publishes the console's plain-HTTP listener so a bare-metal node can reach
# the ACME endpoint, fetch the CA, and enroll its cert (the console serves
# HTTP here even under mTLS). Bound to 127.0.0.1 by default; setting
# TURNSTONE_HOST_IP exposes the WHOLE console HTTP API — including the
# cert-issuing ACME endpoint — on that interface, so the JWT secret's
# strength is the only gate. Browsers use Caddy :8443, never this port.
ports:
- "${TURNSTONE_HOST_IP:-127.0.0.1}:8090:8090"
environment:
TURNSTONE_JWT_SECRET: *jwt-secret
TURNSTONE_DB_BACKEND: *db-backend
@@ -219,6 +240,13 @@ services:
# -------------------------------------------------------------------
searxng:
image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest}
# Published so a bare-metal node's web_search can reach it. SearxNG has NO
# auth, so it is bound to 127.0.0.1 by default; setting TURNSTONE_HOST_IP
# exposes it on that interface — an open search proxy on your LAN, which also
# triggers the SearxNG AGPL-3.0 §13 source-offer obligation (see docs/docker.md).
# In-compose nodes always use the internal http://searxng:8080 and ignore this.
ports:
- "${TURNSTONE_HOST_IP:-127.0.0.1}:${SEARXNG_API_PORT:-8081}:8080"
volumes:
- ./turnstone/deploy/searxng:/etc/searxng:ro
- searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts)
+72
View File
@@ -0,0 +1,72 @@
# Running a bare-metal turnstone-server under systemd
These units run a `turnstone-server` **outside** Docker (e.g. on a box with a
local GPU) so it joins an existing cluster — typically the docker-compose stack
in [`compose.yaml`](../../compose.yaml). They are the hardened, production-shaped
counterpart to the quick `turnstone-server …` invocation in
[`docs/docker.md`](../../docs/docker.md) ("Join a bare-metal host").
| File | Purpose |
|------|---------|
| `turnstone-server.service` | The hardened server unit (sandboxed; secrets via `config.toml`). |
| `turnstone.slice` | Shared memory/process budget for colocated Turnstone units. |
| `turnstone-server.service.d/node.conf.example` | Per-host identity + cluster URLs drop-in (no secrets). |
## Cluster-side prerequisite
The compose stack must publish Postgres, the console's ACME endpoint, and SearxNG
on an address the bare-metal host can reach. Start it with `TURNSTONE_HOST_IP`
set to the compose host's LAN IP (default `127.0.0.1` keeps everything host-local):
```bash
TURNSTONE_HOST_IP=<compose-host-ip> docker compose up -d
```
## Install (run as root on the bare-metal host)
```bash
# 1. A dedicated, unprivileged user.
useradd --system --no-create-home --shell /usr/sbin/nologin turnstone
# 2. Install turnstone into a venv at /opt/turnstone-venv (lacme/mTLS is a core dep).
uv venv /opt/turnstone-venv --python 3.12
uv pip install --python /opt/turnstone-venv 'turnstone @ git+https://github.com/turnstonelabs/turnstone'
# …or from a local checkout: uv pip install --python /opt/turnstone-venv /path/to/turnstone
# 3. Secrets — match the cluster's JWT secret + DB credentials (kept out of env).
install -d -m 750 -o turnstone -g turnstone /etc/turnstone
cat > /etc/turnstone/config.toml <<'TOML'
[auth]
jwt_secret = "<same secret as the cluster>"
[database]
backend = "postgresql"
url = "postgresql+psycopg://turnstone:<password>@<compose-host-ip>:5432/turnstone"
[api]
base_url = "http://localhost:8000/v1" # a real model backend is configured in the console UI
api_key = "dummy"
TOML
chown turnstone:turnstone /etc/turnstone/config.toml
chmod 600 /etc/turnstone/config.toml
# 4. Units + per-host drop-in.
cp turnstone-server.service turnstone.slice /etc/systemd/system/
install -d /etc/systemd/system/turnstone-server.service.d
cp turnstone-server.service.d/node.conf.example \
/etc/systemd/system/turnstone-server.service.d/node.conf
$EDITOR /etc/systemd/system/turnstone-server.service.d/node.conf # set the addresses
# 5. Go.
systemctl daemon-reload
systemctl enable --now turnstone-server.service
journalctl -u turnstone-server -f # watch it register + (if the cluster runs mTLS) enroll
```
`tls.enabled` is **not** set here — a joining node inherits it from the cluster's
shared settings (the database). If the cluster runs mTLS, the node auto-enrolls a
cert from the console's ACME endpoint and re-advertises itself over `https://`.
> **mTLS + cross-host caveat:** a node on a *different* host than the console
> currently can't complete ACME enrollment — the console advertises an
> unroutable in-container address in its ACME directory
> ([turnstonelabs/lacme#22](https://github.com/turnstonelabs/lacme/issues/22)).
> Same-host bare-metal nodes, and any node in a non-mTLS cluster, are unaffected.
+85
View File
@@ -0,0 +1,85 @@
# Run a bare-metal turnstone-server as a systemd service so it joins a cluster
# (e.g. the docker-compose stack) from outside Docker — typically to use a local
# GPU. Install steps + the cluster-side prerequisites are in deploy/systemd/README.md
# and docs/docker.md ("Join a bare-metal host"). Per-host identity + the cluster
# URLs go in a drop-in (see node.conf.example); secrets go in config.toml.
[Unit]
Description=Turnstone server (chat workstreams + LLM gateway)
Documentation=https://github.com/turnstonelabs/turnstone
# Postgres is required. After= orders against a colocated postgresql.service
# when present and silently no-ops otherwise (the cluster DB is usually remote).
After=network.target postgresql.service
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=turnstone
Group=turnstone
# Secrets live in config.toml — JWT secret, Postgres URL+password, LLM API key —
# kept out of os.environ so a prompt-injected tool can't dump them via `env`.
Environment=TURNSTONE_CONFIG=/etc/turnstone/config.toml
Environment=TURNSTONE_LOG_LEVEL=info
Slice=turnstone.slice
# Per-host node identity + cluster wiring (TURNSTONE_NODE_ID / _ADVERTISE_URL /
# _CONSOLE_URL / _SEARXNG_URL) go in a drop-in, not here — see node.conf.example.
StateDirectory=turnstone
StateDirectoryMode=0750
LogsDirectory=turnstone
LogsDirectoryMode=0750
WorkingDirectory=/var/lib/turnstone
# --host 0.0.0.0 so the console collector + peer nodes can dial this node back
# at its advertised address. (A single-node, Caddy-fronted install can use
# 127.0.0.1 instead.) Rewrite --port if :8080 is already taken on the host.
ExecStart=/opt/turnstone-venv/bin/turnstone-server --host 0.0.0.0 --port 8080
Restart=on-failure
RestartSec=5s
TimeoutStartSec=120
TimeoutStopSec=30
KillSignal=SIGTERM
KillMode=mixed
# --- Resource limits ---
# SSE keeps an fd per active workstream + outbound LLM stream + MCP stdio pipe.
LimitNOFILE=65535
LimitNPROC=8192
TasksMax=8192
LimitCORE=0
# --- Hardening ---
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
UMask=0027
PrivateTmp=true
# PrivateDevices=true — disabled: GPU access via /sys/class/drm
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @mount
StandardOutput=journal
StandardError=journal
SyslogIdentifier=turnstone-server
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,25 @@
# Per-host node identity + cluster wiring for a bare-metal turnstone-server.
# Copy to /etc/systemd/system/turnstone-server.service.d/node.conf and edit the
# addresses, then `systemctl daemon-reload`. Identity + URLs are NOT secrets, so
# they live here; the JWT secret + DB URL live in /etc/turnstone/config.toml.
#
# Addresses below use RFC 5737 documentation IPs — replace them:
# <this-host> = the bare-metal host's own LAN IP (what the console dials back)
# <compose-host> = the host running the cluster / docker-compose stack, started
# with TURNSTONE_HOST_IP=<compose-host> so :8090 and :8081 are
# published on its LAN interface (see docs/docker.md).
[Service]
# Unique node id (defaults to the hostname if unset).
Environment=TURNSTONE_NODE_ID=host-1
# The address peers + the console collector dial back. Auto-upgrades to https://
# once the node enrolls its mTLS cert.
Environment=TURNSTONE_ADVERTISE_URL=http://192.0.2.10:8080
# The cluster console's reachable plain-HTTP ACME/API endpoint. A bare-metal node
# can't resolve the in-cluster name (console:8090), so point it at the published
# port; turnstone-server honors this for cert enrollment.
Environment=TURNSTONE_CONSOLE_URL=http://192.0.2.1:8090
# The cluster's published SearxNG, for the web_search tool.
Environment=TURNSTONE_SEARXNG_URL=http://192.0.2.1:8081
+15
View File
@@ -0,0 +1,15 @@
# Shared resource budget for the colocated Turnstone units. Without a slice each
# unit's MemoryMax= is enforced independently — three units at 85% each can sum
# to 255% of host RAM before any throttles. Under a shared slice the cap is
# hierarchical: the slice ceiling is the real limit. (A bare-metal node that runs
# only turnstone-server still benefits — and keeps the unit's Slice= reference
# valid.) Adjust if the host runs other meaningful workloads alongside Turnstone.
[Unit]
Description=Turnstone services slice (server + console + channel)
Documentation=https://github.com/turnstonelabs/turnstone
Before=slices.target
[Slice]
MemoryHigh=70%
MemoryMax=85%
TasksMax=16384
+75 -11
View File
@@ -2,13 +2,69 @@
"""Health check for turnstone containers.
Usage: healthcheck.py <url>
Exit 0 if the endpoint returns {"status": "ok"}, exit 1 otherwise.
Uses only stdlib no pip dependencies required.
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.
"""
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:
@@ -18,16 +74,24 @@ def main() -> None:
url = sys.argv[1]
try:
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)
_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,
)
sys.exit(1)
except Exception as exc:
print(f"Health check failed: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
+10 -4
View File
@@ -63,7 +63,10 @@ Auth is always enabled. All API endpoints except public paths require a valid to
Include a token in one of two ways:
- **Bearer header**: `Authorization: Bearer <token>`
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
- **Cookie**: the surface-scoped auth cookie — `turnstone_auth_server` on
turnstone-server, `turnstone_auth_console` on turnstone-console (set
automatically by the login endpoint). The names differ so the two surfaces,
when co-hosted on one origin, don't overwrite each other's session.
The server accepts two token types:
@@ -102,7 +105,8 @@ Authenticate with credentials and receive a JWT. Accepts two credential formats:
}
```
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
The response also sets a surface-scoped HttpOnly cookie containing the JWT
(`turnstone_auth_server` on turnstone-server, `turnstone_auth_console` on turnstone-console).
**Response (failure):** `401`
@@ -114,7 +118,8 @@ The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
### `POST /v1/api/auth/logout`
Clears the `turnstone_auth` cookie. No request body required.
Clears the surface-scoped auth cookie (`turnstone_auth_server` /
`turnstone_auth_console`). No request body required.
**Response:** `200`
@@ -199,7 +204,8 @@ this endpoint.
}
```
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
The response also sets a surface-scoped HttpOnly cookie containing the JWT
(`turnstone_auth_server` on turnstone-server, `turnstone_auth_console` on turnstone-console).
**Response (already set up):** `409`
+61 -3
View File
@@ -22,7 +22,7 @@ plugs in.
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
| `turnstone-bootstrap` | `turnstone.bootstrap` | — | LLM-guided setup wizard |
| `turnstone-doctor` | `turnstone.doctor` | — | LLM-backed cluster diagnostics |
---
@@ -702,7 +702,7 @@ agent_model = "claude"
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
and `"openai-compatible"`.
`"openai-compatible"`, and `"anthropic-compatible"`.
**Per-model sampling overrides:** Each model can specify `temperature`,
`max_tokens`, and `reasoning_effort` to override the global defaults from
@@ -765,6 +765,63 @@ 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
@@ -1134,7 +1191,8 @@ Three hierarchical scopes control endpoint access:
`/metrics`, `/openapi.json`, `/docs`, `/api/auth/*`, and `/api/auth/setup`
are always allowed.
2. **Token extraction**`Authorization: Bearer <token>` header first, then
`turnstone_auth` cookie as fallback.
surface-scoped auth cookie (`turnstone_auth_server` on the node server,
`turnstone_auth_console` on the console) as fallback.
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
indicates API token.
4. **Validation** — JWT signature check or API token hash lookup in storage.
+12 -25
View File
@@ -12,7 +12,6 @@ Existing bulk endpoints at time of writing:
|---------------------------------------------------------|--------------------------|------------------------------------------|
| `GET /v1/api/cluster/ws/live?ids=a,b,c` | bulk read | `{results, denied, truncated}` |
| model tool `spawn_batch` | bulk create (per-item) | `{results, denied}` |
| `POST /v1/api/workstreams/{ws_id}/stop_cascade` | cascade mutation | `{cancelled, failed, skipped}` |
| `POST /v1/api/workstreams/{ws_id}/close_all_children` | cascade mutation | `{closed, failed, skipped}` |
---
@@ -146,7 +145,7 @@ consistently-typed across the read and create cases.
```
Where `<bucket>` is the endpoint-specific name for "succeeded" —
`cancelled` for `stop_cascade`, `closed` for `close_all_children`.
`closed` for `close_all_children`.
The three buckets partition the input set exactly once:
| Bucket | Meaning |
@@ -161,20 +160,6 @@ be partial. `skipped` is pre-resolved — the target is already in
the terminal state the cascade was aiming at, so it's neither a
win to report nor a fault to fix.
### Example — `stop_cascade`
```json
{
"status": "ok",
"cancelled": ["child-1", "child-3"],
"failed": [],
"skipped": ["child-2"]
}
```
A subsequent retry would target only `failed` ids, not `skipped`
ones — the latter are already done.
### Example — `close_all_children`
```json
@@ -186,10 +171,12 @@ ones — the latter are already done.
}
```
Same partition, different success-bucket name. When `coord_client`
is unavailable (session loaded but no HTTP client attached — a
construction bug) every id goes to `failed` so the operator notices
rather than getting a silent all-skipped response.
Here the success bucket is `closed`. A subsequent retry would
target only `failed` ids, not `skipped` ones — the latter are
already done. When `coord_client` is unavailable (session loaded
but no HTTP client attached — a construction bug) every id goes to
`failed` so the operator notices rather than getting a silent
all-skipped response.
---
@@ -232,12 +219,12 @@ rather than getting a silent all-skipped response.
- **Phase 6** shipped `cluster/ws/live` as the first Shape A endpoint
(`{results, denied, truncated}`).
- **Phase 7** shipped `stop_cascade` as the first Shape B endpoint
(`{cancelled, failed, skipped}`).
- **Phase 7** introduced the Shape B cascade-mutation envelope
(`{<bucket>, failed, skipped}`) for the coordinator's
cancel-cascade path.
- **Phase 8 PR A** shipped `spawn_batch` (Shape A, keyed by idx) and
`close_all_children` (Shape B, twin of `stop_cascade`), which
crystallised the two-shape-per-semantic-category policy codified
here.
`close_all_children` (Shape B), which crystallised the
two-shape-per-semantic-category policy codified here.
Before adding a third shape, read this doc and argue for why the
new surface doesn't fit either A or B. Two idioms in the cluster
+35 -47
View File
@@ -18,7 +18,7 @@ schema changes.
> auth and the `admin.coordinator` permission. A session-scoped JWT
> is minted per login (see [docs/oidc.md](oidc.md) / [docs/security.md](security.md));
> a service token may call the read paths but destructive governance
> paths (`/restrict`, `/stop_cascade`, `/close_all_children`) require
> paths (`/restrict`, `/close_all_children`) require
> the explicit `admin.coordinator` grant — a service-token owner
> match isn't enough.
@@ -44,7 +44,6 @@ schema changes.
| 6 | Wait for fan-out | model-side tool `wait_for_workstream` |
| 7 | Govern | `POST /v1/api/workstreams/{ws_id}/trust` |
| | | `POST /v1/api/workstreams/{ws_id}/restrict` |
| | | `POST /v1/api/workstreams/{ws_id}/stop_cascade` |
| | | `POST /v1/api/workstreams/{ws_id}/close_all_children` |
| 8 | Approve / cancel | `POST /v1/api/workstreams/{ws_id}/approve` |
| | | `POST /v1/api/workstreams/{ws_id}/cancel` |
@@ -53,7 +52,7 @@ schema changes.
Refer to `/openapi.json` (Swagger UI at `/docs`) on any
`turnstone-console` process for the authoritative operation ids and
schemas. Coordinator-only verbs (`/children`, `/trust`, `/restrict`,
`/stop_cascade`, `/close_all_children`) 404 against `kind=interactive`
`/close_all_children`) 404 against `kind=interactive`
rows; the shared verbs (`/send`, `/approve`, `/cancel`, `/events`,
`/history`, `/open`, `/close`, etc.) work on both kinds.
@@ -237,14 +236,21 @@ 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.
`mode="all"` waits for every polled child to reach a real
terminal state.
- **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.
- **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).
- **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.
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
loop — a wait consumes one assistant turn regardless of how long the
@@ -254,10 +260,10 @@ rounds to a 10× token-efficiency win.
---
## 7. Governance — trust, restrict, stop_cascade, close_all_children
## 7. Governance — trust, restrict, close_all_children
These four endpoints let an operator steer a live coordinator session
mid-flight. All four emit an audit event tagged
These three endpoints let an operator steer a live coordinator session
mid-flight. All three emit an audit event tagged
`coordinator.<action>` via the dedicated audit executor so a cascade
burst can't starve audit writes.
@@ -287,28 +293,6 @@ idempotent — calling twice with overlapping lists converges to the
union. Revocations don't survive a session close/reopen; operators
opt in per session. Cap 256 tool names per request, 128 chars each.
### `POST /stop_cascade` — cancel the subtree
```http
POST /v1/api/workstreams/{ws_id}/stop_cascade
{}
```
Cancels the coordinator's in-flight generation AND dispatches
`cancel_workstream` through the routing proxy for every direct
child in the in-memory registry. Returns:
```json
{"status": "ok", "cancelled": ["child-1", "child-3"], "failed": [], "skipped": ["child-2"]}
```
Response uses the [cascade-mutation bulk shape](bulk-endpoints.md):
`cancelled` = accepted, `failed` = dispatch error worth retrying,
`skipped` = upstream 404 (already gone — stale registry entry or
the row was deleted between snapshot and dispatch). Grandchildren
aren't touched directly; they sit behind their parent's cancel and
propagate via the child's SSE stream.
### `POST /close_all_children` — soft-close the direct fan-out
```http
@@ -322,16 +306,16 @@ Response:
{"status": "ok", "closed": ["c-1", "c-2"], "failed": [], "skipped": []}
```
Soft-close cascade bounded by the same semaphore as `stop_cascade`.
The `reason` (up to 512 chars) propagates into each closed child's
audit + `workstream_config` for postmortem. Unlike `stop_cascade`
this does NOT recurse into grandchildren — the model-facing tool
that pairs with this endpoint asks for a bounded teardown of the
coordinator's own fan-out. For a full-subtree teardown, use
`stop_cascade`.
Soft-close cascade bounded by a concurrency semaphore. The `reason`
(up to 512 chars) propagates into each closed child's audit +
`workstream_config` for postmortem. The model-facing tool that
pairs with this endpoint asks for a bounded teardown of the
coordinator's own fan-out. This *soft-closes*; to *cancel* the
fan-out instead, cancel the coordinator (§8) — a coordinator cancel
auto-cascades to its direct children.
See [bulk-endpoints.md](bulk-endpoints.md) for why both endpoints
share the cascade-mutation shape and how it differs from the
See [bulk-endpoints.md](bulk-endpoints.md) for why `close_all_children`
uses the cascade-mutation shape and how it differs from the
`spawn_batch` / `cluster/ws/live` shape.
---
@@ -349,7 +333,10 @@ POST /v1/api/workstreams/{ws_id}/approve
{"approved": true, "feedback": null, "always": true} // always-approve this tool name
```
`cancel` drops the in-flight generation but leaves the coordinator
`cancel` drops the coordinator's in-flight generation and, for a
coordinator, auto-cascades the cancel to its direct children:
`cancel_workstream` is dispatched through the routing proxy for
every direct child in the registry. The coordinator itself is left
idle and open for a fresh `send`:
```http
@@ -366,9 +353,10 @@ POST /v1/api/workstreams/{ws_id}/close
{}
```
Soft-closes the session — state persists, children keep running (use
`close_all_children` or `stop_cascade` first to wind them down), the
worker thread exits, SSE streams send a final `stream_end` and
Soft-closes the session — state persists, children keep running
(wind them down first with `close_all_children`, or by cancelling
the coordinator, which cascades the cancel to its direct children),
the worker thread exits, SSE streams send a final `stream_end` and
disconnect. The row is reopenable via
`POST /v1/api/workstreams/{ws_id}/open` so long as it hasn't been
deleted.
@@ -383,7 +371,7 @@ deleted.
- [bulk-endpoints.md](bulk-endpoints.md) — the two bulk-shape
idioms (`{results, denied, truncated}` vs
`{<bucket>, failed, skipped}`) used by `cluster/ws/live`,
`spawn_batch`, `stop_cascade`, and `close_all_children`.
`spawn_batch`, and `close_all_children`.
- [architecture.md](architecture.md) — cluster-wide architecture
including how coordinator sessions fit next to node-hosted
interactive workstreams.
+27 -13
View File
@@ -77,7 +77,7 @@ or MCP config can do adds to it. Current members:
| `delete_workstream` | wind-down | Hard-delete one child. Requires approval. |
| `list_nodes` | discover | Enumerate live cluster nodes + capabilities. |
| `skills` (action=find) | discover | Browse the skill catalog; opt-in `kind` filter narrows by audience. |
| `memory` | persist | Orchestration scratchpad keyed by the `coordinator` scope. |
| `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. |
| `tasks` | plan | Orchestrator-only scratchpad. Children don't see it. |
@@ -168,19 +168,33 @@ 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 varies by tool:
`user_id=owner` in storage. The rejection shape is uniform and
recovery-oriented:
- **Mutating ops** (`send_to_workstream`, `close_workstream`,
`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".
`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.
Pattern: capture each spawn result in the next tool call's input.
The JSON tool-result carries `{"child_ws_id": "...", "name": "...",
@@ -337,7 +351,7 @@ persona drift without a real LLM in the loop.
`spawn_batch` and `close_all_children` use, so your skill can
parse results / denied arrays correctly.
- [governance.md](governance.md) — the broader governance surface
(`/trust`, `/restrict`, `/stop_cascade`, role-based permissions)
(`/trust`, `/restrict`, role-based permissions)
that wraps every coord session.
- [settings.md](settings.md) — `coordinator.model_alias` and
`coordinator.reasoning_effort` settings that gate which LLM runs
+34 -16
View File
@@ -59,9 +59,11 @@ 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.
PostgreSQL, the console's ACME endpoint (`:8090`), and SearxNG (`:8081`) are
published on `127.0.0.1`, so a `turnstone-server` running directly on the same
machine — for example to use a local GPU — can join the same cluster (enrolling
its mTLS cert and running `web_search`) and show up in the console alongside the
containerized nodes.
Put the secret and connection settings in `~/.config/turnstone/config.toml`
(secrets belong in this file, not the process environment — keep it `0600`,
@@ -85,18 +87,31 @@ command line:
```bash
chmod 600 ~/.config/turnstone/config.toml
TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
TURNSTONE_NODE_ID=host-1 \
TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
TURNSTONE_CONSOLE_URL=http://localhost:8090 \
TURNSTONE_SEARXNG_URL=http://localhost:8081 \
turnstone-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.
`host.docker.internal`. `TURNSTONE_CONSOLE_URL` points the node at the console's
published ACME endpoint so it can enroll its mTLS certificate (needed only when
the cluster runs mTLS; harmless otherwise), and `TURNSTONE_SEARXNG_URL` points
`web_search` at the published SearxNG. The `jwt_secret` and DB credentials above
are the dev-stack defaults — match whatever you set in `.env` if you changed them.
To let a server on a **different** machine join, start the stack with
`TURNSTONE_HOST_IP=<this host's LAN IP>` — that binds PostgreSQL, the console
ACME endpoint, and SearxNG to that interface. Then on the remote box set the
three URLs above to that IP, and set `TURNSTONE_ADVERTISE_URL` to the **remote**
box's own IP (the address the console dials back). **Set a strong
`POSTGRES_PASSWORD` first** — `TURNSTONE_HOST_IP` exposes the database (and every
user account + API-token hash in it), the console API, and the unauthenticated
SearxNG to your network.
To run the bare-metal node as a hardened, persistent service instead of by hand,
use the systemd units in [`deploy/systemd/`](../deploy/systemd/).
## Production stack
@@ -110,7 +125,7 @@ docker compose -f turnstone/deploy/compose.yaml up
It's the same shape as the dev stack — Caddy-fronted console, channel, and a
PostgreSQL all share one database so the console discovers the node — but it
pulls released images, runs a single server node, and has **no baked-in
secrets**. Set these in `.env` first (`turnstone-bootstrap` generates them):
secrets**. Set these in `.env` first (generate with `openssl rand -hex 32`):
```bash
TURNSTONE_JWT_SECRET=<python -c "import secrets; print(secrets.token_hex(32))">
@@ -174,15 +189,18 @@ overrides.
### 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:
publishes the console's ACME endpoint and SearxNG on localhost so a bare-metal
node can enroll its cert and run `web_search`. Everything else is reached through
Caddy or proxied by the console:
| 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 |
| `SEARXNG_API_PORT` | `8081` | Host port for the SearxNG API a bare-metal node's `web_search` dials (dev stack) |
| `TURNSTONE_HOST_IP` | `127.0.0.1` | Interface PostgreSQL, the console ACME endpoint, and SearxNG bind on (dev stack). Set to this host's LAN IP so a bare-metal node on **another machine** can reach them — set a strong `POSTGRES_PASSWORD` first (it also exposes the DB and the unauthenticated SearxNG to your network). |
| `POSTGRES_BIND` | `127.0.0.1` | Production stack (`turnstone/deploy/compose.yaml`) only: interface PostgreSQL binds on; set to the host's LAN IP for remote joins. |
### Channel gateway
@@ -242,7 +260,7 @@ interface, or anyone who can reach it can search through your instance.
Both stacks install all entry points into a single image (`turnstone`,
`turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`,
`turnstone-eval`, `turnstone-bootstrap`):
`turnstone-eval`, `turnstone-doctor`):
```bash
docker compose build # build the dev image
+30 -6
View File
@@ -40,7 +40,7 @@ api_key = ""
smart_approvals = false # auto-approve high-confidence "approve" LLM verdicts (opt-in)
confidence_threshold = 0.95 # Smart Approvals auto-approve bar (LLM recommendation=approve)
max_context_ratio = 0.5 # max % of judge context window for history
timeout = 60.0 # seconds (generous for local models)
timeout = 120.0 # seconds (generous for local models)
read_only_tools = true # judge can use read_file/list_directory
cancel_on_approval = false # stop judging remaining tool calls once user decides
```
@@ -71,7 +71,7 @@ All fields are optional. The judge is enabled by default; use `enabled = false`
--judge / --no-judge Enable/disable (default: enabled)
--judge-model MODEL Model for judge
--judge-provider PROVIDER Provider for judge
--judge-timeout SECONDS LLM judge timeout (default: 60)
--judge-timeout SECONDS LLM judge timeout (default: 120)
--judge-confidence FLOAT Confidence threshold, 0-1 (default: 0.95)
```
@@ -193,9 +193,10 @@ Security hardening blocks access to sensitive paths:
### Timeout
The `timeout` setting (default 60 seconds) is a total budget across all judge
turns. Time is decremented after each LLM call. If the budget expires mid-turn,
the judge attempts to parse whatever partial response is available.
The `timeout` setting (default 120 seconds) applies **per turn**, not as a total
budget across turns — each of the up to 5 turns gets a fresh budget, so a slow
earlier turn doesn't starve later ones. If a turn's budget expires, the judge
attempts to parse whatever partial response is available.
---
@@ -231,6 +232,23 @@ 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.
@@ -242,7 +260,13 @@ 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
- 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 console admin panel exposes verdict history via:
+30 -5
View File
@@ -26,15 +26,40 @@ 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 |
| 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 |
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:
+1 -1
View File
@@ -108,7 +108,7 @@ pgbouncer:
maxClientConn: 5000
maxDbConnections: 80
```
:
---
## Configuration reference
+18 -17
View File
@@ -6,10 +6,9 @@ Turnstone ships several parallel release tracks from a single PyPI package.
| Track | Versions | Branch | Docker tags | PyPI install |
|-------|----------|--------|-------------|--------------|
| **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 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` |
- **Stable** tracks receive bugfixes only. The most-recent stable minor
owns the `:stable` / `:latest` Docker tags and the default PyPI
@@ -17,8 +16,10 @@ 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; older stable branches continue to receive
security fixes until explicitly retired.
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.
## Version Scheme
@@ -33,17 +34,17 @@ Turnstone ships several parallel release tracks from a single PyPI package.
## Releasing an Experimental Version (from main)
```bash
scripts/release.sh 1.5.0a2 --push
scripts/release.sh 1.7.0a2 --push
```
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.
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.
## Releasing a Stable Patch (from stable/X.Y)
```bash
git checkout stable/1.4
git checkout stable/1.6
git cherry-pick <commit-hash> # bugfix from main
scripts/release.sh 1.4.1 --push
scripts/release.sh 1.6.1 --push
```
## Promoting Experimental to Stable
@@ -52,19 +53,19 @@ When `main` is ready for a stable release:
```bash
# 1. Tag the stable release on main
scripts/release.sh 1.5.0 --push
scripts/release.sh 1.6.0 --push
# 2. Create the stable maintenance branch from that tag
git branch stable/1.5 v1.5.0
git push origin stable/1.5
git branch stable/1.6 v1.6.0
git push origin stable/1.6
# 3. Start the next experimental cycle on main
scripts/release.sh 1.6.0a1 --push
scripts/release.sh 1.7.0a1 --push
```
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.
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).
## CI/CD Pipeline
+32 -2
View File
@@ -88,6 +88,32 @@ 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
@@ -218,7 +244,10 @@ const client = new TurnstoneServer({
### Node Bootstrap Flow
1. Node starts, connects to shared database (plain connection)
2. Discovers console URL from `services` table
2. Discovers the console URL from the `services` table — or honors an explicit
`TURNSTONE_CONSOLE_URL` (a bare-metal node outside the compose network can't
resolve the in-cluster `console` name, so it points this at the console's
published ACME endpoint)
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
@@ -265,7 +294,8 @@ cert's SANs don't include the dialed name.
The console registers itself in the `services` table on startup. If the console
hasn't started or the registration expired (1 hour TTL), nodes can't discover
it. Use `--console-url` explicitly.
it. Set `TURNSTONE_CONSOLE_URL` to a reachable console address (this is also how
a bare-metal node that can't resolve the in-cluster `console` name enrolls).
### Browser HTTPS to the console
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
.venv/
*.db
*.db-wal
*.db-shm
.ruff_cache/
.pytest_cache/
.mypy_cache/
uv.lock
+282
View File
@@ -0,0 +1,282 @@
# Understone
A small, multiplayer, BBS-style **ANSI door game** served over the Model
Context Protocol (MCP). It is a text RPG in the spirit of *Legend of the Red
Dragon* — explore an overworld of box-drawing maps, fight wandering monsters,
shop and rest in town, and descend a dungeon — except the "door" is an MCP
server and the player drives it by talking to an AI assistant.
The server is the rules engine and the single source of truth. Players share
**one persistent world**: your assistant calls tools, the server returns
authoritative frames and facts, and the assistant narrates the story around
them.
This is a self-contained reference example. It depends only on `mcp` — there
is no dependency on Turnstone itself — so it runs against any MCP client.
## How to play
There is **no prompt to paste and no persona to configure**. The tool schema
is the whole interface. Once the server is registered with your assistant:
1. Tell your assistant you'd like to play an ANSI door game / text dungeon
RPG (it can discover the tools by name and description).
2. The assistant calls `door_help` to learn how to run the world, then
`door_join` with your adventurer's name.
3. Play unfolds as a conversation: "head east", "fight it", "rest at the inn".
Everything the assistant needs to run the game well is returned by
`door_help`.
## Gameplay
A run is a little RPG loop, played a bit each day:
- **Explore** the overworld of box-drawing maps. Walking is free, but the wild
country has texture — a step may turn up a wandering monster, a purse of
gold, a healing spring, a small trap (which can never kill you), or a scrap
of old Vale lore. Only one such find happens per move, and the non-combat
ones don't interrupt your walk.
- **Fight, shop, and heal** in and around town. Fighting and descending one
rung of the dungeon each spend one of your daily turns; resting, shopping and
moving do not.
- **Delve the deep, a rung at a time.** The dungeon is a ladder of guardians:
each `descend` faces the next one past your deepest and either advances your
depth or bounces you home (your depth persists either way). Carry a few
**potions in your satchel**`quaff` the strongest when you choose, and if a
fight would kill you the satchel saves you automatically, the elixir burning
down your throat at death's edge. Clearing a rung also yields **forge ore**,
which rides the satchel (a won forest fight sometimes turns up a little, too).
- **Forge an edge — with gold AND ore.** At the shop's **forge** you can add a
+1 edge to your equipped weapon or armour, up to a cap, each step dearer than
the last. A step costs gold *and* the ore you won in the deep — so the forge is
fed by descending, not just by a fat purse. Watch, too, for the **rare beasts**
that prowl the forest: felling one is Herald news and always drops a draught.
- **Win the game** by slaying **the Wyrm Below**. Once your hero is seasoned
enough AND has plumbed the deep to its floor, `challenge` it at the dungeon. A
victory frees the Vale, carves your run into the **Hall of Legends**, and — in
the tradition of the classic BBS door games — begins a new life: your
character resets to first-day gear and stats but keeps a permanent ★ for every
Wyrm slain, ready to do it all again.
- **Read the news.** `door_log` is the **Understone Herald**, a shared
broadsheet of notable deeds across the whole world — who joined, who rose a
level, who was dragged home by a goblin, and who freed the Vale.
- **Make it social.** It is a shared world, so you can touch other players.
`ambush` a rival who has not yet acted today — a classic
style player-kill that robs a sleeping foe of some gold, except the surest
defence is simply to take your own turn (an active player is awake and can't
be caught). Lose the ambush and *you* are the one who flees, shamed on the
feed. `post` a private note another player reads on their next visit (it
never reaches the public Herald). Or `gamble` a little gold at the inn's dice
against the house. Ambush spends a turn; mail and dice do not.
- **Bank your coin.** The inn keeps a strongbox: `deposit` gold into the
**vault** and `withdraw` it later (no turn either way). Banked gold is **safe
from ambush** — a sleeping-robber only ever lifts what you carry — and it is
the one thing that **survives a Wyrm-win reset**, carrying wealth across runs.
## Installation
This example uses [`uv`](https://docs.astral.sh/uv/). From the example
directory:
```bash
cd examples/door-game
uv venv
uv pip install -e .
```
That installs the `understone` entry point into the environment.
To run the tests and quality gates:
```bash
uv pip install -e ".[test,dev]"
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run mypy understone/
```
## Running the server
By default the server speaks the **stdio** transport, which is how MCP clients
launch a per-session subprocess:
```bash
understone
```
To host one shared world over HTTP for several clients, run the
**streamable-http** transport as a single long-lived process:
```bash
UNDERSTONE_TRANSPORT=streamable-http understone
```
### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `UNDERSTONE_DB` | `./understone.db` | SQLite database file for the world's state. |
| `UNDERSTONE_WORLD` | _(packaged pack)_ | Directory of a content pack to load instead of the bundled Vale of Understone. |
| `UNDERSTONE_TRANSPORT` | `stdio` | `stdio` or `streamable-http`. |
| `UNDERSTONE_HOST` | `127.0.0.1` | Bind host (streamable-http only). |
| `UNDERSTONE_PORT` | `8077` | Bind port (streamable-http only). |
| `UNDERSTONE_PATH` | `/mcp` | HTTP path for the MCP endpoint (streamable-http only). |
## The Watch — a live spectator view
When the server runs under the **streamable-http** transport, it also serves a
read-only **Watch** page: the lobby TV of the Vale. Point a browser at
```
http://127.0.0.1:8077/watch
```
(the host and port follow `UNDERSTONE_HOST` / `UNDERSTONE_PORT`). It is a
period **CRT spectator console** — a green-and-amber phosphor map of the whole
world with every adventurer's `☻` marker, a live **Understone Herald** feed, the
**Hall of Legends**, and a roster of who is currently abroad. It refreshes every
couple of seconds; if it loses contact it dims and reads `SIGNAL LOST` until the
server returns. The console's palette follows the pack: a world may pick its own
CRT colour with `settings.watch_theme` (`phosphor` green, `amber` gold, `ice`
blue, `ember` red), defaulting to the Vale's green if it says nothing.
The Watch is **strictly read-only**. Input never flows through it — there are no
controls, no forms, nothing that can change the world. It reads the same shared
state the tools do and paints it; that is all. There is no authentication, in
keeping with the rest of this easter-egg server (see the safety note below), so
treat the page as you would the MCP endpoint itself.
> _Screenshot: the Watch console — a phosphor-green overworld map with amber
> `☻` markers, the Herald feed and Hall of Legends down the right-hand rail.
> (Image placeholder; run the server and open the URL to see it live.)_
When the Watch is up, the `door_join` welcome and the `door_help` manual both
print its URL so players (and the assistant narrating for them) know it exists.
If you bind to `0.0.0.0` to share the world across a network, advertise a host
that browsers can actually reach (your machine's LAN address or hostname) rather
than `0.0.0.0` itself — the link is composed from `UNDERSTONE_HOST`.
## Authoring worlds
The Vale of Understone is just the *bundled* world. The whole game — its map,
monsters, economy, and endgame — is a **content pack**: a directory of six JSON
files the server loads at start. Nothing about the Vale is privileged; point
the server at another pack and it runs that world instead. This is the seam
where the game becomes its own authoring target: a pack is plain data, so a
person *or an LLM* can write one, and the same zero-setup philosophy that makes
the game playable with no prompt makes it **authorable with no code**.
The loop has these commands:
```bash
understone newpack mypack # scaffold a pack (copies the Vale as a template)
# ...edit or LLM-generate the JSON in mypack/ to describe your world...
understone validate mypack # check it; prints a report or names what's wrong
understone simulate mypack # play a greedy bot through it and measure the balance
UNDERSTONE_WORLD=mypack understone # serve your world
understone worlds # list the bundled worlds and whether each is sound
```
`newpack` writes a starting template plus an `AUTHORING.md` manual — the
file-by-file schema, the enforced limits, and design guidance — written to be
followed cold by a model. `validate` loads the pack through exactly the same
hardened loader the server uses and either prints a summary ending **"This pack
is sound. The door stands open."** or fails with one precise line naming the
file, the row, and the field at fault.
`simulate` is the **balance instrument**: it drives a deliberately simple,
greedy bot through the *real* game — the same `join`/`move`/`action` calls the
tools make — over a seeded RNG and an injected clock, then prints a report
(final level, gold earned, fights fought, rungs cleared, whether and when the
Wyrm fell). It is a tuning probe, not a player to admire: it answers "is this
world *shaped* right, and is it *winnable*?". Pass `--days N`, `--seed S`, or
`--seeds K` for a multi-seed sweep with means and spreads. `worlds` lists every
bundled world — the default Vale plus any alternate packs shipped under
`understone/world/packs/` — loading each so it can report it as sound or flawed.
**A second bundled world: The Cinder Wastes.** Understone ships a second world
alongside the Vale, in `understone/world/packs/cinder-wastes/` — a volcanic
ash-and-slag map whose Watch page glows ember-red instead of the Vale's green
phosphor. It is the pipeline's own dogfood: it was authored **by an LLM working
only from `AUTHORING.md` and the `validate` loop**, with no engine code touched,
then bundled verbatim. `understone worlds` lists it as sound, and
`understone simulate understone/world/packs/cinder-wastes --days 50 --seeds 3`
shows the greedy bot taking its Magma Wyrm — the end-to-end proof that a world
described purely as data, from the manual alone, is genuinely playable to
victory. Serve it with
`UNDERSTONE_WORLD=understone/world/packs/cinder-wastes understone`.
Packs are validated **hard** at load: every map glyph must render as exactly
one terminal column (no fullwidth runes, no emoji, no combining marks — the
frames are box-drawing rectangles) and may not collide with the frame's
box-drawing lines or the player markers, dimensions and counts are bounded,
display names are length-checked, and every cross-reference (a legend
character, a starting item, the boss monster, a dungeon tier) must resolve. The
loader also pins the rules that keep the endgame coherent: a world has exactly
one boss, and a dungeon tier's lead monster (its fixed rung guardian) may not be
a rare. Because packs are now routinely untrusted, generated output, those error
messages are not a nuisance — they are the **feedback loop**. Iterate against
them until the door stands open.
## Registering with Turnstone
Understone is an ordinary MCP server, so it plugs into Turnstone's MCP client
config two ways.
**Stdio (per-session subprocess).** Turnstone launches the `understone`
command for each session. Each session gets its own subprocess, so for a
truly shared world prefer the HTTP form below; stdio is simplest for solo
play.
```toml
[mcp.servers.understone]
command = "understone"
[mcp.servers.understone.env]
UNDERSTONE_DB = "/var/lib/understone/world.db"
```
**Streamable-HTTP (one shared world).** Run a single Understone process with
`UNDERSTONE_TRANSPORT=streamable-http` and point every client at its URL. This
is the right setup for multiplayer: one process, one database, one world that
all adventurers share.
```toml
[mcp.servers.understone]
url = "http://localhost:8077/mcp"
```
> **Operator note.** For multiplayer, start exactly one shared process —
> `UNDERSTONE_TRANSPORT=streamable-http understone` — and have all clients use
> the url form. The world lives in a single SQLite file written by that one
> process.
## The tools
| Tool | What it does |
|------|--------------|
| `door_help` | The game-master manual. Start here. |
| `door_join` | Create or resume an adventurer; returns the opening map. |
| `door_status` | The character sheet (read-only). |
| `door_look` | Redraw the current view — overworld map or location menu. |
| `door_move` | Walk the overworld (free; no daily turn spent). |
| `door_action` | Context verbs: fight, flee, ambush (a rival), rest, deposit/withdraw (the inn vault), buy, sell, forge (a +1 edge, gold + ore), heal, gamble (inn dice), descend (one rung), challenge (the Wyrm), post (mail another player), quaff (a carried potion), leave. |
| `door_log` | The Understone Herald — the shared feed of notable deeds. |
| `door_rank` | The leaderboard, plus the Hall of Legends (★ marks Wyrm kills). |
| `door_bestow` | Game-master grant of a little gold/healing for a story beat. |
## A note on identity and safety
This example is an **easter egg**, not a hardened service. Identity is
**self-asserted**: a "player" is just a name passed to the tools, and there is
**no authentication** — anyone who can reach the server can act as any name.
That is fine for a shared toy world among people who trust each other, and
deliberately out of scope for a game. Do not store anything sensitive in it,
and if you expose the HTTP transport beyond localhost, put it behind whatever
access control your environment already provides.
The game master's `door_bestow` channel can only grant small, capped amounts
of in-game gold and healing — never items, never turns — and every grant is
written to the public in-world log, so its reach is bounded by design.
+55
View File
@@ -0,0 +1,55 @@
[build-system]
requires = ["hatchling>=1.29"]
build-backend = "hatchling.build"
[project]
name = "understone"
version = "0.10.0"
description = "Understone — a BBS-style ANSI door game served over MCP."
requires-python = ">=3.11"
license = "Apache-2.0"
dependencies = [
"mcp>=1.27,<2",
]
[project.scripts]
understone = "understone.server:main"
[project.optional-dependencies]
test = ["pytest>=9.0"]
dev = ["ruff>=0.9", "mypy>=1.14"]
[tool.hatch.build.targets.wheel]
packages = ["understone"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
ignore = ["E501"]
[tool.ruff.format]
quote-style = "double"
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = ["mcp", "mcp.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
+256
View File
@@ -0,0 +1,256 @@
"""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()
@@ -0,0 +1,7 @@
┌── The Sleeping Drake ───┐
│ A warm hearth crackles. │
│ A bed costs 15 gold. │
│ │
│ (R)est (L)eave │
└─────────────────────────┘
[ status ]
@@ -0,0 +1,8 @@
┌─ Vale ──┐
│@........│
│.........│
│.........│
│.........│
│.........│
└─────────┘
[ status ]
@@ -0,0 +1,8 @@
┌─ Vale ──┐
│.........│
│.........│
│....@....│
│.........│
│.........│
└─────────┘
[ status ]
+374
View File
@@ -0,0 +1,374 @@
"""Tests for the pack-authoring command surface.
Covers the validate/newpack functions directly (sound and broken packs, the
scaffold round-trip, AUTHORING.md generation from the live loader bands, and
the refuse-non-empty guard), the ``server.main`` argv dispatch (validate routes
through and bare invocation still reaches serve without binding a port), and
one end-to-end subprocess smoke of ``python -m understone validate``.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from understone import cli, server
from understone.world import loader
if TYPE_CHECKING:
from collections.abc import Callable
EXAMPLE_DIR = Path(__file__).resolve().parents[1]
SHIPPED = EXAMPLE_DIR / "understone" / "world" / "data"
# The six content files a scaffolded pack must carry, plus the manual.
_PACK_JSONS = {
"terrain.json",
"monsters.json",
"items.json",
"locations.json",
"events.json",
"world.json",
}
# ---------------------------------------------------------------------------
# cli_validate
# ---------------------------------------------------------------------------
def test_cli_validate_sound_pack_reports_and_returns_zero() -> None:
out, err = StringIO(), StringIO()
rc = cli.cli_validate(SHIPPED, out=out, err=err)
assert rc == 0
report = out.getvalue()
assert "This pack is sound. The door stands open." in report
# The report surfaces the headline facts the brief calls for.
assert "The Vale of Understone" in report
assert "96x48" in report
assert "1 boss" in report
assert "% fight" in report
assert err.getvalue() == ""
def test_cli_validate_broken_pack_names_field_and_returns_two(tmp_path: Path) -> None:
# A pack whose daily_turns is out of band: the loader names the field.
pack = _clone_shipped(tmp_path)
_patch_world(pack, _break_daily_turns)
out, err = StringIO(), StringIO()
rc = cli.cli_validate(pack, out=out, err=err)
assert rc == 2
message = err.getvalue()
assert message.startswith("The pack is flawed:")
assert "daily_turns" in message # the offending field is named
assert out.getvalue() == ""
def test_cli_validate_missing_directory_returns_two(tmp_path: Path) -> None:
out, err = StringIO(), StringIO()
rc = cli.cli_validate(tmp_path / "nope", out=out, err=err)
assert rc == 2
assert "The pack is flawed:" in err.getvalue()
# ---------------------------------------------------------------------------
# cli_newpack
# ---------------------------------------------------------------------------
def test_cli_newpack_writes_template_and_manual(tmp_path: Path) -> None:
dest = tmp_path / "mypack"
out, err = StringIO(), StringIO()
rc = cli.cli_newpack(dest, out=out, err=err)
assert rc == 0
present = {p.name for p in dest.iterdir()}
assert present >= _PACK_JSONS # the six content files are all there
assert "AUTHORING.md" in present
# Next-steps guidance points the author at the validate verb.
assert "understone validate" in out.getvalue()
def test_cli_newpack_scaffold_validates(tmp_path: Path) -> None:
"""The load-bearing test: a freshly scaffolded pack loads cleanly.
newpack -> load_world round-trip. If the template the scaffolder copies
ever drifts out of the loader's bands, this fails immediately.
"""
dest = tmp_path / "mypack"
assert cli.cli_newpack(dest, out=StringIO(), err=StringIO()) == 0
world = loader.load_world(dest)
assert world.name == "The Vale of Understone"
assert world.width == 96
def test_cli_newpack_authoring_md_renders_live_band(tmp_path: Path) -> None:
"""AUTHORING.md's bands are generated from the loader, not hand-copied.
The daily_turns band is read straight from the live loader table and must
appear verbatim in the scaffolded manual proving generation from source.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
lo, hi = loader.SETTINGS_BANDS["daily_turns"]
assert lo is not None and hi is not None
assert f"`{lo}..{hi}`" in manual
assert "daily_turns" in manual
def test_cli_newpack_authoring_md_has_width_rule_and_live_palette(tmp_path: Path) -> None:
"""AUTHORING.md documents the one-column rule and renders the live palette.
The width section states the Western-monospace assumption, and the safe
palette is generated from ``textwidth.SAFE_PALETTE`` (same can't-drift
pattern as the bands table) every glyph appears, in a backticked cell.
"""
from understone.engine.textwidth import SAFE_PALETTE
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "## Glyph width" in manual
assert "exactly one terminal column" in manual
assert "Western monospace" in manual # the stated assumption
assert "Safe glyph palette" in manual
for glyph in SAFE_PALETTE:
assert f"`{glyph}`" in manual, f"palette glyph {glyph!r} missing from manual"
def test_cli_newpack_authoring_md_documents_action_sets(tmp_path: Path) -> None:
"""AUTHORING.md documents each building's real verb menu.
The per-building menus are an explicit table: the inn's `gamble` (v0.8) and
the v0.10 vault verbs `deposit`/`withdraw`, the shop's `forge`, and so on.
This pins the table rows and the "quaff anywhere" note so a doc regression
trips.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "| `inn` | `rest`, `deposit`, `withdraw`, `gamble`, `leave` |" in manual
assert "| `shop` | `buy`, `sell`, `forge`, `leave` |" in manual
assert "| `healer` | `heal`, `leave` |" in manual
assert "| `dungeon` | `descend`, `challenge`, `leave` |" in manual
assert "`quaff`" in manual and "legal **anywhere**" in manual
# The vault is described where its verbs are listed.
assert "VAULT" in manual and "SAFE from ambush" in manual
def test_cli_newpack_authoring_md_documents_ore_forge(tmp_path: Path) -> None:
"""AUTHORING.md documents the v0.10 ore-gated forge: material slot + settings.
The forge ore is a `material` item earned in combat; the four ore settings
(item, per-plus, dungeon drop, forest chance) are documented, and the band
figures are generated from the live loader so they cannot drift.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "`material`" in manual # the new slot
assert "forge_ore_item" in manual
assert "ore_forest_chance" in manual # the float setting (prose, not the band table)
# The two banded ore settings carry their LIVE bands.
lo, hi = loader.SETTINGS_BANDS["ore_dungeon_drop"]
assert f"`{lo}..{hi}`" in manual
assert "earns in combat" in manual or "earned in combat" in manual
def test_cli_newpack_authoring_md_states_color_advisory_and_spawn_walkable(
tmp_path: Path,
) -> None:
"""AUTHORING.md states color is advisory (loader does not validate it) and
that spawn must be on walkable terrain both v0.8 honesty fixes."""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
# color is documented as advisory / not validated (it matches loader behaviour).
assert "advisory and not validated" in manual
# spawn's walkability requirement is now stated where spawn is introduced.
assert "must be on walkable terrain" in manual
def test_cli_newpack_authoring_md_color_roles_generated_from_enum(tmp_path: Path) -> None:
"""AUTHORING.md's colour-role vocabulary is generated from the Color enum.
The v0.9 fix: the assignable roles were hand-listed (and went stale road
and the per-building roles were missing). They are now generated from
``Color.assignable()`` the single source for the overlay-vs-assignable
split so the manual lists exactly what the Watch can paint and cannot
drift. This asserts the NEW roles appear, that every assignable enum role
appears, and that the non-assignable roles (overlays + DEFAULT) are NOT
offered as author-assignable.
"""
from understone.screen.palette import Color
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
# A sampling of the new v0.9 roles is offered in the manual, backticked.
for role in ("road", "forest", "lava", "barren", "inn", "shop", "healer"):
assert f"`{role}`" in manual, f"new colour role {role!r} missing from manual"
# EVERY assignable enum role appears (generated, so the full set is present).
color_section = manual[manual.index("`color` — a palette role string") :].split("###", 1)[0]
for role in Color.assignable():
assert f"`{role.value}`" in manual, f"assignable role {role.value!r} missing from manual"
# The non-assignable roles (runtime overlays + the DEFAULT fallback) are NOT
# offered as terrain/location colours.
non_assignable = {c for c in Color} - set(Color.assignable())
assert Color.DEFAULT in non_assignable # the fallback is not author-pickable
for role in non_assignable:
assert f"`{role.value}`" not in color_section, (
f"non-assignable role {role.value!r} wrongly offered as author-assignable"
)
def test_cli_newpack_authoring_md_has_validate_coverage_split(tmp_path: Path) -> None:
"""AUTHORING.md honestly separates machine-enforced rules from eyeball-only.
The v0.8 subsection lists what `validate` DOES catch (including the two new
enforcements rare-as-guardian and single-boss) and what it does NOT (chief
among them: location menu `actions` contents are unvalidated).
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "What `validate` checks, and what it cannot" in manual
# The newly-enforced rules are named in the DOES-catch list.
assert "Exactly one boss" in manual
assert "fixed rung guardian) must" in manual # rare-as-guardian enforcement
# The eyeball-only short list names the actions gap and the flavour caveat.
assert "Location menu `actions` contents" in manual
assert "Flavour and narration quality" in manual
def test_cli_newpack_refuses_non_empty_dir(tmp_path: Path) -> None:
dest = tmp_path / "occupied"
dest.mkdir()
(dest / "keep.txt").write_text("mine", encoding="utf-8")
out, err = StringIO(), StringIO()
rc = cli.cli_newpack(dest, out=out, err=err)
assert rc == 2
assert "non-empty" in err.getvalue()
# The pre-existing file is untouched (nothing was scaffolded over it).
assert (dest / "keep.txt").read_text(encoding="utf-8") == "mine"
assert not (dest / "AUTHORING.md").exists()
def test_cli_newpack_into_empty_existing_dir_succeeds(tmp_path: Path) -> None:
"""An existing but empty directory is a fine scaffold target."""
dest = tmp_path / "empty"
dest.mkdir()
assert cli.cli_newpack(dest, out=StringIO(), err=StringIO()) == 0
assert (dest / "AUTHORING.md").exists()
# ---------------------------------------------------------------------------
# server.main argv dispatch
# ---------------------------------------------------------------------------
def test_main_validate_dispatch_returns_status(
tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
# A broken pack routed through main exits 2; a sound one exits 0.
pack = _clone_shipped(tmp_path)
_patch_world(pack, _break_daily_turns)
with pytest.raises(SystemExit) as broken:
server.main(["validate", str(pack)])
assert broken.value.code == 2
with pytest.raises(SystemExit) as sound:
server.main(["validate", str(SHIPPED)])
assert sound.value.code == 0
assert "The door stands open." in capsys.readouterr().out
def test_main_newpack_dispatch(tmp_path: Path) -> None:
dest = tmp_path / "viamain"
with pytest.raises(SystemExit) as exc:
server.main(["newpack", str(dest)])
assert exc.value.code == 0
assert (dest / "AUTHORING.md").exists()
def test_main_worlds_dispatch(capsys: pytest.CaptureFixture) -> None:
"""`understone worlds` routes through main, exits 0, and lists the Vale."""
with pytest.raises(SystemExit) as exc:
server.main(["worlds"])
assert exc.value.code == 0
out = capsys.readouterr().out
assert "vale" in out
assert "The Vale of Understone" in out
assert "UNDERSTONE_WORLD=" in out
def test_bare_invocation_resolves_to_serve_without_side_effects() -> None:
"""Parsing no argv yields the serve path, and parsing has no side effects.
The transport launch (_serve) is reachable, but argument parsing neither
loads a world nor binds a port so this asserts the resolved command
without ever calling _serve.
"""
args = server._build_parser().parse_args([])
assert args.cmd is None # None => the serve branch in main()
assert callable(server._serve)
def test_subprocess_validate_packaged_world_exits_zero() -> None:
"""End-to-end smoke: `python -m understone validate <packaged dir>` exits 0."""
result = subprocess.run(
[sys.executable, "-m", "understone", "validate", str(SHIPPED)],
cwd=EXAMPLE_DIR,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
assert "The door stands open." in result.stdout
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _clone_shipped(tmp_path: Path) -> Path:
dest = tmp_path / "pack"
shutil.copytree(SHIPPED, dest)
return dest
def _patch_world(pack: Path, mutate: Callable[[dict[str, Any]], None]) -> None:
path = pack / "world.json"
data = json.loads(path.read_text(encoding="utf-8"))
mutate(data)
path.write_text(json.dumps(data), encoding="utf-8")
def _break_daily_turns(data: dict[str, Any]) -> None:
"""Set daily_turns out of its 1..100 band so the pack fails to load."""
data["settings"]["daily_turns"] = 0
+123
View File
@@ -0,0 +1,123 @@
"""Combat resolution tests.
Pins determinism (a fixed seed yields identical results twice, log and
deltas), each outcome (win/lose/flee), xp/gold crediting on victory, and
the defeat contract: the result flags a spawn bounce with no xp/gold and a
zero hp delta (the façade applies hp=1 and the move).
"""
from __future__ import annotations
from tests.conftest import make_monster, make_player
from understone.engine.combat import Outcome, resolve_fight, resolve_flee
from understone.engine.rng import GameRNG
# A strong adventurer vs a Field Rat wins on every probed seed.
_WIN_SEED = 1
# A fragile adventurer vs a Stone Wyrm loses on every probed seed.
_LOSE_SEED = 0
# Flee outcomes (probed): seed 1 escapes clean, seed 0 is caught.
_FLEE_CLEAN_SEED = 1
_FLEE_CAUGHT_SEED = 0
def _strong_player() -> object:
return make_player(hp=20, max_hp=20, atk=5, def_=1, xp=0, gold=50)
def _wyrm() -> object:
return make_monster(tier=5, name="Stone Wyrm", hp=60, atk=18, def_=6, xp=140, gold=60)
def test_fight_is_deterministic_under_fixed_seed() -> None:
r1 = resolve_fight(GameRNG(seed=7), make_player(), make_monster())
r2 = resolve_fight(GameRNG(seed=7), make_player(), make_monster())
assert r1.log == r2.log
assert (r1.outcome, r1.xp_delta, r1.gold_delta, r1.hp_delta) == (
r2.outcome,
r2.xp_delta,
r2.gold_delta,
r2.hp_delta,
)
def test_win_credits_xp_and_gold() -> None:
player = make_player(hp=20, max_hp=20, atk=5, def_=1)
monster = make_monster(hp=6, atk=3, def_=0, xp=8, gold=3)
result = resolve_fight(GameRNG(seed=_WIN_SEED), player, monster)
assert result.outcome is Outcome.WIN
assert result.xp_delta == 8
assert result.gold_delta == 3
# hp_delta is non-positive (you may take a scratch) and never fatal here.
assert result.hp_delta <= 0
assert not result.bounce_to_spawn
def test_win_deltas_are_exact_for_pinned_seed() -> None:
player = make_player(hp=20, max_hp=20, atk=5, def_=1)
monster = make_monster(hp=6, atk=3, def_=0, xp=8, gold=3)
result = resolve_fight(GameRNG(seed=_WIN_SEED), player, monster)
# Pinned from a determinism probe; guards against silent damage drift.
assert result.hp_delta == -1
# The engine no longer emits a "falls + reward" line — that sentence is
# composed by the game façade where the xp/gold are actually banked — so
# the WIN log is one line shorter than before and ends on the kill blow.
assert len(result.log) == 4
assert result.log[-1] == "You strike for 6. (Field Rat: 0 HP)"
def test_win_log_does_not_claim_rewards() -> None:
"""The engine narrates the kill blow only; it never claims xp/gold itself.
Reward ownership lives in the façade (so the Wyrm-win legacy reset, which
keeps no xp/gold, narrates no reward). The deltas are still carried on the
result for the caller to apply.
"""
player = make_player(hp=20, max_hp=20, atk=5, def_=1)
monster = make_monster(hp=6, atk=3, def_=0, xp=8, gold=3)
result = resolve_fight(GameRNG(seed=_WIN_SEED), player, monster)
assert result.outcome is Outcome.WIN
assert result.xp_delta == 8 and result.gold_delta == 3 # deltas still set
joined = "\n".join(result.log)
assert "falls" not in joined # no kill/reward sentence in the engine log
assert "XP" not in joined and "gold" not in joined
def test_loss_flags_bounce_without_rewards() -> None:
result = resolve_fight(GameRNG(seed=_LOSE_SEED), _strong_player_loses(), _wyrm())
assert result.outcome is Outcome.LOSE
assert result.bounce_to_spawn is True
assert result.xp_delta == 0
assert result.gold_delta == 0
# Combat does not set hp to 1 itself — that is the façade's job.
assert result.hp_delta == 0
def _strong_player_loses() -> object:
return make_player(hp=12, max_hp=12, atk=4, def_=0)
def test_flee_can_escape_clean() -> None:
player = make_player(hp=20, max_hp=20, def_=1)
monster = make_monster(atk=8, def_=2)
result = resolve_flee(GameRNG(seed=_FLEE_CLEAN_SEED), player, monster)
assert result.outcome is Outcome.FLED
assert result.hp_delta == 0
def test_flee_caught_costs_hp_but_never_kills() -> None:
player = make_player(hp=20, max_hp=20, def_=1)
monster = make_monster(atk=8, def_=2)
result = resolve_flee(GameRNG(seed=_FLEE_CAUGHT_SEED), player, monster)
assert result.outcome is Outcome.FLED
assert result.hp_delta < 0
# A caught flight cannot drop the player to or below zero.
assert player.hp + result.hp_delta >= 1
def test_flee_caught_never_kills_at_low_hp() -> None:
player = make_player(hp=1, max_hp=20, def_=0)
monster = make_monster(atk=40, def_=0)
result = resolve_flee(GameRNG(seed=_FLEE_CAUGHT_SEED), player, monster)
# At 1 HP the most a failed flee can cost is 0 (cannot go below 1).
assert result.hp_delta == 0
File diff suppressed because it is too large Load Diff
+858
View File
@@ -0,0 +1,858 @@
"""Game façade integration tests over the shipped world.
Drives a full session against a temp store, a frozen clock, and a seeded
RNG: join -> status -> look -> move -> action(buy/rest/fight) -> log ->
rank -> bestow. Persistence is exercised by reopening the store.
Negative-test discipline (turn guard and bestow cap):
Two guards are pinned by assertions here. To confirm each assertion has
teeth, the implementer temporarily reverted the guard line and observed
the matching test FAIL, then restored it:
* Turn guard (engine/turns.py spend_turn): replacing
``if player.turns_left <= 0: return False`` with ``return True``
let fighting continue past the daily budget ``test_turn_budget_blocks``
then failed on the "spent for today" assertion. Restored.
* Bestow cap (game.py bestow): removing the ``if cost > remaining``
refusal let an over-budget bestowal through ``test_bestow_cap_refuses``
then failed on the unchanged-gold assertion. Restored.
* Sanitizer control-char guard (game.py _sanitize): disabling the
``not cleaned.isprintable()`` clause let a newline-injected name create a
player row and a public event ``test_join_rejects_control_char_name``
then failed. Restored. (See the comment block above the hygiene tests.)
"""
from __future__ import annotations
import unicodedata
from pathlib import Path
import pytest
from tests.conftest import fixed_clock, utc
from understone.engine.models import Mode
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.world.loader import load_world
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
@pytest.fixture
def clock() -> object:
return fixed_clock(utc(2026, 6, 12, 10, 0))
def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
world = load_world(PACK)
store = Store(tmp_path / "game.db")
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Join / status / look
# ---------------------------------------------------------------------------
def test_join_creates_player_at_spawn(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
out = game.join("Brandr")
player = game.players["Brandr"]
assert (player.x, player.y) == game.world.spawn
assert player.gold == game.world.settings.starting_gold
assert "@" in out
assert game.world.name in out
def test_join_resumes_existing(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.players["Brandr"].gold = 123
out = game.join("Brandr")
assert "Welcome back" in out
assert game.players["Brandr"].gold == 123
def test_status_unknown_player_is_friendly(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
out = game.status("Nobody")
assert "has signed the ledger" in out
assert "door_join" in out
def test_look_overworld_has_frame(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
out = game.look("Brandr")
assert "@" in out
assert "" in out and "" in out
assert len(out) < 2048
def test_overworld_frame_textured_borders_intact(tmp_path: Path, clock: object) -> None:
"""The textured overworld frame keeps square borders and a single player marker.
Structural discipline for the v0.6 texture: variants change the GLYPHS but
must never change the geometry. The box rows are uniform width, exactly one
'@' is painted, and the grass field shows more than one variant in a row
(the deterministic stipple, not a flat sheet of '.').
"""
game = _game(tmp_path, clock)
game.join("Brandr")
frame = game.look("Brandr")
lines = frame.split("\n")
# Box rows: top border + VIEW_H grid rows + bottom border, all equal width.
box = [ln for ln in lines if ln and ln[0] in "┌│└"]
widths = {len(ln) for ln in box}
assert len(widths) == 1, f"textured frame rows ragged: {widths}"
# Exactly one player marker, regardless of the surrounding texture.
assert frame.count("@") == 1
# The grass texture varies: a body row carries at least two of . , '
body = [ln for ln in lines if ln.startswith("")]
assert any(len({ch for ch in ln if ch in ".,'"}) >= 2 for ln in body)
def test_look_in_menu_shows_location(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
# Shop is two cells east of spawn along the road.
game.move("Brandr", "", "east", 2)
assert game.players["Brandr"].mode is Mode.MENU
out = game.look("Brandr")
assert "(B)uy" in out and "(L)eave" in out
# ---------------------------------------------------------------------------
# Move
# ---------------------------------------------------------------------------
def test_move_blocked_in_menu(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.move("Brandr", "", "east", 2) # into the shop menu
out = game.move("Brandr", "", "east", 2)
assert "inside" in out.lower()
def test_move_enters_location(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
out = game.move("Brandr", "", "west", 2) # inn is two cells west
assert game.players["Brandr"].at_location == "inn"
assert "step inside" in out.lower()
# ---------------------------------------------------------------------------
# Actions: rest, fight, turn budget
# ---------------------------------------------------------------------------
def test_rest_heals_and_charges(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.hp = 5
game.move("Brandr", "", "west", 2) # inn
out = game.action("Brandr", "rest", "", "")
assert player.hp == player.max_hp
assert player.gold == game.world.settings.starting_gold - game.world.settings.rest_cost
assert "full health" in out.lower()
def test_rest_when_spent_restores_a_fresh_days_turns(tmp_path: Path, clock: object) -> None:
"""Sleeping at the inn with no turns left rolls into a fresh day's allowance."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
daily = game.world.settings.daily_turns
player.turns_left = 0 # spent for the day
player.hp = 5
game.move("Brandr", "", "west", 2) # step into the inn
out = game.action("Brandr", "rest", "", "")
assert player.turns_left == daily # a fresh day's turns restored
assert player.hp == player.max_hp # and fully mended
assert f"/{daily} ]" in out # footer reflects the refreshed budget
def test_rest_with_turns_in_hand_never_inflates_the_budget(tmp_path: Path, clock: object) -> None:
"""Resting mid-day mends but adds no turns — the top-up only fires at zero."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
daily = game.world.settings.daily_turns
player.turns_left = daily - 3 # turns still in hand
player.hp = 5
game.move("Brandr", "", "west", 2) # step into the inn
game.action("Brandr", "rest", "", "")
assert player.turns_left == daily - 3 # unchanged: no farming past the cap
assert player.hp == player.max_hp # but the heal still lands
def test_fight_spends_a_turn_and_credits(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
# Drop into the forest_near zone so an encounter is available.
player.x, player.y = 35, 25
before_turns = player.turns_left
out = game.action("Brandr", "fight", "", "")
assert player.turns_left == before_turns - 1
assert player.xp > 0
assert "XP" in out
def test_turn_budget_blocks(tmp_path: Path, clock: object) -> None:
"""Pins the spend_turn guard: at 0 turns, fighting is refused.
See the module docstring for the revert-and-observe-failure check that
proves this assertion has teeth.
"""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.x, player.y = 35, 25
player.turns_left = 0
out = game.action("Brandr", "fight", "", "")
assert "spent for today" in out.lower()
# No turn was consumed past zero, and no XP was gained.
assert player.turns_left == 0
assert player.xp == 0
# ---------------------------------------------------------------------------
# Log / rank
# ---------------------------------------------------------------------------
def test_log_reports_then_advances(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
# A second player acting creates a public event Brandr has not yet seen.
game.join("Sigrun")
first = game.log("Brandr")
assert "Sigrun" in first or "Brandr" in first
assert "The Understone Herald" in first # dressed as the broadsheet
# The cursor advanced; a second read with no new events is quiet.
second = game.log("Brandr")
assert "The Understone Herald" in second # the masthead still prints
assert "still" in second.lower() # the herald-flavoured "all quiet" line
def test_rank_marks_caller(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.join("Sigrun")
game.players["Sigrun"].level = 5
out = game.rank("Brandr")
assert "Brandr" in out and "Sigrun" in out
assert "*" in out # the caller's row is marked
assert "" in out # box-drawing table
# ---------------------------------------------------------------------------
# Rank ★ column: stars live in their own column, so a long name keeps them
# ---------------------------------------------------------------------------
def test_win_stars_column_formats() -> None:
"""Zero is blank, 1..5 render as ★ runs, and >5 collapses to ★xN."""
from understone.game import _win_stars
assert _win_stars(0) == ""
assert _win_stars(1) == ""
assert _win_stars(5) == "★★★★★"
assert _win_stars(7) == "★x7"
def test_long_name_with_one_win_keeps_its_star() -> None:
"""A full 24-char name no longer eats its own ★ (the v0.1 truncation bug).
The name occupied the whole 20-wide field before, clipping the star away;
with a separate stars column the survives beside a maximal name.
"""
from understone.engine.rank import RankEntry
from understone.game import _render_rank_table
name = "X" * 24
rows = _render_rank_table([RankEntry(name=name, level=5, xp=100, gold=50, wins=1)], caller="")
body = "\n".join(rows)
assert name in body # the full name is present
assert "" in body # and so is its star
def test_high_win_count_renders_compact_marker() -> None:
"""Seven wins render as the compact ``★x7`` rather than seven glyphs."""
from understone.engine.rank import RankEntry
from understone.game import _render_rank_table
rows = _render_rank_table([RankEntry(name="Champ", level=9, xp=9, gold=9, wins=7)], caller="")
body = "\n".join(rows)
assert "★x7" in body
assert "★★★★★★★" not in body # not seven literal stars
def test_shared_world_other_player_marker(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.join("Sigrun")
# Stand Sigrun one cell east of Brandr's spawn so she lands in the view.
sig = game.players["Sigrun"]
brandr = game.players["Brandr"]
sig.x, sig.y = brandr.x + 1, brandr.y
out = game.look("Brandr")
assert "" in out # the other player shows as '☻'
# ---------------------------------------------------------------------------
# Bestow (+ cap negative test)
# ---------------------------------------------------------------------------
def test_bestow_grants_gold(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
before = player.gold
out = game.bestow("Brandr", "a daring rescue", 10, 0)
assert player.gold == before + 10
assert player.bestow_spent == 10
assert "bestowal" in out.lower()
def test_bestow_heal_charges_only_applied(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.hp = player.max_hp - 3 # only 3 missing
game.bestow("Brandr", "mercy after a hard fight", 0, 10)
assert player.hp == player.max_hp
# Charged for 3 HP at heal_cost_per_hp, not the requested 10.
assert player.bestow_spent == 3 * game.world.settings.heal_cost_per_hp
def test_bestow_requires_reason(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
out = game.bestow("Brandr", " ", 10, 0)
assert "reason" in out.lower()
assert game.players["Brandr"].gold == game.world.settings.starting_gold
def test_bestow_requires_nonzero(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
out = game.bestow("Brandr", "nothing at all", 0, 0)
assert "at least" in out.lower()
def test_bestow_cap_refuses(tmp_path: Path, clock: object) -> None:
"""Pins the bestow cap: an over-budget grant is refused without mutation.
See the module docstring for the revert-and-observe-failure check that
proves this assertion has teeth.
"""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
budget = game.world.settings.bestow_daily_budget
before_gold = player.gold
out = game.bestow("Brandr", "an absurd windfall", budget + 100, 0)
assert "the fates allow" in out.lower()
# Refused cleanly: no gold moved and no pool spent.
assert player.gold == before_gold
assert player.bestow_spent == 0
def test_bestow_pool_resets_next_day(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
game.bestow("Brandr", "first blessing", 20, 0)
assert player.bestow_spent == 20
# Advance the clock past UTC midnight; the next bestow sees a fresh pool.
game.clock = fixed_clock(utc(2026, 6, 13, 0, 5)) # type: ignore[assignment]
game.bestow("Brandr", "a new day's fortune", 20, 0)
assert player.bestow_spent == 20 # reset to 0 then +20, not 40
# ---------------------------------------------------------------------------
# Persistence round-trip through the façade
# ---------------------------------------------------------------------------
def test_state_survives_store_reopen(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
game.players["Brandr"].x, game.players["Brandr"].y = 35, 25
game.action("Brandr", "fight", "", "")
xp_after = game.players["Brandr"].xp
gold_after = game.players["Brandr"].gold
game.store.close()
world = load_world(PACK)
reopened = Store(tmp_path / "game.db")
revived = Game(world, reopened, clock=clock) # type: ignore[arg-type]
assert revived.players["Brandr"].xp == xp_after
assert revived.players["Brandr"].gold == gold_after
# ---------------------------------------------------------------------------
# Day rollover applies to fight/descend, not just join/bestow
# ---------------------------------------------------------------------------
class _MutableClock:
"""A clock whose reported moment can be advanced between calls."""
def __init__(self, moment: object) -> None:
self.moment = moment
def __call__(self) -> object:
return self.moment
def test_fight_refreshes_budget_across_midnight(tmp_path: Path) -> None:
"""A fight on a new UTC day must reset the budget without re-joining.
Before the fix, _resolve_encounter spent a turn without calling
_ensure_day, so an exhausted player who returned the next day was still
blocked until they happened to re-join.
"""
clk = _MutableClock(utc(2026, 6, 12, 23, 0))
world = load_world(PACK)
store = Store(tmp_path / "game.db")
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Brandr")
player = game.players["Brandr"]
player.x, player.y = 35, 25 # forest_near zone: an encounter is available
player.turns_left = 0 # spent for the day
daily = game.world.settings.daily_turns
clk.moment = utc(2026, 6, 13, 0, 5) # cross UTC midnight, no re-join
out = game.action("Brandr", "fight", "", "")
assert "spent for today" not in out.lower() # the fresh day let the fight run
assert player.turns_left == daily - 1 # reset to full, then one spent
assert player.xp > 0
assert f"/{daily} ]" in out # footer shows the refreshed budget
def test_descend_refreshes_budget_across_midnight(tmp_path: Path) -> None:
"""Descending on a new UTC day resets the budget without re-joining."""
clk = _MutableClock(utc(2026, 6, 12, 23, 0))
world = load_world(PACK)
store = Store(tmp_path / "game.db")
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Hero")
player = game.players["Hero"]
# Overwhelming stats so the gauntlet itself never bounces the player.
player.level, player.atk, player.def_ = 20, 200, 100
player.hp = player.max_hp = 500
player.mode = Mode.MENU
player.at_location = "dungeon"
player.turns_left = 0
daily = game.world.settings.daily_turns
clk.moment = utc(2026, 6, 13, 0, 5)
out = game.action("Hero", "descend", "", "")
assert "too weary" not in out.lower()
assert player.turns_left == daily - 1
# ---------------------------------------------------------------------------
# Input hygiene chokepoint (the _sanitize helper)
# ---------------------------------------------------------------------------
#
# Negative-test discipline (security invariant): to prove the control-char
# rejection in Game._sanitize has teeth, the implementer temporarily replaced
# its ``not cleaned.isprintable()`` clause with ``False`` (disabling the
# check) and confirmed test_join_rejects_control_char_name FAILED — the
# injected name created a player row and a public event. The clause was then
# restored. The newline-injection test below is the standing regression for
# that invariant.
def test_join_rejects_control_char_name(tmp_path: Path, clock: object) -> None:
"""A bell/control character in a name is refused with the runes line."""
game = _game(tmp_path, clock)
out = game.join("Bra\x07ndr")
assert "strange runes" in out
assert game.players == {} # no row created
assert game.events == [] # nothing persisted
def test_join_rejects_newline_name_no_persist(tmp_path: Path, clock: object) -> None:
"""An embedded newline (log-injection vector) is refused, nothing written.
The name is kept short so it is the control-char clause not the length
clause that rejects it; this is the standing regression for the
isprintable security invariant documented in the module docstring.
"""
game = _game(tmp_path, clock)
out = game.join("Bra\nndr") # 7 chars: well under the 24 limit
assert "strange runes" in out # the runes (bad-character) refusal, not length
# The security invariant: no player row and no event row escaped the guard.
assert game.players == {}
assert game.events == []
def test_join_rejects_overlong_name(tmp_path: Path, clock: object) -> None:
"""A 25-character name is refused with the narrow-ledger line."""
game = _game(tmp_path, clock)
out = game.join("X" * 25)
assert "ledger is narrow" in out
assert game.players == {}
def test_join_accepts_max_length_name(tmp_path: Path, clock: object) -> None:
"""A 24-character name is exactly at the limit and accepted."""
game = _game(tmp_path, clock)
name = "X" * 24
game.join(name)
assert name in game.players
# ---------------------------------------------------------------------------
# Narrow-ledger width rule (the _sanitize one-column clause, v0.6)
#
# Names/reasons/mail render inside fixed-width frames and tables, so a glyph
# that does not fit a single column would shove a column out of true. The
# sanitizer rejects wide runes and combining marks; a printable-but-wide name
# gets the dedicated narrow-ledger refusal, not the control-char "runes" line.
# ---------------------------------------------------------------------------
def test_join_rejects_wide_cjk_name(tmp_path: Path, clock: object) -> None:
"""A CJK ideograph name is refused with the narrow-ledger line; nothing written."""
game = _game(tmp_path, clock)
out = game.join("")
assert "columns are narrow" in out
assert game.players == {}
assert game.events == []
def test_join_rejects_emoji_name(tmp_path: Path, clock: object) -> None:
"""An emoji in a name (🌲x) is wide and refused with the narrow-ledger line."""
game = _game(tmp_path, clock)
out = game.join("🌲x")
assert "columns are narrow" in out
assert game.players == {}
def test_join_rejects_fullwidth_name(tmp_path: Path, clock: object) -> None:
"""A fullwidth Latin letter () is two columns and refused."""
game = _game(tmp_path, clock)
out = game.join("")
assert "columns are narrow" in out
assert game.players == {}
def test_join_rejects_combining_mark_name(tmp_path: Path, clock: object) -> None:
"""A name with a combining mark (decomposed accent) is refused as wide.
The name is normalised to NFD so the 'o' carries a separate U+0308
combining diaeresis a zero-width code point that desynchronises the
column count. Built explicitly so the source encoding cannot mask it.
"""
game = _game(tmp_path, clock)
decomposed = unicodedata.normalize("NFD", "Bj\u00f6rn")
assert any(unicodedata.combining(ch) for ch in decomposed) # genuinely NFD
out = game.join(decomposed)
assert "columns are narrow" in out
assert game.players == {}
def test_join_accepts_composed_latin_name(tmp_path: Path, clock: object) -> None:
"""A precomposed Latin accent (NFC name) is all single-column and accepted."""
game = _game(tmp_path, clock)
composed = unicodedata.normalize("NFC", "Bj\u00f6rn")
game.join(composed)
assert composed in game.players
def _seed_wide_named_player(db: Path, clock: object, wide_name: str) -> None:
"""Write a stored adventurer whose name is a now-illegal wide rune.
Bypasses ``join`` (which would refuse a wide name at creation) by upserting
a Player row straight through the Store, so the fixture stands in for a save
that predates the narrow-ledger rule. Built by renaming a legitimately-
created hero so every other field stays valid.
"""
from dataclasses import replace
world = load_world(PACK)
seed = Store(db)
game = Game(world, seed, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Brandr")
base = game.players["Brandr"]
seed.upsert_player(replace(base, name=wide_name))
seed.commit()
seed.close()
def test_join_resumes_stored_wide_name(tmp_path: Path, clock: object) -> None:
"""An existing adventurer with a wide-rune name resumes \u2014 identity is never re-gated.
Resume keys off the exact stored name BEFORE the sanitizer, so a character
whose name predates the narrow-ledger rule is welcomed back rather than
locked out. This is the resume-by-exact-name invariant.
"""
db = tmp_path / "game.db"
wide = "\u9f8d"
_seed_wide_named_player(db, clock, wide)
world = load_world(PACK)
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
out = game.join(wide)
assert "Welcome back" in out # resumed, not refused
assert "columns are narrow" not in out
assert wide in game.players
def test_join_still_refuses_new_wide_name(tmp_path: Path, clock: object) -> None:
"""Creation is still gated: a NEW wide name with no stored row is refused.
The resume bypass is exact-name only; a wide name that matches no stored
adventurer falls through to the creation gate and gets the narrow-ledger
refusal, with nothing written.
"""
db = tmp_path / "game.db"
# Seed one wide-named save, then try to CREATE a different wide name.
_seed_wide_named_player(db, clock, "\u9f8d")
world = load_world(PACK)
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
out = game.join("\u7363") # a different wide rune \u2014 no stored row for it
assert "columns are narrow" in out
assert "\u7363" not in game.players
def test_bestow_rejects_newline_reason_no_persist(tmp_path: Path, clock: object) -> None:
"""A newline-embedded bestow reason is refused; no event, pool unchanged."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
events_before = len(game.events)
out = game.bestow("Brandr", "heroics\nand a forged log line", 10, 0)
assert "plainly-spoken" in out
assert len(game.events) == events_before # no bestow event appended
assert player.bestow_spent == 0 # pool untouched
# ---------------------------------------------------------------------------
# Bestow: heal-only at full HP grants nothing (no empty grant persisted)
# ---------------------------------------------------------------------------
def test_bestow_heal_only_at_full_hp_refused(tmp_path: Path, clock: object) -> None:
"""A heal-only bestow at full HP applies nothing and must not persist."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
assert player.hp == player.max_hp # join starts at full health
events_before = len(game.events)
out = game.bestow("Brandr", "a quiet blessing", 0, 10)
assert "already hale" in out
assert len(game.events) == events_before # no "Fortune favours" line written
assert player.bestow_spent == 0 # nothing charged
# ---------------------------------------------------------------------------
# Descend the deep: one rung per descent (see test_descend.py for the ladder)
# ---------------------------------------------------------------------------
def test_descend_fights_one_rung_and_advances(tmp_path: Path, clock: object) -> None:
"""A strong player clears the next rung: one foe fought, rewards banked, depth +1."""
game = _game(tmp_path, clock)
game.join("Hero")
player = game.players["Hero"]
player.level, player.atk, player.def_ = 20, 200, 100
player.hp = player.max_hp = 500
player.mode = Mode.MENU
player.at_location = "dungeon"
before_turns, before_gold, before_xp = player.turns_left, player.gold, player.xp
out = game.action("Hero", "descend", "", "")
# The first rung is the tier-3 guardian (Forest Wolf); deeper rungs do NOT
# appear in one descent — the deep is fought a rung at a time now.
assert "Forest Wolf" in out
assert "Cave Troll" not in out
assert player.deepest_rung == 1
assert player.turns_left == before_turns - 1
assert player.gold > before_gold
assert player.xp > before_xp
def test_descend_bounces_weak_player_to_spawn(tmp_path: Path, clock: object) -> None:
"""A fresh weak player falls on the first rung and wakes at the spawn.
Depth is NOT advanced by a loss, but it persists at whatever it was (here 0).
"""
game = _game(tmp_path, clock)
game.join("Weakling")
player = game.players["Weakling"]
player.mode = Mode.MENU
player.at_location = "dungeon"
out = game.action("Weakling", "descend", "", "")
assert player.hp == 1
assert player.mode is Mode.TILE
assert player.at_location == ""
assert (player.x, player.y) == game.world.spawn
assert player.deepest_rung == 0 # a loss never advances the deep
# Felled by the first rung (the tier-3 Forest Wolf).
assert "Forest Wolf" in out
# ---------------------------------------------------------------------------
# Shop façade: buy / upgrade / sell / heal stat arithmetic
# ---------------------------------------------------------------------------
def test_shop_buy_upgrade_sell_heal_cycle(tmp_path: Path, clock: object) -> None:
"""Equip deltas apply once on buy/upgrade and unwind cleanly on sell."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.gold = 1000
player.mode = Mode.MENU
player.at_location = "shop"
short_sword = game.world.item_by_id("short_sword")
war_axe = game.world.item_by_id("war_axe")
starter = game.world.item_by_id(game.world.settings.starting_weapon)
assert short_sword is not None and war_axe is not None and starter is not None
starter_atk = player.atk # 3 base + rusty dagger bonus
# Buy the short sword: gold falls by its price, atk rises by the delta.
gold0 = player.gold
game.action("Brandr", "buy", "", "short_sword")
assert player.gold == gold0 - short_sword.price
assert player.atk == starter_atk + (short_sword.atk - starter.atk)
atk_with_sword = player.atk
# Upgrade to the war axe: atk reflects the difference, not a double-add.
gold1 = player.gold
game.action("Brandr", "buy", "", "war_axe")
assert player.gold == gold1 - war_axe.price
assert player.atk == atk_with_sword + (war_axe.atk - short_sword.atk)
# Sell the war axe: half-price refund, atk falls back to the starter bonus.
gold2 = player.gold
game.action("Brandr", "sell", "", "")
assert player.gold == gold2 + war_axe.price // 2
assert player.atk == starter_atk
# Heal at the shrine: HP restored, gold debited per missing point.
player.mode = Mode.MENU
player.at_location = "healer"
player.hp = player.max_hp - 5
per_hp = game.world.settings.heal_cost_per_hp
gold3 = player.gold
game.action("Brandr", "heal", "", "")
assert player.hp == player.max_hp
assert player.gold == gold3 - 5 * per_hp
def test_sell_starter_weapon_refused(tmp_path: Path, clock: object) -> None:
"""The starter blade is unsellable regardless of price (no free-gold loop)."""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
assert player.weapon_id == game.world.settings.starting_weapon
player.mode = Mode.MENU
player.at_location = "shop"
gold_before = player.gold
out = game.action("Brandr", "sell", "", "")
assert "nothing worth selling" in out.lower()
assert player.gold == gold_before
# ---------------------------------------------------------------------------
# Bounded in-memory event tail (full history stays in SQLite)
# ---------------------------------------------------------------------------
def test_event_tail_is_capped_but_log_still_works(tmp_path: Path, clock: object) -> None:
"""Loading caps the resident tail; door_log still serves recent events."""
from understone.engine.log import since
from understone.game import EVENT_TAIL_KEEP
db = tmp_path / "game.db"
seed_store = Store(db)
last_id = 0
for i in range(EVENT_TAIL_KEEP + 50):
last_id = seed_store.insert_event("t", "sys", "note", f"event {i}")
seed_store.commit()
seed_store.close()
world = load_world(PACK)
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
# Only the most recent EVENT_TAIL_KEEP events are resident in memory.
assert len(game.events) == EVENT_TAIL_KEEP
assert game.events[-1].event_id == last_id
# door_log still reports events after a recent cursor.
recent_cursor = game.events[-3].event_id
game.join("Brandr")
game.players["Brandr"].log_cursor = recent_cursor
out = game.log("Brandr")
assert "The Understone Herald" in out # broadsheet masthead
assert "since your last visit" in out
fresh, new_cursor = since(game.events, recent_cursor)
assert fresh # there are events past the cursor
assert new_cursor == game.events[-1].event_id
def test_private_mail_survives_tail_eviction(tmp_path: Path, clock: object) -> None:
"""A private note older than the resident tail is still delivered (durable mail).
Public history that falls off the in-memory tail is gone by design (the
broadsheet does not keep), but mail must not be: a note left while the
recipient was away has to surface however many public events have since
pushed it out of the tail. A third player whose cursor also predates the
note must still never see it, because it was never theirs.
"""
from understone.persistence import EVENT_TAIL_KEEP
db = tmp_path / "game.db"
store = Store(db)
game = Game(load_world(PACK), store, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Scribe")
game.join("Reader")
game.join("Bystander")
# Scribe leaves Reader a private note; neither Reader nor Bystander reads it.
secret = "the cellar key is under the third barrel"
game.action("Scribe", "post", "Reader", "", secret)
# Flood the feed past the tail bound so the note is evicted from memory.
for i in range(EVENT_TAIL_KEEP + 20):
store.insert_event("t", "sys", "note", f"broadsheet filler {i}")
store.commit()
store.close()
# Reopen: only the newest tail is resident, so the note now lives in the gap.
reopened = Store(db)
revived = Game(load_world(PACK), reopened, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
note_id = next(
e.event_id
for e in reopened.targeted_events_since("Reader", 0) # note: from SQLite, not the tail
if secret in e.text
)
assert note_id < revived.events[0].event_id # the note really is past the tail
# The recipient still sees the note, backfilled from SQLite...
reader_log = revived.log("Reader")
assert secret in reader_log
assert "While you were away" in reader_log
# ...but a third player never does, even though their cursor predates it too.
third_log = revived.log("Bystander")
assert secret not in third_log
reopened.close()
+118
View File
@@ -0,0 +1,118 @@
"""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
@@ -0,0 +1,288 @@
"""End-to-end MCP integration test — the only test that touches the network.
Boots the real Understone FastMCP app (backed by a temp DB) in a uvicorn
thread, then drives it over the real streamable-HTTP wire with the real MCP
client: initialize, list_tools (all nine door_* names), join, look. A second
client session joins a second adventurer in the SAME process and world, and
the first player's view then shows the '&' other-player marker — proving the
shared-world, single-process contract over a real wire.
A second test drives the read-only Watch routes that ride inside the same app:
GET /watch (the HTML page), /watch/world.json (the static map), and
/watch/state.json (the live snapshot) confirming the spectator endpoints
serve real world data alongside a working /mcp without breaking either.
"""
from __future__ import annotations
import asyncio
import socket
import threading
import time
from typing import TYPE_CHECKING, Any
import httpx
import pytest
import uvicorn
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from understone import server as understone_server
if TYPE_CHECKING:
from pathlib import Path
PACK = str(understone_server.PACKAGED_WORLD_DIR)
def _find_free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return int(port)
def _build_server(port: int, db_path: str) -> uvicorn.Server:
app = understone_server.create_app(db_path, PACK)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", access_log=False)
return uvicorn.Server(config)
def _wait_ready(port: int, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
return
except OSError:
time.sleep(0.05)
raise TimeoutError(f"understone server at 127.0.0.1:{port} not ready after {timeout}s")
@pytest.fixture
def live_server(tmp_path: Path) -> Any:
"""Boot the real Understone app in a background uvicorn thread."""
port = _find_free_port()
db_path = str(tmp_path / "wire.db")
server = _build_server(port, db_path)
def _run() -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
thread = threading.Thread(target=_run, daemon=True, name="understone-itest")
thread.start()
try:
_wait_ready(port)
yield f"http://127.0.0.1:{port}/mcp"
finally:
server.should_exit = True
thread.join(timeout=5)
# create_app installed a module-level game whose Store holds an open
# SQLite connection; close it and clear the singleton so the next test
# builds its own rather than inheriting this temp DB.
if understone_server._GAME is not None:
understone_server._GAME.store.close()
understone_server._GAME = None
# FastMCP caches a StreamableHTTPSessionManager on the module-level mcp
# singleton and refuses a second lifespan .run() on the same instance.
# Reset it so each fixture instance boots a fresh session manager (the
# production server only ever runs one). Without this, a second
# fixture-using test fails on "run() can only be called once".
understone_server.mcp._session_manager = None
async def _call_text(session: ClientSession, name: str, arguments: dict[str, Any]) -> str:
result = await session.call_tool(name, arguments)
chunks = [block.text for block in result.content if getattr(block, "type", None) == "text"]
return "\n".join(chunks)
async def _drive(url: str) -> dict[str, Any]:
"""Run the full client conversation and return observations."""
observations: dict[str, Any] = {}
async with (
streamable_http_client(url) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
tools = await session.list_tools()
observations["tool_names"] = sorted(t.name for t in tools.tools)
observations["join_one"] = await _call_text(session, "door_join", {"player": "Brandr"})
observations["look_one_before"] = await _call_text(
session, "door_look", {"player": "Brandr"}
)
# A SECOND, independent session joins a second adventurer in the same world.
async with (
streamable_http_client(url) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
# Place player two adjacent to player one so they share the view.
await _call_text(session, "door_join", {"player": "Sigrun"})
await _call_text(
session, "door_move", {"player": "Sigrun", "heading": "east", "distance": 1}
)
# Back as player one: the shared world now shows the other adventurer.
async with (
streamable_http_client(url) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
observations["look_one_after"] = await _call_text(
session, "door_look", {"player": "Brandr"}
)
observations["rank"] = await _call_text(session, "door_rank", {"player": "Brandr"})
return observations
def test_mcp_end_to_end(live_server: str) -> None:
obs = asyncio.run(_drive(live_server))
# All nine tools are advertised over the wire.
expected = {
"door_help",
"door_join",
"door_status",
"door_look",
"door_move",
"door_action",
"door_log",
"door_rank",
"door_bestow",
}
assert set(obs["tool_names"]) == expected
# The join + look frames are real ASCII map frames.
assert "@" in obs["join_one"]
look_before = obs["look_one_before"]
assert "@" in look_before
assert "" in look_before and "" in look_before
# Shared-world proof: after player two joins next door, player one sees '☻'.
assert "" in obs["look_one_after"]
# And the leaderboard lists both adventurers (one process, one world).
assert "Brandr" in obs["rank"]
assert "Sigrun" in obs["rank"]
def _watch_base(mcp_url: str) -> str:
"""Derive the app root (where /watch lives) from the /mcp endpoint URL."""
return mcp_url[: -len("/mcp")] if mcp_url.endswith("/mcp") else mcp_url
async def _join_over_mcp(mcp_url: str, name: str) -> None:
"""Sign one adventurer in over the real MCP wire (so state.json sees them)."""
async with (
streamable_http_client(mcp_url) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
await _call_text(session, "door_join", {"player": name})
def test_watch_routes_serve_world_state(live_server: str) -> None:
base = _watch_base(live_server)
# The MCP join writes the player into the shared world the routes read.
asyncio.run(_join_over_mcp(live_server, "Watcher"))
with httpx.Client(timeout=5.0) as client:
page = client.get(f"{base}/watch")
world = client.get(f"{base}/watch/world.json")
state = client.get(f"{base}/watch/state.json")
# The page is real HTML carrying the static masthead.
assert page.status_code == 200
assert page.headers["content-type"].startswith("text/html")
assert "Understone — Live Watch" in page.text
# The static world payload matches the loaded world.
assert world.status_code == 200
world_body = world.json()
assert world_body["width"] == 96
assert world_body["height"] == 48
assert len(world_body["glyph_rows"]) == world_body["height"]
assert all(len(row) == world_body["width"] for row in world_body["glyph_rows"])
# The live snapshot lists the adventurer who joined over MCP.
assert state.status_code == 200
state_body = state.json()
names = {p["name"] for p in state_body["players"]}
assert "Watcher" in names
def test_watch_routes_coexist_with_mcp(live_server: str) -> None:
"""The custom routes don't shadow /mcp: tool calls still work alongside them."""
base = _watch_base(live_server)
async def _drive_both() -> tuple[str, int]:
async with (
streamable_http_client(live_server) as (read, write, _get_session_id),
ClientSession(read, write) as session,
):
await session.initialize()
joined = await _call_text(session, "door_join", {"player": "Coexist"})
with httpx.Client(timeout=5.0) as client:
status = client.get(f"{base}/watch/state.json").status_code
return joined, status
joined, watch_status = asyncio.run(_drive_both())
assert "@" in joined # the MCP tool still returns a real frame
assert watch_status == 200 # and the watch route still answers
def test_streamable_http_host_gate_off_localhost() -> None:
"""A non-localhost bind must accept remote `Host` headers on /mcp.
REGRESSION: FastMCP freezes DNS-rebinding protection (a localhost-only Host
allowlist) at CONSTRUCTION, and ``server`` builds its FastMCP at import with
the default 127.0.0.1 host. A 0.0.0.0/LAN bind therefore answered TCP and
`/watch` but 421'd `/mcp` for every remote node ("Invalid Host header").
``_serve`` drops the allowlist when bound off localhost; this pins the
mechanism a default instance rejects a foreign Host, a protection-disabled
one accepts it (a 421 in the second case is the bug returning).
Uses fresh FastMCP instances (not the module singleton) so there is no
shared-state or app-cache coupling with the live-server tests above.
"""
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.testclient import TestClient
foreign = {
"Host": "192.168.0.239:8077",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
init = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "probe", "version": "0"},
},
}
# Default (localhost-baked allowlist) — a remote Host is refused.
locked = FastMCP("hostgate-locked")
with TestClient(locked.streamable_http_app()) as client:
assert client.post("/mcp", headers=foreign, json=init).status_code == 421
# Protection disabled (what _serve does off localhost) — remote Host accepted.
opened = FastMCP("hostgate-open")
opened.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=False
)
with TestClient(opened.streamable_http_app()) as client:
resp = client.post("/mcp", headers=foreign, json=init)
assert resp.status_code != 421, f"remote Host still rejected: {resp.status_code} {resp.text}"
+336
View File
@@ -0,0 +1,336 @@
"""Movement resolution tests.
Covers edge clipping on all four sides, blocking terrain, the two input
forms (``"NNEE"`` vs heading+distance) and their equivalence, location
entry flipping to MENU, the MAX_STEPS cap, and a stubbed always-encounter
RNG interrupting a walk with a pending fight.
"""
from __future__ import annotations
from tests.conftest import (
FOREST,
GRASS,
WALL,
WATER,
LocationDef,
Zone,
make_player,
make_world,
)
from understone.engine.models import Mode, WorldEvent
from understone.engine.movement import MAX_STEPS, parse_directions, resolve_move
from understone.engine.rng import GameRNG
class _NeverRNG(GameRNG):
"""An RNG whose chance() never fires (no wandering encounters)."""
def __init__(self) -> None:
super().__init__(seed=0)
def chance(self, probability: float) -> bool: # noqa: ARG002
return False
class _AlwaysRNG(GameRNG):
"""An RNG whose chance() always fires (forces an encounter).
The seed still drives ``weighted_index``/``randint``, so different seeds
select different event rows while every encounter roll fires.
"""
def __init__(self, seed: int = 0) -> None:
super().__init__(seed=seed)
def chance(self, probability: float) -> bool: # noqa: ARG002
return True
# ---------------------------------------------------------------------------
# parse_directions
# ---------------------------------------------------------------------------
def test_parse_steps_string() -> None:
assert parse_directions("NNEE", "", 1) == ["N", "N", "E", "E"]
def test_parse_heading_distance() -> None:
assert parse_directions("", "east", 3) == ["E", "E", "E"]
def test_parse_clamps_to_max_steps() -> None:
assert parse_directions("NNNNNNNNNNNN", "", 1) == ["N"] * MAX_STEPS
assert parse_directions("", "north", 99) == ["N"] * MAX_STEPS
def test_parse_rejects_unknown_direction() -> None:
try:
parse_directions("NQ", "", 1)
except ValueError as exc:
assert "Q" in str(exc)
else: # pragma: no cover - failure path
raise AssertionError("expected ValueError")
# ---------------------------------------------------------------------------
# Edge clipping (all four sides)
# ---------------------------------------------------------------------------
def test_clip_north_edge() -> None:
world = make_world()
player = make_player(x=5, y=0)
result = resolve_move(world, player, _NeverRNG(), heading="north", distance=3)
assert player.y == 0
assert result.steps_taken == 0
assert result.blocked
def test_clip_south_edge() -> None:
world = make_world()
player = make_player(x=5, y=10)
result = resolve_move(world, player, _NeverRNG(), heading="south", distance=3)
assert player.y == 10
assert result.blocked
def test_clip_west_edge() -> None:
world = make_world()
player = make_player(x=0, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="west", distance=3)
assert player.x == 0
assert result.blocked
def test_clip_east_edge() -> None:
world = make_world()
player = make_player(x=10, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=3)
assert player.x == 10
assert result.blocked
def test_partial_move_then_clip() -> None:
world = make_world()
player = make_player(x=8, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=5)
# 8 -> 9 -> 10, then edge.
assert player.x == 10
assert result.steps_taken == 2
assert result.blocked
# ---------------------------------------------------------------------------
# Blocking terrain
# ---------------------------------------------------------------------------
def test_blocked_by_wall() -> None:
grid = [[GRASS for _ in range(11)] for _ in range(11)]
grid[5][6] = WALL
world = make_world(grid=grid)
player = make_player(x=5, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=2)
assert player.x == 5
assert result.blocked
assert "wall" in result.blocked_reason
def test_blocked_by_water() -> None:
grid = [[GRASS for _ in range(11)] for _ in range(11)]
grid[4][5] = WATER
world = make_world(grid=grid)
player = make_player(x=5, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="north", distance=2)
assert player.y == 5
assert result.blocked
assert "water" in result.blocked_reason
# ---------------------------------------------------------------------------
# Input-form equivalence and direction correctness
# ---------------------------------------------------------------------------
def test_nnee_lands_at_expected_cell() -> None:
world = make_world()
player = make_player(x=5, y=5)
resolve_move(world, player, _NeverRNG(), steps="NNEE")
# Two north (y-2), two east (x+2).
assert (player.x, player.y) == (7, 3)
def test_heading_equivalent_to_steps() -> None:
world_a = make_world()
player_a = make_player(x=5, y=5)
resolve_move(world_a, player_a, _NeverRNG(), steps="EEE")
world_b = make_world()
player_b = make_player(x=5, y=5)
resolve_move(world_b, player_b, _NeverRNG(), heading="east", distance=3)
assert (player_a.x, player_a.y) == (player_b.x, player_b.y)
def test_max_steps_truncates_long_walk() -> None:
world = make_world(width=40, height=11)
player = make_player(x=0, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=99)
assert result.steps_taken == MAX_STEPS
assert player.x == MAX_STEPS
# ---------------------------------------------------------------------------
# Location entry flips to MENU
# ---------------------------------------------------------------------------
def test_entering_location_flips_menu_mode() -> None:
loc = LocationDef(
key="inn",
kind="inn",
name="The Sleeping Drake",
x=7,
y=5,
glyph="I",
color="town",
actions=("rest", "leave"),
)
world = make_world(locations=[loc])
player = make_player(x=5, y=5)
result = resolve_move(world, player, _NeverRNG(), heading="east", distance=4)
assert player.mode is Mode.MENU
assert player.at_location == "inn"
assert result.entered_location == "inn"
# Stopped on the door at x=7 even though distance asked for 4.
assert (player.x, player.y) == (7, 5)
# ---------------------------------------------------------------------------
# Encounter interrupt
# ---------------------------------------------------------------------------
def test_always_encounter_stops_with_pending_fight() -> None:
grid = [[FOREST for _ in range(11)] for _ in range(11)]
zone = Zone(key="wood", x0=0, y0=0, x1=10, y1=10, tier_lo=1, tier_hi=2)
world = make_world(grid=grid, zones=[zone])
player = make_player(x=5, y=5)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=5)
assert result.pending_fight == (1, 2)
# The encounter fires on the first entered cell.
assert result.steps_taken == 1
assert player.x == 6
def test_no_zone_means_no_encounter() -> None:
grid = [[FOREST for _ in range(11)] for _ in range(11)]
world = make_world(grid=grid, zones=[])
player = make_player(x=5, y=5)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=3)
assert result.pending_fight is None
assert result.steps_taken == 3
# ---------------------------------------------------------------------------
# Weighted non-combat overworld events (v0.2)
# ---------------------------------------------------------------------------
def _event_world(*events: WorldEvent) -> object:
"""An all-forest, fully-zoned world carrying a crafted event table."""
grid = [[FOREST for _ in range(11)] for _ in range(11)]
zone = Zone(key="wood", x0=0, y0=0, x1=10, y1=10, tier_lo=1, tier_hi=2)
return make_world(grid=grid, zones=[zone], events=list(events))
def test_event_fight_stops_the_walk() -> None:
"""A fight-kind event sets pending_fight and halts the walk like v0.1."""
world = _event_world(WorldEvent("fight", 1, "", 0, 0))
player = make_player(x=5, y=5)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=5)
assert result.pending_fight == (1, 2)
assert result.event is None
assert result.steps_taken == 1 # stopped on the first triggering cell
def test_event_gold_credits_and_continues() -> None:
"""A gold event credits the rolled amount and does NOT stop the walk."""
world = _event_world(WorldEvent("gold", 1, "a coin-purse", 5, 5))
player = make_player(x=5, y=5, gold=10)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=3)
assert result.event is not None
assert result.event.kind == "gold"
assert result.event.amount == 5 # min == max == 5, so deterministic
assert player.gold == 15
assert result.pending_fight is None
assert result.steps_taken == 3 # the walk ran to completion
def test_event_heal_caps_at_max_hp() -> None:
"""A heal event never overfills: hp is clamped to max_hp."""
world = _event_world(WorldEvent("heal", 1, "a spring", 50, 50))
player = make_player(x=5, y=5, hp=18, max_hp=20)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=1)
assert player.hp == 20 # +50 requested, capped at the 2 missing
assert result.event is not None and result.event.amount == 2
def test_event_trap_floors_hp_at_one_and_spares_gold() -> None:
"""A trap event never kills (floors at 1 HP) and never touches gold."""
world = _event_world(WorldEvent("trap", 1, "old briars", 500, 500))
player = make_player(x=5, y=5, hp=10, max_hp=20, gold=42)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=1)
assert player.hp == 1 # huge trap, but floored
assert player.gold == 42 # gold untouched
assert result.event is not None and result.event.amount == 9 # only 9 could be taken
def test_event_lore_mutates_nothing() -> None:
"""A lore event changes no state and reports a zero amount."""
world = _event_world(WorldEvent("lore", 1, "an old waystone", 0, 0))
player = make_player(x=5, y=5, hp=15, max_hp=20, gold=7)
before = (player.hp, player.gold)
result = resolve_move(world, player, _AlwaysRNG(), heading="east", distance=2)
assert (player.hp, player.gold) == before
assert result.event is not None and result.event.kind == "lore"
assert result.event.amount == 0
assert result.steps_taken == 2
def test_at_most_one_event_per_walk() -> None:
"""Once any event fires, no further cells roll for the rest of the walk.
Two distinct gold rolls would credit 2 gold (1 each); a single fired event
credits exactly 1, proving the walk stops rolling after the first trigger.
"""
world = _event_world(WorldEvent("gold", 1, "a coin", 1, 1))
player = make_player(x=5, y=5, gold=0)
resolve_move(world, player, _AlwaysRNG(), heading="east", distance=5)
assert player.gold == 1 # exactly one event, not five
def test_each_event_kind_reachable_with_crafted_table() -> None:
"""Equal weights make every kind in a crafted table reachable from movement."""
table = [
WorldEvent("fight", 1, "", 0, 0),
WorldEvent("gold", 1, "g", 1, 1),
WorldEvent("heal", 1, "h", 1, 1),
WorldEvent("trap", 1, "t", 1, 1),
WorldEvent("lore", 1, "l", 0, 0),
]
zone = Zone(key="wood", x0=0, y0=0, x1=0, y1=0, tier_lo=1, tier_hi=2)
grid = [[FOREST for _ in range(11)] for _ in range(11)]
world = make_world(grid=grid, zones=[zone], events=table)
seen: set[str] = set()
for seed in range(60):
player = make_player(x=0, y=1, hp=10, max_hp=20) # one step north into the zone cell
result = resolve_move(world, player, _AlwaysRNG(seed), steps="N")
if result.pending_fight is not None:
seen.add("fight")
elif result.event is not None:
seen.add(result.event.kind)
assert seen == {"fight", "gold", "heal", "trap", "lore"}
+9
View File
@@ -0,0 +1,9 @@
"""Smoke test for the packaging skeleton."""
from __future__ import annotations
import understone
def test_version_present() -> None:
assert understone.__version__ == "0.10.0"
@@ -0,0 +1,269 @@
"""SQLite persistence tests.
Covers idempotent schema init, a full player round-trip through every
column (including ``def_``, ``turn_day``, ``log_cursor`` and the bestow
fields), event append with cursor-based catch-up, leaderboard tie-breaks,
and that WAL journaling is active.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from tests.conftest import make_player
from understone.engine.log import since
from understone.engine.models import Mode
from understone.persistence import Store
if TYPE_CHECKING:
from pathlib import Path
def _store(tmp_path: Path) -> Store:
return Store(tmp_path / "understone.db")
def test_schema_init_is_idempotent(tmp_path: Path) -> None:
db = tmp_path / "understone.db"
Store(db).close()
# Re-opening the same file must not error or duplicate schema.
second = Store(db)
assert second.get_meta("schema_version") == "1"
second.close()
def test_wal_mode_active(tmp_path: Path) -> None:
store = _store(tmp_path)
assert store.journal_mode().lower() == "wal"
store.close()
def test_player_round_trip_all_columns(tmp_path: Path) -> None:
store = _store(tmp_path)
player = make_player(
name="Brandr",
x=12,
y=7,
hp=18,
max_hp=26,
level=3,
xp=305,
gold=88,
atk=9,
def_=4,
weapon_id="short_sword",
armor_id="leather_armor",
turns_left=6,
turn_day=739_400,
mode=Mode.MENU,
at_location="inn",
log_cursor=42,
bestow_spent=15,
bestow_day=739_400,
posts_sent=3,
post_day=739_400,
gambles=2,
gamble_day=739_400,
banked=420,
)
store.upsert_player(player)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
loaded = players["Brandr"]
assert loaded == player
assert loaded.banked == 420
# Spot-check the fields most prone to silent drop.
assert loaded.def_ == 4
assert loaded.turn_day == 739_400
assert loaded.log_cursor == 42
assert loaded.bestow_spent == 15
assert loaded.bestow_day == 739_400
assert loaded.mode is Mode.MENU
# The v0.5 social columns survive the round-trip too.
assert loaded.posts_sent == 3
assert loaded.post_day == 739_400
assert loaded.gambles == 2
assert loaded.gamble_day == 739_400
reopened.close()
def test_event_target_round_trips(tmp_path: Path) -> None:
"""A targeted (private) event keeps its target across a reopen; public is ''."""
store = _store(tmp_path)
pub = store.insert_event("t1", "Brandr", "join", "set out")
priv = store.insert_event("t2", "Sigrun", "ambushed", "robbed in your sleep", "Brandr")
store.commit()
store.close()
reopened = _store(tmp_path)
_, events = reopened.load_all()
by_id = {e.event_id: e for e in events}
assert by_id[pub].target == "" # public stays empty
assert by_id[priv].target == "Brandr" # private keeps its recipient
reopened.close()
def test_ambush_table_per_day_uniqueness(tmp_path: Path) -> None:
"""The ambushes PK is (attacker, target, day): one row per pair per day."""
store = _store(tmp_path)
day = 739_400
assert store.has_ambushed("Brandr", "Sigrun", day) is False
store.record_ambush("Brandr", "Sigrun", day)
store.commit()
assert store.has_ambushed("Brandr", "Sigrun", day) is True
# A second record for the same pair/day is a no-op (INSERT OR IGNORE):
# the duplicate must not raise and must not add a row.
store.record_ambush("Brandr", "Sigrun", day)
store.commit()
rows = store._conn.execute(
"SELECT COUNT(*) AS n FROM ambushes WHERE attacker=? AND target=? AND day=?",
("Brandr", "Sigrun", day),
).fetchone()
assert rows["n"] == 1
# A new day is a fresh attempt; the old day stays recorded.
assert store.has_ambushed("Brandr", "Sigrun", day + 1) is False
store.record_ambush("Brandr", "Sigrun", day + 1)
store.commit()
assert store.has_ambushed("Brandr", "Sigrun", day) is True
assert store.has_ambushed("Brandr", "Sigrun", day + 1) is True
store.close()
def test_upsert_updates_existing_row(tmp_path: Path) -> None:
store = _store(tmp_path)
player = make_player(name="Sigrun", gold=10)
store.upsert_player(player)
store.commit()
player.gold = 999
store.upsert_player(player)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
assert players["Sigrun"].gold == 999
assert len(players) == 1
reopened.close()
def test_event_append_and_since_cursor(tmp_path: Path) -> None:
store = _store(tmp_path)
id1 = store.insert_event("t1", "Brandr", "fight", "slew a rat")
id2 = store.insert_event("t2", "Sigrun", "bestow", "blessed with gold")
store.commit()
store.close()
reopened = _store(tmp_path)
_, events = reopened.load_all()
assert [e.event_id for e in events] == [id1, id2]
# Catch up from a cursor before both, then advance past the first.
fresh, cursor = since(events, 0)
assert len(fresh) == 2
assert cursor == id2
after_first, cursor2 = since(events, id1)
assert [e.event_id for e in after_first] == [id2]
assert cursor2 == id2
nothing, cursor3 = since(events, id2)
assert nothing == []
assert cursor3 == id2
reopened.close()
def test_top_ranks_tie_breaks(tmp_path: Path) -> None:
store = _store(tmp_path)
# Same level: higher XP ranks first; equal XP breaks by name ascending.
store.upsert_player(make_player(name="Carol", level=5, xp=1200, gold=10))
store.upsert_player(make_player(name="Alice", level=5, xp=1500, gold=10))
store.upsert_player(make_player(name="Bob", level=5, xp=1500, gold=10))
store.upsert_player(make_player(name="Dave", level=4, xp=9999, gold=10))
store.commit()
ranks = store.top_ranks(limit=10)
assert [r.name for r in ranks] == ["Alice", "Bob", "Carol", "Dave"]
store.close()
def test_top_ranks_honours_limit(tmp_path: Path) -> None:
store = _store(tmp_path)
for i in range(15):
store.upsert_player(make_player(name=f"P{i:02d}", level=i, xp=i * 10))
store.commit()
ranks = store.top_ranks(limit=10)
assert len(ranks) == 10
# Highest level first.
assert ranks[0].name == "P14"
store.close()
def test_meta_round_trip(tmp_path: Path) -> None:
store = _store(tmp_path)
store.set_meta("world_name", "The Vale of Understone")
assert store.get_meta("world_name") == "The Vale of Understone"
assert store.get_meta("missing") is None
store.close()
def test_retention_columns_round_trip(tmp_path: Path) -> None:
"""The retention columns survive a reopen: depth, the v0.10 stack-encoded
satchel, the two forged plusses, and the v0.10 banked vault gold."""
store = _store(tmp_path)
player = make_player(
name="Delver",
deepest_rung=2,
satchel="minor_potion:3,iron_ore:5", # v0.10 "id:qty" stack encoding
weapon_plus=2,
armor_plus=1,
banked=300,
)
store.upsert_player(player)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
loaded = players["Delver"]
assert loaded == player # full equality across every column
assert loaded.deepest_rung == 2
assert loaded.satchel == "minor_potion:3,iron_ore:5"
assert loaded.weapon_plus == 2
assert loaded.armor_plus == 1
assert loaded.banked == 300
reopened.close()
def test_v0_7_depth_columns_default_for_legacy_rows(tmp_path: Path) -> None:
"""A row written without the new columns loads them at their defaults.
The schema mutates in place (no migration, stamp stays 1), so the new
columns carry DB-side defaults: a pre-v0.7 player row (inserted with the
legacy column set) must read back deepest_rung 0, an empty satchel, and
zero plusses rather than erroring.
"""
store = _store(tmp_path)
store._conn.execute(
"INSERT INTO players "
"(name, x, y, hp, max_hp, level, xp, gold, atk, def_, weapon_id, armor_id, "
" turns_left, turn_day, mode, at_location, created_at, last_seen, log_cursor, "
" bestow_spent, bestow_day) "
"VALUES ('Old', 5, 5, 20, 20, 1, 0, 20, 5, 1, 'rusty_dagger', 'cloth_tunic', "
" 10, 0, 'tile', '', 't0', 't0', 0, 0, 0)",
)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
old = players["Old"]
assert old.deepest_rung == 0
assert old.satchel == ""
assert old.weapon_plus == 0
assert old.armor_plus == 0
assert old.banked == 0 # the v0.10 vault column defaults too
assert reopened.get_meta("schema_version") == "1" # stamp unchanged
reopened.close()
+47
View File
@@ -0,0 +1,47 @@
"""GameRNG tests — the deterministic randomness seam.
Covers the v0.2 ``weighted_index`` helper: that a fixed seed reproduces the
same stream, that the cumulative-sum mapping honours the weights' proportions,
and that every index of a crafted table is reachable.
"""
from __future__ import annotations
from collections import Counter
from understone.engine.rng import GameRNG
def test_weighted_index_is_deterministic_under_seed() -> None:
"""Two RNGs at the same seed yield the identical weighted-index stream."""
weights = [55, 8, 7, 5, 5, 5, 5, 3, 3, 4]
a = GameRNG(seed=2026)
b = GameRNG(seed=2026)
draws_a = [a.weighted_index(weights) for _ in range(50)]
draws_b = [b.weighted_index(weights) for _ in range(50)]
assert draws_a == draws_b
def test_weighted_index_every_index_reachable() -> None:
"""With equal weights, a crafted table sees every index appear."""
weights = [1, 1, 1, 1, 1]
rng = GameRNG(seed=7)
seen = {rng.weighted_index(weights) for _ in range(500)}
assert seen == set(range(len(weights)))
def test_weighted_index_single_entry_always_zero() -> None:
"""A one-row table can only ever pick index 0."""
rng = GameRNG(seed=1)
assert all(rng.weighted_index([9]) == 0 for _ in range(20))
def test_weighted_index_respects_proportions() -> None:
"""A heavily-weighted index dominates the empirical distribution."""
weights = [90, 5, 5]
rng = GameRNG(seed=99)
counts = Counter(rng.weighted_index(weights) for _ in range(4000))
# Index 0 carries 90% of the mass; it must be by far the most common.
assert counts[0] > counts[1] + counts[2]
# And the rare indices still occur (no off-by-one swallowing the tail).
assert counts[1] > 0 and counts[2] > 0
+63
View File
@@ -0,0 +1,63 @@
"""The satchel "id:qty" wire codec (understone.engine.satchel).
Pins the single-source codec the game façade, the Watch payload, and the
balance simulator all decode through. The format is comma-joined ``id:qty``
stacks; this proves a clean round-trip, the defensive bare-id => qty-1 rule, the
malformed/zero/empty fragments that are skipped, and that the encoder never
emits a zero-or-negative stack.
"""
from __future__ import annotations
import pytest
from understone.engine.satchel import decode_satchel, encode_satchel
def test_round_trips_id_qty_stacks() -> None:
"""The canonical "id:qty,id:qty" data decodes and re-encodes unchanged."""
encoded = "minor_potion:3,iron_ore:5"
stacks = decode_satchel(encoded)
assert stacks == [("minor_potion", 3), ("iron_ore", 5)]
assert encode_satchel(stacks) == encoded
def test_bare_id_decodes_as_qty_one() -> None:
"""A colonless chunk is a single item (defensive — never silently dropped)."""
assert decode_satchel("minor_potion") == [("minor_potion", 1)]
# Mixed with a normal stack, order preserved.
assert decode_satchel("minor_potion,iron_ore:5") == [
("minor_potion", 1),
("iron_ore", 5),
]
@pytest.mark.parametrize(
("encoded", "reason"),
[
("id:0", "zero quantity"),
("id:-1", "negative quantity"),
("id:abc", "non-integer quantity"),
(":5", "empty id"),
("", "empty string"),
("minor_potion:3,", "trailing comma yields an empty chunk"),
(",minor_potion:3", "leading comma yields an empty chunk"),
],
)
def test_skips_malformed_or_zero_fragments(encoded: str, reason: str) -> None:
"""A present-but-invalid or non-positive fragment is skipped; valid ones survive."""
stacks = decode_satchel(encoded)
assert all(item_id and qty > 0 for item_id, qty in stacks), reason
# The only valid stack in the trailing/leading-comma cases is the potion.
if "minor_potion:3" in encoded:
assert stacks == [("minor_potion", 3)]
else:
assert stacks == []
def test_encode_drops_non_positive_stacks() -> None:
"""The encoder never emits "id:0" or a negative quantity."""
assert encode_satchel([("minor_potion", 0)]) == ""
assert encode_satchel([("minor_potion", -2)]) == ""
assert encode_satchel([("minor_potion", 2), ("iron_ore", 0)]) == "minor_potion:2"
assert encode_satchel([]) == ""
+169
View File
@@ -0,0 +1,169 @@
"""Screen-layer tests: viewport maths, frame rendering, menu rendering.
Golden discipline: the golden files under ``tests/golden`` are authored by
hand (correct borders/centring, eyeballed) and are NOT machine-dumped
renderer output. Every golden comparison is paired with structural asserts
that hold independent of the exact golden bytes, so a renderer regression
that happens to match a stale golden still trips a structural check.
"""
from __future__ import annotations
from pathlib import Path
from understone.screen.grid import Cell, CellGrid
from understone.screen.menus import render_menu
from understone.screen.palette import Color
from understone.screen.text_renderer import render_frame
from understone.screen.viewport import compute_window
GOLDEN = Path(__file__).parent / "golden"
# ---------------------------------------------------------------------------
# viewport.compute_window
# ---------------------------------------------------------------------------
def test_window_centers_when_interior() -> None:
# 100x100 map, 48x16 view, focus at (50, 50): centred.
x0, y0 = compute_window(100, 100, 48, 16, 50, 50)
assert x0 == 50 - 48 // 2
assert y0 == 50 - 16 // 2
def test_window_clamps_nw_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 0, 0)
assert (x0, y0) == (0, 0)
def test_window_clamps_ne_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 99, 0)
assert x0 == 100 - 48
assert y0 == 0
def test_window_clamps_sw_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 0, 99)
assert x0 == 0
assert y0 == 100 - 16
def test_window_clamps_se_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 99, 99)
assert x0 == 100 - 48
assert y0 == 100 - 16
def test_window_view_larger_than_map_pins_origin() -> None:
x0, y0 = compute_window(10, 8, 48, 16, 5, 4)
assert (x0, y0) == (0, 0)
# ---------------------------------------------------------------------------
# Shared small-grid builders for the golden frames
# ---------------------------------------------------------------------------
_FLOOR = Cell(".", Color.FLOOR)
_PLAYER = Cell("@", Color.PLAYER)
def _floor_grid(rows: int, cols: int) -> CellGrid:
grid = CellGrid(rows, cols)
for r in range(rows):
for c in range(cols):
grid.set(r, c, _FLOOR)
return grid
def _spawn_grid() -> CellGrid:
"""9x5 floor with the player centred at (row 2, col 4)."""
grid = _floor_grid(5, 9)
grid.set(2, 4, _PLAYER)
return grid
def _edge_nw_grid() -> CellGrid:
"""9x5 floor with the player pinned to the NW corner (row 0, col 0)."""
grid = _floor_grid(5, 9)
grid.set(0, 0, _PLAYER)
return grid
# ---------------------------------------------------------------------------
# text_renderer.render_frame
# ---------------------------------------------------------------------------
def test_render_frame_matches_golden_spawn() -> None:
frame = render_frame(_spawn_grid(), title="Vale", status="[ status ]")
expected = (GOLDEN / "viewport_spawn.txt").read_text(encoding="utf-8")
assert frame == expected.rstrip("\n")
def test_render_frame_matches_golden_edge_nw() -> None:
frame = render_frame(_edge_nw_grid(), title="Vale", status="[ status ]")
expected = (GOLDEN / "viewport_edge_nw.txt").read_text(encoding="utf-8")
assert frame == expected.rstrip("\n")
def test_render_frame_structural_invariants() -> None:
frame = render_frame(_spawn_grid(), title="Vale", status="[ status ]")
lines = frame.split("\n")
# Top border, 5 grid rows, bottom border, status = 8 lines.
assert len(lines) == 8
# Title substring lives in the top border.
assert "Vale" in lines[0]
# Uniform width across the box (top border through bottom border).
box_lines = lines[:-1]
widths = {len(line) for line in box_lines}
assert len(widths) == 1, f"box rows ragged: {widths}"
# Exactly one '@' and it sits at the centre column of the interior.
body = lines[1:-2]
at_positions = [(r, line.index("@")) for r, line in enumerate(body) if "@" in line]
assert len(at_positions) == 1
_, col = at_positions[0]
# Interior centre: 1 (left border) + cols//2 = 1 + 4 = 5.
assert col == 1 + 9 // 2
# Status line is preserved verbatim as the last line.
assert lines[-1] == "[ status ]"
def test_render_frame_under_size_budget() -> None:
grid = _floor_grid(16, 48)
grid.set(8, 24, _PLAYER)
frame = render_frame(grid, title="The Vale of Understone", status="[ a long status line here ]")
assert len(frame) < 2048
# ---------------------------------------------------------------------------
# menus.render_menu
# ---------------------------------------------------------------------------
def test_render_menu_matches_golden_inn() -> None:
menu = render_menu(
"The Sleeping Drake",
["A warm hearth crackles.", "A bed costs 15 gold."],
["(R)est", "(L)eave"],
"[ status ]",
)
expected = (GOLDEN / "menu_inn.txt").read_text(encoding="utf-8")
assert menu == expected.rstrip("\n")
def test_render_menu_structural_invariants() -> None:
menu = render_menu(
"The Sleeping Drake",
["A warm hearth crackles.", "A bed costs 15 gold."],
["(R)est", "(L)eave"],
"[ status ]",
)
lines = menu.split("\n")
assert "The Sleeping Drake" in lines[0]
assert lines[-1] == "[ status ]"
box = lines[:-1]
widths = {len(line) for line in box}
assert len(widths) == 1, f"menu box ragged: {widths}"
# Option line is present inside the body.
assert any("(R)est" in line and "(L)eave" in line for line in lines)
+306
View File
@@ -0,0 +1,306 @@
"""Tests for the balance instrument (the greedy bot simulator).
These run the REAL game façade end-to-end, so they double as the fiercest
integration test in the suite: determinism (same inputs identical report),
that the greedy bot makes genuine progress over a Vale run, that its realized
fight share lands in a sane band, that a multi-seed sweep aggregates and the
report renders and the single best end-to-end assertion, that a short seed
sweep actually SLAYS THE WYRM, proving the whole v0.1v0.7 loop is winnable by
an unclever bot.
"""
from __future__ import annotations
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING
from understone import sim
from understone.engine.models import LocationDef, Mode, Zone
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.sim import BalanceReport, simulate
from .conftest import make_monster, make_world
if TYPE_CHECKING:
import pytest
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
# ---------------------------------------------------------------------------
# determinism
# ---------------------------------------------------------------------------
def test_same_inputs_give_identical_report() -> None:
"""Same (pack, days, seed) → byte-identical BalanceReport (frozen + seeded)."""
a = simulate(PACK, 20, 5)
b = simulate(PACK, 20, 5)
assert a == b
assert isinstance(a, BalanceReport)
def test_different_seeds_diverge() -> None:
"""Different seeds produce different runs (the RNG actually threads through)."""
a = simulate(PACK, 20, 1)
b = simulate(PACK, 20, 2)
# The runs are not identical (some headline measure differs).
assert (a.fights_fought, a.total_gold_earned, a.day_of_first_wyrm_kill) != (
b.fights_fought,
b.total_gold_earned,
b.day_of_first_wyrm_kill,
)
# ---------------------------------------------------------------------------
# progress
# ---------------------------------------------------------------------------
def test_bot_makes_progress_over_thirty_days() -> None:
"""A 30-day Vale run climbs past level 1 and actually fights."""
r = simulate(PACK, 30, 1)
assert r.final_level > 1
assert r.fights_fought > 0
assert r.total_gold_earned > 0
# It also plumbs the deep — the rung ladder is reachable for a geared bot.
assert r.rungs_cleared > 0
def test_realized_fight_share_in_sane_band() -> None:
"""The bot's fight share is a real fraction and forest-fight dominant.
A greedy XP grinder spends most of its turns fighting the wood (the rest are
the handful of descents and the Wyrm bout), so the share is high but it is
a genuine fraction in (0, 1], never a degenerate 0 or a value out of range.
"""
r = simulate(PACK, 30, 3)
assert 0.0 < r.realized_fight_share <= 1.0
# Fights dominate the turn-spend, but descents/challenges exist too, so the
# share is below a hard 1.0 floor only loosely — assert the sane half-band.
assert r.realized_fight_share >= 0.5
# ---------------------------------------------------------------------------
# reporting & sweep
# ---------------------------------------------------------------------------
def test_report_renders_without_crashing() -> None:
r = simulate(PACK, 15, 1)
text = sim._render_report("The Vale of Understone", r)
assert "greedy bot" in text
assert "final level" in text
assert "Wyrm slain" in text
def test_cli_simulate_single_seed_renders(tmp_path: Path) -> None:
out = StringIO()
rc = sim.cli_simulate(PACK, 15, 1, out=out)
assert rc == 0
assert "The Vale of Understone" in out.getvalue()
assert "fight share" in out.getvalue()
def test_cli_simulate_sweep_aggregates() -> None:
"""A --seeds sweep prints per-seed lines plus an aggregate with spreads."""
out = StringIO()
rc = sim.cli_simulate(PACK, 20, 1, out=out, seeds=3)
assert rc == 0
text = out.getvalue()
assert "3 seeds" in text
assert "aggregate" in text
# Per-seed lines for each of the three seeds.
for seed in (1, 2, 3):
assert f"seed {seed:>3}" in text or f"seed {seed}" in text
# The aggregate carries a mean [min..max] spread.
assert "[" in text and "]" in text
def test_sweep_reports_are_each_deterministic() -> None:
"""Each seed in a sweep is independently reproducible by single simulate."""
seed = 4
swept = simulate(PACK, 20, seed)
again = simulate(PACK, 20, seed)
assert swept == again
# ---------------------------------------------------------------------------
# the load-bearing assertion: the world is winnable
# ---------------------------------------------------------------------------
def test_greedy_bot_slays_the_wyrm() -> None:
"""The single best end-to-end check: a short seed sweep KILLS THE WYRM.
If a greedy, unclever bot can take the Wyrm Below playing through the real
façade, then the whole authored loop movement, the zone-banded forest, the
economy, the rung ladder, the satchel death-save, the forge, and the endgame
gate composes into a *winnable* game. A run that ever stops winning trips
here. A small sweep (not one lucky seed) so the proof is robust.
"""
reports = [simulate(PACK, 40, seed) for seed in (1, 2, 3)]
kills = [r for r in reports if r.wyrm_killed]
assert kills, "the greedy bot never slew the Wyrm across the seed sweep"
# Every kill records the day it first happened, within the run window.
for r in kills:
assert r.day_of_first_wyrm_kill is not None
assert 1 <= r.day_of_first_wyrm_kill <= 40
# ---------------------------------------------------------------------------
# the bundled ALTERNATE world: The Cinder Wastes (LLM-authored from the manual)
#
# The Vale assertions above are the primary proof. These mirror them against the
# real bundled second world, so the dogfood pack — authored cold from AUTHORING.md
# — is held to the same bar: the bot must make genuine progress through it, and a
# short seed sweep must actually slay its Magma Wyrm. If the authored world ever
# stops being winnable, this trips.
# ---------------------------------------------------------------------------
CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes"
def test_cinder_wastes_bot_makes_progress() -> None:
"""A short Cinder Wastes run climbs past level 1 and genuinely plays.
Fifteen days lands before the bot's first Wyrm kill (~day 24), so the level
is still climbing rather than reset post-win a stable "the world plays"
signal across the durable measures (level, fights, gold, the rung ladder).
"""
r = simulate(CINDER, 15, 1)
assert r.final_level > 1
assert r.fights_fought > 0
assert r.total_gold_earned > 0
assert r.rungs_cleared > 0 # the caldera rung ladder is reachable
def test_cinder_wastes_is_winnable() -> None:
"""The dogfood proof: a greedy bot SLAYS THE MAGMA WYRM in the authored world.
The Cinder Wastes was written by an LLM working only from AUTHORING.md and
the validator. This is the end-to-end demonstration that the manual plus the
loader produce not merely a *valid* pack but a *playable-to-victory* one a
short seed sweep takes the Magma Wyrm. (It is harder than the Vale: the kill
lands later, so the window is wider than the Vale's.)
"""
reports = [simulate(CINDER, 50, seed) for seed in (1, 2, 3)]
kills = [r for r in reports if r.wyrm_killed]
assert kills, "the greedy bot never slew the Magma Wyrm across the seed sweep"
for r in kills:
assert r.day_of_first_wyrm_kill is not None
assert 1 <= r.day_of_first_wyrm_kill <= 50
# ---------------------------------------------------------------------------
# robustness on non-shipped pack shapes: location doors inside hunt zones
#
# The bot runs arbitrary authored packs, not just the two bundled worlds, so a
# zone may overlap a location door. A door cell is "walkable" (you can step onto
# it) but standing on it flips the bot into that location's MENU — useless ground
# for a forest fight, and a "fight" issued from a MENU is rejected by the engine
# WITHOUT spending a turn. These pin the two guards that keep that from spinning
# the per-day loop or over-counting fights.
# ---------------------------------------------------------------------------
def _door(x: int, y: int) -> LocationDef:
"""A bare location door placed at ``(x, y)`` (an inn, for concreteness)."""
return LocationDef(
key="inn",
kind="inn",
name="Wayhouse",
x=x,
y=y,
glyph="",
color="town",
actions=("rest", "leave"),
)
def test_nearest_in_zone_skips_a_door_cell() -> None:
"""A door is never returned as a zone's hunt cell, even when it is nearest.
The zone here spans a column running away from the spawn; its closest-to-spawn
walkable cell IS a location door, with open ground one step further. The
helper must skip the door (it would only trap the bot in a menu) and return
the open cell beyond it the FIX-2 filter, mirroring ``_adjacent_open``.
"""
# 11x11 grass; spawn (5, 5). A door at (5, 6) is the nearest cell inside the
# zone (Manhattan 1); the nearest OPEN in-zone cell is (5, 7) (Manhattan 2).
world = make_world(
locations=[_door(5, 6)],
zones=[Zone(key="wood", x0=5, y0=6, x1=5, y1=9, tier_lo=1, tier_hi=1)],
)
walkable = sim._reachable(world)
assert (5, 6) in walkable # the door cell is walkable...
cell = sim._nearest_in_zone(world, walkable, world.zones[0])
assert cell is not None
assert cell != (5, 6) # ...but the helper does not pick it
assert world.location_at(*cell) is None # the returned cell is open ground
assert cell == (5, 7) # the nearest open in-zone cell beyond the door
def test_zone_hunt_spots_drops_a_zone_with_no_fightable_foe() -> None:
"""A zone whose tier band holds no foe is dropped, not appended with None.
FIX-4: the fallback in ``_best_hunt_spot`` (``ranked[-1]``) must never land on
a zone where no monster can roll. A zone banded to a tier with no monster is
simply not a hunting ground, so it never enters the spot list.
"""
# One zone banded to tier 9 (no monster lives there); the only monster is a
# tier-1 rat. The empty-band zone must be dropped entirely.
world = make_world(
monsters=[make_monster(tier=1)],
zones=[Zone(key="void", x0=4, y0=4, x1=6, y1=6, tier_lo=9, tier_hi=9)],
)
spots = sim._zone_hunt_spots(world, sim._reachable(world))
assert spots == [] # the foe-less zone is not a spot
def test_hunt_yields_the_turn_when_stuck_in_a_menu(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A hunt that ends in a MENU yields the turn instead of over-counting.
The defence-in-depth for FIX-1: should the bot ever reach the fight moment
still inside a location MENU (a door swallowed the walk), the engine would
REJECT the "fight" without spending a turn and the old string-only check
misread that reject as a won bout, over-counting and spinning the loop. The
new mode pre-check must instead leave the menu and return False (yield), so no
phantom fight is recorded and the day loop makes honest progress.
"""
# A door at (5, 4) inside a tier-1 zone. We inject this door cell as the hunt
# spot directly — the pre-FIX-2 state where a door WAS the nearest in-zone
# cell — so the guard, not the spot-selection filter, is what is under test.
world = make_world(
locations=[_door(5, 4)],
zones=[Zone(key="wood", x0=4, y0=3, x1=6, y1=5, tier_lo=1, tier_hi=1)],
monsters=[make_monster(tier=1)],
)
clock = sim._Clock(sim._SIM_START)
game = Game(world, Store(tmp_path / "g.db"), clock=clock, rng=GameRNG(seed=1)) # type: ignore[arg-type]
bot = sim._Bot(game, world, clock)
game.join(bot.name)
bot._hunt_spots = [(1, (5, 4), make_monster(tier=1))]
player = game.players[bot.name]
# Model "a location door swallowed the walk": every navigation step ends with
# the bot back inside the door's menu, so the hunt reaches its fight decision
# still in MENU mode no matter how many times it tries to step clear — exactly
# the trap the guard exists for (a single un-menu + re-walk cannot escape it).
def _walk_into_door(_goal: tuple[int, int]) -> None:
player.mode = Mode.MENU
player.at_location = "inn"
monkeypatch.setattr(bot, "_goto_xy", _walk_into_door)
_walk_into_door((5, 4)) # start the hunt already inside the menu
fought = bot._hunt()
assert fought is False # the turn is yielded, not spent on a menu-reject
assert bot.fights_fought == 0 # no phantom fight recorded
assert game.players[bot.name].mode is Mode.TILE # and the menu was left behind
+862
View File
@@ -0,0 +1,862 @@
"""The v0.5 social slice — ambush (async PvP), inn mail, and inn dice.
Drives the game façade over the shipped world with a frozen clock and a seeded
RNG. Three feature areas:
* AMBUSH the full eligibility matrix (every refusal branch), the win path
(exact gold transfer, victim bounced to spawn at 1 HP, private mail visible
only to the victim, public news), the lose path (attacker bounced, no
transfer), the flee stalemate, per-day once-per-pair, and next-day retry.
* MAIL ``post`` delivers a private note to the target's log once, the sender
is confirmed, the daily cap refuses the overflow, the sanitizer rejects a
newline body, and the Watch state payload NEVER carries a targeted row.
* DICE win/lose/push under a seeded RNG, the bet band, affordability, the
daily cap (a push still counts), and the Herald firing only on a big win.
Negative-test discipline (the SLEEP RULE has teeth):
``test_sleep_rule_guard_has_teeth`` documents the revert-and-observe check.
Disabling the ``target.turn_day >= today`` clause in Game._ambush_refusal
let an ALREADY-AWAKE target be ambushed ``test_ambush_refused_target_awake``
then failed (the attempt resolved instead of being refused). The clause was
restored; that refusal test is the standing regression for the invariant.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import fixed_clock, utc
from understone.engine.models import Mode
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.watch import build_state_payload
from understone.world.loader import load_world
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
# The frozen "today" all these tests run on; the sleep rule keys off its ordinal.
_NOW = utc(2026, 6, 12, 10, 0)
_TODAY = _NOW.toordinal()
@pytest.fixture
def clock() -> object:
return fixed_clock(_NOW)
def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
world = load_world(PACK)
store = Store(tmp_path / "social.db")
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
def _arm_ambush(
game: Game,
*,
attacker_level: int = 5,
target_level: int = 5,
target_asleep: bool = True,
target_gold: int = 100,
) -> tuple[object, object]:
"""Join an attacker + target and tune their sheets for an ambush.
The attacker is overworld and seasoned; the target sits at *target_level*
with *target_gold*, and ``target_asleep`` controls the sleep rule (a
sleeping target has not acted today). Returns ``(attacker, target)``.
"""
game.join("Raider")
game.join("Sleeper")
attacker = game.players["Raider"]
target = game.players["Sleeper"]
attacker.level = attacker_level
target.level = target_level
target.gold = target_gold
target.turn_day = _TODAY - 1 if target_asleep else _TODAY
return attacker, target
# ---------------------------------------------------------------------------
# Ambush — eligibility matrix (each refusal is a distinct in-fiction line)
# ---------------------------------------------------------------------------
def test_ambush_refused_unknown_target(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Raider")
game.players["Raider"].level = 5
out = game.action("Raider", "ambush", "Ghost", "")
assert "signed the ledger" in out # the unknown-player refusal
# No turn spent on an unresolvable target.
assert game.players["Raider"].turns_left == game.world.settings.daily_turns
def test_ambush_refused_self(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Raider")
game.players["Raider"].level = 5
out = game.action("Raider", "ambush", "Raider", "")
assert "yourself" in out.lower()
def test_ambush_refused_young_attacker(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
floor = game.world.settings.ambush_min_level
_arm_ambush(game, attacker_level=floor - 1, target_level=floor + 1)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "shields the young" in out
assert game.players["Raider"].turns_left == game.world.settings.daily_turns
def test_ambush_refused_young_target(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
floor = game.world.settings.ambush_min_level
# Attacker is seasoned but the target is below the floor: still shielded.
_arm_ambush(game, attacker_level=floor + 1, target_level=floor - 1)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "shields the young" in out
def test_ambush_refused_out_of_band(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
band = game.world.settings.ambush_level_band
floor = game.world.settings.ambush_min_level
_arm_ambush(
game,
attacker_level=floor + band + 5,
target_level=floor,
)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "far from your measure" in out
def test_ambush_band_beats_awake_in_refusal_order(tmp_path: Path, clock: object) -> None:
"""PRECEDENCE: the band gate is checked before the sleep rule.
A target who is BOTH out of band AND awake must report the band message,
not the watchful one pinning the documented order (level gates before the
live-play sleep defence).
"""
game = _game(tmp_path, clock)
band = game.world.settings.ambush_level_band
floor = game.world.settings.ambush_min_level
_arm_ambush(
game,
attacker_level=floor + band + 1, # one past the band...
target_level=floor,
target_asleep=False, # ...and also awake
)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "far from your measure" in out # the band gate wins
assert "watchful today" not in out
def test_ambush_band_boundary_exact_is_allowed(tmp_path: Path, clock: object) -> None:
"""Exactly ``ambush_level_band`` apart clears the band gate (it is inclusive).
Armed awake so the very next gate the sleep rule is what speaks: a
'watchful today' refusal proves the band gate let this pair through.
"""
game = _game(tmp_path, clock)
band = game.world.settings.ambush_level_band
floor = game.world.settings.ambush_min_level
_arm_ambush(
game,
attacker_level=floor + band, # exactly band levels above the floor
target_level=floor,
target_asleep=False,
)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "far from your measure" not in out # past the band gate
assert "watchful today" in out # stopped by the next gate instead
def test_ambush_band_boundary_one_over_is_refused(tmp_path: Path, clock: object) -> None:
"""One level past ``ambush_level_band`` is refused with the band message."""
game = _game(tmp_path, clock)
band = game.world.settings.ambush_level_band
floor = game.world.settings.ambush_min_level
_arm_ambush(
game,
attacker_level=floor + band + 1, # just over the band
target_level=floor,
)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "far from your measure" in out
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False
def test_ambush_refused_target_awake(tmp_path: Path, clock: object) -> None:
"""The SLEEP RULE: a target who has already acted today is un-ambushable.
See the module docstring for the revert-and-observe check proving this
refusal has teeth.
"""
game = _game(tmp_path, clock)
_arm_ambush(game, target_asleep=False)
out = game.action("Raider", "ambush", "Sleeper", "")
assert "watchful today" in out
# Refused without resolving: no turn spent, no ambush recorded.
assert game.players["Raider"].turns_left == game.world.settings.daily_turns
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False
def test_ambush_refused_repeat_same_pair_same_day(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game)
# First attempt resolves (attacker overwhelming -> a clean win).
attacker.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
# Re-arm the target as sleeping AND healed above 1 HP (so the mercy rule
# does not intercept first); the SAME pair is still barred for the day.
target.turn_day = _TODAY - 1
target.hp = 20
out = game.action("Raider", "ambush", "Sleeper", "")
assert "already lain in wait" in out
def test_ambush_refused_pile_on_downed_victim(tmp_path: Path, clock: object) -> None:
"""MERCY RULE: a second, DIFFERENT attacker cannot kick a just-bounced sleeper.
The first ambush leaves the victim at 1 HP (still asleep being robbed does
not start their day). A fresh raider then finds them battered in the ditch;
even bandits have standards, so the pile-on is refused outright no turn
spent, no pair-row written for the second attacker.
"""
game = _game(tmp_path, clock)
first, target = _arm_ambush(game, target_gold=100)
first.atk = 200 # one-shot: leaves the victim at 1 HP
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
assert target.hp == 1 # downed and still asleep
# A second, seasoned raider tries to finish the job.
game.join("Marauder")
second = game.players["Marauder"]
second.level = 5
turns_before = second.turns_left
out = game.action("Marauder", "ambush", "Sleeper", "")
assert "battered in the ditch" in out
# No turn spent and no attempt recorded for the second attacker.
assert second.turns_left == turns_before
assert game.store.has_ambushed("Marauder", "Sleeper", _TODAY) is False
def test_ambush_healed_victim_is_ambushable_again(tmp_path: Path, clock: object) -> None:
"""The mercy rule lifts once the victim mends: healed above 1 HP (and still
asleep), a fresh attacker may strike."""
game = _game(tmp_path, clock)
first, target = _arm_ambush(game, target_gold=100)
first.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
assert target.hp == 1
# The victim is tended back above the floor (still asleep this day).
target.hp = 18
game.join("Marauder")
second = game.players["Marauder"]
second.level = 5
second.atk = 200 # one-shot again
out = game.action("Marauder", "ambush", "Sleeper", "")
assert "battered in the ditch" not in out
# The fresh ambush resolved: recorded, and the victim is bounced anew.
assert game.store.has_ambushed("Marauder", "Sleeper", _TODAY) is True
assert target.hp == 1
def test_ambush_refused_zero_turns(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
attacker, _ = _arm_ambush(game)
attacker.turns_left = 0
out = game.action("Raider", "ambush", "Sleeper", "")
assert "spent for today" in out.lower()
# Eligible but exhausted: nothing recorded (the attempt never landed).
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False
# ---------------------------------------------------------------------------
# Ambush — outcomes (win / lose / flee) and the records they leave
# ---------------------------------------------------------------------------
def test_ambush_win_transfers_gold_and_bounces_victim(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=100)
attacker.atk = 200 # one-shot the sleeper
target.hp = 5
pct = game.world.settings.ambush_gold_pct
steal = 100 * pct // 100 # 25 gold at the shipped 25%
raider_gold_before = attacker.gold
out = game.action("Raider", "ambush", "Sleeper", "")
# Exact transfer: attacker up by steal, victim down by the same.
assert attacker.gold == raider_gold_before + steal
assert target.gold == 100 - steal
# The victim wakes at the spawn at 1 HP, knocked out of any menu.
assert target.hp == 1
assert (target.x, target.y) == game.world.spawn
assert target.mode is Mode.TILE
assert target.at_location == ""
assert f"{steal} gold" in out
# The attempt is recorded.
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
def test_ambush_steals_only_carried_gold_not_the_vault(tmp_path: Path, clock: object) -> None:
"""A winning ambush robs carried gold only — banked vault gold is untouched.
The steal is a slice of ``target.gold`` (gold in hand); the strongbox
(``banked``) is safe by design. This pins the vault's whole point: bank your
coin before you sleep and a sleeping-robber cannot lift it.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=40)
target.banked = 1000 # a fat vault the raider must not be able to touch
attacker.atk = 200 # one-shot the sleeper
target.hp = 5
pct = game.world.settings.ambush_gold_pct
steal = 40 * pct // 100 # a slice of the CARRIED 40, not the banked 1000
game.action("Raider", "ambush", "Sleeper", "")
assert target.gold == 40 - steal # carried gold robbed
assert target.banked == 1000 # the vault is wholly untouched
assert attacker.gold == game.world.settings.starting_gold + steal
def test_ambush_win_applies_attacker_wear(tmp_path: Path, clock: object) -> None:
"""A multi-round win banks the attacker's wear: the log narrates the
sleeper's counter-blows, so the sheet must show the HP they cost.
The one-shot win above leaves the attacker untouched, which would mask a
WIN branch that drops ``hp_delta`` on the floor. Here the sleeper is tanky
enough to trade blows before falling (and the attacker still wins), so the
attacker must end below full HP. Stats and seed are tuned so the win is
decisive but not instant.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=100)
attacker.atk, attacker.def_ = 8, 2
attacker.hp = attacker.max_hp = 30
target.atk, target.def_, target.hp = 5, 1, 25
out = game.action("Raider", "ambush", "Sleeper", "")
# The win lands (victim robbed and bounced to 1 HP)...
assert target.hp == 1
assert (
any(crow in out for crow in ("made off", "robbed the sleeping", "lifted")) or "rob" in out
)
# ...but the sleeper's counter-blows cost the attacker real HP this time.
assert attacker.hp < attacker.max_hp
assert attacker.hp >= 1 # never below the floor
def test_ambush_win_news_is_public_and_mail_is_private(tmp_path: Path, clock: object) -> None:
"""The victory crows on the public feed; the victim gets a PRIVATE note.
A THIRD player must see the public ambush line but never the private one.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=80)
attacker.atk = 200
target.hp = 5
game.join("Bystander") # a third player who must never see the private note
game.action("Raider", "ambush", "Sleeper", "")
# The victim reads the private "While you slept" note in their own log.
victim_log = game.log("Sleeper")
assert "While you slept" in victim_log
assert "ambushed you" in victim_log
# The bystander sees the public crow but NOT the private note.
third_log = game.log("Bystander")
assert (
"made off with" in third_log
or "robbed the sleeping" in third_log
or ("lifted" in third_log)
)
assert "While you slept" not in third_log
def test_ambush_win_on_pauper_steals_nothing_but_still_lands(tmp_path: Path, clock: object) -> None:
"""A win over a penniless sleeper: steal is 0, but the beat still plays.
The victim is bounced to the spawn at 1 HP all the same, the public herald
crows the robbery, and the private 'while you slept' note still reaches the
victim the gold transfer being empty changes none of that.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=0)
attacker.atk = 200 # one-shot the sleeper
target.hp = 5
game.join("Bystander")
raider_gold_before = attacker.gold
out = game.action("Raider", "ambush", "Sleeper", "")
# Nothing to steal: both purses are unchanged by the transfer.
assert attacker.gold == raider_gold_before
assert target.gold == 0
assert "0 gold" in out
# The victim is still bounced to the spawn at 1 HP.
assert target.hp == 1
assert (target.x, target.y) == game.world.spawn
assert target.mode is Mode.TILE
assert target.at_location == ""
# Public herald fires (a bystander reads the crow)...
third_log = game.log("Bystander")
assert any(crow in third_log for crow in ("made off", "robbed the sleeping", "lifted"))
# ...and the private mail still reaches the victim.
victim_log = game.log("Sleeper")
assert "While you slept" in victim_log
assert "ambushed you" in victim_log
def test_ambush_lose_bounces_attacker_no_transfer(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=100)
# The sleeper is deadly: the ambush rebounds onto the attacker.
target.atk = 200
target.def_ = 100
target.hp = 200
attacker_gold_before = attacker.gold
out = game.action("Raider", "ambush", "Sleeper", "")
# No gold moved; the ATTACKER is the one bounced to spawn at 1 HP.
assert attacker.gold == attacker_gold_before
assert target.gold == 100
assert attacker.hp == 1
assert (attacker.x, attacker.y) == game.world.spawn
assert "flee" in out.lower() or "wakes" in out.lower()
# The attempt is still spent.
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
def test_ambush_records_attempt_on_every_outcome(tmp_path: Path, clock: object) -> None:
"""Win, lose, or flee — the (attacker, target, day) row is always written."""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game)
# Tune a flee: when neither side can meaningfully dent the other, the fight
# grinds to the 50-round stalemate guard, which resolves as FLED with no
# transfer. Both deal the 1-damage floor (atk << def), and both carry far
# more HP than 50 rounds can drain, so neither drops first.
attacker.atk, attacker.def_ = 1, 200
attacker.hp = attacker.max_hp = 500
target.atk, target.def_, target.hp = 1, 200, 500
gold_before = attacker.gold
out = game.action("Raider", "ambush", "Sleeper", "")
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
assert attacker.gold == gold_before # a flee moves no gold
assert "slip away" in out.lower() or "nerve" in out.lower()
def test_ambush_next_day_retry_allowed(tmp_path: Path, clock: object) -> None:
"""A new UTC day clears the once-per-pair lock (advance the injected clock)."""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game)
attacker.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
# Advance past UTC midnight; re-arm the sleeper for the new day.
tomorrow = utc(2026, 6, 13, 9, 0)
game.clock = fixed_clock(tomorrow) # type: ignore[assignment]
target.turn_day = tomorrow.toordinal() - 1 # asleep again
target.hp = 5
out = game.action("Raider", "ambush", "Sleeper", "")
assert "already lain in wait" not in out # the new day permits a fresh attempt
assert game.store.has_ambushed("Raider", "Sleeper", tomorrow.toordinal()) is True
def test_sleep_rule_guard_has_teeth(tmp_path: Path, clock: object) -> None:
"""Pin the sleep rule on a single-field divergence.
The un-ambushable case and the ambushable case differ ONLY in ``turn_day``:
with the target awake the action is refused, and flipping that one field to
asleep makes the very same attempt resolve and record.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_asleep=False)
attacker.atk = 200
target.hp = 5
refused = game.action("Raider", "ambush", "Sleeper", "")
assert "watchful today" in refused
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False
# Flip ONLY the sleep field; now the very same attempt lands.
target.turn_day = _TODAY - 1
resolved = game.action("Raider", "ambush", "Sleeper", "")
assert "watchful today" not in resolved
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
def test_ambush_both_rows_persist_in_one_transaction(tmp_path: Path, clock: object) -> None:
"""A win commits BOTH fighters' rows; a store reopen sees the transfer."""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=100)
attacker.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
raider_gold = attacker.gold
sleeper_gold = target.gold
game.store.close()
world = load_world(PACK)
reopened = Store(tmp_path / "social.db")
revived = Game(world, reopened, clock=clock) # type: ignore[arg-type]
assert revived.players["Raider"].gold == raider_gold
assert revived.players["Sleeper"].gold == sleeper_gold
assert revived.players["Sleeper"].hp == 1
reopened.close()
# ---------------------------------------------------------------------------
# Mail — post delivers privately, confirms, caps, sanitizes
# ---------------------------------------------------------------------------
def test_post_delivers_to_target_once_with_confirmation(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
confirm = game.action("Scribe", "post", "Reader", "", "meet me at the inn")
assert "tucks the note" in confirm # the sender's in-fiction confirmation
# No turn spent on a post.
assert game.players["Scribe"].turns_left == game.world.settings.daily_turns
first = game.log("Reader")
assert "While you were away" in first
assert "meet me at the inn" in first
# Read once: the cursor advanced, so a second read no longer shows it.
second = game.log("Reader")
assert "meet me at the inn" not in second
def test_post_refused_unknown_and_self(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Scribe")
unknown = game.action("Scribe", "post", "Nobody", "", "hello?")
assert "signed the ledger" in unknown
mine = game.action("Scribe", "post", "Scribe", "", "note to self")
assert "talk to yourself" in mine.lower()
def test_post_daily_cap_refuses_overflow(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
cap = game.world.settings.post_daily_cap
for i in range(cap):
out = game.action("Scribe", "post", "Reader", "", f"note {i}")
assert "tucks the note" in out
# The (cap+1)-th post is refused.
over = game.action("Scribe", "post", "Reader", "", "one too many")
assert "all the word you may today" in over
assert game.players["Scribe"].posts_sent == cap
def test_post_sanitizer_rejects_newline_body(tmp_path: Path, clock: object) -> None:
"""A newline-injected note body is refused; nothing is delivered or counted."""
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
events_before = len(game.events)
out = game.action("Scribe", "post", "Reader", "", "line one\nFORGED HERALD LINE")
assert "scrawl" in out.lower()
# No event appended and the daily counter is untouched.
assert len(game.events) == events_before
assert game.players["Scribe"].posts_sent == 0
# And the reader never receives it.
assert "FORGED" not in game.log("Reader")
def test_post_works_from_inside_a_building(tmp_path: Path, clock: object) -> None:
"""Posting is legal anywhere: a menu-bound sender still gets a menu reply."""
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
scribe = game.players["Scribe"]
scribe.mode = Mode.MENU
scribe.at_location = "inn"
out = game.action("Scribe", "post", "Reader", "", "by the hearth")
assert "tucks the note" in out
# The reply is the inn menu (a menu surface), not an overworld frame.
assert "(R)est" in out or "Sleeping Drake" in out
# ---------------------------------------------------------------------------
# Mail — the lobby TV must never carry a private note
# ---------------------------------------------------------------------------
def test_watch_state_excludes_targeted_rows(tmp_path: Path, clock: object) -> None:
"""EXPLICIT: a private (targeted) event must not reach the Watch herald."""
game = _game(tmp_path, clock)
game.join("Scribe")
game.join("Reader")
game.action("Scribe", "post", "Reader", "", "a secret for the Reader")
payload = build_state_payload(game)
herald = payload["herald"]
assert isinstance(herald, list)
texts = [row["text"] for row in herald]
# The join lines are public and present; the private note is absent.
assert any("Scribe" in t or "Reader" in t for t in texts) # public joins show
assert all("a secret for the Reader" not in t for t in texts)
def test_watch_state_excludes_private_ambush_note(tmp_path: Path, clock: object) -> None:
"""The ambush victim's private alert is filtered from the lobby TV too."""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=80)
attacker.atk = 200
target.hp = 5
game.action("Raider", "ambush", "Sleeper", "")
herald_texts = [row["text"] for row in build_state_payload(game)["herald"]] # type: ignore[union-attr]
# The PUBLIC ambush crow is on the feed...
assert any(
"Sleeper" in t and ("made off" in t or "robbed" in t or "lifted" in t) for t in herald_texts
)
# ...but the PRIVATE "While you slept" note never is.
assert all("While you slept" not in t for t in herald_texts)
# ---------------------------------------------------------------------------
# Dice — win / lose / push under a seeded RNG, bands, cap, herald gate
# ---------------------------------------------------------------------------
def _at_inn(game: Game, name: str) -> object:
"""Join *name* and seat them at the inn (MENU surface)."""
game.join(name)
player = game.players[name]
player.mode = Mode.MENU
player.at_location = "inn"
return player
def test_gamble_win_under_seeded_rng(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100
# Seed 2 makes the gamble child roll 11 (you) vs 9 (house) -> a win.
game.rng = GameRNG(seed=2)
out = game.action("Gambler", "gamble", "", "", "", 10)
assert player.gold == 110 # stake doubled back
assert "win" in out.lower()
# No turn spent; one game counted.
assert player.turns_left == game.world.settings.daily_turns
assert player.gambles == 1
def test_gamble_lose_under_seeded_rng(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100
# Seed 0 rolls 4 (you) vs 9 (house) -> a loss.
game.rng = GameRNG(seed=0)
out = game.action("Gambler", "gamble", "", "", "", 10)
assert player.gold == 90
assert "lose" in out.lower()
assert player.gambles == 1
def test_gamble_push_under_seeded_rng(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100
# Seed 1 rolls 6 vs 6 -> a push: no gold change, but it still counts.
game.rng = GameRNG(seed=1)
out = game.action("Gambler", "gamble", "", "", "", 10)
assert player.gold == 100
assert "push" in out.lower()
assert player.gambles == 1 # a push still consumes a daily game
def test_gamble_bet_band_refused(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100_000
max_bet = game.world.settings.gamble_max_bet
low = game.action("Gambler", "gamble", "", "", "", 0)
assert f"1 to {max_bet}" in low
high = game.action("Gambler", "gamble", "", "", "", max_bet + 1)
assert f"1 to {max_bet}" in high
# A rejected bet neither moves gold nor counts toward the cap.
assert player.gold == 100_000
assert player.gambles == 0
def test_gamble_unaffordable_refused(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 5
out = game.action("Gambler", "gamble", "", "", "", 10) # within band, can't cover
assert "can't cover" in out.lower()
assert player.gold == 5
assert player.gambles == 0
def test_gamble_daily_cap_refused(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 100_000
cap = game.world.settings.gamble_daily_cap
player.gambles = cap # already at the cap
out = game.action("Gambler", "gamble", "", "", "", 5)
assert "enough for one day" in out
assert player.gambles == cap # not incremented past the cap
def test_gamble_outside_inn_refused(tmp_path: Path, clock: object) -> None:
"""The dice live at the inn: the verb is illegal in another building."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.at_location = "shop" # the shop has no 'gamble' action
player.gold = 100
out = game.action("Gambler", "gamble", "", "", "", 10)
assert "can't 'gamble' here" in out.lower()
assert player.gold == 100
def test_gamble_big_win_heralds(tmp_path: Path, clock: object) -> None:
"""A win of >= 25 gold reaches the public Herald; a small one does not."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 1000
# A 50-gold win (>= the 25 threshold) writes a public dice line.
game.rng = GameRNG(seed=2) # a winning roll
events_before = len(game.events)
game.action("Gambler", "gamble", "", "", "", 50)
new = game.events[events_before:]
assert any(e.kind == "gamble" and e.target == "" for e in new)
assert player.gold == 1050
def test_gamble_small_win_is_quiet(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_inn(game, "Gambler")
player.gold = 1000
# A 10-gold win is below the 25-gold Herald threshold: no public line.
game.rng = GameRNG(seed=2)
events_before = len(game.events)
game.action("Gambler", "gamble", "", "", "", 10)
new = game.events[events_before:]
assert all(e.kind != "gamble" for e in new)
assert player.gold == 1010
# ---------------------------------------------------------------------------
# The Vault — deposit/withdraw at the inn (no turn; banked gold is safe)
# ---------------------------------------------------------------------------
def test_deposit_moves_gold_to_the_vault_no_turn(tmp_path: Path, clock: object) -> None:
"""Deposit moves coin from hand to vault, costs no turn, and is friendly."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 100
turns_before = player.turns_left
out = game.action("Saver", "deposit", "", "", "", 60)
assert player.gold == 40
assert player.banked == 60
assert player.turns_left == turns_before # banking spends no turn
assert "strongbox" in out.lower()
def test_withdraw_moves_gold_back_to_hand(tmp_path: Path, clock: object) -> None:
"""Withdraw moves coin from vault to hand."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 10
player.banked = 90
game.action("Saver", "withdraw", "", "", "", 50)
assert player.gold == 60
assert player.banked == 40
def test_deposit_amount_exceeding_holdings_refused(tmp_path: Path, clock: object) -> None:
"""Depositing more than you carry is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 30
player.banked = 0
out = game.action("Saver", "deposit", "", "", "", 50)
assert player.gold == 30 # unchanged
assert player.banked == 0
assert "1 to 30" in out
def test_deposit_with_nothing_in_hand_refused(tmp_path: Path, clock: object) -> None:
"""Depositing with an empty hand is a friendly refusal."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 0
out = game.action("Saver", "deposit", "", "", "", 10)
assert player.banked == 0
assert "no coin" in out.lower()
def test_withdraw_amount_exceeding_vault_refused(tmp_path: Path, clock: object) -> None:
"""Withdrawing more than is banked is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 0
player.banked = 20
out = game.action("Saver", "withdraw", "", "", "", 50)
assert player.gold == 0
assert player.banked == 20 # unchanged
assert "1 to 20" in out
def test_withdraw_empty_vault_refused(tmp_path: Path, clock: object) -> None:
"""Withdrawing from an empty vault is a friendly refusal."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.banked = 0
out = game.action("Saver", "withdraw", "", "", "", 10)
assert player.gold == game.world.settings.starting_gold # unchanged
assert "empty" in out.lower()
def test_status_shows_carried_and_vault_gold(tmp_path: Path, clock: object) -> None:
"""door_status reports gold as carried-on-hand plus banked-in-the-vault."""
game = _game(tmp_path, clock)
game.join("Saver")
player = game.players["Saver"]
player.gold = 75
player.banked = 250
out = game.status("Saver")
assert "75 on hand" in out
assert "250 in the vault" in out
+60
View File
@@ -0,0 +1,60 @@
"""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
@@ -0,0 +1,81 @@
"""The one-glyph-one-column grid contract (understone.engine.textwidth).
Pins the accept/reject boundary of :func:`is_grid_safe` and proves every
:data:`SAFE_PALETTE` entry clears it. The acceptances include the
East-Asian-Width *Ambiguous* CP437 glyphs the game leans on (`` ``),
which render single-column under the Western monospace our surfaces use; the
rejections are the genuinely double-width and zero-width classes that tear a
frame.
"""
from __future__ import annotations
import unicodedata
import pytest
from understone.engine.textwidth import SAFE_PALETTE, is_grid_safe
from understone.world.loader import RESERVED_GLYPHS
# Single-column glyphs that must be admitted: plain ASCII, a Latin accent that
# is one composed code point, and the Ambiguous-width CP437 set the re-skin uses.
_ACCEPTED = ["a", "Z", "ö", "", "", "", "", "", "", "", ".", "$", " "]
# Must be rejected, with the reason each one trips the gate.
_REJECTED = {
"": "wide CJK ideograph (EAW=W) — two columns",
"🌲": "emoji (EAW=W) — two columns",
"": "fullwidth Latin A (EAW=F) — two columns",
"": "decomposed e + combining acute — two code points",
"́": "a lone combining acute — zero width",
"👨‍👩": "ZWJ sequence — multiple code points",
"ab": "two characters",
"": "empty string",
"\t": "a control character",
}
@pytest.mark.parametrize("ch", _ACCEPTED)
def test_is_grid_safe_accepts(ch: str) -> None:
assert is_grid_safe(ch) is True
@pytest.mark.parametrize("text", list(_REJECTED), ids=list(_REJECTED.values()))
def test_is_grid_safe_rejects(text: str) -> None:
assert is_grid_safe(text) is False
def test_safe_palette_is_all_grid_safe() -> None:
"""Every curated palette glyph clears the gate — the appendix can't ship a dud."""
bad = [g for g in SAFE_PALETTE if not is_grid_safe(g)]
assert bad == [], f"palette has non-grid-safe glyphs: {bad}"
def test_safe_palette_has_no_reserved_glyphs() -> None:
"""No palette glyph is a loader-reserved marker — the 'author-usable' promise.
The appendix tells a pack author to pull any palette glyph for terrain,
structures, or actors, but the loader rejects the box-drawing frame lines
and the '@'/'' player markers (``loader.RESERVED_GLYPHS``). A palette entry
that is also reserved would hand the author a glyph that load-fails the
exact doc-vs-enforcement trap. Guarding the intersection keeps "all tested
safe AND author-usable" enforced, not merely asserted on width.
"""
collisions = set(SAFE_PALETTE) & RESERVED_GLYPHS
assert collisions == set(), f"palette offers loader-reserved glyphs: {sorted(collisions)}"
def test_safe_palette_has_no_duplicates() -> None:
"""The palette is a set in spirit; a dupe would be an authoring slip."""
assert len(SAFE_PALETTE) == len(set(SAFE_PALETTE))
def test_ambiguous_width_glyphs_are_accepted() -> None:
"""Document the load-bearing call: EAW=Ambiguous is admitted, not barred.
These are the CP437 glyphs the game depends on; if a future tightening
barred Ambiguous, the whole re-skin would vanish from the map.
"""
for ch in "█♣↑∩≈★":
assert unicodedata.east_asian_width(ch) == "A"
assert is_grid_safe(ch) is True
+94
View File
@@ -0,0 +1,94 @@
"""Daily-turn budget and UTC rollover tests.
Covers spend/refuse semantics, the lazy reset when the UTC day advances
(including a 23:59 -> 00:01 crossing on the same Player instance), and the
shared rollover of the bestow pool.
"""
from __future__ import annotations
from tests.conftest import fixed_clock, make_player, utc
from understone.engine.turns import ensure_day, spend_turn
def test_spend_decrements() -> None:
player = make_player(turns_left=3)
assert spend_turn(player) is True
assert player.turns_left == 2
def test_spend_refuses_at_zero_without_mutation() -> None:
player = make_player(turns_left=0)
before = player.turns_left
assert spend_turn(player) is False
assert player.turns_left == before
def test_ensure_day_resets_on_new_day() -> None:
day = utc(2026, 6, 12).toordinal()
player = make_player(turns_left=0, turn_day=day - 1, bestow_spent=20, bestow_day=day - 1)
reset = ensure_day(player, fixed_clock(utc(2026, 6, 12, 9, 0)), daily_turns=10)
assert reset is True
assert player.turns_left == 10
assert player.turn_day == day
assert player.bestow_spent == 0
assert player.bestow_day == day
def test_ensure_day_noop_within_same_day() -> None:
day = utc(2026, 6, 12).toordinal()
# Every day marker is already today, so no allowance (turns, bestow, posts,
# dice) is touched — the rollover is a pure no-op.
player = make_player(
turns_left=4,
turn_day=day,
bestow_spent=10,
bestow_day=day,
post_day=day,
gamble_day=day,
)
reset = ensure_day(player, fixed_clock(utc(2026, 6, 12, 23, 0)), daily_turns=10)
assert reset is False
assert player.turns_left == 4
assert player.bestow_spent == 10
def test_midnight_crossing_refreshes_on_same_instance() -> None:
# Evening of day one: spend down to a low budget.
player = make_player(turns_left=10, turn_day=0, bestow_spent=0, bestow_day=0)
evening = utc(2026, 6, 12, 23, 59)
ensure_day(player, fixed_clock(evening), daily_turns=10)
for _ in range(8):
spend_turn(player)
assert player.turns_left == 2
# Just past midnight (UTC) the next action refreshes the budget.
after_midnight = utc(2026, 6, 13, 0, 1)
reset = ensure_day(player, fixed_clock(after_midnight), daily_turns=10)
assert reset is True
assert player.turns_left == 10
assert player.turn_day == after_midnight.toordinal()
def test_bestow_pool_resets_on_the_same_boundary() -> None:
player = make_player(bestow_spent=25, bestow_day=utc(2026, 6, 12).toordinal())
ensure_day(player, fixed_clock(utc(2026, 6, 13, 0, 1)), daily_turns=10)
assert player.bestow_spent == 0
assert player.bestow_day == utc(2026, 6, 13).toordinal()
def test_social_caps_reset_on_the_same_boundary() -> None:
"""Posts and dice counts ride the same UTC rollover as turns and bestow."""
yesterday = utc(2026, 6, 12).toordinal()
player = make_player(
posts_sent=5,
post_day=yesterday,
gambles=5,
gamble_day=yesterday,
)
reset = ensure_day(player, fixed_clock(utc(2026, 6, 13, 0, 1)), daily_turns=10)
assert reset is True
assert player.posts_sent == 0
assert player.post_day == utc(2026, 6, 13).toordinal()
assert player.gambles == 0
assert player.gamble_day == utc(2026, 6, 13).toordinal()
+591
View File
@@ -0,0 +1,591 @@
"""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"]
@@ -0,0 +1,143 @@
"""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
@@ -0,0 +1,851 @@
"""Content-pack loader tests.
Asserts the shipped pack loads, and that representative malformed packs
each raise :class:`WorldLoadError` with a readable message: a bad legend
character, a location placed on non-walkable terrain, a row-width / height
mismatch, and an economy setting outside its sanity band.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from understone.errors import WorldLoadError
from understone.world.loader import load_world
SHIPPED = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
def test_shipped_pack_loads() -> None:
world = load_world(SHIPPED)
assert world.name == "The Vale of Understone"
assert world.width == 96
assert world.height == 48
assert world.is_walkable(*world.spawn)
assert len(world.locations) == 4
assert len(world.zones) == 2
# Tiers 1..5 are the random foes; tier 6 is the boss (the Wyrm Below).
assert {m.tier for m in world.monsters} == {1, 2, 3, 4, 5, 6}
boss = world.monster_by_id(world.settings.boss_monster)
assert boss is not None and boss.boss and boss.name == "the Wyrm Below"
def _clone_pack(tmp_path: Path) -> Path:
dest = tmp_path / "pack"
shutil.copytree(SHIPPED, dest)
return dest
def _rewrite(path: Path, mutate: Any) -> None:
data = json.loads(path.read_text(encoding="utf-8"))
mutate(data)
path.write_text(json.dumps(data), encoding="utf-8")
def test_bad_legend_char_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Splice an unknown glyph into the middle of a terrain row.
row = list(data["terrain_rows"][24])
row[40] = "Z"
data["terrain_rows"][24] = "".join(row)
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="not in the legend"):
load_world(pack)
def test_location_on_non_walkable_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Move the inn onto a tree-border tile (col 0 is the tree frame).
for loc in data["locations"]:
if loc["key"] == "inn":
loc["x"] = 0
loc["y"] = 24
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="non-walkable"):
load_world(pack)
def test_dimension_mismatch_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Truncate one row so its width no longer matches the declared width.
data["terrain_rows"][10] = data["terrain_rows"][10][:-5]
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="wide but width is"):
load_world(pack)
def test_height_mismatch_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["terrain_rows"] = data["terrain_rows"][:-1]
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="rows but height is"):
load_world(pack)
def test_settings_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["daily_turns"] = 0 # band is 1..100
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="daily_turns"):
load_world(pack)
def test_start_hp_zero_rejected(tmp_path: Path) -> None:
"""A starting HP of 0 is out of band (1..500): a hero must begin alive."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["start_hp"] = 0 # band is 1..500
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="start_hp"):
load_world(pack)
def test_unknown_starting_item_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["starting_weapon"] = "no_such_blade"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="not a known item id"):
load_world(pack)
def test_missing_pack_file_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
(pack / "monsters.json").unlink()
with pytest.raises(WorldLoadError, match="missing pack file"):
load_world(pack)
def test_monster_nonpositive_hp_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[0]["hp"] = 0 # a monster with no hit points is unkillable nonsense
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] hp must be >= 1"):
load_world(pack)
def test_monster_negative_stat_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[1]["gold"] = -5
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[1\] gold must be >= 0"):
load_world(pack)
def test_item_negative_price_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[1]["price"] = -10 # a negative price would pay the player to take it
_rewrite(pack / "items.json", mutate)
with pytest.raises(WorldLoadError, match=r"items\.json\[1\] price must be >= 0"):
load_world(pack)
def test_dungeon_tier_without_monster_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Tier 9 has no monster in the pack, so the gauntlet rung is unfillable.
data["settings"]["dungeon_tiers"] = [4, 9]
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"dungeon_tiers\[1\] = 9 has no non-boss monster"):
load_world(pack)
def test_dungeon_tiers_empty_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["dungeon_tiers"] = []
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="dungeon_tiers must be a non-empty list"):
load_world(pack)
def test_dungeon_tier_backed_only_by_boss_rejected(tmp_path: Path) -> None:
"""A boss-only tier is unfillable: the gauntlet excludes boss monsters.
Tier 6 in the shipped pack holds only the Wyrm Below (a boss). A gauntlet
rung at tier 6 would draw from monsters_for_tier_band, which filters bosses
out, so the rung silently does nothing the loader must reject it instead.
The message says "no NON-boss monster" (not merely "no monster"): the boss
is present at that tier, it just cannot fill a rung, and the wording must
point the author at exactly that.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["dungeon_tiers"] = [4, 6] # 6 is the boss-only tier
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"dungeon_tiers\[1\] = 6 has no non-boss monster"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.2 loader rejections: the event table and the Wyrm settings
# ---------------------------------------------------------------------------
def test_events_without_fight_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Strip every fight row; a walk could then never spawn a monster.
data["events"] = [e for e in data["events"] if e["kind"] != "fight"]
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match="at least one 'fight' entry"):
load_world(pack)
def test_event_zero_weight_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["events"][0]["weight"] = 0
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match="weight must be > 0"):
load_world(pack)
def test_event_min_exceeds_max_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
# Find a value-bearing row and invert its band.
for event in data["events"]:
if event["kind"] == "gold":
event["min"], event["max"] = 9, 2
break
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match="min 9 exceeds max 2"):
load_world(pack)
def test_event_amount_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
for event in data["events"]:
if event["kind"] == "heal":
event["max"] = 500 # heal band is 1..100
break
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match=r"heal amount .* is out of band"):
load_world(pack)
def test_event_nonfight_blank_text_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
for event in data["events"]:
if event["kind"] == "lore":
event["text"] = " "
break
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match="requires non-empty 'text'"):
load_world(pack)
def test_boss_monster_unknown_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["boss_monster"] = "no_such_wyrm"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="is not a known monster id"):
load_world(pack)
def test_boss_monster_not_flagged_boss_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
# Give a plain monster an id and point boss_monster at it; it lacks the
# boss flag, so it must be rejected as the endgame foe.
data[0]["id"] = "field_rat"
_rewrite(pack / "monsters.json", mutate)
def point(data: dict[str, Any]) -> None:
data["settings"]["boss_monster"] = "field_rat"
_rewrite(pack / "world.json", point)
with pytest.raises(WorldLoadError, match='must be flagged "boss": true'):
load_world(pack)
def test_wyrm_min_level_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["wyrm_min_level"] = 0 # band is 1..50
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="wyrm_min_level"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.5 social settings: ambush / post / gamble economy bands
# ---------------------------------------------------------------------------
def test_ambush_gold_pct_out_of_band_rejected(tmp_path: Path) -> None:
"""The steal percentage is a 0..100 band; 101 is rejected by name."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ambush_gold_pct"] = 101 # band is 0..100
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="ambush_gold_pct"):
load_world(pack)
def test_ambush_level_band_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ambush_level_band"] = 11 # band is 0..10
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="ambush_level_band"):
load_world(pack)
def test_gamble_max_bet_out_of_band_rejected(tmp_path: Path) -> None:
"""A max bet of 0 is below the 1..10000 floor: the house needs a real stake."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["gamble_max_bet"] = 0 # band is 1..10000
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="gamble_max_bet"):
load_world(pack)
def test_post_daily_cap_out_of_band_rejected(tmp_path: Path) -> None:
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["post_daily_cap"] = 51 # band is 0..50
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="post_daily_cap"):
load_world(pack)
def test_missing_social_setting_rejected(tmp_path: Path) -> None:
"""A pack that predates the social settings fails loudly (no silent default)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
del data["settings"]["ambush_min_level"]
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="ambush_min_level"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.4 loader hardening: glyphs, map size, count caps, and name lengths
#
# Packs are now routinely untrusted LLM output, so the loader bands the shapes
# that could tear a frame, balloon memory, or impersonate a player. Each
# rejection still names the file and field at fault.
# ---------------------------------------------------------------------------
def test_box_drawing_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A terrain glyph may not be a frame box-drawing line (it would tear borders)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "" # the horizontal frame run
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* box-drawing"):
load_world(pack)
def test_player_marker_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A terrain glyph may not be '@' — that is the player's own marker."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "@"
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* reserved for player markers"):
load_world(pack)
def test_other_player_marker_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A terrain glyph may not be '' — the v0.6 other-player marker."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = ""
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* reserved for player markers"):
load_world(pack)
def test_ampersand_terrain_glyph_now_accepted(tmp_path: Path) -> None:
"""'&' is no longer an actor marker (☻ took that role), so it is pack-legal.
The load itself is the assertion it must not raise the actor-marker
rejection. A grass cell then carries the new glyph.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "&"
_rewrite(pack / "terrain.json", mutate)
world = load_world(pack) # no WorldLoadError: '&' is admitted
grass = next(
world.terrain_at(x, y)
for y in range(world.height)
for x in range(world.width)
if world.terrain_at(x, y).key == "grass"
)
assert grass.glyph == "&"
def test_wide_cjk_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A Wide (EAW=W) ideograph would render two columns and tear the frame."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = ""
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* exactly one column"):
load_world(pack)
def test_fullwidth_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A Fullwidth (EAW=F) Latin letter is two columns and is rejected."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "" # U+FF21 FULLWIDTH LATIN CAPITAL LETTER A
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* exactly one column"):
load_world(pack)
def test_reskinned_shipped_pack_glyphs() -> None:
"""The shipped pack carries the v0.6 re-skin and still loads cleanly.
The load-bearing guard for the re-skin: water is and the three lettered
buildings became //. If a data edit reverts a glyph, this trips.
"""
world = load_world(SHIPPED)
waters = {
world.terrain_at(x, y).glyph
for y in range(world.height)
for x in range(world.width)
if world.terrain_at(x, y).key == "water"
}
assert waters == {""}
by_key = {loc.key: loc.glyph for loc in world.locations}
assert by_key["inn"] == ""
assert by_key["healer"] == ""
assert by_key["dungeon"] == ""
assert by_key["shop"] == "$" # the shop glyph is unchanged
def test_multichar_location_glyph_rejected(tmp_path: Path) -> None:
"""A location glyph must be exactly one character."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["inn"]["glyph"] = "In" # two characters
_rewrite(pack / "locations.json", mutate)
with pytest.raises(WorldLoadError, match=r"locations\.json.* single character"):
load_world(pack)
def test_oversized_map_rejected(tmp_path: Path) -> None:
"""A 300x300 map is past the dimension ceiling (8..256)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["width"] = 300
data["height"] = 300
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"world\.json width = 300 is out of band"):
load_world(pack)
def test_too_many_events_rejected(tmp_path: Path) -> None:
"""An event table over the 500-row cap is rejected before it is decoded."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
filler = {"kind": "lore", "weight": 1, "text": "filler"}
data["events"] = [filler.copy() for _ in range(501)]
_rewrite(pack / "events.json", mutate)
with pytest.raises(WorldLoadError, match=r"events\.json defines 501 events; the limit is 500"):
load_world(pack)
def test_overlong_monster_name_rejected(tmp_path: Path) -> None:
"""A 49-character monster name is one past the 48-char display limit."""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[0]["name"] = "x" * 49
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] name is 49 characters"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.7 loader rejections: the satchel/forge bands, rare_drop_item, monster weight
# ---------------------------------------------------------------------------
def test_rare_drop_item_unknown_rejected(tmp_path: Path) -> None:
"""A rare_drop_item that names no item is rejected with the item-id message."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["rare_drop_item"] = "no_such_draught"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="rare_drop_item = 'no_such_draught' is not a known"):
load_world(pack)
def test_rare_drop_item_non_consumable_rejected(tmp_path: Path) -> None:
"""A rare_drop_item that names a weapon (not a consumable) is rejected.
The drop goes straight into the satchel to be quaffed, so a weapon or
armour id is incoherent the loader pins the slot.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["rare_drop_item"] = "iron_sword" # a weapon, not a draught
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="rare_drop_item = 'iron_sword' must be a consumable"):
load_world(pack)
def test_satchel_max_out_of_band_rejected(tmp_path: Path) -> None:
"""satchel_max above its 1..10 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["satchel_max"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"satchel_max = 11 is out of band \(1\.\.10\)"):
load_world(pack)
def test_forge_max_plus_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_max_plus above its 0..10 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_max_plus"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_max_plus = 11 is out of band \(0\.\.10\)"):
load_world(pack)
def test_forge_base_cost_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_base_cost below its floor of 1 is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_base_cost"] = 0
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_base_cost = 0 is out of band \(1\.\.10000\)"):
load_world(pack)
def test_forge_ore_item_unknown_rejected(tmp_path: Path) -> None:
"""A forge_ore_item that names no item is rejected with the item-id message."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_ore_item"] = "no_such_ore"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="forge_ore_item = 'no_such_ore' is not a known"):
load_world(pack)
def test_forge_ore_item_non_material_rejected(tmp_path: Path) -> None:
"""A forge_ore_item that names a non-material (a potion) is rejected.
Ore is carried in the satchel and spent at the forge, never equipped or
quaffed, so a consumable/weapon/armour id is incoherent the loader pins
the slot to ``material`` (mirroring the rare_drop_item consumable check).
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_ore_item"] = "greater_potion" # a draught, not ore
_rewrite(pack / "world.json", mutate)
with pytest.raises(
WorldLoadError, match="forge_ore_item = 'greater_potion' must be a material"
):
load_world(pack)
def test_forge_ore_per_plus_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_ore_per_plus above its 0..10 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_ore_per_plus"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_ore_per_plus = 11 is out of band \(0\.\.10\)"):
load_world(pack)
def test_ore_dungeon_drop_out_of_band_rejected(tmp_path: Path) -> None:
"""ore_dungeon_drop above its 0..20 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ore_dungeon_drop"] = 21
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"ore_dungeon_drop = 21 is out of band \(0\.\.20\)"):
load_world(pack)
def test_ore_forest_chance_out_of_band_rejected(tmp_path: Path) -> None:
"""ore_forest_chance outside 0.0..1.0 is a load error (it is a probability)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ore_forest_chance"] = 1.5
_rewrite(pack / "world.json", mutate)
with pytest.raises(
WorldLoadError, match=r"ore_forest_chance = 1.5 is out of band \(0.0..1.0\)"
):
load_world(pack)
def test_monster_zero_weight_rejected(tmp_path: Path) -> None:
"""A monster weight of 0 is rejected (the weighted pick needs a positive total)."""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[0]["weight"] = 0
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] weight must be > 0"):
load_world(pack)
def test_shipped_pack_carries_rares_and_weights() -> None:
"""The shipped pack parses the v0.7 rare beasts with their low weights."""
world = load_world(SHIPPED)
rares = [m for m in world.monsters if m.rare]
names = {m.name for m in rares}
assert names == {"the Gilded Stag", "the Hollow Knight"}
assert all(m.weight == 1 for m in rares) # rares surface seldom
# The rare_drop_item resolves to a consumable.
drop = world.item_by_id(world.settings.rare_drop_item)
assert drop is not None and drop.slot.value == "consumable"
# The new economy settings land on their shipped values.
assert world.settings.satchel_max == 3
assert world.settings.forge_base_cost == 60
assert world.settings.forge_max_plus == 3
assert world.settings.dungeon_tiers == (3, 4, 5)
# v0.10 ore-forge settings resolve, and the forge ore is a material item.
assert world.settings.forge_ore_item == "iron_ore"
ore = world.item_by_id(world.settings.forge_ore_item)
assert ore is not None and ore.slot.value == "material"
assert world.settings.forge_ore_per_plus == 1
assert world.settings.ore_dungeon_drop == 2
assert world.settings.ore_forest_chance == 0.2
def test_monster_weight_and_rare_default_when_omitted(tmp_path: Path) -> None:
"""A monster spec without weight/rare loads as weight 10, rare False.
Both fields are optional with defaults, so an unannotated common monster
(the shipped Field Rat) parses to the default weight and the non-rare flag.
"""
world = load_world(SHIPPED)
rat = next(m for m in world.monsters if m.name == "Field Rat")
assert rat.weight == 10 # the default biasing weight
assert rat.rare is False
# ---------------------------------------------------------------------------
# v0.8 loader hardening: rare-as-rung-guardian and the single-boss invariant
#
# AUTHORING states both as rules; v0.8 makes them machine-checked. A rare in
# the lead slot of a dungeon tier would be silently promoted to a fixed rung
# guardian (and pulled from the rare pool); a stray second boss would validate
# clean yet make "the one endgame foe" a lie.
# ---------------------------------------------------------------------------
def test_rare_as_first_dungeon_tier_monster_rejected(tmp_path: Path) -> None:
"""A rare in the FIRST slot of a dungeon tier becomes a fixed guardian — rejected.
Tier 3 backs a ``dungeon_tiers`` rung and its first monster (the Forest
Wolf) is the rung guardian (``band[0]``). Flagging that lead monster rare
would quietly turn the rare into the fixed, repeatable guardian and remove
it from the weighted rare roll, so the loader rejects it by name.
"""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
wolf = next(m for m in data if m["name"] == "Forest Wolf") # first tier-3
wolf["rare"] = True
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(
WorldLoadError,
match=r"'Forest Wolf' is rare but is the first tier-3 monster.*fixed guardian",
):
load_world(pack)
def test_rare_after_guardian_in_dungeon_tier_accepted(tmp_path: Path) -> None:
"""A rare placed AFTER the guardian in the same dungeon tier loads cleanly.
The shipped pack already does exactly this (the Hollow Knight is the third
tier-3 entry, behind the Forest Wolf guardian). Inserting another rare also
after the guardian must not trip the new check only the LEAD slot of a
dungeon tier is constrained.
"""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
# Splice a second tier-3 rare in just before the boss (well after the
# tier-3 guardian), so the tier's first non-boss monster is unchanged.
extra = {
"tier": 3,
"name": "the Ashen Stalker",
"hp": 26,
"atk": 10,
"def": 3,
"xp": 55,
"gold": 75,
"weight": 1,
"rare": True,
}
data.insert(len(data) - 1, extra)
_rewrite(pack / "monsters.json", mutate)
world = load_world(pack) # no WorldLoadError: the rare is not the lead foe
tier3 = world.monsters_for_tier_band(3, 3)
assert tier3[0].name == "Forest Wolf" # the guardian is still the non-rare lead
assert any(m.name == "the Ashen Stalker" and m.rare for m in tier3)
def test_two_bosses_rejected(tmp_path: Path) -> None:
"""Two ``boss``-flagged monsters are rejected: a world has exactly one boss."""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
# Give the Field Rat the boss flag too; now two monsters claim the role.
rat = next(m for m in data if m["name"] == "Field Rat")
rat["boss"] = True
rat["id"] = "field_rat"
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"flags 2 monsters as .boss.* true"):
load_world(pack)
def test_single_boss_accepted() -> None:
"""The shipped pack carries exactly one boss and loads — the single-boss path.
The positive half of the invariant: the Wyrm Below is the only boss, so the
load succeeds and the boss count is exactly one.
"""
world = load_world(SHIPPED)
bosses = [m for m in world.monsters if m.boss]
assert len(bosses) == 1
assert bosses[0].name == "the Wyrm Below"
def test_overlapping_zones_rejected(tmp_path: Path) -> None:
"""Overlapping zone rectangles are a load error.
``zone_for`` returns the FIRST matching zone, so two zones sharing any cell
would silently shadow one tier band there exactly the bug a cold-authored
pack shipped (a 1-column caldera-edge strip dropped to the low band). Pull
the deep zone west so its rect overlaps the near zone and confirm the loader
refuses it rather than loading the ambiguity.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
for zone in data["zones"]:
if zone["key"] == "dungeon_deep":
zone["rect"][0] = 50 # now overlaps forest_near's x30..60 strip
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="overlap"):
load_world(pack)
+206
View File
@@ -0,0 +1,206 @@
"""Tests for bundled-world discovery and the ``worlds`` listing.
Covers the discovery helper (the Vale leads, alternate packs follow
alphabetically, non-pack directories are skipped) and the ``cli_worlds``
listing it backs: a sound fixture pack reports "sound", a deliberately-flawed
fixture pack reports "flawed", and the Vale is always listed first. The
``packs/`` directory is monkeypatched to a temp fixture tree so these tests
never depend on the real (separately-authored) second world.
"""
from __future__ import annotations
import json
import shutil
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING, Any
from understone import cli
from understone import world as world_pkg
from understone.world import VALE_SLUG, bundled_world_dirs
if TYPE_CHECKING:
import pytest
SHIPPED = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
def _make_packs(tmp_path: Path, *, sound: list[str], flawed: dict[str, Any]) -> Path:
"""Build a temp ``packs/`` tree: sound slugs plus flawed-world slugs.
Each sound slug is a verbatim copy of the shipped Vale; each flawed slug is
a copy whose ``world.json`` is patched with the given settings overrides so
it fails to load. Returns the packs root to monkeypatch ``PACKS_DIR`` onto.
"""
packs = tmp_path / "packs"
packs.mkdir()
for slug in sound:
shutil.copytree(SHIPPED, packs / slug)
for slug, overrides in flawed.items():
dest = packs / slug
shutil.copytree(SHIPPED, dest)
world_json = dest / "world.json"
data = json.loads(world_json.read_text(encoding="utf-8"))
data["settings"].update(overrides)
world_json.write_text(json.dumps(data), encoding="utf-8")
return packs
# ---------------------------------------------------------------------------
# bundled_world_dirs discovery
# ---------------------------------------------------------------------------
def test_bundled_world_dirs_vale_leads_then_alpha(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
packs = _make_packs(tmp_path, sound=["zephyr", "ashfall"], flawed={})
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
found = bundled_world_dirs()
slugs = [slug for slug, _ in found]
# The Vale is always first; alternates follow alphabetically.
assert slugs == [VALE_SLUG, "ashfall", "zephyr"]
# The Vale entry points at the packaged data dir, not a packs subdir.
assert found[0][1] == world_pkg.PACKAGED_WORLD_DIR
def test_bundled_world_dirs_skips_non_pack_entries(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
packs = _make_packs(tmp_path, sound=["real"], flawed={})
# A README placeholder and a directory with no world.json are NOT worlds.
(packs / "README.md").write_text("placeholder", encoding="utf-8")
(packs / "empty_dir").mkdir()
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
slugs = [slug for slug, _ in bundled_world_dirs()]
assert slugs == [VALE_SLUG, "real"]
def test_bundled_world_dirs_handles_absent_packs_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing packs/ directory yields just the Vale (never raises)."""
monkeypatch.setattr(world_pkg, "PACKS_DIR", tmp_path / "does_not_exist")
found = bundled_world_dirs()
assert [slug for slug, _ in found] == [VALE_SLUG]
# ---------------------------------------------------------------------------
# cli_worlds listing
# ---------------------------------------------------------------------------
def test_cli_worlds_lists_vale_sound_first(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(world_pkg, "PACKS_DIR", tmp_path / "empty")
out, err = StringIO(), StringIO()
rc = cli.cli_worlds(out=out, err=err)
assert rc == 0
text = out.getvalue()
lines = [ln for ln in text.splitlines() if ln.strip()]
# The very first listing line is the Vale, reported sound, with its size.
assert lines[0].split()[0] == VALE_SLUG
assert "The Vale of Understone" in lines[0]
assert "96x48" in lines[0]
assert "sound" in lines[0]
# The serve hint closes the listing.
assert "UNDERSTONE_WORLD=" in text
assert "the default Vale needs no setting" in text
def test_cli_worlds_reports_sound_alternate(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
packs = _make_packs(tmp_path, sound=["mirefen"], flawed={})
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
out = StringIO()
cli.cli_worlds(out=out)
text = out.getvalue()
line = next(ln for ln in text.splitlines() if ln.strip().startswith("mirefen"))
assert "sound" in line
assert "flawed" not in line
def test_cli_worlds_flags_flawed_alternate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# daily_turns 0 is out of its 1..100 band: the pack fails to load.
packs = _make_packs(tmp_path, sound=["sound_one"], flawed={"broken": {"daily_turns": 0}})
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
out = StringIO()
rc = cli.cli_worlds(out=out)
assert rc == 0 # a flawed pack is reported, never fatal
text = out.getvalue()
broken_line = next(ln for ln in text.splitlines() if ln.strip().startswith("broken"))
assert "flawed:" in broken_line
assert "daily_turns" in broken_line # the offending field surfaces
# The sound pack alongside it still reports sound — one bad pack doesn't
# poison the survey.
sound_line = next(ln for ln in text.splitlines() if ln.strip().startswith("sound_one"))
assert "sound" in sound_line
def test_cli_worlds_vale_sorts_before_flawed_alternate(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Even with an alphabetically-earlier flawed pack, the Vale leads."""
packs = _make_packs(tmp_path, sound=[], flawed={"aaa_broken": {"start_hp": 0}})
monkeypatch.setattr(world_pkg, "PACKS_DIR", packs)
out = StringIO()
cli.cli_worlds(out=out)
lines = [ln for ln in out.getvalue().splitlines() if ln.strip()]
assert lines[0].split()[0] == VALE_SLUG
assert lines[1].strip().startswith("aaa_broken")
assert "flawed:" in lines[1]
# ---------------------------------------------------------------------------
# the REAL bundled alternate world (no monkeypatch): The Cinder Wastes
#
# The tests above stub PACKS_DIR to a fixture tree so they never depend on the
# separately-authored pack. These two exercise the actual shipped packs/ — the
# bundled Cinder Wastes must discover, load, validate, and appear in the listing
# as sound, so a broken or unbundled alternate trips here.
# ---------------------------------------------------------------------------
CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes"
def test_bundled_cinder_wastes_loads_and_validates() -> None:
"""The bundled Cinder Wastes loads through the (strict v0.8) loader cleanly.
It is LLM-authored from AUTHORING.md alone, so this is the dogfood proof
that the manual + validator produce a pack the real loader accepts and,
after v0.8, one that passes the stricter rare-as-guardian and single-boss
checks (its rares sit after their guardians; it has exactly one boss).
"""
from understone.world.loader import load_world
world = load_world(CINDER)
assert world.name == "The Cinder Wastes"
assert world.settings.watch_theme == "ember" # the thematic ember CRT palette
bosses = [m for m in world.monsters if m.boss]
assert len(bosses) == 1 and bosses[0].name == "the Magma Wyrm"
# The boss id resolves and is the declared endgame foe.
assert world.settings.boss_monster == "magma_wyrm"
def test_cli_worlds_lists_bundled_cinder_wastes_sound() -> None:
"""`understone worlds` discovers the real bundled Cinder Wastes as sound.
No monkeypatch: this runs against the actual packs/ directory, so it asserts
the genuinely-shipped second world appears in the listing (alongside the
fixture-based listing tests above, which stay).
"""
out = StringIO()
rc = cli.cli_worlds(out=out)
assert rc == 0
line = next(ln for ln in out.getvalue().splitlines() if ln.strip().startswith("cinder-wastes"))
assert "The Cinder Wastes" in line
assert "sound" in line
assert "flawed" not in line
+600
View File
@@ -0,0 +1,600 @@
"""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
@@ -0,0 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.10.0"
@@ -0,0 +1,4 @@
from understone.server import main
if __name__ == "__main__":
main()
+724
View File
@@ -0,0 +1,724 @@
"""The pack-authoring command surface — validate a pack and scaffold a new one.
This module is deliberately pure: it imports only the loader and the standard
library, takes no part in argument parsing (``server.main`` owns the argparse
front end), and writes to the streams it is handed. That keeps the authoring
loop ``newpack`` then ``validate`` testable as plain function calls.
Three entry points back the three verbs:
* :func:`cli_validate` loads a pack and, on success, prints a human-readable
report; on failure it prints the loader's author-facing message and returns
a non-zero code. This is the feedback half of the loop.
* :func:`cli_newpack` scaffolds a new pack: it copies the bundled world as a
starting template and writes an ``AUTHORING.md`` manual whose bands table is
generated from the loader's own band data, so the documented limits can
never drift from the enforced ones.
* :func:`cli_worlds` lists the bundled worlds the default Vale plus every
alternate pack shipped under ``world/packs/`` loading each so it can report
whether it is sound or flawed, the discovery seam for "worlds without authors".
"""
from __future__ import annotations
import shutil
import sys
from typing import TYPE_CHECKING, TextIO
from understone.engine.textwidth import SAFE_PALETTE
from understone.errors import WorldLoadError
from understone.world import PACKAGED_WORLD_DIR, bundled_world_dirs, loader
if TYPE_CHECKING:
from pathlib import Path
from understone.engine.world import World
# The six packaged content files copied verbatim as a new pack's template.
_PACK_FILES = (
"terrain.json",
"monsters.json",
"items.json",
"locations.json",
"events.json",
"world.json",
)
def cli_validate(pack_dir: Path, out: TextIO | None = None, err: TextIO | None = None) -> int:
"""Load *pack_dir* and report; return 0 if sound, 2 if it fails to load.
On success a pack report is written to *out* and the function returns 0.
On any :class:`WorldLoadError` the loader's message — which names the
file, index, and field at fault is written to *err* and the function
returns 2. The author iterates against that message until the pack loads.
*out*/*err* default to the live ``sys.stdout``/``sys.stderr`` resolved at
call time, so a caller (or pytest's capture) may redirect them.
"""
out = out if out is not None else sys.stdout
err = err if err is not None else sys.stderr
try:
world = loader.load_world(pack_dir)
except WorldLoadError as exc:
print(f"The pack is flawed: {exc}", file=err)
return 2
print(_pack_report(world), file=out)
return 0
def cli_newpack(dest: Path, out: TextIO | None = None, err: TextIO | None = None) -> int:
"""Scaffold a new content pack at *dest*; return 0, or 2 if *dest* is taken.
Refuses to write into an existing non-empty directory (so an author never
clobbers work in progress). Otherwise it creates *dest*, copies the six
packaged content files as a starting template, and writes an
``AUTHORING.md`` manual generated from the live loader bands. The author
then edits or regenerates the JSON and runs ``validate``.
*out*/*err* default to the live ``sys.stdout``/``sys.stderr`` resolved at
call time, so a caller (or pytest's capture) may redirect them.
"""
out = out if out is not None else sys.stdout
err = err if err is not None else sys.stderr
if dest.exists() and dest.is_dir() and any(dest.iterdir()):
print(f"refusing to scaffold into non-empty directory: {dest}", file=err)
return 2
if dest.exists() and not dest.is_dir():
print(f"refusing to scaffold over a file: {dest}", file=err)
return 2
dest.mkdir(parents=True, exist_ok=True)
for name in _PACK_FILES:
shutil.copyfile(PACKAGED_WORLD_DIR / name, dest / name)
(dest / "AUTHORING.md").write_text(build_authoring_md(), encoding="utf-8")
print(f"Scaffolded a new pack at {dest}.", file=out)
print("Six content files plus AUTHORING.md are in place; the template is the", file=out)
print("shipped Vale of Understone, ready to edit or regenerate.", file=out)
print(f"Next: edit or regenerate the JSON, then: understone validate {dest}", file=out)
return 0
def cli_worlds(out: TextIO | None = None, err: TextIO | None = None) -> int:
"""List every bundled world, reporting each as sound or flawed; return 0.
Discovers the worlds through :func:`~understone.world.bundled_world_dirs`
(the default Vale first, then the alternate packs alphabetically) and loads
each one. Each world is one line its slug, name, ``WxH``, and either
``sound`` or ``flawed: <short reason>`` so a shipped pack that has gone
out of band is visible at a glance rather than only failing at serve time.
A flawed world is reported, not fatal: the listing always returns 0 and
always ends with the hint for serving an alternate. *err* is accepted for a
uniform signature with the other verbs; the listing writes only to *out*.
"""
out = out if out is not None else sys.stdout
for slug, world_dir in bundled_world_dirs():
print(_world_line(slug, world_dir), file=out)
print("", file=out)
print(
"Serve one with UNDERSTONE_WORLD=<path> (or the default Vale needs no setting).",
file=out,
)
return 0
def _world_line(slug: str, world_dir: Path) -> str:
"""Render one ``worlds`` listing line for the world at *world_dir*.
Loads the world to report its real name, dimensions, and soundness. A pack
that fails to load is summarised as ``flawed: <reason>`` using the loader's
own author-facing message (truncated to keep the listing to one line per
world), never raised the listing surveys every bundled world even when one
is broken.
"""
try:
world = loader.load_world(world_dir)
except WorldLoadError as exc:
return f" {slug:<10} flawed: {_short_reason(str(exc))}"
return f" {slug:<10} {world.name}{world.width}x{world.height} — sound"
# How much of a loader error message the one-line ``worlds`` summary keeps.
_FLAW_REASON_MAX = 70
def _short_reason(message: str) -> str:
"""Trim a loader error to a single readable clause for the worlds listing."""
flattened = " ".join(message.split())
if len(flattened) <= _FLAW_REASON_MAX:
return flattened
return flattened[: _FLAW_REASON_MAX - 1].rstrip() + ""
def _pack_report(world: World) -> str:
"""Render the success report for a loaded *world*.
Counts and shares are computed from the runtime world so the figures match
what the engine will actually run, not what the JSON nominally declares.
"""
settings = world.settings
boss_count = sum(1 for m in world.monsters if m.boss)
fight_share = _fight_share_pct(world)
lines = [
f"{world.name}{world.width}x{world.height}",
f" monsters : {len(world.monsters)} ({boss_count} boss)",
f" items : {len(world.items)}",
f" zones : {len(world.zones)}",
f" events : {len(world.events)} ({fight_share}% fight by weight)",
(
" settings : "
f"{settings.daily_turns} turns/day, "
f"bestow budget {settings.bestow_daily_budget}, "
f"Wyrm gate level {settings.wyrm_min_level}"
),
"",
"This pack is sound. The door stands open.",
]
return "\n".join(lines)
def _fight_share_pct(world: World) -> int:
"""Return the share of overworld encounter weight that is a ``fight``.
Reported by weight, not row count, because weight is the draw probability
the engine actually rolls against it is the number an author tunes to hit
the ~55% fight feel.
"""
total = sum(e.weight for e in world.events)
if total == 0:
return 0
fight = sum(e.weight for e in world.events if e.kind == "fight")
return round(100 * fight / total)
def build_authoring_md() -> str:
"""Build the AUTHORING.md manual, bands table and glyph palette included.
Both the bands section and the safe-glyph palette are generated from live
source the loader's own band tables and ``textwidth.SAFE_PALETTE`` — so
the documented limits and the suggested glyphs are exactly what the loader
enforces and admits, and cannot silently drift from it.
"""
md = _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands())
md = md.replace("{{PALETTE}}", _render_palette())
md = md.replace("{{COLOR_ROLES}}", _render_color_roles())
return md.replace("{{VALIDATE_COVERAGE}}", _render_validate_coverage())
def _render_bands() -> str:
"""Render the bands reference straight from the loader's band data."""
parts: list[str] = []
parts.append("### Map and counts\n")
parts.append(
f"* Map width and height: each `{loader.MAP_DIM_MIN}`..`{loader.MAP_DIM_MAX}` cells."
)
# monsters/items/events are their own files; locations and zones are lists
# inside world.json, so name each cap's real source.
count_source = {
"monsters": "`monsters.json`",
"items": "`items.json`",
"events": "`events.json`",
"locations": "`world.json` → `locations`",
"zones": "`world.json` → `zones`",
}
for name, cap in loader.MAX_COUNTS.items():
parts.append(f"* {count_source[name]}: at most `{cap}` entries.")
parts.append(
f"* Display names (monster, item, location): at most "
f"`{loader.MAX_NAME_LEN}` printable characters."
)
parts.append(
"* Map glyphs (terrain, location, legend keys): exactly one terminal "
"column (one printable code point, no fullwidth runes, no combining "
"marks — see the width rule above), and never one of "
+ ", ".join(f"`{g}`" for g in _reserved_glyph_list())
+ " (the frame box-drawing lines and the `@`/`☻` player markers)."
)
parts.append("")
parts.append("### Economy and progression settings (`world.json` → `settings`)\n")
parts.append("| field | allowed range |")
parts.append("| --- | --- |")
for field_name, (lo, hi) in loader.SETTINGS_BANDS.items():
rng = f"{lo}..{hi}" if hi is not None else f"{lo} or more"
parts.append(f"| `{field_name}` | `{rng}` |")
parts.append("")
parts.append("### Overworld event amounts (`events.json`, per kind)\n")
parts.append("| kind | min..max amount |")
parts.append("| --- | --- |")
for kind, (lo, hi) in loader.EVENT_AMOUNT_BANDS.items():
parts.append(f"| `{kind}` | `{lo}..{hi}` |")
parts.append(
"\n(`fight` and `lore` carry no amount; `fight` draws its foe from the "
"zone tier band, `lore` is pure flavour text.)\n"
)
parts.append("### Watch theme (`world.json` → `settings.watch_theme`)\n")
legal = ", ".join(f"`{name}`" for name in sorted(loader.WATCH_THEMES))
parts.append(
f"OPTIONAL. The CRT palette the live Watch page paints your world in, "
f"one of: {legal}. It defaults to `{loader.DEFAULT_WATCH_THEME}` (the "
f"original green phosphor), so you may leave it out entirely — a pack "
f"that omits it looks exactly as the bundled Vale always has. Set it to "
f"give your world its own colour: `amber` is a warm gold monitor, `ice` "
f"a cold pale blue, `ember` a hot red/orange. An unknown name is a load "
f"error naming the legal set."
)
parts.append("\n### The ore-gated forge (`world.json` → `settings`)\n")
ore_per = loader.SETTINGS_BANDS["forge_ore_per_plus"]
dungeon = loader.SETTINGS_BANDS["ore_dungeon_drop"]
parts.append(
"Forging a +1 edge now costs both GOLD and ORE — a `material` item the "
"hero earns in combat, never buys. Four settings bind it:"
)
parts.append(
"* `forge_ore_item` — REQUIRED. The item id of your world's forge ore; "
"it must name an `items.json` entry whose `slot` is `material` (an "
"unknown id or a non-material slot is a load error). The Vale uses "
"`iron_ore`."
)
parts.append(
f"* `forge_ore_per_plus` — band `{ore_per[0]}..{ore_per[1]}`. Ore per +1 "
f"step: a +N forge costs `(current_plus + 1) * forge_ore_per_plus` ore. "
f"{_forge_ore_worked_example()}"
)
parts.append(
f"* `ore_dungeon_drop` — band `{dungeon[0]}..{dungeon[1]}`. Ore granted "
f"on every WON dungeon rung — the reliable source. The Vale drops 2."
)
parts.append(
"* `ore_forest_chance` — a `0.0`..`1.0` probability (a float, validated "
"outside the integer band table). The chance a WON forest fight yields "
"one ore — the occasional bonus source. The Vale uses `0.2`."
)
parts.append(
"\nOre rides the satchel as a stack, so it shares the `satchel_max` "
"DISTINCT-stack budget with potions (per-stack quantity is unbounded). "
"Tune the two sources so a hero who descends steadily earns enough ore "
"to forge without grinding — the `simulate` bot will tell you if the "
"gate stalls a winnable run."
)
return "\n".join(parts)
def _forge_ore_worked_example() -> str:
"""Render the per-step ore costs from the bundled Vale's live forge settings.
The starter template :func:`cli_newpack` copies IS the bundled Vale, so the
worked figures are computed from its actual ``forge_ore_per_plus`` and
``forge_max_plus`` rather than hardcoded a retune of the template moves
the manual with it. The steps are ``per_plus * (i + 1)`` for each ``i`` in
``range(forge_max_plus)``; the total is what it costs to max one slot.
"""
settings = loader.load_world(PACKAGED_WORLD_DIR).settings
per_plus = settings.forge_ore_per_plus
max_plus = settings.forge_max_plus
steps = [per_plus * (i + 1) for i in range(max_plus)]
if not steps:
return (
f"At the template's value of {per_plus}, slots cannot be forged (`forge_max_plus` 0)."
)
ladder = ", ".join(str(cost) for cost in steps)
total = sum(steps)
return (
f"At the template's value of {per_plus}, the steps cost {ladder} ore "
f"({total} ore to max a slot at `forge_max_plus` {max_plus})."
)
def _reserved_glyph_list() -> list[str]:
"""Return the reserved glyphs in a stable, readable order for the manual."""
box = [g for g in "┌┐└┘─│═" if g in loader.RESERVED_GLYPHS]
actors = [g for g in "@☻" if g in loader.RESERVED_GLYPHS]
return box + actors
def _render_palette() -> str:
"""Render the safe-glyph appendix straight from ``textwidth.SAFE_PALETTE``.
The glyphs are emitted in their declared order, wrapped in backticks so the
monospace renders them as discrete cells. Generated from the live constant,
so the suggested palette is exactly the set the loader's width gate admits.
"""
glyphs = " ".join(f"`{g}`" for g in SAFE_PALETTE)
return (
"Any single-column glyph the loader accepts is fair game, but these "
"carry the period BBS / CP437 flavour and are all guaranteed safe:\n\n"
f"{glyphs}"
)
def _render_color_roles() -> str:
"""Render the author-assignable colour roles, generated from the Color enum.
The Watch knows how to paint exactly the roles in ``screen.palette.Color``;
``Color.assignable()`` is the single source for which of those an author may
put on terrain or a location (the runtime overlay roles an actor/item wears,
and the DEFAULT fallback, are filtered out there). Generated from the enum,
so the documented vocabulary can never drift from what the Watch can
actually colour the same can't-drift discipline as the bands and the
safe-glyph palette. ``color`` itself stays advisory: the loader does not
validate it, so a typo is harmless and an unknown role just paints as the
default; these are simply the roles the Watch recognises.
"""
from understone.screen.palette import Color
return ", ".join(f"`{role.value}`" for role in Color.assignable())
def _render_validate_coverage() -> str:
"""Render the list of rules the loader actually enforces, generated from it.
The figures that can drift (the number of banded settings, the name-length
cap, the reserved glyphs) are read from the live loader so the list cannot
fall out of step with what `validate` does; the prose names each family of
check. This is the machine-enforced half of the honesty split in the manual
the eyeball-only half is hand-written below it, because "is the fiction
any good" is exactly what the loader can never see.
"""
settings_count = len(loader.SETTINGS_BANDS)
reserved = ", ".join(f"`{g}`" for g in _reserved_glyph_list())
bullets = [
f"* **Economy and progression bands** — every one of the {settings_count} `settings` fields must sit in its allowed range (the table above), and `growth` must be present and non-negative.",
f"* **Glyph safety** — every terrain, location, and legend glyph must render exactly one column and must not be a reserved marker ({reserved}).",
"* **Map integrity** — `width`/`height` in band, every `terrain_rows` row exactly `width` long with `height` rows, and every row character in the `legend`.",
"* **Walkability** — `spawn` and every placed location must sit on walkable terrain (and no two locations share a cell).",
f"* **Display-name length** — every monster, item, and location name within `{loader.MAX_NAME_LEN}` printable characters; content lists within their caps.",
"* **The fight row** — `events.json` must hold at least one `fight` entry, with weights `> 0`, `min <= max`, and amounts in their per-kind band.",
'* **Cross-references** — `legend` → terrain key, location placements → `locations.json` keys, `starting_weapon`/`starting_armor` → item ids, `boss_monster` → a monster flagged `"boss": true`, `rare_drop_item` → a consumable item id, and `forge_ore_item` → a `material` item id.',
"* **Zone tiers** — every zone's tier band must overlap at least one monster tier.",
"* **Dungeon ladder** — every `dungeon_tiers` tier must have a non-boss monster, and that tier's FIRST monster (its fixed rung guardian) must not be `rare`.",
'* **Exactly one boss** — at most one monster may carry `"boss": true`.',
]
return "\n".join(bullets)
_AUTHORING_TEMPLATE = """\
# Authoring a world pack for Understone
A *world pack* is a directory of six JSON files that the server loads at start
to become the entire game world its map, its monsters, its economy, its
endgame. There is no code to write: you describe a world as data, the loader
validates it hard, and the server runs it. This file is the manual; you can
follow it cold, by hand or with an LLM.
The loop is short:
1. `understone newpack mypack` scaffold this template (you are reading the
copy it wrote into `mypack/AUTHORING.md`).
2. Edit or regenerate the JSON files to describe your world.
3. `understone validate mypack` the loader checks the pack and either prints
a report ending **"This pack is sound. The door stands open."** or tells you
exactly which file, row, and field is wrong.
4. Repeat step 2 until it is sound, then serve it:
`UNDERSTONE_WORLD=mypack understone`.
The loader's error messages are written FOR you: every failure names the file,
the index, and the field, and says what was expected. Treat them as the
feedback loop iterate until the report says the door stands open.
---
## The six files and how they fit together
| file | shape | holds |
| --- | --- | --- |
| `terrain.json` | object keyed by legend char | terrain kinds: glyph, walkability, encounter rate |
| `monsters.json` | list | monster stat blocks, tiered; one flagged the boss |
| `items.json` | list | weapons, armour, consumables for the shop |
| `locations.json` | object keyed by location key | building kinds: name, glyph, menu actions, flavour |
| `events.json` | object with an `events` list | the weighted overworld encounter table |
| `world.json` | object | the map, placements, zones, and `settings` that bind it all |
The cross-references the loader enforces:
* every character in `world.json` `legend` must name a terrain `key` from
`terrain.json`; every character in `terrain_rows` must be in that legend;
* every placement in `world.json` `locations` must name a key defined in
`locations.json`, and must sit on walkable terrain;
* `settings.starting_weapon` / `starting_armor` must be ids from
`items.json`; `settings.boss_monster` must be an id from `monsters.json`
that is flagged `"boss": true`; `settings.rare_drop_item` must be an id from
`items.json` whose `slot` is `consumable`; `settings.forge_ore_item` must be
an id from `items.json` whose `slot` is `material`;
* every tier in `settings.dungeon_tiers` must be backed by a NON-boss monster;
* every zone's tier band must overlap at least one monster tier.
---
## File-by-file schema
### `terrain.json`
An object whose keys are the single-character legend symbols used in the map.
```json
{
".": {"key": "grass", "glyph": ".", "walkable": true, "encounter_rate": 0.1, "color": "floor"}
}
```
* `key` internal name the map legend resolves to.
* `glyph` the single character drawn on the map (see glyph rules below).
* `walkable` may a player stand here.
* `encounter_rate` `0.0`..`1.0`, the per-step chance a walk rolls the event
table on this terrain.
* `color` a palette role string. It is **advisory and not validated**: the
loader stores it but the text frame draws glyphs only (it is monochrome), so
any string loads and an unrecognised role simply maps to the default at render
time. Where colour DOES show is the live Watch page, which paints each role a
distinct hue. The roles the Watch knows how to paint pick the closest fit
are: {{COLOR_ROLES}}. A typo here is harmless, not a load error; it just
paints as the default. The four runtime overlay colours (the hero, rival
players, monsters, dropped items) are set by the engine, not assignable here.
### `monsters.json`
A list of stat blocks. `tier` groups foes by difficulty; zones and the dungeon
gauntlet draw from tiers. Exactly one monster should be the boss.
```json
{"tier": 2, "name": "Goblin", "hp": 12, "atk": 5, "def": 1, "xp": 18, "gold": 7}
```
The boss adds an `id` and `"boss": true`, and is referenced by
`settings.boss_monster`:
```json
{"tier": 6, "name": "the Wyrm Below", "hp": 120, "atk": 24, "def": 8,
"xp": 400, "gold": 250, "boss": true, "id": "wyrm_below"}
```
Two optional fields tune random forest encounters. `weight` (default `10`,
must be `> 0`) biases the weighted draw within a zone band a low weight
surfaces seldom and `rare` (default `false`) marks a named beast that, on
its kill, fires a public Herald flash and drops the pack's `rare_drop_item`
into the slayer's satchel. Rung guardians ignore both (a rung always takes the
FIRST monster of its tier, never a weighted roll), so a rare should not be the
first entry of a tier that backs a `dungeon_tiers` rung.
```json
{"tier": 2, "name": "the Gilded Stag", "hp": 16, "atk": 6, "def": 2,
"xp": 40, "gold": 60, "weight": 1, "rare": true}
```
### `items.json`
A list of equipment, consumables, and crafting materials. `slot` is `weapon`,
`armor`, `consumable`, or `material`. Weapons add `atk`, armour adds `def`,
consumables `heal`; a `material` carries none of these it is the forge ORE,
carried in the satchel and spent at the forge.
```json
{"id": "short_sword", "name": "Short Sword", "slot": "weapon", "atk": 5, "price": 40}
```
The forge ore is a `material` item the player EARNS in combat (not the shop):
price it `0` ore is never bought or sold and point `settings.forge_ore_item`
at its id. A won dungeon rung always drops `settings.ore_dungeon_drop` of it, and
a won forest fight has a `settings.ore_forest_chance` chance of one.
```json
{"id": "iron_ore", "name": "Iron Ore", "slot": "material", "price": 0}
```
### `locations.json`
An object keyed by location key. Each entry is a building kind with a menu of
`actions` the player may take inside it.
```json
{
"inn": {"kind": "inn", "name": "The Sleeping Drake", "glyph": "I",
"color": "town", "actions": ["rest", "gamble", "leave"],
"flavor": ["Lamplight pools on worn oak tables."]}
}
```
Give each building the menu that matches its role. The four building kinds and
the verbs the engine honours inside each are:
| `kind` | actions the engine understands |
| --- | --- |
| `inn` | `rest`, `deposit`, `withdraw`, `gamble`, `leave` |
| `shop` | `buy`, `sell`, `forge`, `leave` |
| `healer` | `heal`, `leave` |
| `dungeon` | `descend`, `challenge`, `leave` |
The inn's `deposit`/`withdraw` are the VAULT: a player banks gold into the inn
strongbox (`deposit amount=<gold>`) and draws it back (`withdraw amount=<gold>`).
Banked gold is SAFE from ambush a sleeping-robber only ever lifts gold in hand
and it SURVIVES the Wyrm-win legacy reset, so it is the one store of wealth
that carries across runs. Both cost no turn.
`quaff` (drink a satchel tonic) is legal **anywhere** and needs no menu entry.
The `actions` list is advisory it is the menu the narrator offers, NOT a
validated whitelist (see "What `validate` checks" below): a verb the engine does
not back simply confuses the narrator, so give each building only the verbs from
its row above.
### `events.json`
An object with an `events` list the weighted overworld encounter table the
server rolls as a player walks.
```json
{"events": [
{"kind": "fight", "weight": 82, "text": "Something snarls out of the brush."},
{"kind": "gold", "weight": 8, "text": "a rotted coin-purse", "min": 4, "max": 12}
]}
```
* `kind` `fight`, `gold`, `heal`, `trap`, or `lore`.
* `weight` relative draw weight (`> 0`).
* `text` required (non-empty) for every kind except `fight`.
* `min`/`max` required for the value-bearing kinds (`gold`, `heal`, `trap`).
There MUST be at least one `fight` row, or a walk could never find a monster.
### `world.json`
The binding file: `name`, `width`, `height`, `spawn` `[x, y]` (the hero's start
cell, which must be on walkable terrain), a `legend` mapping characters to
terrain keys, `terrain_rows` (one string per row, each exactly `width` long), a
`locations` list of `{"key", "x", "y"}` placements (each also on walkable
terrain), a `zones` list (rectangles that bias monster tiers), and a `settings`
object.
```json
{"key": "forest_near", "rect": [30, 18, 60, 36], "tier_lo": 1, "tier_hi": 2}
```
---
## The bands — the limits the loader enforces
These are generated from the loader's own tables, so they are exactly what
`validate` checks. A value outside its band is a load error.
{{BANDS}}
---
## Glyph width — the one-column rule
Every glyph drawn on the map must occupy **exactly one terminal column**. The
frames are box-drawing rectangles; a glyph that renders two columns (a CJK
ideograph like ``, an emoji like `🌲`, a fullwidth ``) shoves its row right
and tears the border, and a combining mark (a decomposed `é`, a lone accent)
stacks onto its neighbour and breaks the count the other way. The loader
rejects all of these at load.
What is admitted is judged for the **Western monospace** metrics every
Understone surface actually uses (the Watch's pinned font stack, a chat
client's code block): under those metrics the East-Asian "Ambiguous" width
class renders single-column, and that class is the CP437 heartland ``, ``,
``, ``, ``, `` all live there so the rule admits it and bars only the
genuinely double-width Wide and Fullwidth classes.
### Safe glyph palette
{{PALETTE}}
---
## Design guidance
**Turn economy.** `daily_turns` is the whole pacing lever: only fighting,
descending, and challenging the Wyrm spend a turn (moving, resting, shopping
are free). A small budget (the Vale uses 10) makes this a correspondence game
played a little each day. Set `rest_cost`, `heal_cost_per_hp`, and shop prices
so a day's gold roughly covers a day's recovery too cheap and there is no
tension, too dear and a hero stalls.
**Tier curve.** Lay monster tiers as a rising staircase: each tier should be a
real step up in `hp`/`atk` and a real step up in `xp`/`gold`, so the reward of
pushing into a harder zone pays for the risk. Keep two or three foes per tier
for variety. The boss should tower over the top random tier it is the climax.
**Encounter feel.** Aim for roughly 55% of overworld encounter WEIGHT on
`fight` rows; the rest is the texture of travel small gold finds, healing
springs, harmless traps, and lore that hints at the endgame. (The validate
report prints your actual fight share so you can tune it.)
**Glyphs.** Map glyphs must render as exactly one terminal column (see the
one-column rule above) and must never collide with the frame's box-drawing
lines or the `@`/`` player markers. Pick glyphs that read at a glance the
bundled Vale uses `.` open ground, `` water, `` tree, `` inn, `$` shop, ``
healer, `` dungeon and lean on the safe palette for period flavour.
**Boss rules.** Exactly one monster carries `"boss": true` and an `id`, and
`settings.boss_monster` points at it. The boss is the only win condition and is
faced only through the `challenge` verb, gated by `settings.wyrm_min_level`. A
boss tier must NOT appear in `settings.dungeon_tiers`: the gauntlet excludes
boss monsters, so a boss-only rung would be unfillable back every dungeon
tier with at least one ordinary monster.
**The deep, the satchel, and the forge.** `dungeon_tiers` is now a RUNG LADDER
fought one rung per `descend` list the tiers shallow-to-deep, and make it long
enough to feel like a journey (the Vale uses three). The Wyrm gates on reaching
the floor as well as on level. Size the satchel with `satchel_max` it caps the
DISTINCT stacks the bag holds (potions and ore each take a slot; per-stack
quantity is unbounded), and it is the death-save reserve, so keep it small (the
Vale carries 3). The forge is the late-game GOLD-AND-ORE sink: `forge_base_cost`
is the gold price of a +1 edge and scales up each tier (`base * (current_plus +
1)`), capped at `forge_max_plus`, and each step ALSO costs ore (see the ore-gated
forge above). Ore is won in the deep (and seldom in the forest), so the forge is
fed by descending price the gold so a fully-forged piece is a multi-day saving,
and set the ore sources so a steady delver can afford it without a grind.
**Rare beasts.** A rare monster is a small legend: give it a low `weight` so it
surfaces seldom, stats and rewards a clear notch above its tier, and remember it
always drops `rare_drop_item` (a consumable) into the satchel. Keep rares OFF
the first slot of any `dungeon_tiers` tier, or they would become a fixed rung
guardian instead of a rare roll `validate` now ENFORCES this, so a rare in a
dungeon tier's lead slot is a load error, not just bad form. Place the rare
anywhere after that tier's first ordinary monster.
**Location menus.** Give each building only the actions it can honour, drawn
from the per-kind table under `locations.json` above. An inn that offers `buy`
but no shop logic will confuse the narrator. This is the one major thing
`validate` does NOT check (see below): a wrong or invented verb loads fine and
only muddles the narration, so it is on you to match each menu to its building.
---
## The validate loop
Run `understone validate mypack` after every change. On success you get a
report name, size, monster/item/zone/event counts, fight share, and the key
settings ending in **"This pack is sound. The door stands open."** On
failure you get one precise line naming the file, the row, and the field.
The error messages are deliberately instructive: they are the authoring API.
Keep editing and re-validating until the door stands open, then point the
server at your pack with `UNDERSTONE_WORLD=mypack`.
### What `validate` checks, and what it cannot
`validate` runs your pack through the very loader the server uses, so a pack
that validates will load and serve. But the loader checks *structure and
references*, not *meaning* it cannot read your fiction. Keep the split honest:
**`validate` DOES catch (a load error if wrong):**
{{VALIDATE_COVERAGE}}
**`validate` does NOT catch (the eyeball-only short list):**
* **Location menu `actions` contents.** The list is the narrator's menu, not a
validated whitelist: a verb the engine does not back (a typo, or a fictional
`pray`) loads fine and only confuses the narration. Match each building's menu
to the per-kind table under `locations.json`.
* **Flavour and narration quality.** Names, `flavor` lines, event `text`, the
feel of the tier curve and the economy the loader checks they are present
and in band, never whether they are *good*. That judgement is yours; the
`simulate` bot can tell you a world is winnable and sanely paced, but only you
can tell whether it is worth playing.
"""
@@ -0,0 +1,6 @@
"""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.
"""
@@ -0,0 +1,117 @@
"""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
@@ -0,0 +1,108 @@
"""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)
@@ -0,0 +1,63 @@
"""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
@@ -0,0 +1,232 @@
"""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"
@@ -0,0 +1,223 @@
"""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")
@@ -0,0 +1,42 @@
"""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]
@@ -0,0 +1,58 @@
"""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))
@@ -0,0 +1,62 @@
"""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)
@@ -0,0 +1,99 @@
"""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
"",
"",
"",
)
@@ -0,0 +1,65 @@
"""Daily action budget and the UTC-day rollover.
Turns refresh lazily: the first action on a new UTC day resets the
budget rather than relying on a scheduled job. The same rollover resets
the per-player bestow pool and the social daily caps (posts left, dice
played), so every daily allowance shares one boundary. The clock is
injected so tests can cross midnight deterministically.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
from understone.engine.models import Player
def _utc_ordinal(clock: Callable[[], datetime]) -> int:
"""Return today's proleptic-Gregorian ordinal in UTC."""
return clock().toordinal()
def ensure_day(player: Player, clock: Callable[[], datetime], daily_turns: int) -> bool:
"""Refresh daily allowances if the UTC day has advanced.
Returns ``True`` when a reset occurred. On a new UTC day this resets the
turn budget (to *daily_turns*), the bestow pool, the daily post count, and
the daily dice count each back to its baseline stamping the current UTC
ordinal onto every day marker. Each counter is reset independently so a
stale stamp on one never suppresses the refresh of another.
"""
today = _utc_ordinal(clock)
reset = False
if player.turn_day != today:
player.turns_left = daily_turns
player.turn_day = today
reset = True
if player.bestow_day != today:
player.bestow_spent = 0
player.bestow_day = today
reset = True
if player.post_day != today:
player.posts_sent = 0
player.post_day = today
reset = True
if player.gamble_day != today:
player.gambles = 0
player.gamble_day = today
reset = True
return reset
def spend_turn(player: Player) -> bool:
"""Consume one daily turn.
Returns ``True`` and decrements when a turn is available; returns
``False`` and leaves state untouched when the budget is exhausted.
"""
if player.turns_left <= 0:
return False
player.turns_left -= 1
return True
@@ -0,0 +1,117 @@
"""Runtime world model — terrain, locations, zones, content tables, settings.
Built by ``world.loader`` from JSON. The engine queries this for
walkability, encounter rates, location lookups, and tier-banded monster
selection. It holds no mutable game state.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from understone.engine.models import (
Item,
LocationDef,
Monster,
Settings,
TerrainDef,
WorldEvent,
Zone,
)
class World:
"""An immutable-after-construction view of the game map and content."""
def __init__(
self,
*,
name: str,
width: int,
height: int,
spawn: tuple[int, int],
terrain: list[list[TerrainDef]],
locations: list[LocationDef],
zones: list[Zone],
monsters: list[Monster],
items: list[Item],
settings: Settings,
events: list[WorldEvent] | None = None,
) -> None:
self.name = name
self.width = width
self.height = height
self.spawn = spawn
self.terrain = terrain
self.locations = locations
self.zones = zones
self.monsters = monsters
self.items = items
self.settings = settings
self.events: list[WorldEvent] = events or []
self._event_weights: list[int] = [e.weight for e in self.events]
self._loc_by_xy: dict[tuple[int, int], LocationDef] = {
(loc.x, loc.y): loc for loc in locations
}
self._loc_by_key: dict[str, LocationDef] = {loc.key: loc for loc in locations}
self._item_by_id: dict[str, Item] = {it.item_id: it for it in items}
self._monster_by_id: dict[str, Monster] = {
m.monster_id: m for m in monsters if m.monster_id
}
def in_bounds(self, x: int, y: int) -> bool:
"""Return whether ``(x, y)`` is inside the map rectangle."""
return 0 <= x < self.width and 0 <= y < self.height
def terrain_at(self, x: int, y: int) -> TerrainDef:
"""Return the terrain definition at ``(x, y)`` (caller bounds-checks)."""
return self.terrain[y][x]
def location_at(self, x: int, y: int) -> LocationDef | None:
"""Return the location placed at ``(x, y)``, if any."""
return self._loc_by_xy.get((x, y))
def location_by_key(self, key: str) -> LocationDef | None:
"""Return the location with the given key, if any."""
return self._loc_by_key.get(key)
def item_by_id(self, item_id: str) -> Item | None:
"""Return the item with the given id, if any."""
return self._item_by_id.get(item_id)
def monster_by_id(self, monster_id: str) -> Monster | None:
"""Return the monster with the given id, if any (boss lookup)."""
return self._monster_by_id.get(monster_id)
def event_weights(self) -> list[int]:
"""Return the parallel weight list for the overworld event table."""
return self._event_weights
def is_walkable(self, x: int, y: int) -> bool:
"""Return whether a player may stand on ``(x, y)``.
Out-of-bounds is never walkable. A location tile is always walkable
regardless of its underlying terrain (you can step onto the door).
"""
if not self.in_bounds(x, y):
return False
if (x, y) in self._loc_by_xy:
return True
return self.terrain[y][x].walkable
def zone_for(self, x: int, y: int) -> Zone | None:
"""Return the first zone whose rectangle contains ``(x, y)``."""
for zone in self.zones:
if zone.contains(x, y):
return zone
return None
def monsters_for_tier_band(self, lo: int, hi: int) -> list[Monster]:
"""Return non-boss monsters whose tier falls within ``[lo, hi]``.
Boss monsters (the Wyrm Below) are never returned: they are the fixed
endgame foe, faced only through the deliberate ``challenge`` verb, and
must never surface as a random encounter or a dungeon-gauntlet rung.
"""
return [m for m in self.monsters if lo <= m.tier <= hi and not m.boss]
+24
View File
@@ -0,0 +1,24 @@
"""Shared exception types for Understone.
These never cross the MCP boundary the server layer catches everything
and renders an in-fiction line but they let internal layers fail with a
readable, specific message.
"""
from __future__ import annotations
class UnderstoneError(Exception):
"""Base class for all Understone errors."""
class WorldLoadError(UnderstoneError):
"""Raised when a content pack fails to parse or validate.
The message is written to be readable by a pack author: it names the
file, the offending field, and what was expected.
"""
class PersistenceError(UnderstoneError):
"""Raised when the save store cannot be opened or migrated."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
"""SQLite persistence — the only storage layer, ``sqlite3`` only.
A single connection is held for the process lifetime. MCP tool handlers are
synchronous and run on one event-loop thread, so writes serialise naturally
and no connection pool or lock is needed [VERIFIED: handlers are sync def].
WAL journaling is enabled so reads never block the single writer.
The store loads all players and recent events into memory at construction
(a write-through cache). State-changing tools update the cache and the DB in
one transaction; the game façade owns the per-action commit policy.
The connection is opened with ``check_same_thread=False`` because the Store
may be CONSTRUCTED on a different thread than the event-loop thread that later
serves tools (both the test fixture and ``main`` do this). Post-construction
access is single-threaded: sync tools run inline on the loop [verified against
mcp 1.27 func_metadata], so writes still serialise without a lock.
"""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
from understone.engine.log import Event
from understone.engine.models import Mode, Player
from understone.engine.rank import HallEntry, RankEntry
if TYPE_CHECKING:
from pathlib import Path
# Pre-1.0 the schema mutates in place and the stamp is not yet meaningful;
# version discipline (and migrations) begins at 1.0.
_SCHEMA_VERSION = 1
# How many of the newest events to hydrate at construction. Full history stays
# in SQLite; this bounds the in-memory tail. Single source of truth — game.py
# imports it for the runtime trim, so the load size and the trim size cannot
# diverge. An ops knob (memory ceiling), never an economy value.
EVENT_TAIL_KEEP = 500
_PLAYER_COLUMNS = (
"name",
"x",
"y",
"hp",
"max_hp",
"level",
"xp",
"gold",
"atk",
"def_",
"weapon_id",
"armor_id",
"turns_left",
"turn_day",
"mode",
"at_location",
"created_at",
"last_seen",
"log_cursor",
"bestow_spent",
"bestow_day",
"wins",
"posts_sent",
"post_day",
"gambles",
"gamble_day",
"deepest_rung",
"satchel",
"weapon_plus",
"armor_plus",
"banked",
)
class Store:
"""A write-through SQLite store for players and the shared event log."""
def __init__(self, db_path: str | Path) -> None:
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA foreign_keys=ON")
self._init_schema()
# -- schema ----------------------------------------------------------
def _init_schema(self) -> None:
self._conn.executescript(
"""
CREATE TABLE IF NOT EXISTS players (
name TEXT PRIMARY KEY,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
hp INTEGER NOT NULL,
max_hp INTEGER NOT NULL,
level INTEGER NOT NULL,
xp INTEGER NOT NULL,
gold INTEGER NOT NULL,
atk INTEGER NOT NULL,
def_ INTEGER NOT NULL,
weapon_id TEXT NOT NULL,
armor_id TEXT NOT NULL,
turns_left INTEGER NOT NULL,
turn_day INTEGER NOT NULL,
mode TEXT NOT NULL,
at_location TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen TEXT NOT NULL,
log_cursor INTEGER NOT NULL,
bestow_spent INTEGER NOT NULL,
bestow_day INTEGER NOT NULL,
wins INTEGER NOT NULL DEFAULT 0,
posts_sent INTEGER NOT NULL DEFAULT 0,
post_day INTEGER NOT NULL DEFAULT 0,
gambles INTEGER NOT NULL DEFAULT 0,
gamble_day INTEGER NOT NULL DEFAULT 0,
deepest_rung INTEGER NOT NULL DEFAULT 0,
satchel TEXT NOT NULL DEFAULT '',
weapon_plus INTEGER NOT NULL DEFAULT 0,
armor_plus INTEGER NOT NULL DEFAULT 0,
banked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
actor TEXT NOT NULL,
kind TEXT NOT NULL,
text TEXT NOT NULL,
target TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS ambushes (
attacker TEXT NOT NULL,
target TEXT NOT NULL,
day INTEGER NOT NULL,
PRIMARY KEY (attacker, target, day)
);
CREATE TABLE IF NOT EXISTS hall_of_fame (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
win_ts TEXT NOT NULL,
run_days INTEGER NOT NULL,
level_at_win INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
self._conn.execute(
"INSERT OR IGNORE INTO meta(key, value) VALUES('schema_version', ?)",
(str(_SCHEMA_VERSION),),
)
self._conn.commit()
def set_meta(self, key: str, value: str) -> None:
"""Upsert a meta key (e.g. ``world_name``) and commit."""
self._conn.execute(
"INSERT INTO meta(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
self._conn.commit()
def get_meta(self, key: str) -> str | None:
"""Return a meta value, or ``None`` if unset."""
row = self._conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
return None if row is None else str(row["value"])
# -- load ------------------------------------------------------------
def load_all(self) -> tuple[dict[str, Player], list[Event]]:
"""Load every player and the most recent events into memory.
Only the newest ``EVENT_TAIL_KEEP`` events are resident; the full history
remains in SQLite. The tail is fetched newest-first then reversed so
the returned list stays ascending by id (the order ``since`` expects).
"""
players = {
row["name"]: _row_to_player(row) for row in self._conn.execute("SELECT * FROM players")
}
rows = self._conn.execute(
"SELECT * FROM events ORDER BY id DESC LIMIT ?", (EVENT_TAIL_KEEP,)
).fetchall()
events = [_row_to_event(row) for row in reversed(rows)]
return players, events
# -- writes (no commit here; the façade commits per action) ----------
def upsert_player(self, player: Player) -> None:
"""Insert or update a player row (no commit)."""
placeholders = ", ".join("?" for _ in _PLAYER_COLUMNS)
assignments = ", ".join(f"{col}=excluded.{col}" for col in _PLAYER_COLUMNS if col != "name")
self._conn.execute(
f"INSERT INTO players ({', '.join(_PLAYER_COLUMNS)}) VALUES ({placeholders}) "
f"ON CONFLICT(name) DO UPDATE SET {assignments}",
_player_to_row(player),
)
def insert_event(self, ts: str, actor: str, kind: str, text: str, target: str = "") -> int:
"""Append an event row (no commit) and return its new id.
``target`` is empty for a public event or a player name for a private
note that only that player reads in their own catch-up.
"""
cur = self._conn.execute(
"INSERT INTO events(ts, actor, kind, text, target) VALUES(?, ?, ?, ?, ?)",
(ts, actor, kind, text, target),
)
return int(cur.lastrowid or 0)
def insert_hall_row(self, name: str, win_ts: str, run_days: int, level_at_win: int) -> int:
"""Append a Hall of Legends row (no commit) and return its new id."""
cur = self._conn.execute(
"INSERT INTO hall_of_fame(name, win_ts, run_days, level_at_win) VALUES(?, ?, ?, ?)",
(name, win_ts, run_days, level_at_win),
)
return int(cur.lastrowid or 0)
def record_ambush(self, attacker: str, target: str, day: int) -> None:
"""Mark that *attacker* has spent their ambush on *target* for *day*.
Idempotent: the ``(attacker, target, day)`` primary key means a repeat
write is ignored, so re-recording the same attempt is harmless. No
commit the façade folds this into the per-action transaction.
"""
self._conn.execute(
"INSERT OR IGNORE INTO ambushes(attacker, target, day) VALUES(?, ?, ?)",
(attacker, target, day),
)
def has_ambushed(self, attacker: str, target: str, day: int) -> bool:
"""Return whether *attacker* already ambushed *target* on *day*."""
row = self._conn.execute(
"SELECT 1 FROM ambushes WHERE attacker=? AND target=? AND day=?",
(attacker, target, day),
).fetchone()
return row is not None
def commit(self) -> None:
"""Commit the current transaction."""
self._conn.commit()
# -- read-only queries ----------------------------------------------
def top_ranks(self, limit: int = 10) -> list[RankEntry]:
"""Return the leaderboard ordered by level, xp, then name."""
rows = self._conn.execute(
"SELECT name, level, xp, gold, wins FROM players "
"ORDER BY level DESC, xp DESC, name ASC LIMIT ?",
(limit,),
)
return [
RankEntry(
name=row["name"],
level=row["level"],
xp=row["xp"],
gold=row["gold"],
wins=row["wins"],
)
for row in rows
]
def top_hall(self, limit: int = 5) -> list[HallEntry]:
"""Return the most recent Hall of Legends rows, newest first."""
rows = self._conn.execute(
"SELECT name, win_ts, run_days, level_at_win FROM hall_of_fame "
"ORDER BY id DESC LIMIT ?",
(limit,),
)
return [
HallEntry(
name=row["name"],
win_ts=row["win_ts"],
run_days=row["run_days"],
level_at_win=row["level_at_win"],
)
for row in rows
]
def targeted_events_since(self, viewer: str, cursor: int) -> list[Event]:
"""Return *viewer*'s private notes past *cursor*, ascending by id.
Public history older than the resident tail is ephemeral by design (the
broadsheet does not keep), but private mail is durable: a note left while
the recipient was away must survive however many public events have since
pushed it out of the in-memory tail. The façade pulls the recipient's
targeted rows from SQLite to backfill that gap before rendering.
"""
rows = self._conn.execute(
"SELECT * FROM events WHERE target=? AND id>? ORDER BY id",
(viewer, cursor),
).fetchall()
return [_row_to_event(row) for row in rows]
def journal_mode(self) -> str:
"""Return the active journal mode (for diagnostics / tests)."""
row = self._conn.execute("PRAGMA journal_mode").fetchone()
return str(row[0])
def close(self) -> None:
"""Close the underlying connection."""
self._conn.close()
def _player_to_row(player: Player) -> tuple[object, ...]:
return (
player.name,
player.x,
player.y,
player.hp,
player.max_hp,
player.level,
player.xp,
player.gold,
player.atk,
player.def_,
player.weapon_id,
player.armor_id,
player.turns_left,
player.turn_day,
str(player.mode),
player.at_location,
player.created_at,
player.last_seen,
player.log_cursor,
player.bestow_spent,
player.bestow_day,
player.wins,
player.posts_sent,
player.post_day,
player.gambles,
player.gamble_day,
player.deepest_rung,
player.satchel,
player.weapon_plus,
player.armor_plus,
player.banked,
)
def _row_to_player(row: sqlite3.Row) -> Player:
return Player(
name=row["name"],
x=row["x"],
y=row["y"],
hp=row["hp"],
max_hp=row["max_hp"],
level=row["level"],
xp=row["xp"],
gold=row["gold"],
atk=row["atk"],
def_=row["def_"],
weapon_id=row["weapon_id"],
armor_id=row["armor_id"],
turns_left=row["turns_left"],
turn_day=row["turn_day"],
mode=Mode(row["mode"]),
at_location=row["at_location"],
created_at=row["created_at"],
last_seen=row["last_seen"],
log_cursor=row["log_cursor"],
bestow_spent=row["bestow_spent"],
bestow_day=row["bestow_day"],
wins=row["wins"],
posts_sent=row["posts_sent"],
post_day=row["post_day"],
gambles=row["gambles"],
gamble_day=row["gamble_day"],
deepest_rung=row["deepest_rung"],
satchel=row["satchel"],
weapon_plus=row["weapon_plus"],
armor_plus=row["armor_plus"],
banked=row["banked"],
)
def _row_to_event(row: sqlite3.Row) -> Event:
return Event(
event_id=row["id"],
ts=row["ts"],
kind=row["kind"],
actor=row["actor"],
text=row["text"],
target=row["target"],
)
@@ -0,0 +1,5 @@
"""Screen layer — pure rendering of game state into text frames.
No ANSI SGR escape sequences are emitted in v1; colour is carried as
metadata on cells for a future renderer but never written to output.
"""
@@ -0,0 +1,35 @@
"""Shared box-drawing glyphs and the title-in-border helper.
The frame renderer and the menu renderer both draw a single box with a
centred title in the top border. The glyph set and that border logic live
here so the two renderers cannot drift apart.
"""
from __future__ import annotations
TL = "" # top-left corner
TR = "" # top-right corner
BL = "" # bottom-left corner
BR = "" # bottom-right corner
H = "" # horizontal run
V = "" # vertical edge
def border_with_title(inner: int, title: str) -> str:
"""Build the top border ``┌──title──┐`` with the title centred in the run.
*inner* is the interior width (between the corners). The title is wrapped
in single spaces and centred; if it does not fit the run it is truncated.
An empty/blank title yields a plain horizontal run.
"""
label = title.strip()
if not label:
return TL + (H * inner) + TR
framed = f" {label} "
if len(framed) > inner:
framed = framed[:inner]
pad = inner - len(framed)
left = pad // 2
right = pad - left
middle = (H * left) + framed + (H * right)
return TL + middle + TR
@@ -0,0 +1,61 @@
"""A 2-D grid of single-glyph cells.
The grid is the renderer's input surface: callers paint terrain and actors
into cells, then hand the grid to ``text_renderer`` for framing.
"""
from __future__ import annotations
from dataclasses import dataclass
from understone.screen.palette import Color
@dataclass(frozen=True, slots=True)
class Cell:
"""A single rendered position: exactly one glyph plus a colour role."""
glyph: str
color: Color
def __post_init__(self) -> None:
if len(self.glyph) != 1:
raise ValueError(f"cell glyph must be exactly one character, got {self.glyph!r}")
_BLANK = Cell(" ", Color.DEFAULT)
class CellGrid:
"""A mutable ``rows`` x ``cols`` grid of cells."""
def __init__(self, rows: int, cols: int) -> None:
if rows <= 0 or cols <= 0:
raise ValueError(f"grid must be positive, got {rows}x{cols}")
self.rows = rows
self.cols = cols
self._cells: list[list[Cell]] = [[_BLANK for _ in range(cols)] for _ in range(rows)]
def blank(self) -> None:
"""Reset every cell to the blank cell."""
for r in range(self.rows):
for c in range(self.cols):
self._cells[r][c] = _BLANK
def set(self, r: int, c: int, cell: Cell) -> None:
"""Paint *cell* at row *r*, column *c* (bounds-checked)."""
if not (0 <= r < self.rows and 0 <= c < self.cols):
raise IndexError(f"cell ({r},{c}) out of bounds for {self.rows}x{self.cols}")
self._cells[r][c] = cell
def get(self, r: int, c: int) -> Cell:
"""Return the cell at row *r*, column *c* (bounds-checked)."""
if not (0 <= r < self.rows and 0 <= c < self.cols):
raise IndexError(f"cell ({r},{c}) out of bounds for {self.rows}x{self.cols}")
return self._cells[r][c]
def row_glyphs(self, r: int) -> str:
"""Return row *r* as a string of its glyphs."""
if not (0 <= r < self.rows):
raise IndexError(f"row {r} out of bounds for {self.rows} rows")
return "".join(cell.glyph for cell in self._cells[r])
@@ -0,0 +1,47 @@
"""Classic door-game menu rendering for location interiors.
A menu is a boxed title, a block of flavour/body lines, an option line of
the ``(B)uy (S)ell (L)eave`` form, and a footer status line.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from understone.screen.box import BL, BR, H, V, border_with_title
if TYPE_CHECKING:
from collections.abc import Sequence
def render_menu(
title: str,
lines: Sequence[str],
options: Sequence[str],
status: str,
) -> str:
"""Render a boxed location menu.
*title* is centred in the top border, *lines* form the body (each
left-padded inside the box), *options* are joined with two spaces into
an option line, and *status* prints under the box.
"""
body = list(lines)
option_line = " ".join(options)
if option_line:
body.append("")
body.append(option_line)
inner = _inner_width(title, body)
out = [border_with_title(inner, title)]
for line in body:
out.append(V + " " + line.ljust(inner - 1) + V)
out.append(BL + (H * inner) + BR)
out.append(status)
return "\n".join(out)
def _inner_width(title: str, body: Sequence[str]) -> int:
"""Choose an inner width that fits the title and the widest body line."""
title_need = len(title.strip()) + 4
body_need = max((len(line) + 2 for line in body), default=0)
return max(title_need, body_need, 24)
@@ -0,0 +1,65 @@
"""Colour vocabulary for cells.
Colours are *stored* on cells and rendered by the live Watch page, which maps
each role to a hue (see ``watch.PALETTE``). The text frame renderer stays
monochrome it emits glyphs only so a cell's colour rides the grid model
untouched until a colour-aware renderer (the Watch today, an ANSI terminal
later) reads it.
"""
from __future__ import annotations
from enum import Enum
class Color(Enum):
"""Semantic colour roles for grid cells.
One global vocabulary, shared by every world there are no per-world or
per-theme palettes. Roles are split into two families: the runtime overlay
colours an actor or item wears (``PLAYER``/``OTHER_PLAYER``/``MONSTER``/
``ITEM``) and the author-assignable terrain/location roles a pack paints its
map with (everything else). A future colour renderer maps each role to a
hue; the Watch already does (see ``watch.PALETTE``).
"""
DEFAULT = "default"
WALL = "wall"
FLOOR = "floor"
PLAYER = "player"
OTHER_PLAYER = "other_player"
MONSTER = "monster"
ITEM = "item"
WATER = "water"
TREE = "tree"
TOWN = "town"
DUNGEON = "dungeon"
# Expanded terrain/location roles (v0.9) — so distinct types read by hue and
# not only by glyph. ROAD splits paths off FLOOR; FOREST is lush dense
# vegetation; SCRUB is its barren counterpart — rough, non-lush dense terrain
# (volcanic cinder, desert scrub) that must NOT read as green woods; LAVA
# gives molten ground its own orange (no longer mis-sharing WATER's blue);
# BARREN gives open wasteland ground a taupe; INN/SHOP/HEALER give each town
# building its own hue (TOWN stays as a generic fallback).
ROAD = "road"
FOREST = "forest"
SCRUB = "scrub"
LAVA = "lava"
BARREN = "barren"
INN = "inn"
SHOP = "shop"
HEALER = "healer"
@classmethod
def assignable(cls) -> list[Color]:
"""The roles a pack may paint terrain or a location with.
One source of truth for the overlay-vs-assignable split. Excludes the
runtime overlay colours an actor/item wears (``PLAYER``/
``OTHER_PLAYER``/``MONSTER``/``ITEM``) and the ``DEFAULT`` fallback
none of which an author assigns. Consumers (the authoring manual and
its test) read this so the documented vocabulary can never drift from
the enum. Returned in definition order.
"""
overlay = {cls.DEFAULT, cls.PLAYER, cls.OTHER_PLAYER, cls.MONSTER, cls.ITEM}
return [role for role in cls if role not in overlay]
@@ -0,0 +1,32 @@
"""Frame rendering — wrap a grid in a single box border with a title and status.
Deterministic and glyph-only (no ANSI). The title is centred within the
top border run; the status line is printed under the closed box.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from understone.screen.box import BL, BR, H, V, border_with_title
if TYPE_CHECKING:
from understone.screen.grid import CellGrid
def render_frame(grid: CellGrid, *, title: str, status: str) -> str:
"""Render *grid* inside a box, with *title* in the top border and *status* below.
The inner width equals the grid's column count. The title is centred
in the horizontal run of the top border; if it does not fit it is
truncated to the available run.
"""
inner = grid.cols
top = border_with_title(inner, title)
bottom = BL + (H * inner) + BR
lines = [top]
for r in range(grid.rows):
lines.append(V + grid.row_glyphs(r) + V)
lines.append(bottom)
lines.append(status)
return "\n".join(lines)
@@ -0,0 +1,54 @@
"""Deterministic terrain texturing — vary a terrain glyph by map position.
A field of identical `.`s reads flat; swapping in an occasional `,` or `'`
gives the overworld a hand-stippled BBS texture without storing anything on the
map. The variation is a PURE FUNCTION OF THE CELL COORDINATE, so it is stable
across redraws (a cell always picks the same variant) and reproducible the
model never sees it, only the renderer.
Only *terrain* cells are textured. The player marker, the other-player marker,
and location glyphs are painted on top and are never varied, so the eye can
always find them.
LOCKSTEP CONTRACT. The Watch page (``understone.watch.WATCH_HTML``) paints its
own base map in JavaScript and reproduces the EXACT same selection the same
``VARIANTS`` rows and the same ``(x * _HASH_X + y * _HASH_Y) % n`` index. The
page builds that index string FROM the :data:`_HASH_X` / :data:`_HASH_Y`
constants here (``understone.watch`` imports them), so a retune of either
number moves the JS with it; only the ``VARIANTS`` table must still be mirrored
by hand, or the live map and the tool frames will drift apart.
"""
from __future__ import annotations
# The position hash multipliers. The variant index for a cell is
# ``(x * _HASH_X + y * _HASH_Y) % len`` — two odd, coprime constants chosen so
# neighbouring cells spread across the variant row rather than banding. The
# Watch JS builds its own copy of this formula FROM these same two numbers
# (``understone.watch`` imports them), so a retune here moves the page in
# lockstep; a guard test pins the agreement.
_HASH_X = 31
_HASH_Y = 17
# Base glyph -> the ordered string of glyphs it may render as. The base glyph
# is index 0, so a cell that hashes to 0 is unchanged. Glyphs not listed here
# are never varied. Keep in lockstep with the Watch JS VARIANTS map.
VARIANTS: dict[str, str] = {
".": ".,'",
"": "≋≈",
}
def textured(glyph: str, x: int, y: int) -> str:
"""Return the variant of *glyph* for cell ``(x, y)``, or *glyph* unchanged.
When *glyph* has a :data:`VARIANTS` row, the cell coordinate selects one of
its variants by ``(x * _HASH_X + y * _HASH_Y) % len`` a fixed,
position-only hash so the choice is stable per cell and identical to the
Watch's. Glyphs with no row (every actor and location glyph, and any
un-listed terrain) are returned as-is.
"""
choices = VARIANTS.get(glyph)
if choices is None:
return glyph
return choices[(x * _HASH_X + y * _HASH_Y) % len(choices)]
@@ -0,0 +1,40 @@
"""Viewport window maths — pure integer arithmetic, no state.
Computes the top-left corner of a view window over a larger map, centred
on a focus point but clamped to map edges so the window never wraps and
never runs off the map. At edges the focus point sits off-centre.
"""
from __future__ import annotations
def compute_window(
map_w: int,
map_h: int,
view_w: int,
view_h: int,
cx: int,
cy: int,
) -> tuple[int, int]:
"""Return ``(x0, y0)`` top-left map coords for a view centred on ``(cx, cy)``.
The window is clamped so ``[x0, x0 + view_w)`` stays within
``[0, map_w)`` (and likewise for the vertical axis). When the map is
smaller than the view the origin pins to ``0``.
"""
x0 = _clamp_axis(map_w, view_w, cx)
y0 = _clamp_axis(map_h, view_h, cy)
return x0, y0
def _clamp_axis(map_size: int, view_size: int, center: int) -> int:
"""Clamp one axis: centre on ``center`` then pull inside the map edges."""
if view_size >= map_size:
return 0
origin = center - view_size // 2
max_origin = map_size - view_size
if origin < 0:
return 0
if origin > max_origin:
return max_origin
return origin
+648
View File
@@ -0,0 +1,648 @@
"""MCP server for Understone — the only module that imports ``mcp`` (or ``starlette``).
Nine ``door_*`` tools form the entire player interface. Every handler is a
synchronous ``def`` that takes and returns ``str``; no exception is allowed
to cross the MCP boundary (each handler catches, logs server-side, and
returns an in-fiction line). The handlers are thin wrappers over a single
module-level :class:`~understone.game.Game`; all rules live behind that
façade.
Three extra HTTP routes (``/watch`` and its two JSON feeds) serve the
read-only spectator page from :mod:`understone.watch`. They are registered via
FastMCP's ``custom_route`` and ride inside the streamable-http app; the
``starlette`` request/response types appear ONLY here, mirroring the MCP SDK's
own ``custom_route`` examples. The routes are unauthenticated by design and
strictly read-only they never mutate or persist world state.
Usage::
understone # via entry point (stdio transport)
python -m understone # via module
understone validate PATH # check a content pack loads cleanly
understone newpack PATH # scaffold a new pack + authoring manual
understone worlds # list the bundled worlds and their soundness
Environment variables
---------------------
UNDERSTONE_DB SQLite path (default: ./understone.db)
UNDERSTONE_WORLD Content-pack directory (default: packaged world/data)
UNDERSTONE_TRANSPORT "stdio" (default) or "streamable-http"
UNDERSTONE_HOST Bind host for http transport (default: 127.0.0.1)
UNDERSTONE_PORT Bind port for http transport (default: 8077)
UNDERSTONE_PATH HTTP path for the MCP endpoint (default: /mcp)
"""
from __future__ import annotations
import argparse
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.responses import HTMLResponse, JSONResponse, Response
from understone import cli, sim, watch
from understone.errors import WorldLoadError
from understone.game import Game
from understone.persistence import Store
from understone.world import PACKAGED_WORLD_DIR
from understone.world.loader import load_world
if TYPE_CHECKING:
from starlette.applications import Starlette
from starlette.requests import Request
log = logging.getLogger(__name__)
_PREMISE = (
"Understone is a shared-world BBS door game played through these tools. "
"Call door_help first to learn how to narrate it."
)
# The DM manual returned by door_help — a module constant so it is stable and
# greppable. It teaches an assistant how to run the game responsibly.
_DM_MANUAL = """\
UNDERSTONE A GUIDE FOR THE GAME MASTER
WHAT THIS IS
Understone is a multiplayer, BBS-style ANSI door game a small text RPG in
the lineage of the classic BBS door games. Many players share ONE
persistent world hosted by this server. You are the storyteller at the
terminal; the server is the rules engine and the single source of truth.
THE GOLDEN RULE
The server is authoritative. Never invent dice rolls, loot, gold, hit
points, map tiles, or outcomes. Every number and event comes back from a
tool call. Narrate AROUND the facts the tools return never ahead of them.
If you want something to happen, call the tool and see what the world says.
THE TWO MODES OF PLAY
1. The overworld (TILE mode). Tools return an ASCII "keyframe": a bordered
map window centred on the player. '@' is the player, '' is another
adventurer, glyphs are buildings ( inn, $ shop, healer, dungeon).
Movement here is FREE it costs no daily turns.
2. Location interiors (MENU mode). Stepping onto a building opens a menu of
options like (R)est, (B)uy, (H)eal, (D)escend, (L)eave.
PRESENTING FRAMES
When a tool returns a map or a menu, show it to the player VERBATIM inside a
fenced code block so the box-drawing lines stay aligned. Then add your prose
underneath. Do not redraw or paraphrase the frame.
NARRATION
Be vivid and in-fiction. Turn the terse result lines ("You travel 3 steps.",
"+8 XP, +3 gold.") into atmosphere. Keep your additions consistent with the
returned facts and the high-fantasy tone of the Vale of Understone.
THE DAILY RHYTHM
Each adventurer has a small budget of turns per real-world UTC day. Only
fighting, descending, and challenging the Wyrm spend a turn; moving,
resting, shopping and looking do not. When the budget is gone, the day is
done encourage the player to return tomorrow. This is a correspondence
game: a little each day.
WANDERING THE FOREST (the texture of a walk)
A step through wild country may turn up more than a monster. The server
rolls a private encounter table as the player walks: most often a foe (which
stops the walk for a fight or flight), but sometimes a purse of gold, a
healing spring, a small trap (it can never kill it floors at 1 HP), or a
scrap of Vale lore. The non-combat finds are applied at once, narrated in
the move result, and do NOT stop the walk; at most one such event happens
per move. These finds are PRIVATE they are not Herald news so narrate
them as the quiet texture of travelling, and watch the lore: it whispers of
something coiled beneath the dungeon.
DELVING DEEP (the reasons to come back)
Beneath the daily reset are four standing draws that reward a returning hero.
* THE RUNG LADDER. The dungeon is a ladder of guardians fought one rung per
'descend' (each costs a daily turn). A descent faces the NEXT rung past your
deepest; a win advances your depth and you climb back out, a loss bounces
you home but your depth PERSISTS you re-enter where you left off. Reaching
the last rung opens the Wyrm's door (the depth gate above). Narrate the deep
as a slow, earned descent, a rung at a time.
* THE SATCHEL AND THE DEATH-SAVE. A small satchel carries a few potions
(buying one at the shop now STOWS it instead of drinking it). 'quaff'
(anywhere, no turn) drinks the strongest. The heart of it: if a fight would
KILL the active fighter and they carry a potion, the strongest is drunk
AUTOMATICALLY they survive standing at the potion's value, no bounce, no
spawn reset. Play that beat big: the elixir burning down their throat at the
edge of death. (A sleeping ambush victim never auto-quaffs they are
asleep.)
* THE FORGE GOLD AND ORE. The shop's forge adds a +1 edge to the equipped
weapon or armour ('forge' target="weapon"/"armour"), up to a cap, each tier
dearer than the last. A step costs gold AND forge ore a material the hero
EARNS in combat, never buys: every cleared dungeon rung drops some, and a won
forest fight sometimes turns up a little. So the forge is fed by descending,
not just by a fat purse; a hero short of ore is told so. Swapping or selling
a forged piece loses the edge with it.
* RARE BEASTS. A few named beasts prowl the forest, surfacing seldom. Felling
one is loud a public Herald flash and it always guards a draught that
drops into the satchel (if there is room). Treat a rare kill as a small
legend in itself.
THE WYRM BELOW (the endgame, and how to win)
Deep under the dungeon sleeps the Wyrm Below a fixed, fearsome boss and
the ONLY win condition. At the dungeon, a sufficiently seasoned hero may
'challenge' it (door_action action="challenge"). The Wyrm gates on BOTH
level AND depth: an under-level hero is turned away first, and even a high
hero who has not plumbed the deep to its floor (see DELVING DEEP) is told the
Wyrm will not stir. Once both are met, the challenge spends a daily turn and
resolves in one call, like a fight.
* On victory the hero FREES THE VALE. The triumph is heralded to everyone,
the run is carved into the Hall of Legends (shown by door_rank), and the
hero is reborn in a classic-door-game-style legacy reset: level, gold, gear and stats
return to first-day values and they stand again at the town but they
keep a permanent for the win, and may set out to do it all again. Their
remaining turns for the day and their place in the world carry over.
* On defeat the Wyrm devours them; they wake at the spawn, barely alive.
Play this beat big: it is the climax of a whole run. Narrate the reset as the
Vale renewing itself around an undying legend, not as a death.
BESTOWING FORTUNE (use sparingly)
door_bestow lets you, the storyteller, grant a little gold or healing to
mark a great story moment a heroic rescue, a clever solution, a poignant
death-defiance. It NEVER grants items (gear comes from the shop) and NEVER
grants turns (the clock does not bend). It is capped by a small daily pool
per player, and every bestowal is written to the public log for all to see.
Treat it as seasoning, not a salt-shaker: reserve it for the rare, earned
beat, and never promise a reward you cannot actually deliver within the cap.
THE SOCIAL LAYER (rivals, mail, and dice)
Understone is a SHARED world, and three verbs let players touch one another.
* AMBUSH (door_action action="ambush" target=<player>, on the overworld).
A classic-door-game-style player-kill: you fall upon a RIVAL WHO HAS NOT YET ACTED
TODAY and rob them. The SLEEP RULE is the heart of it a player who has
already taken their turn that day is awake and cannot be ambushed, so the
surest defence is simply to play. The gatekeeper shields the young (both of
you must clear a level floor) and only matches near-equals (a level band).
On a win you take a slice of their gold and they wake at the spawn at 1 HP;
a public Herald crows the deed and the victim gets a PRIVATE note. But the
sleeper may WAKE: lose, and YOU are the one who flees bleeding, shamed on
the feed and gaining nothing. You get one attempt per rival per day, win or
lose. Narrate ambush as a real betrayal and losing one as just deserts.
* POST (door_action action="post" target=<player> text=<message>, anywhere).
Leave a private note at the inn for another player; they read it on their
next door_log under "While you were away". It costs no turn, is capped per
day, and the note is PRIVATE it never reaches the public Herald or the
lobby TV. Good for taunts after an ambush, alliances, or a kind word.
* GAMBLE (door_action action="gamble" amount=<gold>, at the inn).
Wager gold on a single throw of 2d6 against the house: roll higher to
double your stake, tie to push, roll lower to lose it. It costs no turn but
is capped per day. A big win is heralded; a quiet one is just a story you
tell. Remind players the house has no mercy and the odds are even at best.
THE VAULT (banking coin at the inn)
The inn keeps a strongbox. DEPOSIT (door_action action="deposit" amount=<gold>)
moves coin from the hero's hand into the vault; WITHDRAW (action="withdraw"
amount=<gold>) draws it back. Neither costs a turn. Two things make the vault
matter: banked gold is SAFE FROM AMBUSH (a sleeping-robber lifts only what the
victim carries), so banking before logging off is the way to protect a purse;
and banked gold SURVIVES THE WYRM-WIN RESET it is the one wealth a reborn
hero keeps, alongside their . Suggest a wary player bank their winnings.
TOOL CHEAT-SHEET
door_help This manual.
door_join(player) Sign in (creates or resumes a character).
door_status(player) Read the character sheet.
door_look(player) Redraw the current view (map or menu).
door_move(player, ...) Walk the overworld (free). steps="NNEE" or
heading="east" + distance=3 (max 8 per call).
door_action(player, action) Context verb: fight, flee, ambush (a rival),
rest, deposit/withdraw (the inn vault), buy,
sell, forge (a +1 edge, gold + ore), heal,
gamble (dice at the inn), descend (one rung),
challenge (the Wyrm), post (a note), quaff (a
carried potion), leave.
door_log(player) Read the Understone Herald (the shared feed).
door_rank(player) The leaderboard + Hall of Legends ( = wins).
door_bestow(player, reason...) Grant a little gold/healing for a story beat.
GETTING STARTED
Ask the player their adventurer's name, call door_join, present the opening
keyframe, and set the scene: a small town at the western edge of a wooded
vale, a road running east toward darker country and a dungeon mouth.
"""
_BLANK_NAME = 'The gatekeeper squints. "I didn\'t catch your name, traveller."'
# Module-level game singleton, built lazily so tests can inject their own.
_GAME: Game | None = None
def _build_game(watch_url: str | None = None) -> Game:
"""Construct the module Game from environment configuration."""
db_path = os.environ.get("UNDERSTONE_DB", "understone.db")
world_dir = os.environ.get("UNDERSTONE_WORLD") or str(PACKAGED_WORLD_DIR)
world = load_world(world_dir)
store = Store(db_path)
return Game(world, store, watch_url=watch_url)
def _game() -> Game:
"""Return the module game, building it on first use."""
global _GAME
if _GAME is None:
_GAME = _build_game()
return _GAME
def _set_game(game: Game) -> None:
"""Install a prebuilt game (used by create_app / tests)."""
global _GAME
_GAME = game
def _guard_name(player: str) -> str | None:
"""Return the blank-name refusal when *player* is empty, else None."""
return None if player.strip() else _BLANK_NAME
mcp: FastMCP = FastMCP(
"understone",
instructions=_PREMISE,
)
@mcp.tool()
def door_help() -> str:
"""Read the Understone game-master manual — start here.
Returns a short guide for running this multiplayer, BBS-style ANSI door
game (a classic text RPG / dungeon adventure): the two play modes, how
to present the ASCII map frames, the daily-turn rhythm, and the full tool
cheat-sheet. Call door_help before your first session to learn how to run
the game, then call door_join to begin.
"""
watch_line = _game().watch_line()
if watch_line:
return f"{_DM_MANUAL}\nTHE LOBBY TV\n {watch_line}\n"
return _DM_MANUAL
@mcp.tool()
def door_join(player: str) -> str:
"""Sign an adventurer into the shared world of Understone — call this first.
Understone is a multiplayer, BBS-style ANSI door game: a text adventure /
dungeon RPG in the spirit of the classic BBS door games, played entirely
through these tools. This creates a new character at the town, or resumes
an existing one by name, and returns the opening overworld map frame. New
to running it? Call door_help before your first session.
Args:
player: The adventurer's name (their identity in the world).
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().join(player)
except Exception:
log.exception("door_join failed for %r", player)
return _unexpected()
@mcp.tool()
def door_status(player: str) -> str:
"""Show an adventurer's character sheet (level, HP, gear, gold, turns).
Read-only. Use it to check progress before deciding what to do next.
Args:
player: The adventurer's name.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().status(player)
except Exception:
log.exception("door_status failed for %r", player)
return _unexpected()
@mcp.tool()
def door_look(player: str) -> str:
"""Redraw what the adventurer currently sees (read-only).
On the overworld this is an ASCII map keyframe centred on the player
('@' is you, '' are other players, glyphs are buildings). Inside a
building it is that location's menu. Present the result verbatim in a
fenced code block, then narrate.
Args:
player: The adventurer's name.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().look(player)
except Exception:
log.exception("door_look failed for %r", player)
return _unexpected()
@mcp.tool()
def door_move(player: str, steps: str = "", heading: str = "", distance: int = 1) -> str:
"""Walk the overworld — movement is free and never costs a daily turn.
Only valid on the overworld (in a building, use door_action 'leave'
first). Give EITHER a compact ``steps`` string of cardinal letters such
as "NNEE", OR a ``heading`` ("north"/"south"/"east"/"west") with a
``distance``. At most 8 cells move per call; the walk stops early at
walls, water, a building door, or a wandering monster.
Args:
player: The adventurer's name.
steps: Cardinal letters, e.g. "NNEE" (takes precedence if given).
heading: A compass direction used with distance.
distance: How many cells to travel along heading (1-8).
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().move(player, steps, heading, distance)
except Exception:
log.exception("door_move failed for %r", player)
return _unexpected()
@mcp.tool()
def door_action(
player: str,
action: str,
target: str = "",
item: str = "",
text: str = "",
amount: int = 0,
) -> str:
"""Take a context-sensitive action in the world.
The legal verbs depend on where the adventurer is. On the overworld:
'fight' or 'flee' a wandering monster (fighting spends one daily turn), or
'ambush' a named rival who has not yet acted today a sleeping-rival
robbery in the spirit of the classic door-game player-kill (target=<name>, spends a turn).
Inside a building: 'rest' (inn), 'buy'/'sell'/'forge' (shop), 'heal'
(healer), or 'leave'. At the inn you may also 'gamble' a stake of gold at
dice (amount=<gold>), or bank coin in the vault with 'deposit'/'withdraw'
(amount=<gold>) banked gold is safe from ambush and survives a Wyrm-win
reset. Buying a potion now stows it in your satchel rather than drinking it;
'forge' (target="weapon"/"armour", at the shop) spends gold AND forge ore
(won in the deep) to add a +1 edge to your equipped gear, up to a cap. At
the dungeon:
'descend' ONE rung of the deep the next guardian past your deepest, one
per turn or 'challenge' the Wyrm Below, the endgame boss and the only way
to win. Descending a rung advances your depth; reaching the floor opens the
Wyrm's door. The challenge is gated by BOTH level and depth (you must have
plumbed the deep to its floor) and, once allowed, spends a daily turn and
resolves in a single call like a fight: a victory frees the Vale and begins
a new life (see door_help), a defeat bounces you home. Anywhere and at no
turn cost: 'post' leaves a private note for another player (target=<name>,
text=<message>) read on their next door_log, and 'quaff' drinks the
strongest potion from your satchel. An illegal verb returns the verbs valid
right here.
Args:
player: The adventurer's name.
action: The verb to attempt (fight, flee, ambush, rest, deposit,
withdraw, buy, sell, forge, heal, gamble, descend, challenge, post,
quaff, leave).
target: The other player's name for 'ambush'/'post', or the slot
("weapon"/"armour") for 'forge'.
item: For shop 'buy', the item id to purchase.
text: For 'post', the note left for the target (<= 120 characters).
amount: For inn 'gamble', the gold wagered on the dice; for the vault
'deposit'/'withdraw', the gold moved.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().action(player, action, target, item, text, amount)
except Exception:
log.exception("door_action failed for %r action=%r", player, action)
return _unexpected()
@mcp.tool()
def door_log(player: str) -> str:
"""Catch up on what happened in the shared world while the player was away.
Returns the events since this adventurer last checked (fights, deaths,
blessings, descents by anyone) and advances their personal marker.
Args:
player: The adventurer's name.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().log(player)
except Exception:
log.exception("door_log failed for %r", player)
return _unexpected()
@mcp.tool()
def door_rank(player: str = "") -> str:
"""Show the Roll of Heroes — the top-ten leaderboard.
Ordered by level, then experience, then name. If the caller names
themselves and they place in the top ten, their row is marked. Each
beside a name is one slaying of the Wyrm Below. Below the table, the Hall
of Legends lists the most recent completed runs (name, level at the kill,
days the run took, date); it is omitted while no one has yet won.
Args:
player: The caller's name (optional; marks their row when present).
"""
try:
return _game().rank(player)
except Exception:
log.exception("door_rank failed for %r", player)
return _unexpected()
@mcp.tool()
def door_bestow(player: str, reason: str, gold: int = 0, heal: int = 0) -> str:
"""Grant a small gift of gold or healing to mark a great story moment.
This is the game master's discretionary channel, to be used SPARINGLY for
earned, story-driven beats. It grants only gold and/or healing never
items (gear comes from the shop) and never turns (the daily clock does not
bend). Each grant is capped by a small daily pool per adventurer and is
written to the public log, so spend it on the rare moment that deserves
it; do not promise more than the cap allows. When a pack sets healing to
cost nothing, a bestowed heal is free and draws nothing from the pool.
Args:
player: The adventurer receiving the gift.
reason: A short, in-fiction reason (<= 120 characters).
gold: Gold to grant (>= 0).
heal: HP to restore (>= 0); only the missing portion is applied and
charged against the pool.
"""
blank = _guard_name(player)
if blank is not None:
return blank
try:
return _game().bestow(player, reason, gold, heal)
except Exception:
log.exception("door_bestow failed for %r", player)
return _unexpected()
# FastMCP.custom_route has no return annotation upstream (mcp 1.27.2), so mypy
# reads the decorator as untyped; the ignore is scoped to that single gap.
@mcp.custom_route("/watch", methods=["GET"]) # type: ignore[untyped-decorator]
async def watch_page(_request: Request) -> Response:
"""Serve the read-only CRT spectator page (static HTML, no world reads)."""
return HTMLResponse(watch.WATCH_HTML)
@mcp.custom_route("/watch/world.json", methods=["GET"]) # type: ignore[untyped-decorator]
async def watch_world(_request: Request) -> Response:
"""Serve the STATIC map payload (dimensions, coloured rows, locations)."""
return JSONResponse(watch.build_world_payload(_game().world))
@mcp.custom_route("/watch/state.json", methods=["GET"]) # type: ignore[untyped-decorator]
async def watch_state(_request: Request) -> Response:
"""Serve the DYNAMIC snapshot (players, Herald, Hall) — read-only.
The builder reads the module Game with no ``await`` in between, so each
response is a consistent point-in-time snapshot of the shared world.
"""
return JSONResponse(watch.build_state_payload(_game()))
def _unexpected() -> str:
"""In-fiction line for an unexpected server-side error."""
return (
"A strange fog rolls through the Vale and the moment slips away. "
"(Something went wrong; try again.)"
)
def create_app(
db_path: str, world_dir: str | None = None, watch_url: str | None = None
) -> Starlette:
"""Build the streamable-HTTP ASGI app backed by a fresh game.
Used by both ``main`` (for the http transport) and the integration tests,
so tests can point at a temp DB without environment juggling. ``watch_url``,
when given, is the spectator page URL the join banner and help manual
advertise; ``main`` derives it from the bind host/port.
"""
world = load_world(world_dir or str(PACKAGED_WORLD_DIR))
store = Store(db_path)
_set_game(Game(world, store, watch_url=watch_url))
return mcp.streamable_http_app()
def _serve() -> None:
"""Serve the Understone MCP world over the configured transport.
Honours UNDERSTONE_TRANSPORT: "stdio" (default) or "streamable-http".
For http, host/port/path are read from the environment and applied to the
FastMCP settings before serving. This is the actual transport launch; it is
kept separate from argument parsing so the parse step has no side effects.
"""
logging.basicConfig(level=logging.INFO)
transport = os.environ.get("UNDERSTONE_TRANSPORT", "stdio")
if transport == "streamable-http":
host = os.environ.get("UNDERSTONE_HOST", "127.0.0.1")
port = int(os.environ.get("UNDERSTONE_PORT", "8077"))
mcp.settings.host = host
mcp.settings.port = port
mcp.settings.streamable_http_path = os.environ.get("UNDERSTONE_PATH", "/mcp")
mcp.settings.stateless_http = False
# FastMCP freezes DNS-rebinding protection (a localhost-only Host
# allowlist) at CONSTRUCTION, and this module builds `mcp` at import
# with the default 127.0.0.1 host — so a 0.0.0.0/LAN bind would 421
# "Invalid Host" on /mcp for every remote node (the multi-node case).
# Binding off localhost means we intend to accept other hosts, so drop
# the allowlist here (matching the SDK's own default for a non-localhost
# bind). These routes are unauthenticated by design — serve only on a
# trusted network. UNDERSTONE_HOST controls the bind.
if host not in ("127.0.0.1", "localhost", "::1"):
mcp.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=False
)
# The spectator page is only reachable over http, so its URL is composed
# here from the bind address. A 0.0.0.0 bind should advertise a host a
# browser can actually reach (see the README Watch section).
watch_url = f"http://{host}:{port}/watch"
# Build the game eagerly (with the watch URL) so a config error surfaces
# before serving and the join/help advertisements carry the page link.
try:
_set_game(_build_game(watch_url))
except WorldLoadError as exc:
raise SystemExit(f"failed to load world: {exc}") from exc
mcp.run(transport="streamable-http")
return
try:
_game()
except WorldLoadError as exc:
raise SystemExit(f"failed to load world: {exc}") from exc
mcp.run(transport="stdio")
def _build_parser() -> argparse.ArgumentParser:
"""Build the ``understone`` argument parser: serve (default), validate, newpack.
Parsing is deliberately free of side effects no world load, no port bind
so the resolved subcommand can be inspected without serving anything.
"""
parser = argparse.ArgumentParser(
prog="understone",
description=(
"Understone — a BBS-style ANSI door game served over MCP, plus the "
"tools to author its world packs."
),
)
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("serve", help="serve the MCP world (the default with no command)")
validate = sub.add_parser("validate", help="validate a content pack and print a report")
validate.add_argument("path", type=Path, help="the pack directory to validate")
newpack = sub.add_parser("newpack", help="scaffold a new content pack from the bundled world")
newpack.add_argument("path", type=Path, help="the directory to create the pack in")
sub.add_parser("worlds", help="list the bundled worlds and whether each is sound")
sim = sub.add_parser("simulate", help="run a greedy balance bot over a pack and report")
sim.add_argument("path", type=Path, help="the pack directory to simulate")
sim.add_argument("--days", type=int, default=30, help="sim-days to play (default 30)")
sim.add_argument("--seed", type=int, default=1, help="RNG seed (default 1)")
sim.add_argument(
"--seeds",
type=int,
default=None,
help="run a sweep of this many seeds from --seed and aggregate",
)
return parser
def main(argv: list[str] | None = None) -> None:
"""Run the Understone command line: serve, or author a world pack.
With no arguments (the entry point and ``python -m understone``) this serves
the MCP world exactly as before. ``validate PATH`` and ``newpack PATH`` drive
the pack-authoring loop and exit with the verb's status code.
"""
args = _build_parser().parse_args(argv)
if args.cmd == "validate":
raise SystemExit(cli.cli_validate(args.path))
if args.cmd == "newpack":
raise SystemExit(cli.cli_newpack(args.path))
if args.cmd == "worlds":
raise SystemExit(cli.cli_worlds())
if args.cmd == "simulate":
raise SystemExit(sim.cli_simulate(args.path, args.days, args.seed, seeds=args.seeds))
_serve()
File diff suppressed because it is too large Load Diff
+745
View File
@@ -0,0 +1,745 @@
"""The Watch page — a read-only CRT spectator view of the shared world.
This module is PURE: it imports nothing from ``mcp`` or ``starlette``. It owns
two payload builders and one self-contained HTML page; the server wires them to
HTTP routes. Input never flows through here the Watch is the lobby TV, not a
controller.
* :func:`build_world_payload` the STATIC map: dimensions, the legend-coloured
terrain rows, and the placed locations. Fetched once by the page.
* :func:`build_state_payload` the DYNAMIC snapshot: every player's position
and vitals, the recent Herald feed, and the Hall of Legends. Polled.
* :data:`WATCH_HTML` one inline-everything page (vanilla JS, phosphor CRT
styling) that paints the base map once and overlays the players each poll.
A correspondence game leaves every adventurer on the board between their turns,
so the state payload reports *all* players, not just the active ones.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from understone.engine.models import Mode
from understone.engine.satchel import decode_satchel
from understone.screen import texture
if TYPE_CHECKING:
from understone.engine.log import Event
from understone.engine.world import World
from understone.game import Game
# How many of the newest Herald events the Watch shows, oldest-first.
_HERALD_LIMIT = 15
# How many Hall-of-Legends runs the Watch shows.
_HALL_LIMIT = 5
def build_world_payload(world: World) -> dict[str, object]:
"""Return the STATIC map payload the Watch page fetches once.
``glyph_rows`` is the base terrain rendered glyph-for-glyph (locations are
NOT burned in here they ride in ``locations`` so the client can colour
them as an overlay). ``legend`` maps every terrain glyph that appears to a
palette colour *name*; the client owns the namehex mapping. Completeness is
a contract: every glyph in ``glyph_rows`` has a ``legend`` entry.
``theme`` is the pack's Watch CRT palette (``settings.watch_theme``); the
page looks it up in its own JS THEME table on fetch and swaps the CSS
custom-property values, so each world has its own phosphor colour.
"""
glyph_rows: list[str] = []
legend: dict[str, str] = {}
for y in range(world.height):
chars: list[str] = []
for x in range(world.width):
terrain = world.terrain_at(x, y)
chars.append(terrain.glyph)
legend.setdefault(terrain.glyph, terrain.color)
glyph_rows.append("".join(chars))
locations = [
{
"x": loc.x,
"y": loc.y,
"glyph": loc.glyph,
"name": loc.name,
"color": loc.color,
}
for loc in world.locations
]
return {
"name": world.name,
"width": world.width,
"height": world.height,
"theme": world.settings.watch_theme,
"glyph_rows": glyph_rows,
"legend": legend,
"locations": locations,
}
def build_state_payload(game: Game) -> dict[str, object]:
"""Return the DYNAMIC snapshot payload the Watch page polls.
Reports every player (a correspondence game keeps idle pieces on the
board), the last :data:`_HERALD_LIMIT` events oldest-first, and the top
:data:`_HALL_LIMIT` completed runs. ``ts`` is the game clock, so a seeded
test clock drives a deterministic payload.
"""
players = [
{
"name": p.name,
"x": p.x,
"y": p.y,
"level": p.level,
"wins": p.wins,
"hp": p.hp,
"max_hp": p.max_hp,
"mode": p.mode.value if isinstance(p.mode, Mode) else str(p.mode),
"gold": p.gold,
"banked": p.banked,
"satchel": _satchel_entries(game, p.satchel),
}
for p in game.players.values()
]
herald = [
{"ts": event.ts, "kind": event.kind, "text": event.text} for event in _recent_events(game)
]
hall = [
{
"name": entry.name,
"level_at_win": entry.level_at_win,
"run_days": entry.run_days,
"win_ts": entry.win_ts,
}
for entry in game.store.top_hall(_HALL_LIMIT)
]
return {
"ts": game.clock().isoformat(),
"players": players,
"herald": herald,
"hall": hall,
}
def _satchel_entries(game: Game, satchel: str) -> list[dict[str, object]]:
"""Decode a player's ``"id:qty"`` satchel into ``[{"name", "qty"}, ...]``.
Decodes the bag through the shared
:func:`~understone.engine.satchel.decode_satchel` codec, then resolves each
stack's id to its display name via the world's item table; an id no longer in
the pack (a save edited out from under it) falls back to the raw id, so the
lobby TV never shows a blank entry. The Watch is read-only, so it only
decodes the name-resolution is the only work that lives here.
"""
entries: list[dict[str, object]] = []
for item_id, qty in decode_satchel(satchel):
item = game.world.item_by_id(item_id)
entries.append({"name": item.name if item is not None else item_id, "qty": qty})
return entries
def _recent_events(game: Game) -> list[Event]:
"""Return the last :data:`_HERALD_LIMIT` resident PUBLIC events, oldest-first.
PRIVATE notes (a non-empty ``target`` ambush victim alerts, inn mail)
are filtered out first: the lobby TV is a public broadsheet and must never
show a message addressed to one player. The façade keeps events in
ascending id order, so the tail of the public slice IS the newest public
window correct even when AUTOINCREMENT ids are sparse.
"""
public = [event for event in game.events if not event.target]
return public[-_HERALD_LIMIT:]
# The Watch page. One self-contained document: inline CSS + vanilla JS, no
# external assets, no innerHTML-with-data (every dynamic node is built with
# createElement / textContent). The base map is painted once from world.json;
# players are an absolutely-positioned overlay repainted from state.json every
# two seconds. On a fetch failure the page dims and shows "SIGNAL LOST".
#
# ``__HASH_EXPR__`` is filled below from the texture-module hash constants, so
# the JS index formula tracks a Python-side retune (see _build_watch_html).
_WATCH_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Understone Live Watch</title>
<style>
:root {
--phosphor: #7dffa0;
--phosphor-dim: #2f7a46;
--amber: #ffb44d;
--bg: #050a06;
--panel: #0a140d;
--edge: #163a22;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--phosphor);
font-family: "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "Courier New", monospace;
font-size: 14px;
line-height: 1.2;
}
body::after {
/* Scanline overlay faint, non-interactive. */
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.22) 3px,
rgba(0, 0, 0, 0) 4px
);
z-index: 50;
}
body.lost { filter: grayscale(0.7) brightness(0.55); }
header {
padding: 10px 16px;
border-bottom: 1px solid var(--edge);
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
text-shadow: 0 0 6px rgba(125, 255, 160, 0.5);
}
header h1 {
margin: 0;
font-size: 18px;
letter-spacing: 2px;
text-transform: uppercase;
}
.live {
color: var(--amber);
font-size: 13px;
letter-spacing: 1px;
text-shadow: 0 0 6px rgba(255, 180, 77, 0.5);
}
.live .dot {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 8px var(--amber);
animation: pulse 2s ease-in-out infinite;
}
body.lost .live .dot { animation: none; background: var(--phosphor-dim); box-shadow: none; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
main {
display: flex;
gap: 16px;
padding: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.map-frame {
position: relative;
border: 1px solid var(--edge);
background: var(--panel);
padding: 8px;
overflow: auto;
max-width: 100%;
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.6);
transition: filter 1.2s ease;
}
/* Time-of-day wash, toggled from the UTC hour of the state payload. The
overlay is non-interactive and sits above the map but below the scanlines.
night: a subtle blue dim; dawn/dusk: a faint amber wash; day: nothing. */
.map-frame::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 1.2s ease, background-color 1.2s ease;
z-index: 5;
}
.map-frame.night { filter: brightness(0.78) saturate(0.85); }
.map-frame.night::after { opacity: 1; background-color: rgba(74, 120, 200, 0.16); }
.map-frame.twilight::after { opacity: 1; background-color: rgba(255, 180, 77, 0.12); }
#map {
position: relative;
white-space: pre;
text-shadow: 0 0 4px rgba(125, 255, 160, 0.35);
}
#map .row { display: block; }
#overlay {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
#overlay .pc {
position: absolute;
color: var(--amber);
text-shadow: 0 0 6px rgba(255, 180, 77, 0.8);
}
aside {
flex: 1 1 280px;
min-width: 260px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
border: 1px solid var(--edge);
background: var(--panel);
padding: 10px 12px;
}
.card h2 {
margin: 0 0 8px;
font-size: 13px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--phosphor);
border-bottom: 1px solid var(--edge);
padding-bottom: 4px;
}
ul { margin: 0; padding: 0; list-style: none; }
li { padding: 2px 0; }
.muted { color: var(--phosphor-dim); }
.subline { font-size: 12px; padding-left: 2px; }
.adv-name { color: var(--amber); }
.stars { color: var(--amber); letter-spacing: 1px; }
.feed li { border-bottom: 1px dotted var(--edge); padding: 4px 0; }
.feed li:last-child { border-bottom: none; }
.feed .ts { color: var(--phosphor-dim); margin-right: 6px; }
</style>
</head>
<body>
<header>
<h1 id="world-name">The Understone Watch</h1>
<div class="live"><span class="dot"></span><span id="live-label">CONNECTING</span></div>
</header>
<main>
<div class="map-frame">
<div id="map"><div id="overlay"></div></div>
</div>
<aside>
<section class="card">
<h2>Adventurers</h2>
<ul id="adventurers"><li class="muted"></li></ul>
</section>
<section class="card">
<h2>Hall of Legends</h2>
<ul id="hall"><li class="muted">No legends yet.</li></ul>
</section>
<section class="card">
<h2>The Understone Herald</h2>
<ul id="herald" class="feed"><li class="muted"></li></ul>
</section>
</aside>
</main>
<script>
(function () {
"use strict";
// Per-world CRT palette. Each named theme is a set of CSS custom-property
// values applied to :root when world.json arrives (the pack's
// settings.watch_theme picks one). "phosphor" holds the EXACT values of the
// :root block above, so the default Vale is pixel-for-pixel unchanged; the
// others re-tint the whole console:
// phosphor the original green CRT (default).
// amber a warm gold CRT (classic amber monochrome monitor).
// ice a pale, cold blue CRT.
// ember a hot red/orange CRT.
// The day/night wash from v0.6 composes ON TOP of whichever theme is set.
var THEMES = {
phosphor: {
"--phosphor": "#7dffa0",
"--phosphor-dim": "#2f7a46",
"--amber": "#ffb44d",
"--bg": "#050a06",
"--panel": "#0a140d",
"--edge": "#163a22"
},
amber: {
"--phosphor": "#ffc14d",
"--phosphor-dim": "#7a5320",
"--amber": "#fff0a8",
"--bg": "#0a0702",
"--panel": "#14100a",
"--edge": "#3a2c16"
},
ice: {
"--phosphor": "#9fe6ff",
"--phosphor-dim": "#2f5f7a",
"--amber": "#ffe07d",
"--bg": "#04080a",
"--panel": "#0a1014",
"--edge": "#16303a"
},
ember: {
"--phosphor": "#ff8a6b",
"--phosphor-dim": "#7a3320",
"--amber": "#ffd07d",
"--bg": "#0a0503",
"--panel": "#140a07",
"--edge": "#3a1c16"
}
};
// Swap the CSS custom-property values for the pack's theme. Unknown or
// missing theme names fall back to "phosphor", so the console always has a
// coherent palette even if a future theme reaches the page unknown.
function applyTheme(name) {
var theme = THEMES[name] || THEMES.phosphor;
for (var prop in theme) {
if (Object.prototype.hasOwnProperty.call(theme, prop)) {
document.documentElement.style.setProperty(prop, theme[prop]);
}
}
}
// Palette colour-name -> phosphor-tinted hex. ONE global map, shared by every
// world (no per-world or per-theme palettes). Mirrors understone.screen.palette
// Color values 1:1 a guard test asserts every Color role has an entry here,
// so a shipped role can never silently fall back to default. The base map is
// coloured from this, never from the server.
var PALETTE = {
default: "#7dffa0",
wall: "#5a6b60",
floor: "#3f7a52",
player: "#ffb44d",
other_player: "#ffd089",
monster: "#ff6b6b",
item: "#ffe07d",
water: "#4aa6c8",
tree: "#3fae6a",
town: "#ffd089",
dungeon: "#c98bff",
// v0.9 expanded terrain/location roles, chosen for hue separation:
road: "#b89a6a",
forest: "#6a9f3f",
scrub: "#9c6038",
lava: "#ff7a3c",
barren: "#9a8b7a",
inn: "#ff9d4d",
shop: "#ffd24d",
healer: "#5fd6b0"
};
function colorFor(name) {
return PALETTE[name] || PALETTE.default;
}
// Deterministic terrain texture. MUST stay in lockstep with
// understone.screen.texture: the same base->variants rows and the same
// index formula. The formula below is INTERPOLATED from texture._HASH_X /
// _HASH_Y at module build time, so a Python-side retune rewrites this line;
// only the VARIANTS rows must still be mirrored by hand.
var VARIANTS = {
".": ".,'",
"\\u224b": "\\u224b\\u2248"
};
function textured(ch, x, y) {
var choices = VARIANTS[ch];
if (!choices) { return ch; }
return choices.charAt((__HASH_EXPR__) % choices.length);
}
var overlay = document.getElementById("overlay");
var mapEl = document.getElementById("map");
var liveLabel = document.getElementById("live-label");
var dims = null; // {width, height} once the map is painted.
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function clockLabel(iso) {
var d = new Date(iso);
if (isNaN(d.getTime())) { return "--:--:--"; }
return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
}
function stars(wins) {
if (wins <= 0) { return ""; }
if (wins <= 5) { return "\\u2605".repeat(wins); }
return "\\u2605x" + wins;
}
// Paint the base map ONCE. Each row is a sequence of <span> runs, a new run
// only where the legend colour changes, so a row is a handful of spans.
function paintMap(world) {
applyTheme(world.theme);
document.getElementById("world-name").textContent = world.name + " — Live Watch";
var legend = world.legend || {};
var rows = world.glyph_rows || [];
for (var y = 0; y < rows.length; y++) {
var row = rows[y];
var rowEl = document.createElement("div");
rowEl.className = "row";
var runText = "";
var runColor = null;
for (var x = 0; x < row.length; x++) {
var ch = row.charAt(x);
// Colour keys off the BASE terrain glyph; the rendered glyph is the
// position-keyed variant (a variant shares its terrain's colour).
var col = colorFor(legend[ch]);
if (runColor === null) { runColor = col; }
if (col !== runColor) {
rowEl.appendChild(makeSpan(runText, runColor));
runText = "";
runColor = col;
}
runText += textured(ch, x, y);
}
if (runText.length) { rowEl.appendChild(makeSpan(runText, runColor)); }
mapEl.insertBefore(rowEl, overlay);
}
dims = { width: world.width, height: world.height };
paintLocations(world.locations || []);
}
function makeSpan(text, color) {
var span = document.createElement("span");
span.style.color = color;
span.textContent = text;
return span;
}
// Locations are painted into the overlay layer (above the base terrain) so
// their glyph and colour win over the terrain beneath the door.
function paintLocations(locations) {
for (var i = 0; i < locations.length; i++) {
var loc = locations[i];
var el = document.createElement("span");
el.className = "pc";
el.style.left = "calc(" + loc.x + " * 1ch)";
el.style.top = "calc(" + loc.y + " * 1lh)";
el.style.color = colorFor(loc.color);
el.style.textShadow = "0 0 6px " + colorFor(loc.color);
el.textContent = loc.glyph;
el.title = loc.name;
overlay.appendChild(el);
}
}
// Player markers live in their own layer, cleared and repainted each poll.
var pcLayer = document.createElement("div");
pcLayer.id = "pc-layer";
overlay.appendChild(pcLayer);
function paintPlayers(players) {
while (pcLayer.firstChild) { pcLayer.removeChild(pcLayer.firstChild); }
for (var i = 0; i < players.length; i++) {
var p = players[i];
var el = document.createElement("span");
el.className = "pc";
el.style.left = "calc(" + p.x + " * 1ch)";
el.style.top = "calc(" + p.y + " * 1lh)";
// Every adventurer on the lobby TV is "another player" (there is no
// viewer here), so all wear the other-player marker. Mirrors the ''
// the game frame paints for rivals.
el.textContent = "\\u263b";
el.title = p.name;
pcLayer.appendChild(el);
}
}
function renderAdventurers(players) {
var list = document.getElementById("adventurers");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!players.length) {
list.appendChild(muted("The Vale is empty."));
return;
}
var sorted = players.slice().sort(function (a, b) {
return b.level - a.level || a.name.localeCompare(b.name);
});
for (var i = 0; i < sorted.length; i++) {
var p = sorted[i];
var li = document.createElement("li");
var name = document.createElement("span");
name.className = "adv-name";
name.textContent = p.name;
li.appendChild(name);
var star = stars(p.wins);
if (star) {
var s = document.createElement("span");
s.className = "stars";
s.textContent = " " + star;
li.appendChild(s);
}
var rest = document.createElement("span");
rest.className = "muted";
rest.textContent = " Lv" + p.level + " HP " + p.hp + "/" + p.max_hp;
li.appendChild(rest);
// A dim sub-line: gold on hand and (if any) gold in the vault. The whole
// shared world is on the lobby TV, so every hero's purse is public here.
var gold = document.createElement("div");
gold.className = "muted subline";
var goldText = (p.gold || 0) + "g";
if (p.banked) { goldText += " +" + p.banked + " vault"; }
gold.textContent = goldText;
li.appendChild(gold);
// A second dim sub-line: the satchel stacks ("Name ×qty"), or empty.
var sat = document.createElement("div");
sat.className = "muted subline";
sat.textContent = satchelText(p.satchel || []);
li.appendChild(sat);
list.appendChild(li);
}
}
// Render the satchel stacks as a compact dot-joined line, or an empty note.
function satchelText(stacks) {
if (!stacks.length) { return "satchel empty"; }
var parts = [];
for (var i = 0; i < stacks.length; i++) {
parts.push(stacks[i].name + " \\u00d7" + stacks[i].qty);
}
return parts.join(" \\u00b7 ");
}
function renderHall(hall) {
var list = document.getElementById("hall");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!hall.length) {
list.appendChild(muted("No legends yet."));
return;
}
for (var i = 0; i < hall.length; i++) {
var h = hall[i];
var li = document.createElement("li");
var star = document.createElement("span");
star.className = "stars";
star.textContent = "\\u2605 ";
li.appendChild(star);
var name = document.createElement("span");
name.className = "adv-name";
name.textContent = h.name;
li.appendChild(name);
var rest = document.createElement("span");
rest.className = "muted";
rest.textContent = " Lv" + h.level_at_win + " " + h.run_days + "d " + (h.win_ts || "").slice(0, 10);
li.appendChild(rest);
list.appendChild(li);
}
}
function renderHerald(herald) {
var list = document.getElementById("herald");
while (list.firstChild) { list.removeChild(list.firstChild); }
if (!herald.length) {
list.appendChild(muted("The Vale is still."));
return;
}
for (var i = 0; i < herald.length; i++) {
var e = herald[i];
var li = document.createElement("li");
var ts = document.createElement("span");
ts.className = "ts";
ts.textContent = clockLabel(e.ts);
li.appendChild(ts);
var text = document.createElement("span");
text.textContent = e.text;
li.appendChild(text);
list.appendChild(li);
}
}
function muted(text) {
var li = document.createElement("li");
li.className = "muted";
li.textContent = text;
return li;
}
function setLive(connected, iso) {
if (connected) {
document.body.classList.remove("lost");
liveLabel.textContent = "LIVE \\u2022 updated " + clockLabel(iso);
} else {
document.body.classList.add("lost");
liveLabel.textContent = "SIGNAL LOST";
}
}
var mapFrame = document.querySelector(".map-frame");
// Tint the map by the UTC hour of the world clock. The bands:
// night 20:00-05:59 -> subtle dim + blue ('night' class)
// dawn 06:00-07:59 -> faint amber wash ('twilight' class)
// dusk 18:00-19:59 -> faint amber wash ('twilight' class)
// day 08:00-17:59 -> no tint
// UTC (not local) so every spectator sees the same sky as the game clock.
function applyDayPhase(iso) {
var d = new Date(iso);
mapFrame.classList.remove("night", "twilight");
if (isNaN(d.getTime())) { return; }
var h = d.getUTCHours();
if (h >= 20 || h < 6) {
mapFrame.classList.add("night");
} else if (h < 8 || h >= 18) {
mapFrame.classList.add("twilight");
}
}
function getJSON(url) {
return fetch(url, { cache: "no-store" }).then(function (r) {
if (!r.ok) { throw new Error("HTTP " + r.status); }
return r.json();
});
}
function poll() {
getJSON("./watch/state.json").then(function (state) {
paintPlayers(state.players || []);
renderAdventurers(state.players || []);
renderHall(state.hall || []);
renderHerald(state.herald || []);
applyDayPhase(state.ts);
setLive(true, state.ts);
}).catch(function () {
setLive(false, null);
});
}
var POLL_MS = 2000;
// Bootstrap retries until the base map loads, so a spectator who opens the
// page during a server blip recovers without a manual reload. The poll
// interval starts exactly once, on the first successful boot.
function boot() {
getJSON("./watch/world.json").then(function (world) {
paintMap(world);
poll();
setInterval(poll, POLL_MS);
}).catch(function () {
setLive(false, null);
setTimeout(boot, POLL_MS);
});
}
boot();
})();
</script>
</body>
</html>
"""
def _build_watch_html() -> str:
"""Fill the texture hash formula into the page template.
The JS ``textured`` index is interpolated from
:data:`~understone.screen.texture._HASH_X` / ``_HASH_Y`` so the page's
formula is a derivation of the same two constants the Python renderer uses;
a retune of either moves both, and a guard test pins the agreement.
"""
hash_expr = f"x * {texture._HASH_X} + y * {texture._HASH_Y}"
return _WATCH_HTML_TEMPLATE.replace("__HASH_EXPR__", hash_expr)
WATCH_HTML = _build_watch_html()
@@ -0,0 +1,41 @@
"""Content-pack loading — JSON on disk becomes a runtime ``World``."""
from __future__ import annotations
from pathlib import Path
# The bundled starter pack ("The Vale of Understone"). Single source of truth
# for where packaged content lives — the server's default world and the
# scaffolder's template both resolve here.
PACKAGED_WORLD_DIR = Path(__file__).resolve().parent / "data"
# Zero-or-more bundled ALTERNATE worlds live one directory deeper, each in its
# own ``<slug>/`` subdirectory carrying a ``world.json``. The Vale is special
# (it is the default and lives at ``data/``); alternates are discovered here.
PACKS_DIR = Path(__file__).resolve().parent / "packs"
# The reserved slug of the default Vale — it is never a packs/ subdirectory but
# is always listed first by the discovery helper below.
VALE_SLUG = "vale"
def bundled_world_dirs() -> list[tuple[str, Path]]:
"""Return every bundled world as ``(slug, directory)``, the Vale first.
The default Vale (slug :data:`VALE_SLUG`, the ``data/`` directory) always
leads; the alternates follow in slug-alphabetical order. An alternate is
any immediate subdirectory of :data:`PACKS_DIR` that contains a
``world.json`` non-pack files (the README placeholder) and directories
without a world file are skipped, so the list is exactly the loadable
worlds. This is the single discovery path the ``worlds`` listing and any
future world resolver share.
"""
found: list[tuple[str, Path]] = [(VALE_SLUG, PACKAGED_WORLD_DIR)]
if PACKS_DIR.is_dir():
alternates = [
(entry.name, entry)
for entry in PACKS_DIR.iterdir()
if entry.is_dir() and (entry / "world.json").is_file()
]
found.extend(sorted(alternates, key=lambda pair: pair[0]))
return found
@@ -0,0 +1,102 @@
{
"events": [
{
"kind": "fight",
"weight": 82,
"text": "Something snarls out of the brush."
},
{
"kind": "gold",
"weight": 8,
"text": "a rotted coin-purse half-buried in moss",
"min": 4,
"max": 12
},
{
"kind": "gold",
"weight": 7,
"text": "a few coins spilled from some luckless traveller",
"min": 2,
"max": 9
},
{
"kind": "gold",
"weight": 2,
"text": "a hoard-cache prised from beneath a toppled menhir",
"min": 40,
"max": 80
},
{
"kind": "heal",
"weight": 5,
"text": "a clearwater spring",
"min": 5,
"max": 12
},
{
"kind": "heal",
"weight": 5,
"text": "a quiet fae blessing",
"min": 4,
"max": 10
},
{
"kind": "heal",
"weight": 5,
"text": "a moss-bed where weary travellers mend",
"min": 6,
"max": 14
},
{
"kind": "trap",
"weight": 5,
"text": "a snare of old briars",
"min": 3,
"max": 9
},
{
"kind": "trap",
"weight": 5,
"text": "loose stones twist your ankle",
"min": 2,
"max": 8
},
{
"kind": "trap",
"weight": 5,
"text": "a hidden pit-deadfall, its cover long rotted through",
"min": 4,
"max": 11
},
{
"kind": "lore",
"weight": 3,
"text": "an old waystone, its rune worn to a coiled, scaled shape."
},
{
"kind": "lore",
"weight": 3,
"text": "the wind carries a low note from the east, like something vast turning in its sleep."
},
{
"kind": "lore",
"weight": 4,
"text": "a charcoal sketch nailed to a tree: a stair of stone descending into a great open mouth."
},
{
"kind": "lore",
"weight": 3,
"text": "a scorched ring in the grass where no plant grows, the soil still faintly warm."
},
{
"kind": "lore",
"weight": 3,
"text": "a ranger's cairn marking the dungeon road, three skulls set facing the deep as warning."
},
{
"kind": "lore",
"weight": 4,
"text": "fishermen swear the vale lake has no bottom, and that on still nights it breathes."
}
]
}
@@ -0,0 +1,85 @@
[
{
"id": "rusty_dagger",
"name": "Rusty Dagger",
"slot": "weapon",
"atk": 2,
"price": 0
},
{
"id": "short_sword",
"name": "Short Sword",
"slot": "weapon",
"atk": 5,
"price": 40
},
{
"id": "iron_sword",
"name": "Iron Sword",
"slot": "weapon",
"atk": 7,
"price": 80
},
{
"id": "war_axe",
"name": "War Axe",
"slot": "weapon",
"atk": 9,
"price": 120
},
{
"id": "cloth_tunic",
"name": "Cloth Tunic",
"slot": "armor",
"def": 1,
"price": 0
},
{
"id": "padded_jerkin",
"name": "Padded Jerkin",
"slot": "armor",
"def": 2,
"price": 25
},
{
"id": "leather_armor",
"name": "Leather Armor",
"slot": "armor",
"def": 3,
"price": 50
},
{
"id": "chainmail",
"name": "Chainmail",
"slot": "armor",
"def": 6,
"price": 140
},
{
"id": "minor_potion",
"name": "Minor Potion",
"slot": "consumable",
"heal": 15,
"price": 12
},
{
"id": "greater_potion",
"name": "Greater Potion",
"slot": "consumable",
"heal": 40,
"price": 35
},
{
"id": "elixir_of_the_vale",
"name": "Elixir of the Vale",
"slot": "consumable",
"heal": 70,
"price": 60
},
{
"id": "iron_ore",
"name": "Iron Ore",
"slot": "material",
"price": 0
}
]
@@ -0,0 +1,53 @@
{
"inn": {
"kind": "inn",
"name": "The Sleeping Drake",
"glyph": "⌂",
"color": "inn",
"actions": ["rest", "deposit", "withdraw", "gamble", "leave"],
"flavor": [
"Lamplight pools on worn oak tables.",
"The innkeeper nods toward the hearth.",
"A night's rest restores you fully.",
"An iron strongbox by the bar keeps coin safe from sleeping-robbers.",
"In the corner, a dice cup waits for a wager."
]
},
"shop": {
"kind": "shop",
"name": "Gravel & Sons Outfitters",
"glyph": "$",
"color": "shop",
"actions": ["buy", "sell", "forge", "leave"],
"flavor": [
"Racks of steel and leather line the walls.",
"Buying a weapon or armour equips it at once.",
"Potions go into your satchel, to quaff when the need is dire.",
"The forge glows: bring coin to better your blade or your guard.",
"Old gear sells back for half its price."
]
},
"healer": {
"kind": "healer",
"name": "The Quiet Shrine",
"glyph": "✚",
"color": "healer",
"actions": ["heal", "leave"],
"flavor": [
"Incense curls beneath a still blue flame.",
"The keeper mends wounds for coin, per point of vigour."
]
},
"dungeon": {
"kind": "dungeon",
"name": "The Understone Deep",
"glyph": "∩",
"color": "dungeon",
"actions": ["descend", "challenge", "leave"],
"flavor": [
"A cold throat of stone descends into dark.",
"To descend is to face a troll, then something worse.",
"Deeper still, the old tales say, the Wyrm Below coils and waits."
]
}
}
@@ -0,0 +1,125 @@
[
{
"tier": 1,
"name": "Field Rat",
"hp": 6,
"atk": 3,
"def": 0,
"xp": 8,
"gold": 3
},
{
"tier": 1,
"name": "Mud Hare",
"hp": 7,
"atk": 5,
"def": 0,
"xp": 9,
"gold": 2
},
{
"tier": 2,
"name": "Goblin",
"hp": 12,
"atk": 4,
"def": 1,
"xp": 18,
"gold": 7
},
{
"tier": 2,
"name": "Bandit Scout",
"hp": 14,
"atk": 5,
"def": 1,
"xp": 20,
"gold": 9
},
{
"tier": 2,
"name": "the Gilded Stag",
"hp": 16,
"atk": 6,
"def": 2,
"xp": 40,
"gold": 60,
"weight": 1,
"rare": true
},
{
"tier": 3,
"name": "Forest Wolf",
"hp": 20,
"atk": 7,
"def": 2,
"xp": 35,
"gold": 14
},
{
"tier": 3,
"name": "Bog Stalker",
"hp": 22,
"atk": 8,
"def": 2,
"xp": 38,
"gold": 16
},
{
"tier": 3,
"name": "the Hollow Knight",
"hp": 30,
"atk": 11,
"def": 4,
"xp": 80,
"gold": 110,
"weight": 1,
"rare": true
},
{
"tier": 4,
"name": "Cave Troll",
"hp": 38,
"atk": 11,
"def": 4,
"xp": 70,
"gold": 30
},
{
"tier": 4,
"name": "Barrow Wight",
"hp": 35,
"atk": 12,
"def": 4,
"xp": 65,
"gold": 28
},
{
"tier": 5,
"name": "Stone Wyrm",
"hp": 60,
"atk": 18,
"def": 6,
"xp": 140,
"gold": 60
},
{
"tier": 5,
"name": "Vale Reaver",
"hp": 55,
"atk": 17,
"def": 6,
"xp": 130,
"gold": 55
},
{
"tier": 6,
"name": "the Wyrm Below",
"hp": 120,
"atk": 24,
"def": 8,
"xp": 400,
"gold": 250,
"boss": true,
"id": "wyrm_below"
}
]
@@ -0,0 +1,44 @@
{
".": {
"key": "grass",
"glyph": ".",
"walkable": true,
"encounter_rate": 0.1,
"color": "floor"
},
"T": {
"key": "tree",
"glyph": "♣",
"walkable": false,
"encounter_rate": 0.0,
"color": "tree"
},
"~": {
"key": "water",
"glyph": "≋",
"walkable": false,
"encounter_rate": 0.0,
"color": "water"
},
"=": {
"key": "road",
"glyph": "▒",
"walkable": true,
"encounter_rate": 0.02,
"color": "road"
},
"f": {
"key": "forest",
"glyph": "↑",
"walkable": true,
"encounter_rate": 0.25,
"color": "forest"
},
"#": {
"key": "wall",
"glyph": "█",
"walkable": false,
"encounter_rate": 0.0,
"color": "wall"
}
}
@@ -0,0 +1,136 @@
{
"name": "The Vale of Understone",
"width": 96,
"height": 48,
"spawn": [20, 24],
"legend": {
".": "grass",
"T": "tree",
"~": "water",
"=": "road",
"f": "forest",
"#": "wall"
},
"terrain_rows": [
"TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT",
"TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT",
"TT..............................................f....f.ff.ffffffffffffffffffffffffffffffffffffTT",
"TT..............................................f..f...f.fffffffffffffffffffffffffffffffffffffTT",
"TT....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ffffTT",
"TT....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ffffTT",
"TT....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ffffTT",
"TT....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ffffTT",
"TT................................................f.ff.ff.fff.ffffffffffffffffffffffffffffffffTT",
"TT................................................f..ff.f.ffffffffffffffffffffffffffffffffffffTT",
"TT.....................................................ffffff.ffffffffffffffffffffffffffffffffTT",
"TT...................................................f.ff...ffffffff##f##fffffffffffffffffffffTT",
"TT.............................................f.f.f..f.ffffffffffff#=.f#fffffffffffffffffffffTT",
"TT....................................................f.f.ff.fffff====f##fffffffffffffffffffffTT",
"TT.................................................f.f...fff.fffff=fffffffffffffffffffffffffffTT",
"TT......................................................fff.ffffff=fffffffffffffffffffffffffffTT",
"TT................................................f...fffff.ffffff=fffffffffffffffffffffffffffTT",
"TT.........................................................ff.ffff=fffffffffffffffffffffffffffTT",
"TT...................................................f.f.ff.ffffff=fffffffffffffffffffffffffffTT",
"TT.....................................................ffff..fffff=fffffffffffffffffffffffffffTT",
"TT................................................f....f....ffffff=fffffffffffffffffffffffffffTT",
"TT................................................f..f.ffff..fffff=fffffffffffffffffffffffffffTT",
"TT.......................................................ff.f.ffff=fffffffffffffffffffffffffffTT",
"TT...............................................f.ff.f.....ffffff=fffffffffffffffffffffffffffTT",
"TT..................==.============================================fffffffffffffffffffffffffffTT",
"TT...............................................f..f....fff.fffffffffffffffffffffffffffffffffTT",
"TT...................................................ff.f.f.f.ffffffffffffffffffffffffffffffffTT",
"TT..................................................f...f.fff.ffffffffffffffffffffffffffffffffTT",
"TT...................................................f...ffff.ffffffffffffffffffffffffffffffffTT",
"TT..................................................ff.fff.ff.ffffffffffffffffffffffffffffffffTT",
"TT.................................................f......f.ffffffffffffffffffffffffffffffffffTT",
"TT....................................................ff..ff..ffffffffffffffffffffffffffffffffTT",
"TT....................................................ff..f..fffffffffffffffffffffffffffffffffTT",
"TT......................................................ffffffffffffffffffffffffffffffffffffffTT",
"TT....................................................f.fff...ffffffffffffffffffffffffffffffffTT",
"TT.................................................ff....fffffffffffffffffffffffffffffffffffffTT",
"TT...............................................f........ffffffffffffffffffffffffffffffffffffTT",
"TT..................................................f.ff.....fffffffffffffffffffffffffffffffffTT",
"TT...............................................f.f...ff.f..fffffffffffffffffffffffffffffffffTT",
"TT.....................................................ff..f.fffffffffffffffffffffffffffffffffTT",
"TT...................................................ff...ffffffffffffffffffffffffffffffffffffTT",
"TT................................................f....f.ffff.ffffffffffffffffffffffffffffffffTT",
"TT..................................................f...ff.fffffffffffffffffffffffffffffffffffTT",
"TT....................................................f.ff.f..ffffffffffffffffffffffffffffffffTT",
"TT.............................................ff......fffffffffffffffffffffffffffffffffffffffTT",
"TT................................................f.f...f....fffffffffffffffffffffffffffffffffTT",
"TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT",
"TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT"
],
"locations": [
{
"key": "inn",
"x": 18,
"y": 24
},
{
"key": "shop",
"x": 22,
"y": 24
},
{
"key": "healer",
"x": 20,
"y": 22
},
{
"key": "dungeon",
"x": 70,
"y": 12
}
],
"zones": [
{
"key": "forest_near",
"rect": [30, 18, 60, 36],
"tier_lo": 1,
"tier_hi": 2
},
{
"key": "dungeon_deep",
"rect": [66, 8, 80, 20],
"tier_lo": 3,
"tier_hi": 5
}
],
"settings": {
"daily_turns": 10,
"rest_cost": 15,
"heal_cost_per_hp": 1,
"starting_gold": 37,
"starting_weapon": "rusty_dagger",
"starting_armor": "cloth_tunic",
"start_hp": 20,
"start_atk": 3,
"start_def": 0,
"xp_base": 100,
"growth": {
"max_hp": 6,
"atk": 2,
"def": 1
},
"bestow_daily_budget": 25,
"dungeon_tiers": [3, 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": "greater_potion",
"forge_ore_item": "iron_ore",
"forge_ore_per_plus": 1,
"ore_dungeon_drop": 2,
"ore_forest_chance": 0.2,
"watch_theme": "phosphor"
}
}

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