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.
@@ -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
@@ -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,5 @@
1
+ import uro_server
2
+
3
+
4
+ def test_server_imports() -> None:
5
+ assert uro_server.__version__
@@ -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"