fix(gateway)+benchmark: surprise proxy wire gap; live cross-stack e2e (loop 3, iteration 35)

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]>
This commit is contained in:
John SmithandClaude committed 2026-08-26 17:24:30 -04:00
1 parent e0c5f30643
commit b661542ec8
4 files changed
+560 -2

No files matched your search

+1
View File
@@ -539,3 +539,4 @@ TDD red-first → gates → commit → push → row below.
### L3.33 — 4c1196b — feat(gateway): session spotlight aggregation — pytest 1347
### L3.32 — 3aaecc4 — feat(engine): SRD 5.1 Surprise round, rewind-consistent — cargo 518
### L3.34 — 0e799e7 — feat(client): Surprise badge + mark/clear controls — vitest 781
### L3.35 — [working tree, uncommitted] — fix(gateway): SRD Surprise adjudication proxy wire gap + live cross-stack surprise/delay/spotlight/timeline e2e — pytest 1350/34 standalone, 1366/18 w/ engine, benchmark ALL PASSED
+86 -2
View File
@@ -91,6 +91,10 @@ class TestProxyAuthRequired:
"turn-next": ("/api/v1/engine/turn-next", {"session_id": "s"}),
"combat-begin": ("/api/v1/engine/combat/begin", {"session_id": "s"}),
"combat-end": ("/api/v1/engine/combat/end", {"session_id": "s"}),
"combat-surprise": (
"/api/v1/engine/combat/surprise",
{"session_id": "s", "entity_id": "e", "surprised": True},
),
"damage": (
"/api/v1/engine/damage",
{"session_id": "s", "target_id": "t", "source_event_sequence": 1},
@@ -2039,6 +2043,74 @@ class TestCombatProxy:
assert captured["payload"] == {}
assert captured["actor"] == {"user_id": "gm-1", "role": "gm"}
def test_surprise_without_a_token_is_unauthorized(self):
resp = client.post(
"/api/v1/engine/combat/surprise",
json={"session_id": "s", "entity_id": "e", "surprised": True},
)
assert resp.status_code == 401, "missing credential is an honest 401"
def test_surprise_forwards_gm_role_and_engine_path(self, monkeypatch):
"""Iteration 35 wire-gap regression: the browser's GM Surprised toggle
posts /api/v1/engine/combat/surprise, but the gateway never registered
the proxied form before this iteration — the button 404'd at this
layer while the engine's adjudication route went untested
cross-stack. The proxy must forward ONLY the single-combatant triple
plus the caller's real identity to the engine's canonical path."""
captured: dict = {}
async def fake_engine_request(method, path, payload=None, *, actor=None):
captured.update({"method": method, "path": path, "payload": payload})
captured["actor"] = actor
return {
"status": "SURPRISE_GRANTED",
"entity_id": str(uuid.uuid5(uuid.NAMESPACE_URL, "thorin")),
"surprised": [str(uuid.uuid5(uuid.NAMESPACE_URL, "thorin"))],
"changed": True,
}
monkeypatch.setattr(engine_client, "engine_request", fake_engine_request)
token = self._token("gm-1", "gm")
resp = client.post(
"/api/v1/engine/combat/surprise",
params={"token": token},
json={
"session_id": self.SESSION_ID,
"entity_id": "thorin",
"surprised": True,
},
)
assert resp.status_code == 200
assert resp.json()["status"] == "SURPRISE_GRANTED"
assert captured["method"] == "POST"
assert captured["path"] == (
f"/api/v1/sessions/{self.SESSION_ID}/combat/surprise"
)
# Only the entity + flag travel; the session rides the path; the REAL
# caller identity (GM seat) reaches the engine RBAC.
assert captured["payload"] == {
"entity_id": str(uuid.uuid5(uuid.NAMESPACE_URL, "thorin")),
"surprised": True,
}
assert captured["actor"] == {"user_id": "gm-1", "role": "gm"}
def test_surprise_rejects_extra_body_fields(self):
"""Trust-inversion regression: no surprise-list or order overrides may
be smuggled through — adjudication is one combatant at a time."""
token = self._token("gm-1", "gm")
resp = client.post(
"/api/v1/engine/combat/surprise",
params={"token": token},
json={
"session_id": self.SESSION_ID,
"entity_id": "thorin",
"surprised": True,
"surprised_list": ["orc-1", "orc-2"],
},
)
assert resp.status_code == 422
def test_begin_rejects_extra_body_fields(self):
"""Trust-inversion regression: no initiative overrides smuggled past
the proxy — clients reference the session, never supply combat math."""
@@ -2055,11 +2127,23 @@ class TestCombatProxy:
def test_combat_unreachable_engine_maps_to_502(self, monkeypatch):
monkeypatch.setattr(engine_client, "ENGINE_API_URL", "http://localhost:59999")
for route in ("combat/begin", "combat/end"):
cases = (
("combat/begin", {"session_id": self.SESSION_ID}),
("combat/end", {"session_id": self.SESSION_ID}),
(
"combat/surprise",
{
"session_id": self.SESSION_ID,
"entity_id": "thorin",
"surprised": True,
},
),
)
for route, body in cases:
resp = client.post(
f"/api/v1/engine/{route}",
params={"token": self._token("gm-1", "gm")},
json={"session_id": self.SESSION_ID},
json=body,
)
assert resp.status_code == 502, route
assert "unreachable" in resp.json()["detail"].lower()
@@ -0,0 +1,434 @@
"""LIVE cross-stack exercise: SRD Surprise + Delay + spotlight + timeline.
Loop3 iteration 35: no cross-stack test had exercised Surprise or Delay
end-to-end over HTTP button-click semantics — the Rust unit gates covered
the engine, the vitest suites covered the client against a MOCKED gateway,
and the pytest gate exercised the gateway against a FAKE engine. This module
drives the real gateway (in-process ASGI via TestClient) against the REAL
vtt-server engine (crates/vtt-server) over ENGINE_API_URL.
Liveness gating follows the repo's engine-live convention (test_engine_proxy):
tests SKIP (never fail) when no engine answers on ENGINE_API_URL — CI without
the Rust binary stays green, and a plain pytest run leaves the engine-live
benchmark to the benchmark launcher (scripts/run_all_benchmarks.sh boots the
engine before its pytest phase, so THIS live module runs inside every full
milestone benchmark). This module never boots an engine of its own.
Scenario (GM + one simulated player, all over real HTTP routes the browser
also hits):
create session -> spawn a player-owned PC, a hidden surprised NPC and a
visible NPC -> begin combat -> GM grants Surprise to the PC and the hidden
NPC through the gateway proxy POST /api/v1/engine/combat/surprise (the
iteration-35 wire gap the client's toggle targets) -> the PLAYER snapshot
must show the PC surprised WITHOUT leaking the hidden NPC's flag -> advance
a turn -> the surprise window closes (released after the first turn) ->
delay the PC -> advance again -> the delayed skip works -> POST
spotlight/report -> GET timeline.
Honesty: this is a live run only when the engine responds; every assertion
is against observable HTTP responses from the same routes the browser uses.
"""
from __future__ import annotations
import asyncio
import os
import time
import uuid
import httpx
import pytest
from fastapi.testclient import TestClient
from vtt_orchestrator import server as server_module
from vtt_orchestrator.server import _sign_token, app
client = TestClient(app)
ENGINE_URL = os.environ.get("ENGINE_API_URL", "http://localhost:8088")
def _engine_up() -> bool:
try:
httpx.get(f"{ENGINE_URL}/health", timeout=1.0)
return True
except httpx.HTTPError:
return False
@pytest.fixture(scope="module")
def live_engine():
"""Skip-when-down gate, identical to test_engine_proxy: the module runs
ONLY when an engine answers on ENGINE_API_URL. The benchmark launcher
(scripts/run_all_benchmarks.sh, step [2/4]) boots that engine before
pytest runs, so the milestone benchmark exercises this live; a plain
pytest run with no engine skips it — CI without the Rust binary stays
green. This module never boots an engine of its own."""
if not _engine_up():
pytest.skip("vtt-server engine not running on ENGINE_API_URL")
yield
@pytest.fixture(autouse=True)
def _clean_spotlight_scores():
"""The spotlight score table is module singleton state; start and end
empty (same isolation the spotlight suite gives its tables)."""
server_module.spotlight_scores.clear_all()
yield
server_module.spotlight_scores.clear_all()
def _token(user_id: str, role: str) -> str:
return _sign_token({"user_id": user_id, "role": role, "exp": time.time() + 600})
def _auth(user_id: str, role: str) -> dict:
return {"Authorization": f"Bearer {_token(user_id, role)}"}
def _eid(tag: str) -> str:
"""Stable entity id for a tag (reproducible across runs)."""
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"iter35-live-{tag}"))
KAEL = _eid("kael-pc")
LYRA = _eid("lyra-pc")
SHADOW = _eid("shadow-stalker")
SENTRY = _eid("goblin-sentry")
def _spawn_payload(
entity_id: str,
name: str,
*,
is_player: bool,
owner: str | None,
is_visible: bool,
) -> dict:
"""Full server-side stat block — the same shape the engine proxies accept
and the lobby/character deploy flows send (see test_engine_proxy)."""
return {
"id": entity_id,
"compendium_id": f"iter35_{name.replace(' ', '_').lower()}",
"name": name,
"is_player": is_player,
"owner_player_id": owner,
"current_hp": 12,
"max_hp": 12,
"temp_hp": 0,
"ac": 14,
"speed_feet": 30.0,
"position": [2.5, 2.5, 0.0],
"zone_id": "Zone_Default",
"abilities": {
"strength": 16,
"dexterity": 14,
"constitution": 14,
"intelligence": 10,
"wisdom": 12,
"charisma": 10,
},
"conditions": [],
"action_budget": {
"action": True,
"bonus_action": True,
"reaction": True,
"movement_remaining_feet": 30.0,
"free_object_interaction": True,
},
"spell_slots_remaining": {},
"attacks": [],
"resistances": [],
"vulnerabilities": [],
"immunities": [],
"inventory": {"items": {}},
"is_conscious": True,
"is_dead": False,
"is_visible": is_visible,
}
def _bind_lobby_to_engine_session(host_id: str, guest_id: str, session_id: str) -> None:
"""Create a lobby (session's seats) and bind it to the engine session,
exactly like lobby launch does — required by the spotlight and timeline
routes. Mirrors the binding helper in test_session_spotlight."""
created = client.post(
"/api/v1/lobbies",
params={"token": _token(host_id, "player")},
json={"name": "Iteration 35 Live Table"},
)
assert created.status_code == 200, created.text
lobby_id = created.json()["lobby_id"]
joined = client.post(
f"/api/v1/lobbies/{lobby_id}/join",
params={"token": _token(guest_id, "player")},
json={"invite_code": created.json()["invite_code"]},
)
assert joined.status_code == 200, joined.text
asyncio.run(server_module.storage_backend.set_lobby_session(lobby_id, session_id))
def _session_state(session_id: str, who: dict) -> dict:
resp = client.post(
"/api/v1/engine/session-state",
headers=who,
json={"session_id": session_id},
)
assert resp.status_code == 200, resp.text
return resp.json()
def _gateway_error_code(resp) -> str:
"""FastAPI wraps the gateway/engine rejection detail in {detail}; the
machine-readable code (FORBIDDEN_ROLE / ALREADY_DELAYED / ...) lives at
detail.error — the same key the client's rejectionFrom() unwraps."""
body = resp.json()
return (body.get("detail") or {}).get("error", body.get("error", ""))
def test_live_surprise_delay_spotlight_timeline_stack(live_engine):
"""The full live scenario, one focused run (see module docstring)."""
gm = _auth("gm-live", "gm")
player = _auth("player-live", "player")
# -- 1. create a session through the gateway proxy ---------------------
created = client.post(
"/api/v1/engine/session",
headers=gm,
json={"session_name": "Iteration 35 Surprise & Delay Table",
"campaign_id": "aethertable-default"},
)
assert created.status_code == 200, created.text
session_id: str = created.json()["session_id"]
# -- 2. spawn the combatants (GM seat; hidden NPCs need a GM) -----------
for payload in (
_spawn_payload(KAEL, "Kael Halvar", is_player=True, owner="player-live", is_visible=True),
_spawn_payload(LYRA, "Lyra Nightwind", is_player=True, owner="lyra-live", is_visible=True),
_spawn_payload(SHADOW, "Shadow Stalker", is_player=False, owner=None, is_visible=False),
_spawn_payload(SENTRY, "Goblin Sentry", is_player=False, owner=None, is_visible=True),
):
spawned = client.post(
"/api/v1/engine/spawn",
headers=gm,
json={"session_id": session_id, "entity": payload},
)
assert spawned.status_code == 200, spawned.text
# -- 3. begin combat (GM button; the proxy forwards only the session) ---
begun = client.post(
"/api/v1/engine/combat/begin",
headers=gm,
json={"session_id": session_id},
)
assert begun.status_code == 200, begun.text
combat = begun.json()
assert combat["status"] == "COMBAT_BEGAN"
assert combat["in_combat"] is True
assert combat.get("round") == 1
order = combat["order"]
assert {e["entity_id"] for e in order} == {KAEL, LYRA, SHADOW, SENTRY}
# -- 4. GM adjudicates Surprise through the gateway proxy (the fixed
# /api/v1/engine/combat/surprise route — the client's toggle) -----
granted = client.post(
"/api/v1/engine/combat/surprise",
headers=gm,
json={"session_id": session_id, "entity_id": KAEL, "surprised": True},
)
assert granted.status_code == 200, granted.text
assert granted.json()["status"] == "SURPRISE_GRANTED"
granted_hidden = client.post(
"/api/v1/engine/combat/surprise",
headers=gm,
json={"session_id": session_id, "entity_id": SHADOW, "surprised": True},
)
assert granted_hidden.status_code == 200, granted_hidden.text
assert granted_hidden.json()["status"] == "SURPRISE_GRANTED"
# Non-GM adjudication is refused by the engine's RBAC (403 verbatim).
refused = client.post(
"/api/v1/engine/combat/surprise",
headers=player,
json={"session_id": session_id, "entity_id": SENTRY, "surprised": True},
)
assert refused.status_code == 403
assert _gateway_error_code(refused) == "FORBIDDEN_ROLE"
# -- 5. PLAYER snapshot: own surprise visible, hidden NPC's surprise must
# NOT leak (the Iteration-32 projection, now verified cross-stack) --
player_view = _session_state(session_id, player)
p_surprised = [str(s) for s in player_view["combat"]["surprised"]]
assert KAEL in p_surprised, "the player sees their own surprised PC"
assert SHADOW not in p_surprised, "a hidden surprised NPC must not leak to the player"
p_order = [str(o) for o in player_view["combat"]["order"]]
assert SHADOW not in p_order, "the hidden NPC must not appear in the player's order"
p_entities = player_view.get("entities", {})
assert SHADOW not in p_entities, "the hidden NPC's stat block must not be served to the player"
assert p_entities[KAEL]["name"] == "Kael Halvar", "an owned PC ships its full stat block"
# GM snapshot is the ledger of truth: both ids verbatim.
gm_view = _session_state(session_id, gm)
g_surprised = [str(s) for s in gm_view["combat"]["surprised"]]
assert KAEL in g_surprised and SHADOW in g_surprised, "GM sees the full surprise set"
# -- 6. a STILL-surprised combatant cannot Delay (Iteration-32 disjoint):
# the engine refuses before the first turn concludes --------------
still_surprised_delay = client.post(
"/api/v1/engine/delay",
headers=player,
json={"session_id": session_id, "entity_id": KAEL},
)
assert still_surprised_delay.status_code == 409
assert _gateway_error_code(still_surprised_delay) == "ENTITY_SURPRISED"
# Advance turns: a surprised combatant releases the moment its FIRST TURN
# concludes — the cursor passes over the surprised slot and the penalty
# drops. One /turn/next releases the surprised slots it passes before the
# next clean actor, so the set drains monotonically over at most a full
# initiative lap (never re-grows). Verify the drain live on both views.
prev_count: int | None = None
drained = False
for _ in range(len(order) + 2):
for who, who_name in ((gm, "GM"), (player, "player")):
view = _session_state(session_id, who)
cur = [str(s) for s in view["combat"]["surprised"]]
if prev_count is not None:
assert len(cur) <= prev_count, (
f"surprise set must shrink monotonically ({who_name} view)"
)
prev_count = len(cur)
if len(cur) == 0:
drained = True
if drained:
break
advanced = client.post(
"/api/v1/engine/turn-next",
headers=gm,
json={"session_id": session_id},
)
assert advanced.status_code == 200, advanced.text
assert advanced.json()["status"] == "TURN_ADVANCED"
assert drained, "surprise must drain to empty once every first turn has concluded"
# Round counter has marched; snap current round from the GM truth view.
gm_view = _session_state(session_id, gm)
round_after_drain = gm_view["combat"]["round"]
assert round_after_drain >= 2
# -- 7. once released, Kael delays as its owner --------------------------
parked_delay = client.post(
"/api/v1/engine/delay",
headers=player,
json={"session_id": session_id, "entity_id": KAEL},
)
assert parked_delay.status_code == 200, parked_delay.text
assert parked_delay.json()["status"] == "DELAY_TAKEN"
double_delay = client.post(
"/api/v1/engine/delay",
headers=player,
json={"session_id": session_id, "entity_id": KAEL},
)
assert double_delay.status_code == 409
assert _gateway_error_code(double_delay) == "ALREADY_DELAYED"
# Ownership RBAC: an UNOWNED entity is controllable by any non-spectator
# (documented residual allowance), but another player's PC is not.
unowned_delay = client.post(
"/api/v1/engine/delay",
headers=player,
json={"session_id": session_id, "entity_id": SENTRY},
)
assert unowned_delay.status_code == 200, unowned_delay.text
assert unowned_delay.json()["status"] == "DELAY_TAKEN"
# Undo the unowned park so the later delayed-skip assertions stay focused
# on Kael alone being parked.
client.post(
"/api/v1/engine/delay/resume",
headers=player,
json={"session_id": session_id, "entity_id": SENTRY},
)
foreign_delay = client.post(
"/api/v1/engine/delay",
headers=player,
json={"session_id": session_id, "entity_id": LYRA},
)
assert foreign_delay.status_code == 403
assert _gateway_error_code(foreign_delay) == "ENTITY_NOT_OWNED"
parked = _session_state(session_id, gm)
assert KAEL in [str(d) for d in parked["combat"]["delayed"]], "Kael is parked out of order"
# -- 8. advance again: the delayed slot is skipped, cursor lands elsewhere
advanced2 = client.post(
"/api/v1/engine/turn-next",
headers=gm,
json={"session_id": session_id},
)
assert advanced2.status_code == 200, advanced2.text
assert advanced2.json()["round"] == round_after_drain + 1
after_skip = _session_state(session_id, gm)
comb = after_skip["combat"]
assert comb["round"] == round_after_drain + 1
assert KAEL in [str(d) for d in comb["delayed"]], "Kael stays parked (skip is not a resume)"
current_actor = comb["order"][comb["turn_index"]]
assert str(current_actor) != KAEL, "the delayed combatant's slot must be passed over"
# -- 9. spotlight self-report (bound seat over real HTTP) ----------------
_bind_lobby_to_engine_session("player-live", "gm-live", session_id)
reported = client.post(
f"/api/v1/sessions/{session_id}/spotlight/report",
headers=_auth("player-live", "player"),
json={
"seat_user_id": "player-live",
"duration_ms": 2500,
"occurred_at": int(time.time() * 1000),
},
)
assert reported.status_code == 200, reported.text
assert reported.json()["status"] == "recorded"
assert reported.json()["seat_user_id"] == "player-live"
# A participant crediting ANOTHER seat is refused (SPOTLIGHT_SPOOFED_SEAT).
spoofed = client.post(
f"/api/v1/sessions/{session_id}/spotlight/report",
headers=_auth("player-live", "player"),
json={
"seat_user_id": "gm-live",
"duration_ms": 1000,
"occurred_at": int(time.time() * 1000),
},
)
assert spoofed.status_code == 403
assert _gateway_error_code(spoofed) == "SPOTLIGHT_SPOOFED_SEAT"
# -- 10. timeline: engine ledger events merged over real HTTP ------------
timeline = client.get(
f"/api/v1/sessions/{session_id}/timeline",
headers=gm,
)
assert timeline.status_code == 200, timeline.text
entries = timeline.json()["entries"]
engine_types = [e.get("event_type") for e in entries if e.get("kind") == "engine"]
assert "SESSION_CREATED" in engine_types
assert "SURPRISE_GRANTED" in engine_types
assert "SURPRISE_RELEASED" in engine_types
assert "DELAY_TAKEN" in engine_types
assert "TURN_ADVANCED" in engine_types
# Player's timeline must not carry the hidden NPC's identity anywhere.
p_timeline = client.get(
f"/api/v1/sessions/{session_id}/timeline",
headers=player,
)
assert p_timeline.status_code == 200, p_timeline.text
body = p_timeline.text
assert "Shadow Stalker" not in body, (
"a hidden NPC's name must not surface in the player's timeline feed"
)
gm_timeline_body = timeline.text
assert "Shadow Stalker" in gm_timeline_body, "GM sees the hidden NPC's identity"
+39
View File
@@ -3260,6 +3260,45 @@ async def engine_combat_end(req: EngineCombatActionRequest, token: str = Depends
)
class EngineSurpriseAdjudicationRequest(BaseModel):
"""Body for the SRD Surprise adjudication proxy: the session reference
plus ONE combatant's surprised flag. First-round-only and GM-admin-only —
the engine re-verifies both from the caller identity it resolves."""
session_id: str
entity_id: str
surprised: bool
class Config:
extra = "forbid"
@app.post("/api/v1/engine/combat/surprise")
async def engine_combat_surprise(
req: EngineSurpriseAdjudicationRequest, token: str = Depends(_require_auth)
):
"""GM-only: grant or revoke one combatant's SRD Surprise flag.
Iteration 35 (cross-stack gap): the browser's GM Surprised toggle
(client/src/api/combat_surprise.ts) targets ``/api/v1/engine/combat/
surprise`` and the engine defines the adjudication route, but the
gateway never registered the proxied form — so the button 404'd at
this layer. Same contract as /combat/begin and /combat/end: forwards
ONLY the session reference plus the caller's real identity; the
engine's RBAC re-verifies the GM seat and the first-round window."""
return await _engine_call(
engine_client.engine_request(
"POST",
f"/api/v1/sessions/{engine_client._coerce_uuid(req.session_id)}/combat/surprise",
{
"entity_id": engine_client._coerce_uuid(req.entity_id),
"surprised": req.surprised,
},
actor=_caller_actor(token),
)
)
@app.post("/api/v1/engine/damage")
async def engine_damage(req: EngineDamageRequest, token: str = Depends(_require_auth)):
return await _engine_call(