uro-core 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.
Files changed (129) hide show
  1. uro_core-0.2.0/.gitignore +27 -0
  2. uro_core-0.2.0/LICENSE +21 -0
  3. uro_core-0.2.0/PKG-INFO +65 -0
  4. uro_core-0.2.0/README.md +38 -0
  5. uro_core-0.2.0/pyproject.toml +40 -0
  6. uro_core-0.2.0/tests/conftest.py +24 -0
  7. uro_core-0.2.0/tests/test_alien_acceptance.py +247 -0
  8. uro_core-0.2.0/tests/test_anthropic.py +94 -0
  9. uro_core-0.2.0/tests/test_arbiter_shapes.py +162 -0
  10. uro_core-0.2.0/tests/test_backfill_probe.py +83 -0
  11. uro_core-0.2.0/tests/test_beat_rng.py +85 -0
  12. uro_core-0.2.0/tests/test_belief_propagation.py +74 -0
  13. uro_core-0.2.0/tests/test_branching.py +354 -0
  14. uro_core-0.2.0/tests/test_chronicler_hardening.py +304 -0
  15. uro_core-0.2.0/tests/test_core_scaffold.py +5 -0
  16. uro_core-0.2.0/tests/test_cross_branch_query.py +62 -0
  17. uro_core-0.2.0/tests/test_deterministic_plan.py +125 -0
  18. uro_core-0.2.0/tests/test_domain.py +53 -0
  19. uro_core-0.2.0/tests/test_dryrun.py +39 -0
  20. uro_core-0.2.0/tests/test_encounter.py +433 -0
  21. uro_core-0.2.0/tests/test_example_hello_uro.py +29 -0
  22. uro_core-0.2.0/tests/test_export.py +135 -0
  23. uro_core-0.2.0/tests/test_extraction_parse.py +51 -0
  24. uro_core-0.2.0/tests/test_memory.py +235 -0
  25. uro_core-0.2.0/tests/test_meteor.py +251 -0
  26. uro_core-0.2.0/tests/test_participant_memory.py +102 -0
  27. uro_core-0.2.0/tests/test_party.py +315 -0
  28. uro_core-0.2.0/tests/test_pbta.py +143 -0
  29. uro_core-0.2.0/tests/test_pipeline.py +255 -0
  30. uro_core-0.2.0/tests/test_planner.py +322 -0
  31. uro_core-0.2.0/tests/test_projections.py +157 -0
  32. uro_core-0.2.0/tests/test_prompts.py +35 -0
  33. uro_core-0.2.0/tests/test_providers.py +58 -0
  34. uro_core-0.2.0/tests/test_reaction_layer.py +1513 -0
  35. uro_core-0.2.0/tests/test_recall.py +218 -0
  36. uro_core-0.2.0/tests/test_registry.py +98 -0
  37. uro_core-0.2.0/tests/test_rest_management.py +312 -0
  38. uro_core-0.2.0/tests/test_ruleset.py +241 -0
  39. uro_core-0.2.0/tests/test_session_tokens.py +149 -0
  40. uro_core-0.2.0/tests/test_timeline_integration.py +122 -0
  41. uro_core-0.2.0/tests/test_war_story.py +146 -0
  42. uro_core-0.2.0/tests/test_worldpack.py +75 -0
  43. uro_core-0.2.0/tests/test_worldpack_import.py +178 -0
  44. uro_core-0.2.0/uro_core/__init__.py +8 -0
  45. uro_core-0.2.0/uro_core/_distill_core.py +256 -0
  46. uro_core-0.2.0/uro_core/adapters/__init__.py +1 -0
  47. uro_core-0.2.0/uro_core/adapters/postgres/__init__.py +1 -0
  48. uro_core-0.2.0/uro_core/adapters/postgres/migrations/.gitkeep +0 -0
  49. uro_core-0.2.0/uro_core/adapters/postgres/migrations/001_init.sql +46 -0
  50. uro_core-0.2.0/uro_core/adapters/postgres/migrations/002_llm_calls.sql +16 -0
  51. uro_core-0.2.0/uro_core/adapters/postgres/migrations/003_projections.sql +35 -0
  52. uro_core-0.2.0/uro_core/adapters/postgres/migrations/004_embeddings.sql +23 -0
  53. uro_core-0.2.0/uro_core/adapters/postgres/migrations/005_timeline_branching.sql +56 -0
  54. uro_core-0.2.0/uro_core/adapters/postgres/migrations/006_branch_name_unique.sql +9 -0
  55. uro_core-0.2.0/uro_core/adapters/postgres/migrations/007_pc_binding.sql +14 -0
  56. uro_core-0.2.0/uro_core/adapters/postgres/migrations/008_sheets.sql +13 -0
  57. uro_core-0.2.0/uro_core/adapters/postgres/migrations/009_items.sql +13 -0
  58. uro_core-0.2.0/uro_core/adapters/postgres/migrations/010_factions_edges.sql +27 -0
  59. uro_core-0.2.0/uro_core/adapters/postgres/migrations/011_threads.sql +11 -0
  60. uro_core-0.2.0/uro_core/adapters/postgres/migrations/012_actor_status.sql +5 -0
  61. uro_core-0.2.0/uro_core/adapters/postgres/migrations/013_campaign_ruleset.sql +6 -0
  62. uro_core-0.2.0/uro_core/adapters/postgres/migrations/014_memory_index_commit_idx.sql +7 -0
  63. uro_core-0.2.0/uro_core/adapters/postgres/migrations/015_counters.sql +16 -0
  64. uro_core-0.2.0/uro_core/adapters/postgres/migrations/016_campaign_seed.sql +12 -0
  65. uro_core-0.2.0/uro_core/adapters/postgres/migrations/017_participant_notes.sql +21 -0
  66. uro_core-0.2.0/uro_core/adapters/postgres/migrations/018_session_tokens.sql +19 -0
  67. uro_core-0.2.0/uro_core/adapters/postgres/migrations/019_claim_created_day.sql +6 -0
  68. uro_core-0.2.0/uro_core/adapters/postgres/projector.py +409 -0
  69. uro_core-0.2.0/uro_core/adapters/postgres/store.py +1759 -0
  70. uro_core-0.2.0/uro_core/authored.py +48 -0
  71. uro_core-0.2.0/uro_core/chronicler.py +86 -0
  72. uro_core-0.2.0/uro_core/domain/__init__.py +1 -0
  73. uro_core-0.2.0/uro_core/domain/events.py +992 -0
  74. uro_core-0.2.0/uro_core/domain/hashing.py +20 -0
  75. uro_core-0.2.0/uro_core/domain/ids.py +8 -0
  76. uro_core-0.2.0/uro_core/engines/__init__.py +1 -0
  77. uro_core-0.2.0/uro_core/engines/actor.py +84 -0
  78. uro_core-0.2.0/uro_core/engines/history.py +96 -0
  79. uro_core-0.2.0/uro_core/engines/probe.py +149 -0
  80. uro_core-0.2.0/uro_core/engines/rules.py +279 -0
  81. uro_core-0.2.0/uro_core/engines/rules_gauntlet.py +420 -0
  82. uro_core-0.2.0/uro_core/errors.py +39 -0
  83. uro_core-0.2.0/uro_core/export.py +146 -0
  84. uro_core-0.2.0/uro_core/memory/__init__.py +1 -0
  85. uro_core-0.2.0/uro_core/metering.py +24 -0
  86. uro_core-0.2.0/uro_core/pipeline/__init__.py +1 -0
  87. uro_core-0.2.0/uro_core/pipeline/encounter.py +90 -0
  88. uro_core-0.2.0/uro_core/pipeline/engine.py +852 -0
  89. uro_core-0.2.0/uro_core/pipeline/extraction.py +208 -0
  90. uro_core-0.2.0/uro_core/pipeline/mechanics.py +51 -0
  91. uro_core-0.2.0/uro_core/pipeline/plan.py +192 -0
  92. uro_core-0.2.0/uro_core/pipeline/prompts.py +48 -0
  93. uro_core-0.2.0/uro_core/pipeline/recall.py +274 -0
  94. uro_core-0.2.0/uro_core/ports/__init__.py +1 -0
  95. uro_core-0.2.0/uro_core/ports/event_store.py +151 -0
  96. uro_core-0.2.0/uro_core/ports/participant.py +41 -0
  97. uro_core-0.2.0/uro_core/ports/projections.py +157 -0
  98. uro_core-0.2.0/uro_core/ports/tokens.py +33 -0
  99. uro_core-0.2.0/uro_core/ports/vector.py +29 -0
  100. uro_core-0.2.0/uro_core/prompts/__init__.py +1 -0
  101. uro_core-0.2.0/uro_core/prompts/extractor.system.j2 +36 -0
  102. uro_core-0.2.0/uro_core/prompts/narrator.system.j2 +7 -0
  103. uro_core-0.2.0/uro_core/prompts/planner.system.j2 +12 -0
  104. uro_core-0.2.0/uro_core/providers/__init__.py +4 -0
  105. uro_core-0.2.0/uro_core/providers/adapters/__init__.py +4 -0
  106. uro_core-0.2.0/uro_core/providers/adapters/anthropic.py +146 -0
  107. uro_core-0.2.0/uro_core/providers/adapters/openai_compat.py +132 -0
  108. uro_core-0.2.0/uro_core/providers/adapters/stub.py +57 -0
  109. uro_core-0.2.0/uro_core/providers/base.py +48 -0
  110. uro_core-0.2.0/uro_core/providers/router.py +66 -0
  111. uro_core-0.2.0/uro_core/py.typed +0 -0
  112. uro_core-0.2.0/uro_core/rulesets/__init__.py +4 -0
  113. uro_core-0.2.0/uro_core/rulesets/base.py +202 -0
  114. uro_core-0.2.0/uro_core/rulesets/registry.py +58 -0
  115. uro_core-0.2.0/uro_core/rulesets/rng.py +42 -0
  116. uro_core-0.2.0/uro_core/rulesets/uro_basic/__init__.py +8 -0
  117. uro_core-0.2.0/uro_core/rulesets/uro_basic/ruleset.py +328 -0
  118. uro_core-0.2.0/uro_core/rulesets/uro_pbta/__init__.py +11 -0
  119. uro_core-0.2.0/uro_core/rulesets/uro_pbta/ruleset.py +278 -0
  120. uro_core-0.2.0/uro_core/session.py +268 -0
  121. uro_core-0.2.0/uro_core/timeline/__init__.py +1 -0
  122. uro_core-0.2.0/uro_core/timeline/models.py +140 -0
  123. uro_core-0.2.0/uro_core/worldpack/__init__.py +1 -0
  124. uro_core-0.2.0/uro_core/worldpack/backfill.py +73 -0
  125. uro_core-0.2.0/uro_core/worldpack/importer.py +64 -0
  126. uro_core-0.2.0/uro_core/worldpack/models.py +116 -0
  127. uro_core-0.2.0/uro_core/worldpack/parse.py +130 -0
  128. uro_core-0.2.0/uro_core/worldpack/rules.py +593 -0
  129. uro_core-0.2.0/uro_core/worldpack/sufficiency.py +103 -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_core-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,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: uro-core
