Milestone verification caught the exact defect class it exists for: the
client's GM Surprised toggle dials /api/v1/engine/combat/surprise but
the gateway never registered the proxy — the button 404'd in production
while the vitest mock (which stubs fetch) passed green. Adds
EngineSurpriseAdjudicationRequest + engine_combat_surprise forwarding
{entity_id, surprised} + the caller's real identity; the engine's own
RBAC still enforces GM-seat and first-round window. Regression guards:
combat-surprise in anonymous-401 cases + TestCombatProxy (missing-token
401, path+coerced-UUID payload+real-actor forwarding, extra-field 422,
unreachable-engine 502).
New engine-live module test_live_surprise_delay_timeline.py drives the
real gateway against the real engine (skips when engine down, matching
repo convention): surprise grant through the proxy, hidden-NPC surprise
not leaked to a player feed, ENTITY_SURPRISED delay refusal, surprise
release after first turns, delayed-slot skip, spotlight report +
SPOTLIGHT_SPOOFED_SEAT denial, timeline surfacing SURPRISE_GRANTED/
RELEASED + DELAY_TAKEN with no hidden name in the player feed.
Benchmark run: all gates PASS — playtest MCR 100%, HCI 1.0, AFPR 0.0%,
auditor recall 14/14; cargo 518; pytest 1350/34 standalone (+3 guards),
1366/18 engine-up; client 768/58 + relay 66.
Co-Authored-By: Claude <[email protected]>
F1: /api/v1/engine/delay and /api/v1/engine/delay/resume proxies added
mirroring the dodge/dash/disengage template (EngineEntityActionRequest +
_maneuver_proxy) — the client's existing Delay/Resume UI now actually
reaches the engine; engine verdicts surface verbatim.
F7: encounter-balance roster capped at min=1/max=64; new
_require_staff_role sub-dependency hoists role verification ahead of
body parsing so a non-staff caller with an oversized/malformed body
gets 403 without the gateway allocating/validating the array; route
moved into the tight media bucket (10/min) with rationale documented.
F8: ambience coalescing rewritten with asyncio.shield + an _adopt done-
callback — leader cancellation propagates only to the leader; the
upstream generation continues to populate the cache for later callers,
and late joiners keep coalescing onto the adopted task (exactly 1
upstream call). Normal failure paths still propagate via the shared
future so coalesced-failure semantics are preserved; cap violations
(413) also wake waiters.
Gates: pytest 1232 passed / 33 skipped (baseline 1217, +15).
Co-Authored-By: Claude <[email protected]>
spend_inspiration forwarded on attack/check/save; new authenticated
escape-grapple proxy via _maneuver_proxy (seed non-forwardable per
policy, engine re-checks force privilege); GM-gated inspiration
grant/revoke proxies with zero engine dials for players. 23 tests
red-first; pytest 922 passed / 28 skipped.
Co-Authored-By: Claude <[email protected]>
All narrative-facing /engine/* proxies resolve callers via _require_auth and
forward verified identity (_caller_actor); optional-token service-principal
fallback removed. 28-route anonymous-401 contract test matrix. Client now
attaches ?token=/Bearer on session/attack/check/map/metrics/presence with an
honest NOT_AUTHENTICATED rejection when signed out. Full benchmark run green:
MCR 100%, HCI 1.0, AFPR 0.0%, recall 100%, trust probes 10/10.
Co-Authored-By: Claude <[email protected]>
Completes the SRD standard-action suite. Off-hand attack reuses the
normal hit math with only the damage expression swapped, positive
ability mod withheld per SRD (negative kept), Light property modeled
explicitly (opt-in on stat blocks; undeclared weapons refused, never
silently permitted), and a new spend_bonus_action enforcement point —
plus SRD ordering (Attack action must already be spent). Help stores a
consumable advantage promise ON THE BENEFICIARY, consumed exactly once
by same-side attackers through both attack routes, cleared on refresh,
and rewound with Action refund. Ledger + gateway proxies + client
helpers throughout; seed never client-forwardable. 18 red-first tests;
228 workspace / 476 pytest green, clippy -D clean.
Co-Authored-By: Claude <[email protected]>
Two workstreams sharing server.py, landed together:
Ready action (71): ReadiedAction on EntityState with Action spend via
the single enforcement point, READY_ACTION_SET ledger event, cleared on
round refresh and rewind; engine route + gateway proxy + CharacterSheet
"Holding:" display with description input. GM adjudication by design —
no automatic trigger matching. 6 red-first tests across layers.
Fourth-audit remediation (72): MED-HIGH deploy crash — Roll20 imports
persisting speed:None crashed float(None) → 500 on every deploy of such
characters; guarded fallback to 30 with math.isfinite checks and the WS
movement budget consuming the same value. LOW: out-of-range import
levels now disclose their clamp instead of silently snapping. 8
red-first tests.
Full suite: 470 passed / 22 skipped; clippy -D warnings clean.
Co-Authored-By: Claude <[email protected]>
All six engine action routes (grapple/shove/dodge/dash/disengage/
stabilize) gain gateway proxies with strict extra=forbid bodies and
identity forwarding — notably the deterministic seed is deliberately
NOT forwardable, so clients can't pin their own rolls. CharacterSheet
gains a Combat Maneuvers panel: target picker from the projected
roster, skill/effect toggles, self-buttons, downed-ally stabilize list
with an honest hint when player-role projection hides ally HP; results
quote engine fields and rejection codes verbatim with zero optimistic
mutation. 10 new proxy tests; suite at 459.
Co-Authored-By: Claude <[email protected]>
Second independent audit findings, fixed red-first:
- HIGH: /engine/session-state required no token and returned FULL
engine state verbatim via a service-principal fallback. The route
now requires valid auth (401 otherwise) and no code path returns
anonymous full state.
- MED-HIGH: ledger events bypassed role projection, leaking HP/damage
numbers for hidden entities while replay export redacted them. Both
now share one projection policy (fail-closed summaries for
spectators/unknown roles).
- MED: GET /characters/{id} had an IDOR — any authenticated user could
read any character; now owner-scoped with 404 for foreign/garbage ids
(no existence oracle).
Six remediation tests verified failing before each fix.
Co-Authored-By: Claude <[email protected]>
Two features sharing server.py, landed together:
Initiative order end-to-end (5.6): vtt-core begin/end_combat roll
d20+DEX initiative with deterministic seeded order (total desc, DEX
desc, name asc), COMBAT_BEGAN/ENDED ledger events; engine routes
/sessions/{id}/combat/{begin,end} with RBAC; gateway proxies forward
caller identity; the HUD renders the real rolled order with an honest
"Combat not started" empty state and GM controls. 7 core unit tests +
4 integration + mocked proxy tests.
Role-projected session state (spectator follow-up): session-state and
session-replay now project by caller role — GM/admin see everything,
players see own entities in full plus others as public board-token
projections, spectators get visible-only projections with HP/damage
amounts stripped from replay summaries; service-principal reads stay
verbatim; unknown roles fail closed to spectator view. is_player/
is_dead kept in projections because campaign_sim's targeting depends
on them through this endpoint. 20 red-first tests.
Suite at 325 passed.
Co-Authored-By: Claude <[email protected]>
The browser had no readable path to authoritative post-rewind state,
so local tokens drifted until next hydration. The x-card response now
carries a full serialized GameSession snapshot taken after the rewind
(Null-degrading on serialization failure), and the gateway gains POST
/api/v1/engine/session-state — the first browser-readable proxy to
authoritative engine state, with identity forwarding when a token is
present. Per-role visibility filtering documented as future work.
8 pytest proxy tests + 1 integration test pinning post-heal rewind HP.
Co-Authored-By: Claude <[email protected]>
POST /api/v1/engine/heal and /api/v1/engine/rest follow the established
proxy pattern: required token Query param, _caller_actor forwarded so
engine RBAC authorizes the real caller, strict Pydantic models
(extra=forbid, Literal rest kind) mirroring the engine's
deny_unknown_fields contract so no client math crosses the boundary.
8 new proxy-identity tests; suite at 150 passed.
Co-Authored-By: Claude <[email protected]>
- CORS: replace wildcard origins + credentials with an env-driven
allowlist (CORS_ALLOW_ORIGINS), mirroring the engine's strict_cors.
- identity: engine proxies (spawn/cast-spell/move/turn-next/damage/
reactions-arm, plus attack and death-save) now verify the caller's
token and mint a forwarded-identity HMAC token carrying the real
user_id and role, so the engine's RBAC authorizes the actual actor.
Previously every proxied action spoke as 'orchestrator-service' with
no role and was rejected ENTITY_NOT_OWNED on player-owned entities —
deployed characters could not be driven end-to-end. Omitting the
token keeps legacy service-mediated behavior.
- tests: proxy identity forwarding, 401 on invalid tokens, contract
route updated to act as GM.
Co-Authored-By: Claude <[email protected]>