uro-server 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uro_server-0.2.0/.gitignore +27 -0
- uro_server-0.2.0/LICENSE +21 -0
- uro_server-0.2.0/PKG-INFO +40 -0
- uro_server-0.2.0/README.md +24 -0
- uro_server-0.2.0/pyproject.toml +33 -0
- uro_server-0.2.0/tests/test_server.py +497 -0
- uro_server-0.2.0/tests/test_server_scaffold.py +5 -0
- uro_server-0.2.0/uro_server/__init__.py +8 -0
- uro_server-0.2.0/uro_server/app.py +645 -0
- uro_server-0.2.0/uro_server/py.typed +0 -0
- uro_server-0.2.0/uro_server/sessions.py +118 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
.python-version.local
|
|
9
|
+
|
|
10
|
+
# Tooling caches
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# Deployment config holds credential references — commit uro.example.toml instead (docs/14)
|
|
18
|
+
uro.toml
|
|
19
|
+
.env
|
|
20
|
+
|
|
21
|
+
# IDE / OS
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
24
|
+
.DS_Store
|
|
25
|
+
|
|
26
|
+
# Stray local data dumps — never commit (a PII file named `data` was found here once)
|
|
27
|
+
/data
|
uro_server-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cupskeee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uro-server
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Uro Engine headless server — thin FastAPI shell over uro-core (REST + WebSocket)
|
|
5
|
+
Project-URL: Homepage, https://github.com/cupskeee/uro
|
|
6
|
+
Project-URL: Repository, https://github.com/cupskeee/uro
|
|
7
|
+
Project-URL: Issues, https://github.com/cupskeee/uro/issues
|
|
8
|
+
Author: cupskeee
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Requires-Dist: fastapi>=0.115
|
|
13
|
+
Requires-Dist: uro-core[llm,postgres]==0.2.0
|
|
14
|
+
Requires-Dist: uvicorn[standard]>=0.51.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# uro-server
|
|
18
|
+
|
|
19
|
+
**The Uro Engine headless server — a thin FastAPI shell over [`uro-core`](https://pypi.org/project/uro-core/).**
|
|
20
|
+
|
|
21
|
+
`uro-server` exposes the engine as a REST + WebSocket API. It is transport and session wiring
|
|
22
|
+
only — no engine logic. It runs the WebSocket play channel (broadcast fan-out, token auth) and
|
|
23
|
+
the Chronicler outcome endpoint over a testable dependency seam.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install uro-server
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Runtime
|
|
32
|
+
|
|
33
|
+
The server drives the engine, which requires **PostgreSQL 17 + pgvector** and (for live play) an
|
|
34
|
+
LLM provider — both pulled via `uro-core`'s extras. The reference way to run a server is
|
|
35
|
+
`uro serve` from [`uro-cli`](https://pypi.org/project/uro-cli/), which builds the dependencies.
|
|
36
|
+
|
|
37
|
+
## Links
|
|
38
|
+
|
|
39
|
+
- Source and docs (see `docs/08-api-and-sessions.md`): https://github.com/cupskeee/uro
|
|
40
|
+
- License: MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# uro-server
|
|
2
|
+
|
|
3
|
+
**The Uro Engine headless server — a thin FastAPI shell over [`uro-core`](https://pypi.org/project/uro-core/).**
|
|
4
|
+
|
|
5
|
+
`uro-server` exposes the engine as a REST + WebSocket API. It is transport and session wiring
|
|
6
|
+
only — no engine logic. It runs the WebSocket play channel (broadcast fan-out, token auth) and
|
|
7
|
+
the Chronicler outcome endpoint over a testable dependency seam.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install uro-server
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Runtime
|
|
16
|
+
|
|
17
|
+
The server drives the engine, which requires **PostgreSQL 17 + pgvector** and (for live play) an
|
|
18
|
+
LLM provider — both pulled via `uro-core`'s extras. The reference way to run a server is
|
|
19
|
+
`uro serve` from [`uro-cli`](https://pypi.org/project/uro-cli/), which builds the dependencies.
|
|
20
|
+
|
|
21
|
+
## Links
|
|
22
|
+
|
|
23
|
+
- Source and docs (see `docs/08-api-and-sessions.md`): https://github.com/cupskeee/uro
|
|
24
|
+
- License: MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "uro-server"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Uro Engine headless server — thin FastAPI shell over uro-core (REST + WebSocket)"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [{ name = "cupskeee" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
# The transport shell imports no adapter itself, but a deployed play server needs the store
|
|
11
|
+
# and a provider at runtime, so it pulls both uro-core extras (D-43). uro-core is pinned to the
|
|
12
|
+
# exact lockstep version (single-versioned workspace) so a partial upgrade can't mismatch them;
|
|
13
|
+
# `just release` bumps all three together and `{workspace = true}` fails `uv sync` on a drift.
|
|
14
|
+
dependencies = [
|
|
15
|
+
"uro-core[postgres,llm]==0.2.0",
|
|
16
|
+
"fastapi>=0.115",
|
|
17
|
+
"uvicorn[standard]>=0.51.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/cupskeee/uro"
|
|
22
|
+
Repository = "https://github.com/cupskeee/uro"
|
|
23
|
+
Issues = "https://github.com/cupskeee/uro/issues"
|
|
24
|
+
|
|
25
|
+
[tool.uv.sources]
|
|
26
|
+
uro-core = { workspace = true }
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["uro_server"]
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Phase 5 inc 5.1: the transport shell — token auth + broadcast-shaped WS play channel (docs/08).
|
|
2
|
+
|
|
3
|
+
The acceptance's leg (a): two clients (two tokens) attached to one campaign both receive the
|
|
4
|
+
SAME streamed beats. Tested with fake deps so the transport is exercised without a live DB/model
|
|
5
|
+
(the engine path is tested in uro-core).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from starlette.testclient import TestClient
|
|
13
|
+
from starlette.websockets import WebSocketDisconnect
|
|
14
|
+
from uro_server.app import ServerDeps, create_app
|
|
15
|
+
from uro_server.sessions import SessionHub
|
|
16
|
+
|
|
17
|
+
_TOKENS = {"tok-a": "player-1", "tok-b": "player-2"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fake_deps() -> ServerDeps:
|
|
21
|
+
async def campaign_exists(campaign_id: str) -> bool:
|
|
22
|
+
return campaign_id == "camp-1"
|
|
23
|
+
|
|
24
|
+
async def run_beat(campaign_id: str, participant: str, text: str) -> AsyncIterator[str]:
|
|
25
|
+
for word in ("A ", "grim ", "tide ", "rises."):
|
|
26
|
+
yield word
|
|
27
|
+
|
|
28
|
+
return ServerDeps(
|
|
29
|
+
resolve_participant=lambda token: _TOKENS.get(token),
|
|
30
|
+
campaign_exists=campaign_exists,
|
|
31
|
+
run_beat=run_beat,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _recv_until(ws: Any, target: str, cap: int = 25) -> list[dict[str, Any]]:
|
|
36
|
+
msgs: list[dict[str, Any]] = []
|
|
37
|
+
for _ in range(cap):
|
|
38
|
+
m = ws.receive_json()
|
|
39
|
+
msgs.append(m)
|
|
40
|
+
if m.get("type") == target:
|
|
41
|
+
return msgs
|
|
42
|
+
raise AssertionError(f"never received {target!r}: {[m.get('type') for m in msgs]}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- broadcast fan-out (the multiplayer seam) ---
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def test_session_hub_fans_out_to_all_subscribers() -> None:
|
|
49
|
+
hub = SessionHub()
|
|
50
|
+
a, b = hub.subscribe("c"), hub.subscribe("c")
|
|
51
|
+
other = hub.subscribe("d")
|
|
52
|
+
await hub.publish("c", {"type": "beat_committed"})
|
|
53
|
+
assert a.get_nowait()["type"] == "beat_committed"
|
|
54
|
+
assert b.get_nowait()["type"] == "beat_committed" # both connections got the SAME message
|
|
55
|
+
assert other.empty() # a different campaign is isolated
|
|
56
|
+
assert hub.connections("c") == 2
|
|
57
|
+
hub.unsubscribe("c", a)
|
|
58
|
+
hub.unsubscribe("c", b)
|
|
59
|
+
assert hub.connections("c") == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- the acceptance leg: two clients, one campaign, same beats ---
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_two_clients_receive_the_same_beat() -> None:
|
|
66
|
+
client = TestClient(create_app(_fake_deps()))
|
|
67
|
+
with (
|
|
68
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-a") as a,
|
|
69
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-b") as b,
|
|
70
|
+
):
|
|
71
|
+
a.send_json({"type": "intent", "text": "I scan the drowned pier"})
|
|
72
|
+
msgs_a = _recv_until(a, "beat_committed")
|
|
73
|
+
msgs_b = _recv_until(b, "beat_committed")
|
|
74
|
+
|
|
75
|
+
def committed(msgs: list[dict[str, Any]]) -> dict[str, Any]:
|
|
76
|
+
return next(m for m in msgs if m["type"] == "beat_committed")
|
|
77
|
+
|
|
78
|
+
# BOTH clients saw the same committed beat — narration and the acting participant
|
|
79
|
+
assert committed(msgs_a)["narration"] == "A grim tide rises."
|
|
80
|
+
assert committed(msgs_b)["narration"] == "A grim tide rises."
|
|
81
|
+
assert committed(msgs_b)["participant_id"] == "player-1" # b sees a's beat (broadcast)
|
|
82
|
+
# and both streamed the narration chunk-by-chunk
|
|
83
|
+
assert [m for m in msgs_b if m["type"] == "narration_chunk"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_healthz() -> None:
|
|
87
|
+
assert TestClient(create_app(_fake_deps())).get("/healthz").json() == {"status": "ok"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_bad_token_is_rejected_before_accept() -> None:
|
|
91
|
+
client = TestClient(create_app(_fake_deps()))
|
|
92
|
+
with (
|
|
93
|
+
pytest.raises(WebSocketDisconnect) as excinfo,
|
|
94
|
+
client.websocket_connect("/campaigns/camp-1/play?token=nope"),
|
|
95
|
+
):
|
|
96
|
+
pass
|
|
97
|
+
assert excinfo.value.code == 4401 # unauthorized, closed before accept
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_unknown_campaign_is_rejected() -> None:
|
|
101
|
+
client = TestClient(create_app(_fake_deps()))
|
|
102
|
+
with (
|
|
103
|
+
pytest.raises(WebSocketDisconnect) as excinfo,
|
|
104
|
+
client.websocket_connect("/campaigns/nope/play?token=tok-a"),
|
|
105
|
+
):
|
|
106
|
+
pass
|
|
107
|
+
assert excinfo.value.code == 4404 # no such campaign
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- Chronicler mode: the outcome-bundle endpoint (D-25) ---
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_outcome_endpoint_distills_the_bundle() -> None:
|
|
114
|
+
recorded: dict[str, Any] = {}
|
|
115
|
+
|
|
116
|
+
async def report_outcome(campaign_id: str, bundle: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
recorded["campaign"], recorded["bundle"] = campaign_id, bundle
|
|
118
|
+
return {"committed_events": 4, "commit_id": "c:abc"}
|
|
119
|
+
|
|
120
|
+
deps = _fake_deps()
|
|
121
|
+
deps.report_outcome = report_outcome
|
|
122
|
+
resp = TestClient(create_app(deps)).post(
|
|
123
|
+
"/campaigns/camp-1/encounters/e:battle-7/outcome?token=tok-a",
|
|
124
|
+
json={
|
|
125
|
+
"feats": [{"actor": "a:hero", "description": "split the champion"}],
|
|
126
|
+
"witnesses": ["a:raider1"],
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
assert resp.status_code == 200
|
|
130
|
+
assert resp.json()["committed_events"] == 4
|
|
131
|
+
assert recorded["bundle"]["encounter_id"] == "e:battle-7" # path param injected into the bundle
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_outcome_endpoint_rejects_a_token_scoped_to_another_campaign() -> None:
|
|
135
|
+
# D-41: the outcome endpoint mutates the timeline, so a campaign-scoped minted token (D-39) must
|
|
136
|
+
# be enforced HERE too, not just on the WS play channel. A static token (campaign None) is
|
|
137
|
+
# server-wide and passes.
|
|
138
|
+
hit: dict[str, bool] = {}
|
|
139
|
+
|
|
140
|
+
async def report_outcome(campaign_id: str, bundle: dict[str, Any]) -> dict[str, Any]:
|
|
141
|
+
hit["reached"] = True
|
|
142
|
+
return {"committed_events": 0, "commit_id": "c", "receipt": []}
|
|
143
|
+
|
|
144
|
+
deps = _fake_deps()
|
|
145
|
+
deps.report_outcome = report_outcome
|
|
146
|
+
deps.token_campaign = lambda t: "camp-1" if t == "tok-a" else None # tok-a minted for camp-1
|
|
147
|
+
client = TestClient(create_app(deps))
|
|
148
|
+
blocked = client.post("/campaigns/camp-2/encounters/e/outcome?token=tok-a", json={})
|
|
149
|
+
assert blocked.status_code == 403 and "reached" not in hit # never reached the distiller
|
|
150
|
+
own = client.post("/campaigns/camp-1/encounters/e/outcome?token=tok-a", json={})
|
|
151
|
+
assert own.status_code == 200 # …but tok-a works on its OWN campaign
|
|
152
|
+
static = client.post("/campaigns/camp-2/encounters/e/outcome?token=tok-b", json={})
|
|
153
|
+
assert static.status_code == 200 # …and tok-b (unscoped/static) is server-wide
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_outcome_endpoint_rejects_a_bad_token() -> None:
|
|
157
|
+
resp = TestClient(create_app(_fake_deps())).post(
|
|
158
|
+
"/campaigns/camp-1/encounters/e/outcome?token=nope", json={}
|
|
159
|
+
)
|
|
160
|
+
assert resp.status_code == 401 # an external resolver mutates state → must be authed
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_outcome_endpoint_501_when_chronicler_disabled() -> None:
|
|
164
|
+
resp = TestClient(create_app(_fake_deps())).post( # authed, but report_outcome=None
|
|
165
|
+
"/campaigns/camp-1/encounters/e/outcome?token=tok-a", json={}
|
|
166
|
+
)
|
|
167
|
+
assert resp.status_code == 501
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- Phase 6 (D-30) cross-phase seam (P6 x P5): per-campaign ruleset pin vs one-Engine server ---
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_beat_failure_is_broadcast_not_a_ws_crash() -> None:
|
|
174
|
+
# A beat that raises (e.g. a ruleset mismatch, a provider error) must degrade to a graceful
|
|
175
|
+
# 'beat_failed' broadcast, keeping the session alive — not crash the WS connection.
|
|
176
|
+
async def boom(campaign_id: str, participant: str, text: str): # type: ignore[no-untyped-def]
|
|
177
|
+
raise RuntimeError("kaboom")
|
|
178
|
+
yield "" # pragma: no cover — makes this an async generator
|
|
179
|
+
|
|
180
|
+
async def campaign_exists(campaign_id: str) -> bool:
|
|
181
|
+
return campaign_id == "camp-1"
|
|
182
|
+
|
|
183
|
+
deps = ServerDeps(
|
|
184
|
+
resolve_participant=lambda t: _TOKENS.get(t),
|
|
185
|
+
campaign_exists=campaign_exists,
|
|
186
|
+
run_beat=boom,
|
|
187
|
+
)
|
|
188
|
+
with TestClient(create_app(deps)).websocket_connect("/campaigns/camp-1/play?token=tok-a") as ws:
|
|
189
|
+
_recv_until(ws, "participant_joined")
|
|
190
|
+
ws.send_json({"type": "intent", "text": "I swing"})
|
|
191
|
+
msgs = _recv_until(ws, "beat_failed")
|
|
192
|
+
failed = msgs[-1]
|
|
193
|
+
assert failed["error"] == "kaboom" and failed["intent"] == "I swing"
|
|
194
|
+
# the connection is still alive — another intent still gets a fresh beat_failed
|
|
195
|
+
ws.send_json({"type": "intent", "text": "again"})
|
|
196
|
+
assert _recv_until(ws, "beat_failed")[-1]["intent"] == "again"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_engine_deps_rejects_a_ruleset_mismatch_with_a_clear_error() -> None:
|
|
200
|
+
# engine_deps.run_beat must reject a campaign pinned to a DIFFERENT ruleset than the server's
|
|
201
|
+
# single Engine holds — with a diagnostic naming both, not a raw ValidationError from deep in
|
|
202
|
+
# sheet validation (P6 per-campaign pin x P5 one-ruleset server).
|
|
203
|
+
from uro_core.pipeline.engine import Engine
|
|
204
|
+
from uro_core.providers.adapters.stub import StubProvider
|
|
205
|
+
from uro_core.providers.router import ProviderRouter
|
|
206
|
+
from uro_core.rulesets.uro_basic import UroBasic
|
|
207
|
+
from uro_core.timeline.models import Campaign
|
|
208
|
+
from uro_server.app import engine_deps
|
|
209
|
+
|
|
210
|
+
class _FakeStore:
|
|
211
|
+
async def get_campaign(self, campaign_id: str) -> Campaign:
|
|
212
|
+
return Campaign(
|
|
213
|
+
campaign_id=campaign_id,
|
|
214
|
+
world_id="w",
|
|
215
|
+
branch_id="b",
|
|
216
|
+
ruleset_id="uro-pbta", # pinned to the alien ruleset
|
|
217
|
+
ruleset_version=">=0",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
store = _FakeStore()
|
|
221
|
+
engine = Engine(store, ProviderRouter(bindings={}, default=StubProvider()), ruleset=UroBasic())
|
|
222
|
+
deps = engine_deps(store, engine, _TOKENS) # type: ignore[arg-type]
|
|
223
|
+
|
|
224
|
+
with pytest.raises(ValueError, match="uro-pbta"):
|
|
225
|
+
async for _ in deps.run_beat("camp-1", "player-1", "I seize the vein"):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --- Phase 7 (OQ-7 → D-31): PartyArbiter round-robin over the WS play channel ---
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_party_arbiter_round_robin_over_the_ws_channel() -> None:
|
|
233
|
+
# Two connected participants share one campaign; free-roam turns rotate round-robin. The
|
|
234
|
+
# holder's intent runs; an out-of-turn intent is told NOT_YOUR_TURN (not rejected); the token
|
|
235
|
+
# rotates on beat_committed. Read all broadcasts off ONE socket (fan-out reaches both).
|
|
236
|
+
from uro_core.session import PartyArbiter
|
|
237
|
+
|
|
238
|
+
app = create_app(_fake_deps(), arbiter=PartyArbiter())
|
|
239
|
+
client = TestClient(app)
|
|
240
|
+
with (
|
|
241
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-a") as a, # player-1 joins first
|
|
242
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-b") as b, # player-2 second
|
|
243
|
+
):
|
|
244
|
+
_recv_until(a, "participant_joined") # a sees its own join
|
|
245
|
+
_recv_until(a, "participant_joined") # ...and player-2's join → roster [player-1, player-2]
|
|
246
|
+
|
|
247
|
+
# player-2 acts out of turn (player-1 holds) → NOT_YOUR_TURN, no beat
|
|
248
|
+
b.send_json({"type": "intent", "text": "me first"})
|
|
249
|
+
nyt = _recv_until(a, "not_your_turn")[-1]
|
|
250
|
+
assert nyt["participant_id"] == "player-2"
|
|
251
|
+
|
|
252
|
+
# player-1 (the holder) acts → a beat commits, and the token rotates to player-2
|
|
253
|
+
a.send_json({"type": "intent", "text": "my turn"})
|
|
254
|
+
assert _recv_until(a, "beat_committed")[-1]["participant_id"] == "player-1"
|
|
255
|
+
|
|
256
|
+
# now player-2 holds → their intent runs
|
|
257
|
+
b.send_json({"type": "intent", "text": "now me"})
|
|
258
|
+
assert _recv_until(a, "beat_committed")[-1]["participant_id"] == "player-2"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --- D-38 (#9): arbiter shapes beyond round-robin — non-canon lane + proposal-window + vote ---
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_table_talk_fans_out_without_ever_committing_a_beat() -> None:
|
|
265
|
+
# INC-1: the non-canon coordination lane broadcasts to the whole session and NEVER calls
|
|
266
|
+
# run_beat — the SOLE path to append_beat. That run_beat is untouched IS the structural
|
|
267
|
+
# non-canon guarantee (no branch-head move, no event, no commit can happen).
|
|
268
|
+
calls: list[str] = []
|
|
269
|
+
|
|
270
|
+
async def run_beat(campaign_id: str, participant: str, text: str): # type: ignore[no-untyped-def]
|
|
271
|
+
calls.append(text)
|
|
272
|
+
yield "narr"
|
|
273
|
+
|
|
274
|
+
async def campaign_exists(campaign_id: str) -> bool:
|
|
275
|
+
return campaign_id == "camp-1"
|
|
276
|
+
|
|
277
|
+
deps = ServerDeps(
|
|
278
|
+
resolve_participant=lambda t: _TOKENS.get(t),
|
|
279
|
+
campaign_exists=campaign_exists,
|
|
280
|
+
run_beat=run_beat,
|
|
281
|
+
)
|
|
282
|
+
client = TestClient(create_app(deps))
|
|
283
|
+
with (
|
|
284
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-a") as a,
|
|
285
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-b") as b,
|
|
286
|
+
):
|
|
287
|
+
_recv_until(a, "participant_joined")
|
|
288
|
+
a.send_json({"type": "table_talk", "text": "we should split up"})
|
|
289
|
+
tt = _recv_until(b, "table_talk")[-1] # the OTHER client received it (broadcast fan-out)
|
|
290
|
+
assert tt["participant_id"] == "player-1" and tt["text"] == "we should split up"
|
|
291
|
+
assert calls == [] # run_beat NEVER ran → no beat, no append_beat, no commit (structural)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_proposal_window_surfaces_a_non_holder_intent_as_a_proposal() -> None:
|
|
295
|
+
# INC-2 (G-10): a non-holder's intent becomes a first-class PROPOSAL (not a silent
|
|
296
|
+
# not_your_turn, not a rejection, and NOT a beat); the holder enacts it as an ordinary beat.
|
|
297
|
+
from uro_core.session import ProposalWindowArbiter
|
|
298
|
+
|
|
299
|
+
client = TestClient(create_app(_fake_deps(), arbiter=ProposalWindowArbiter()))
|
|
300
|
+
with (
|
|
301
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-a") as a, # player-1 holds
|
|
302
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-b") as b, # player-2
|
|
303
|
+
):
|
|
304
|
+
_recv_until(a, "participant_joined")
|
|
305
|
+
_recv_until(a, "participant_joined") # roster [player-1, player-2]
|
|
306
|
+
b.send_json({"type": "intent", "text": "we should bribe the guard"})
|
|
307
|
+
msgs = _recv_until(a, "proposal_opened")
|
|
308
|
+
pr = msgs[-1]
|
|
309
|
+
assert pr["participant_id"] == "player-2" and pr["text"] == "we should bribe the guard"
|
|
310
|
+
assert not any(m["type"] == "beat_started" for m in msgs) # a proposal is NOT a beat
|
|
311
|
+
# the holder enacts → an ordinary beat commits, the token rotates
|
|
312
|
+
a.send_json({"type": "intent", "text": "I bribe the guard"})
|
|
313
|
+
assert _recv_until(a, "beat_committed")[-1]["participant_id"] == "player-1"
|
|
314
|
+
# now player-2 holds → their intent runs as a real beat (proposal-window keeps round-robin)
|
|
315
|
+
b.send_json({"type": "intent", "text": "now me"})
|
|
316
|
+
assert _recv_until(a, "beat_committed")[-1]["participant_id"] == "player-2"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_vote_tallies_on_the_lane_and_decides_without_burning_a_beat() -> None:
|
|
320
|
+
# INC-3 (G-11): votes ride the non-canon lane; the tally is server-side (session-only); a
|
|
321
|
+
# decided vote is announced but NOT auto-enacted (take_pending deferred) — no beat is burned.
|
|
322
|
+
from uro_core.session import VoteArbiter
|
|
323
|
+
|
|
324
|
+
client = TestClient(create_app(_fake_deps(), arbiter=VoteArbiter()))
|
|
325
|
+
with (
|
|
326
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-a") as a,
|
|
327
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-b") as b,
|
|
328
|
+
):
|
|
329
|
+
_recv_until(a, "participant_joined")
|
|
330
|
+
_recv_until(a, "participant_joined") # roster [player-1, player-2]
|
|
331
|
+
a.send_json({"type": "vote", "choice": "go loud"})
|
|
332
|
+
assert _recv_until(a, "vote_tally")[-1]["tally"] == {"go loud": 1} # 1/2, no decision yet
|
|
333
|
+
b.send_json({"type": "vote", "choice": "go loud"})
|
|
334
|
+
msgs = _recv_until(a, "vote_decided")
|
|
335
|
+
assert msgs[-1]["choice"] == "go loud" # 2-0 → decided
|
|
336
|
+
assert not any(m["type"] == "beat_started" for m in msgs) # no vote burned a beat
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_vote_on_a_non_vote_arbiter_gets_feedback_not_silence() -> None:
|
|
340
|
+
# Review fix: the CLI advertises /vote unconditionally, but a non-VoteCoordinator arbiter (the
|
|
341
|
+
# default party) can't tally — the server must say so, not silently swallow the frame.
|
|
342
|
+
from uro_core.session import PartyArbiter
|
|
343
|
+
|
|
344
|
+
client = TestClient(create_app(_fake_deps(), arbiter=PartyArbiter()))
|
|
345
|
+
with client.websocket_connect("/campaigns/camp-1/play?token=tok-a") as a:
|
|
346
|
+
_recv_until(a, "participant_joined")
|
|
347
|
+
a.send_json({"type": "vote", "choice": "go loud"})
|
|
348
|
+
assert _recv_until(a, "vote_unsupported")[-1]["participant_id"] == "player-1"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_vote_resolves_when_the_last_holdout_disconnects() -> None:
|
|
352
|
+
# Review liveness fix: two of three vote the same, the third leaves without voting → the round
|
|
353
|
+
# is now complete; the server recomputes on disconnect (resolve_pending) and broadcasts the
|
|
354
|
+
# decision, instead of the round silently never resolving.
|
|
355
|
+
from uro_core.session import VoteArbiter
|
|
356
|
+
|
|
357
|
+
_THREE = {"tok-a": "player-1", "tok-b": "player-2", "tok-c": "player-3"}
|
|
358
|
+
|
|
359
|
+
async def campaign_exists(campaign_id: str) -> bool:
|
|
360
|
+
return campaign_id == "camp-1"
|
|
361
|
+
|
|
362
|
+
async def run_beat(campaign_id: str, participant: str, text: str): # type: ignore[no-untyped-def]
|
|
363
|
+
yield "narr"
|
|
364
|
+
|
|
365
|
+
deps = ServerDeps(
|
|
366
|
+
resolve_participant=lambda t: _THREE.get(t),
|
|
367
|
+
campaign_exists=campaign_exists,
|
|
368
|
+
run_beat=run_beat,
|
|
369
|
+
)
|
|
370
|
+
client = TestClient(create_app(deps, arbiter=VoteArbiter()))
|
|
371
|
+
with (
|
|
372
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-a") as a,
|
|
373
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-b") as b,
|
|
374
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-c") as c,
|
|
375
|
+
):
|
|
376
|
+
for _ in range(3):
|
|
377
|
+
_recv_until(a, "participant_joined") # roster [player-1, player-2, player-3]
|
|
378
|
+
a.send_json({"type": "vote", "choice": "flee"})
|
|
379
|
+
_recv_until(a, "vote_tally")
|
|
380
|
+
b.send_json({"type": "vote", "choice": "flee"})
|
|
381
|
+
_recv_until(a, "vote_tally") # 2/3 — undecided, waiting on player-3
|
|
382
|
+
c.close() # the last holdout leaves without voting → the round is now 2/2 complete
|
|
383
|
+
assert _recv_until(a, "vote_decided")[-1]["choice"] == "flee"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# --- D-39 (#10): the runtime token registry (durable, hashed, revocable, off the branch axis) ---
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class _FakeTokenStore:
|
|
390
|
+
"""In-memory SessionTokenStore for the registry unit test — mirrors the durable 018 table."""
|
|
391
|
+
|
|
392
|
+
def __init__(self) -> None:
|
|
393
|
+
self.rows: dict[str, tuple[str, str, bool]] = {} # hash → (participant, campaign, revoked)
|
|
394
|
+
|
|
395
|
+
async def mint_token(self, token_hash: str, participant_id: str, campaign_id: str) -> None:
|
|
396
|
+
self.rows[token_hash] = (participant_id, campaign_id, False)
|
|
397
|
+
|
|
398
|
+
async def revoke_token(self, token_hash: str) -> bool:
|
|
399
|
+
row = self.rows.get(token_hash)
|
|
400
|
+
if row is not None and not row[2]:
|
|
401
|
+
self.rows[token_hash] = (row[0], row[1], True)
|
|
402
|
+
return True
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
async def list_session_tokens(self) -> list[tuple[str, str, str]]:
|
|
406
|
+
return [(h, p, c) for h, (p, c, revoked) in self.rows.items() if not revoked]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
async def test_token_registry_mint_resolve_revoke_and_scope() -> None:
|
|
410
|
+
from uro_server.sessions import TokenRegistry
|
|
411
|
+
|
|
412
|
+
# "op" is the operator (admin) tier; "pleb" is a plain player token — NOT admin (D-39 review)
|
|
413
|
+
reg = TokenRegistry(_FakeTokenStore(), {"op": "gm", "pleb": "carol"}, admin_tokens={"op"})
|
|
414
|
+
assert reg.resolve("op") == "gm" and reg.is_admin("op")
|
|
415
|
+
assert reg.resolve("pleb") == "carol" and not reg.is_admin("pleb") # a plain peer is not admin
|
|
416
|
+
tok = await reg.mint("bob", "camp-1")
|
|
417
|
+
assert reg.resolve(tok) == "bob" and not reg.is_admin(tok) # minted → resolves, NOT admin
|
|
418
|
+
assert reg.campaign_of(tok) == "camp-1" # minted tokens are campaign-scoped
|
|
419
|
+
assert reg.campaign_of("op") is None # a static/operator token is server-wide (unscoped)
|
|
420
|
+
assert await reg.revoke(tok) is True
|
|
421
|
+
assert reg.resolve(tok) is None # revoked → denied
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
async def test_token_registry_stores_only_the_hash_and_survives_a_restart() -> None:
|
|
425
|
+
import hashlib
|
|
426
|
+
|
|
427
|
+
from uro_server.sessions import TokenRegistry
|
|
428
|
+
|
|
429
|
+
store = _FakeTokenStore()
|
|
430
|
+
tok = await TokenRegistry(store, {}).mint("bob", "camp-1")
|
|
431
|
+
# only sha256(token) is persisted — never the plaintext
|
|
432
|
+
assert list(store.rows.keys()) == [hashlib.sha256(tok.encode()).hexdigest()]
|
|
433
|
+
# a FRESH registry (a server restart) with a cold cache hydrates from the store and resolves it
|
|
434
|
+
reg2 = TokenRegistry(store, {})
|
|
435
|
+
assert reg2.resolve(tok) is None # cold before hydrate
|
|
436
|
+
await reg2.hydrate()
|
|
437
|
+
assert reg2.resolve(tok) == "bob" # durable across a restart
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_ws_rejects_a_token_scoped_to_another_campaign() -> None:
|
|
441
|
+
# D-39 review: a MINTED token is campaign-scoped; using it on ANOTHER campaign's play channel is
|
|
442
|
+
# rejected before accept (cross-campaign PC hijack). A static/operator token (token_campaign
|
|
443
|
+
# None) is intentionally server-wide and unaffected.
|
|
444
|
+
async def campaign_exists(campaign_id: str) -> bool:
|
|
445
|
+
return campaign_id in ("camp-1", "camp-2")
|
|
446
|
+
|
|
447
|
+
async def run_beat(campaign_id: str, participant: str, text: str): # type: ignore[no-untyped-def]
|
|
448
|
+
yield "x"
|
|
449
|
+
|
|
450
|
+
deps = ServerDeps(
|
|
451
|
+
resolve_participant=lambda t: "bob" if t == "bob-tok" else None,
|
|
452
|
+
campaign_exists=campaign_exists,
|
|
453
|
+
run_beat=run_beat,
|
|
454
|
+
token_campaign=lambda t: "camp-1" if t == "bob-tok" else None, # bob-tok minted for camp-1
|
|
455
|
+
)
|
|
456
|
+
client = TestClient(create_app(deps))
|
|
457
|
+
with client.websocket_connect("/campaigns/camp-1/play?token=bob-tok") as ws:
|
|
458
|
+
_recv_until(ws, "participant_joined") # works on its OWN campaign
|
|
459
|
+
with (
|
|
460
|
+
pytest.raises(WebSocketDisconnect) as exc,
|
|
461
|
+
client.websocket_connect("/campaigns/camp-2/play?token=bob-tok"),
|
|
462
|
+
):
|
|
463
|
+
pass
|
|
464
|
+
assert exc.value.code == 4403 # rejected on a DIFFERENT campaign, before accept
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def test_a_failed_beat_still_rotates_the_party_token() -> None:
|
|
468
|
+
# cross-phase review: a deterministically-failing turn-holder must NOT wedge the party — the
|
|
469
|
+
# token still rotates on beat_failed, so the next participant can act.
|
|
470
|
+
from uro_core.session import PartyArbiter
|
|
471
|
+
|
|
472
|
+
async def always_fail(campaign_id: str, participant: str, text: str): # type: ignore[no-untyped-def]
|
|
473
|
+
raise RuntimeError("boom")
|
|
474
|
+
yield "" # pragma: no cover — makes this an async generator
|
|
475
|
+
|
|
476
|
+
async def campaign_exists(campaign_id: str) -> bool:
|
|
477
|
+
return campaign_id == "camp-1"
|
|
478
|
+
|
|
479
|
+
deps = ServerDeps(
|
|
480
|
+
resolve_participant=lambda t: _TOKENS.get(t),
|
|
481
|
+
campaign_exists=campaign_exists,
|
|
482
|
+
run_beat=always_fail,
|
|
483
|
+
)
|
|
484
|
+
app = create_app(deps, arbiter=PartyArbiter())
|
|
485
|
+
client = TestClient(app)
|
|
486
|
+
with (
|
|
487
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-a") as a, # player-1 (holder)
|
|
488
|
+
client.websocket_connect("/campaigns/camp-1/play?token=tok-b") as b, # player-2
|
|
489
|
+
):
|
|
490
|
+
_recv_until(a, "participant_joined")
|
|
491
|
+
_recv_until(a, "participant_joined")
|
|
492
|
+
# player-1 (holder) submits → beat FAILS; the token must rotate to player-2 anyway
|
|
493
|
+
a.send_json({"type": "intent", "text": "boom"})
|
|
494
|
+
assert _recv_until(a, "beat_failed")[-1]["participant_id"] == "player-1"
|
|
495
|
+
# player-2 now holds → their intent runs (also fails) — NOT a not_your_turn (rotation held)
|
|
496
|
+
b.send_json({"type": "intent", "text": "boom"})
|
|
497
|
+
assert _recv_until(a, "beat_failed")[-1]["participant_id"] == "player-2"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Uro Engine server: transport, sessions, wiring — no engine logic. Governing doc: 08."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("uro-server")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without an install
|
|
8
|
+
__version__ = "0.0.0+unknown"
|