3
+ Version: 0.2.0
4
+ Summary: Uro Engine core library — persistent, AI-driven RPG world engine (embeddable, headless)
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
+ Keywords: ai,event-sourcing,game-engine,llm,rpg,worldbuilding
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: jinja2>=3.1
14
+ Requires-Dist: pydantic>=2.9
15
+ Requires-Dist: python-ulid>=2.7
16
+ Requires-Dist: pyyaml>=6.0
17
+ Provides-Extra: all
18
+ Requires-Dist: asyncpg>=0.30; extra == 'all'
19
+ Requires-Dist: httpx>=0.27; extra == 'all'
20
+ Requires-Dist: pgvector>=0.5.0; extra == 'all'
21
+ Provides-Extra: llm
22
+ Requires-Dist: httpx>=0.27; extra == 'llm'
23
+ Provides-Extra: postgres
24
+ Requires-Dist: asyncpg>=0.30; extra == 'postgres'
25
+ Requires-Dist: pgvector>=0.5.0; extra == 'postgres'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # uro-core
29
+
30
+ **The Uro Engine core library — the embeddable, headless world-state engine.**
31
+
32
+ Uro is a *world-state engine*: the persistent, versioned, forkable canonical record of a
33
+ living fictional world. A model narrates; a fenced extractor distills that narration into a
34
+ queryable world model — actors, places, factions, beliefs, open threads — that stays
35
+ authoritative while the story plays over it. It tracks not only what is *true* but *who knows
36
+ it*, and world state is **versioned and forkable**: a finished campaign's ending becomes a
37
+ point in history any new campaign can continue from, branch off, or explore as a what-if.
38
+
39
+ `uro-core` contains all engine logic and is embeddable in any consumer — the engine only ever
40
+ sees ports (hexagonal architecture), never concrete adapters.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ pip install "uro-core[postgres,llm]"
46
+ ```
47
+
48
+ The base install is the pure engine (it imports only ports). The shipped adapters live behind
49
+ extras:
50
+
51
+ - `postgres` — the bundled event store + semantic memory over PostgreSQL + pgvector.
52
+ - `llm` — the bundled LLM provider adapters (OpenAI-compatible + Anthropic).
53
+ - `all` — both.
54
+
55
+ An embedder can install the base package and supply their own adapters behind the same ports.
56
+
57
+ ## Runtime
58
+
59
+ The bundled store requires **PostgreSQL 17 + pgvector**. The reference setup runs it via
60
+ Docker on host port 5433; see the project repository.
61
+
62
+ ## Links
63
+
64
+ - Source, docs, and the reference CLI/server: https://github.com/cupskeee/uro
65
+ - License: MIT
@@ -0,0 +1,38 @@
1
+ # uro-core
2
+
3
+ **The Uro Engine core library — the embeddable, headless world-state engine.**
4
+
5
+ Uro is a *world-state engine*: the persistent, versioned, forkable canonical record of a
6
+ living fictional world. A model narrates; a fenced extractor distills that narration into a
7
+ queryable world model — actors, places, factions, beliefs, open threads — that stays
8
+ authoritative while the story plays over it. It tracks not only what is *true* but *who knows
9
+ it*, and world state is **versioned and forkable**: a finished campaign's ending becomes a
10
+ point in history any new campaign can continue from, branch off, or explore as a what-if.
11
+
12
+ `uro-core` contains all engine logic and is embeddable in any consumer — the engine only ever
13
+ sees ports (hexagonal architecture), never concrete adapters.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ pip install "uro-core[postgres,llm]"
19
+ ```
20
+
21
+ The base install is the pure engine (it imports only ports). The shipped adapters live behind
22
+ extras:
23
+
24
+ - `postgres` — the bundled event store + semantic memory over PostgreSQL + pgvector.
25
+ - `llm` — the bundled LLM provider adapters (OpenAI-compatible + Anthropic).
26
+ - `all` — both.
27
+
28
+ An embedder can install the base package and supply their own adapters behind the same ports.
29
+
30
+ ## Runtime
31
+
32
+ The bundled store requires **PostgreSQL 17 + pgvector**. The reference setup runs it via
33
+ Docker on host port 5433; see the project repository.
34
+
35
+ ## Links
36
+
37
+ - Source, docs, and the reference CLI/server: https://github.com/cupskeee/uro
38
+ - License: MIT
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "uro-core"
3
+ version = "0.2.0"
4
+ description = "Uro Engine core library — persistent, AI-driven RPG world engine (embeddable, headless)"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ authors = [{ name = "cupskeee" }]
9
+ keywords = ["ai", "llm", "rpg", "game-engine", "event-sourcing", "worldbuilding"]
10
+ requires-python = ">=3.12"
11
+ # Base = the engine logic (the hexagonal core ring imports only these). The shipped
12
+ # concrete adapters live behind extras — see D-43 / docs/14: `import uro_core` and the
13
+ # core ring stay importable without a DB driver or an HTTP client, so an embedder can
14
+ # supply its own adapters. The bundled Postgres store and LLM providers need the extras.
15
+ dependencies = [
16
+ "pydantic>=2.9",
17
+ "python-ulid>=2.7",
18
+ "jinja2>=3.1",
19
+ "pyyaml>=6.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ # The bundled EventStore/VectorIndex adapter (uro_core.adapters.postgres).
24
+ postgres = ["asyncpg>=0.30", "pgvector>=0.5.0"]
25
+ # The bundled LLM provider adapters (uro_core.providers.adapters — OpenAI-compatible + Anthropic).
26
+ llm = ["httpx>=0.27"]
27
+ # Everything the reference deployment uses.
28
+ all = ["uro-core[postgres,llm]"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/cupskeee/uro"
32
+ Repository = "https://github.com/cupskeee/uro"
33
+ Issues = "https://github.com/cupskeee/uro/issues"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["uro_core"]
@@ -0,0 +1,24 @@
1
+ import os
2
+ from collections.abc import AsyncIterator
3
+
4
+ import asyncpg
5
+ import pytest
6
+ import pytest_asyncio
7
+ from uro_core.adapters.postgres.store import PostgresEventStore
8
+
9
+ DSN = os.environ.get("URO_DATABASE_URL", "postgresql://uro:uro@localhost:5433/uro")
10
+
11
+
12
+ @pytest_asyncio.fixture
13
+ async def store() -> AsyncIterator[PostgresEventStore]:
14
+ """A connected, migrated Postgres store — skips the test if no DB is reachable."""
15
+ s = PostgresEventStore(DSN)
16
+ try:
17
+ await s.connect()
18
+ except (OSError, asyncpg.PostgresError) as exc: # pragma: no cover - env dependent
19
+ pytest.skip(f"Postgres unavailable at {DSN} ({exc})")
20
+ try:
21
+ await s.migrate()
22
+ yield s
23
+ finally:
24
+ await s.close()
@@ -0,0 +1,247 @@
1
+ """Phase 6 ACCEPTANCE (OQ-13, D-30): the alien ruleset plays end-to-end. Deterministic — no LLM.
2
+
3
+ The phase thesis: the engine is game-agnostic (D-1). Proof — a structurally non-d20 ruleset
4
+ (uro_pbta: 2d6 vs 7/10, a harm clock, moves, no hp/ac) runs through the SAME port, runner,
5
+ pipeline, persistence, and fork machinery as uro_basic, and produces a graded consequence a
6
+ binary d20 result cannot express (the 7-9 PARTIAL). Asserted on committed events/projections.
7
+ """
8
+
9
+ from collections.abc import AsyncIterator
10
+
11
+ from uro_core.adapters.postgres.store import PostgresEventStore
12
+ from uro_core.domain.events import actor_created, sheet_updated
13
+ from uro_core.domain.ids import new_id
14
+ from uro_core.export import verify_bundle
15
+ from uro_core.pipeline.encounter import run_encounter
16
+ from uro_core.pipeline.engine import Engine
17
+ from uro_core.providers.adapters.stub import hashing_embedding
18
+ from uro_core.providers.base import CompletionRequest
19
+ from uro_core.providers.router import ProviderRouter
20
+ from uro_core.rulesets import registry
21
+ from uro_core.rulesets.base import CharSpec, Combatant
22
+ from uro_core.rulesets.rng import Rng
23
+ from uro_core.rulesets.uro_basic import UroBasic
24
+ from uro_core.rulesets.uro_pbta import Sheet as PbtaSheet
25
+ from uro_core.rulesets.uro_pbta import UroPbtA
26
+
27
+ PBTA = UroPbtA()
28
+ D20 = UroBasic()
29
+
30
+
31
+ def _pbta(forceful: int) -> dict:
32
+ return PBTA.new_character(CharSpec(data={"stats": {"forceful": forceful}}), Rng(0))
33
+
34
+
35
+ def _d20(abilities: dict[str, int]) -> dict:
36
+ return D20.new_character(CharSpec(data={"abilities": abilities}), Rng(0))
37
+
38
+
39
+ # --- 1. the alien conflict resolves decisively and reaches the THREE-tier band (seed-invariant) ---
40
+
41
+
42
+ def test_pbta_conflict_always_resolves_and_uses_three_tiers() -> None:
43
+ def combs() -> list[Combatant]:
44
+ return [
45
+ Combatant(actor_id="a:ash", team="party", sheet=_pbta(3)),
46
+ Combatant(actor_id="a:bane", team="foes", sheet=_pbta(-3)),
47
+ ]
48
+
49
+ tiers: set[str] = set()
50
+ for seed in range(200):
51
+ events, outcome = run_encounter(PBTA, combs(), Rng(seed), encounter_id="e")
52
+ assert outcome.winner_team in ("party", "foes") # a decisive result every seed
53
+ assert len(outcome.out_of_fight) >= 1 # never a draw or turn-cap stall
54
+ tiers |= {e.payload["result"] for e in events if e.event_type == "EncounterTurnTaken"}
55
+ # miss / partial / full ALL occur across play — the graded band a binary success:bool can't hold
56
+ assert {"miss", "partial", "full"} <= tiers
57
+
58
+
59
+ # --- 2. THE HEADLINE: a 7-9 partial leaves a persistent canonical consequence, carried on a fork -
60
+
61
+
62
+ async def test_partial_consequence_persists_and_forks(store: PostgresEventStore) -> None:
63
+ world = await store.create_world(
64
+ f"Ember-{new_id()}", ruleset_id="uro-pbta", ruleset_version=">=0"
65
+ )
66
+ main = world.main_branch_id
67
+ campaign = await store.start_campaign(
68
+ world.world_id,
69
+ main,
70
+ participant_id="p1",
71
+ new_pc_name="Ash",
72
+ new_pc_id="a:ash",
73
+ pc_sheet=_pbta(3),
74
+ ruleset_id="uro-pbta",
75
+ ruleset_version=">=0",
76
+ )
77
+ assert campaign.ruleset_id == "uro-pbta"
78
+ await store.append_beat(main, [actor_created(actor_id="a:bane", name="Bane", tier=2)])
79
+
80
+ # A conflict with a KNOWN 7-9 partial (seed 4): Ash seizes Bane, succeeds, but is left Exposed.
81
+ combatants = [
82
+ Combatant(actor_id="a:ash", team="party", sheet=_pbta(3)),
83
+ Combatant(actor_id="a:bane", team="foes", sheet=_pbta(-3)),
84
+ ]
85
+ events, outcome = run_encounter(PBTA, combatants, Rng(4), encounter_id="e:1")
86
+ results = [e.payload["result"] for e in events if e.event_type == "EncounterTurnTaken"]
87
+ assert "partial" in results # the d20-inexpressible middle band actually happened
88
+ await store.append_beat(main, events)
89
+
90
+ # the partial's STANDING COST is canon: Ash carries the 'Exposed' condition (not a bool)
91
+ ash = await store.get_sheet(main, "a:ash")
92
+ assert ash is not None and "Exposed" in ash["conditions"]
93
+ assert "hp" not in ash # PbtA harm, never a leaked hp scalar
94
+ bane = await store.get_sheet(main, "a:bane")
95
+ assert bane is not None and bane["harm"] == 4 # the harm clock filled → out of the fight
96
+ assert outcome.winner_team == "party" and "a:bane" in outcome.out_of_fight
97
+
98
+ # ...and it forks like any other state — a sibling branch inherits the condition + harm clock
99
+ head = await store.get_branch(main)
100
+ fork = await store.fork_branch(world.world_id, head.head_commit, "aftermath")
101
+ assert "Exposed" in (await store.get_sheet(fork.branch_id, "a:ash"))["conditions"]
102
+ assert (await store.get_sheet(fork.branch_id, "a:bane"))["harm"] == 4
103
+
104
+
105
+ # --- 3. both rulesets coexist in one engine build (the game-agnosticism claim, concretely) ---
106
+
107
+
108
+ def test_d20_and_pbta_coexist_with_distinct_harm_shapes() -> None:
109
+ d20_events, _ = run_encounter(
110
+ D20,
111
+ [
112
+ Combatant(actor_id="a:h", team="party", sheet=_d20({"STR": 20, "CON": 20})),
113
+ Combatant(actor_id="a:g", team="foes", sheet=_d20({"STR": 8, "CON": 6})),
114
+ ],
115
+ Rng(1),
116
+ encounter_id="e",
117
+ )
118
+ pbta_events, _ = run_encounter(
119
+ PBTA,
120
+ [
121
+ Combatant(actor_id="a:h", team="party", sheet=_pbta(3)),
122
+ Combatant(actor_id="a:g", team="foes", sheet=_pbta(-3)),
123
+ ],
124
+ Rng(1),
125
+ encounter_id="e",
126
+ )
127
+ d20_sheets = [e.payload["sheet"] for e in d20_events if e.event_type == "SheetUpdated"]
128
+ pbta_sheets = [e.payload["sheet"] for e in pbta_events if e.event_type == "SheetUpdated"]
129
+ # d20 harm = hp; PbtA harm = a clock — the SAME runner, two irreconcilable shapes, no leak
130
+ assert d20_sheets and all("hp" in s and "harm" not in s for s in d20_sheets)
131
+ assert pbta_sheets and all("harm" in s and "hp" not in s for s in pbta_sheets)
132
+
133
+
134
+ # --- 4. end-to-end pipeline: a registry-bound PbtA campaign plays a fight beat ---
135
+
136
+
137
+ class _Scripted:
138
+ def __init__(self, *, plan_json: str, narration: str) -> None:
139
+ self._plan = plan_json
140
+ self._narration = narration
141
+
142
+ async def stream(self, req: CompletionRequest) -> AsyncIterator[str]:
143
+ yield self._narration
144
+
145
+ async def complete(self, req: CompletionRequest) -> str:
146
+ return self._plan if req.stage_tag == "planner" else '{"actors": [], "claims": []}'
147
+
148
+ async def embed(self, texts: list[str]) -> list[list[float]]:
149
+ return [hashing_embedding(t) for t in texts]
150
+
151
+
152
+ async def test_pbta_fight_through_the_pipeline(store: PostgresEventStore) -> None:
153
+ world = await store.create_world(
154
+ f"Ember-{new_id()}", ruleset_id="uro-pbta", ruleset_version=">=0"
155
+ )
156
+ main = world.main_branch_id
157
+ campaign = await store.start_campaign(
158
+ world.world_id,
159
+ main,
160
+ participant_id="p1",
161
+ new_pc_name="Ash",
162
+ new_pc_id="a:ash",
163
+ pc_sheet=_pbta(5), # a strong mover so the fight resolves quickly
164
+ ruleset_id="uro-pbta",
165
+ ruleset_version=">=0",
166
+ )
167
+ await store.append_beat(
168
+ main,
169
+ [
170
+ actor_created(actor_id="a:bane", name="Bane", tier=2, role="thug"),
171
+ sheet_updated(actor_id="a:bane", sheet=_pbta(-3), ruleset_id="uro-pbta"),
172
+ ],
173
+ )
174
+
175
+ # the campaign's OWN ruleset is resolved via the registry (the 6.3 binding), not hard-coded
176
+ ruleset = registry.resolve(campaign.ruleset_id, campaign.ruleset_version)
177
+ assert isinstance(ruleset, UroPbtA)
178
+ plan = (
179
+ '{"intent_class":"action","triggers":["violence"],'
180
+ '"mechanics":[{"affordance":"seize_by_force","actor":"a:ash","target":"a:bane"}]}'
181
+ )
182
+ engine = Engine(
183
+ store,
184
+ ProviderRouter(bindings={}, default=_Scripted(plan_json=plan, narration="Ash moves in.")),
185
+ ruleset=ruleset,
186
+ )
187
+ result = await engine.run_beat(campaign, "p1", "I seize the Deep Vein from Bane")
188
+
189
+ async with store.pool.acquire() as conn:
190
+ rows = await conn.fetch(
191
+ "SELECT event_type, payload FROM events WHERE commit_id = $1 ORDER BY seq",
192
+ result.commit_id,
193
+ )
194
+ types = [r["event_type"] for r in rows]
195
+ assert types.count("ModeChanged") == 2 # freeroam→encounter→freeroam
196
+ assert "EncounterStarted" in types and "EncounterEnded" in types
197
+ assert types[-1] == "BeatResolved"
198
+ # every turn used the PbtA vocabulary, never a d20 hit/down
199
+ turn_results = {r["payload"]["result"] for r in rows if r["event_type"] == "EncounterTurnTaken"}
200
+ assert turn_results and turn_results <= {"miss", "partial", "full"}
201
+ # harm reached the timeline as opaque PbtA sheets (clock/conditions), never a leaked hp
202
+ fight_sheets = [
203
+ r["payload"]["sheet"]
204
+ for r in rows
205
+ if r["event_type"] == "SheetUpdated" and r["payload"]["actor_id"] in ("a:ash", "a:bane")
206
+ ]
207
+ assert fight_sheets and all("harm" in s and "hp" not in s for s in fight_sheets)
208
+
209
+ # the fight left persistent, ruleset-shaped state readable through the normal projection
210
+ ash = PbtaSheet.model_validate(await store.get_sheet(main, "a:ash"))
211
+ bane = PbtaSheet.model_validate(await store.get_sheet(main, "a:bane"))
212
+ assert not bane.in_fight or not ash.in_fight # a decisive result persisted
213
+
214
+
215
+ # --- 5. cross-phase cohesion: a PbtA world survives export/import (P6 x P5) ---
216
+
217
+
218
+ async def test_pbta_world_survives_export_import(store: PostgresEventStore) -> None:
219
+ # A PbtA world's ruleset binding + opaque (hp-less) sheets must round-trip through export/import
220
+ # exactly as a fork does — the whole stack is ruleset-agnostic, so the Phase-5 export machinery
221
+ # replays PbtA events with no special-casing. (Cohesion check the phase-end review flagged as
222
+ # untested; verified here rather than assumed.)
223
+ src = await store.create_world(
224
+ f"Ember-{new_id()}", ruleset_id="uro-pbta", ruleset_version=">=0"
225
+ )
226
+ await store.append_beat(
227
+ src.main_branch_id,
228
+ [
229
+ actor_created(actor_id="a:river", name="River", tier=2),
230
+ sheet_updated(
231
+ actor_id="a:river",
232
+ sheet={**_pbta(2), "harm": 3, "conditions": ["Exposed"]},
233
+ ruleset_id="uro-pbta",
234
+ ),
235
+ ],
236
+ )
237
+ bundle = await store.export_world(src.world_id)
238
+ verify_bundle(bundle) # self-consistent hash chain
239
+ dst = await store.import_world(bundle)
240
+ assert dst.world_id != src.world_id # a fresh instance
241
+
242
+ # the ruleset binding survived (WorldGenesis carried it) and rebinds on the imported world
243
+ assert await store.world_ruleset(dst.main_branch_id) == ("uro-pbta", ">=0")
244
+ # the opaque PbtA sheet replayed verbatim — harm clock + condition intact, still no hp
245
+ river = await store.get_sheet(dst.main_branch_id, "a:river")
246
+ assert river is not None and river["harm"] == 3 and river["conditions"] == ["Exposed"]
247
+ assert "hp" not in river
@@ -0,0 +1,94 @@
1
+ """Anthropic Messages API adapter — SSE parsing and request shaping, via mock transport."""
2
+
3
+ import httpx
4
+ import pytest
5
+ from uro_core.errors import ProviderError
6
+ from uro_core.providers.adapters.anthropic import AnthropicProvider
7
+ from uro_core.providers.base import CompletionRequest, Message
8
+
9
+
10
+ def _provider(handler) -> AnthropicProvider:
11
+ return AnthropicProvider(
12
+ model="claude-x", api_key="test-key", transport=httpx.MockTransport(handler)
13
+ )
14
+
15
+
16
+ def _req(json_mode: bool = False) -> CompletionRequest:
17
+ return CompletionRequest(
18
+ messages=[
19
+ Message(role="system", content="You are a narrator."),
20
+ Message(role="user", content="Describe the tavern."),
21
+ ],
22
+ stage_tag="narrator",
23
+ json_mode=json_mode,
24
+ )
25
+
26
+
27
+ async def test_stream_yields_text_deltas() -> None:
28
+ sse = (
29
+ 'event: content_block_delta\ndata: {"type":"content_block_delta",'
30
+ '"delta":{"type":"text_delta","text":"A warm "}}\n\n'
31
+ 'event: content_block_delta\ndata: {"type":"content_block_delta",'
32
+ '"delta":{"type":"text_delta","text":"tavern."}}\n\n'
33
+ 'event: message_stop\ndata: {"type":"message_stop"}\n\n'
34
+ )
35
+ provider = _provider(lambda request: httpx.Response(200, text=sse))
36
+ chunks = [c async for c in provider.stream(_req())]
37
+ assert "".join(chunks) == "A warm tavern."
38
+
39
+
40
+ async def test_stream_puts_system_top_level_and_drops_system_message() -> None:
41
+ seen: dict = {}
42
+
43
+ def handler(request: httpx.Request) -> httpx.Response:
44
+ import json
45
+
46
+ seen.update(json.loads(request.content))
47
+ return httpx.Response(200, text='event: message_stop\ndata: {"type":"message_stop"}\n\n')
48
+
49
+ _ = [c async for c in _provider(handler).stream(_req())]
50
+ assert seen["system"] == "You are a narrator." # system is top-level, not a message
51
+ assert [m["role"] for m in seen["messages"]] == ["user"]
52
+ assert seen["max_tokens"] > 0 # required by the API
53
+
54
+
55
+ async def test_complete_joins_text_blocks() -> None:
56
+ data = {"content": [{"type": "text", "text": "It is quiet."}]}
57
+ provider = _provider(lambda request: httpx.Response(200, json=data))
58
+ assert await provider.complete(_req()) == "It is quiet."
59
+
60
+
61
+ async def test_complete_json_mode_prefills_open_brace() -> None:
62
+ # json_mode prefills an assistant '{' so Claude returns a JSON object.
63
+ data = {"content": [{"type": "text", "text": '"actors":[],"claims":[]}'}]}
64
+ provider = _provider(lambda request: httpx.Response(200, json=data))
65
+ result = await provider.complete(_req(json_mode=True))
66
+ assert result == '{"actors":[],"claims":[]}'
67
+
68
+
69
+ async def test_non_200_raises_provider_error() -> None:
70
+ provider = _provider(lambda request: httpx.Response(400, json={"error": {"message": "bad"}}))
71
+ with pytest.raises(ProviderError):
72
+ await provider.complete(_req())
73
+
74
+
75
+ async def test_embed_raises() -> None:
76
+ provider = _provider(lambda request: httpx.Response(200, json={}))
77
+ with pytest.raises(ProviderError):
78
+ await provider.embed(["anything"])
79
+
80
+
81
+ async def test_complete_raises_on_max_tokens_truncation() -> None:
82
+ # A truncated (stop_reason=max_tokens) response returns HTTP 200 with partial JSON;
83
+ # complete() must fail loudly so the extractor degrades instead of parsing garbage.
84
+ data = {"stop_reason": "max_tokens", "content": [{"type": "text", "text": '"actors":['}]}
85
+ provider = _provider(lambda request: httpx.Response(200, json=data))
86
+ with pytest.raises(ProviderError, match="max_tokens"):
87
+ await provider.complete(_req(json_mode=True))
88
+
89
+
90
+ async def test_complete_raises_on_non_json_body() -> None:
91
+ # A gateway/proxy 200 with an HTML body must degrade to ProviderError, not crash.
92
+ provider = _provider(lambda request: httpx.Response(200, text="<html>gateway</html>"))
93
+ with pytest.raises(ProviderError):
94
+ await provider.complete(_req())