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.
- uro_core-0.2.0/.gitignore +27 -0
- uro_core-0.2.0/LICENSE +21 -0
- uro_core-0.2.0/PKG-INFO +65 -0
- uro_core-0.2.0/README.md +38 -0
- uro_core-0.2.0/pyproject.toml +40 -0
- uro_core-0.2.0/tests/conftest.py +24 -0
- uro_core-0.2.0/tests/test_alien_acceptance.py +247 -0
- uro_core-0.2.0/tests/test_anthropic.py +94 -0
- uro_core-0.2.0/tests/test_arbiter_shapes.py +162 -0
- uro_core-0.2.0/tests/test_backfill_probe.py +83 -0
- uro_core-0.2.0/tests/test_beat_rng.py +85 -0
- uro_core-0.2.0/tests/test_belief_propagation.py +74 -0
- uro_core-0.2.0/tests/test_branching.py +354 -0
- uro_core-0.2.0/tests/test_chronicler_hardening.py +304 -0
- uro_core-0.2.0/tests/test_core_scaffold.py +5 -0
- uro_core-0.2.0/tests/test_cross_branch_query.py +62 -0
- uro_core-0.2.0/tests/test_deterministic_plan.py +125 -0
- uro_core-0.2.0/tests/test_domain.py +53 -0
- uro_core-0.2.0/tests/test_dryrun.py +39 -0
- uro_core-0.2.0/tests/test_encounter.py +433 -0
- uro_core-0.2.0/tests/test_example_hello_uro.py +29 -0
- uro_core-0.2.0/tests/test_export.py +135 -0
- uro_core-0.2.0/tests/test_extraction_parse.py +51 -0
- uro_core-0.2.0/tests/test_memory.py +235 -0
- uro_core-0.2.0/tests/test_meteor.py +251 -0
- uro_core-0.2.0/tests/test_participant_memory.py +102 -0
- uro_core-0.2.0/tests/test_party.py +315 -0
- uro_core-0.2.0/tests/test_pbta.py +143 -0
- uro_core-0.2.0/tests/test_pipeline.py +255 -0
- uro_core-0.2.0/tests/test_planner.py +322 -0
- uro_core-0.2.0/tests/test_projections.py +157 -0
- uro_core-0.2.0/tests/test_prompts.py +35 -0
- uro_core-0.2.0/tests/test_providers.py +58 -0
- uro_core-0.2.0/tests/test_reaction_layer.py +1513 -0
- uro_core-0.2.0/tests/test_recall.py +218 -0
- uro_core-0.2.0/tests/test_registry.py +98 -0
- uro_core-0.2.0/tests/test_rest_management.py +312 -0
- uro_core-0.2.0/tests/test_ruleset.py +241 -0
- uro_core-0.2.0/tests/test_session_tokens.py +149 -0
- uro_core-0.2.0/tests/test_timeline_integration.py +122 -0
- uro_core-0.2.0/tests/test_war_story.py +146 -0
- uro_core-0.2.0/tests/test_worldpack.py +75 -0
- uro_core-0.2.0/tests/test_worldpack_import.py +178 -0
- uro_core-0.2.0/uro_core/__init__.py +8 -0
- uro_core-0.2.0/uro_core/_distill_core.py +256 -0
- uro_core-0.2.0/uro_core/adapters/__init__.py +1 -0
- uro_core-0.2.0/uro_core/adapters/postgres/__init__.py +1 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/.gitkeep +0 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/001_init.sql +46 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/002_llm_calls.sql +16 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/003_projections.sql +35 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/004_embeddings.sql +23 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/005_timeline_branching.sql +56 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/006_branch_name_unique.sql +9 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/007_pc_binding.sql +14 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/008_sheets.sql +13 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/009_items.sql +13 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/010_factions_edges.sql +27 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/011_threads.sql +11 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/012_actor_status.sql +5 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/013_campaign_ruleset.sql +6 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/014_memory_index_commit_idx.sql +7 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/015_counters.sql +16 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/016_campaign_seed.sql +12 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/017_participant_notes.sql +21 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/018_session_tokens.sql +19 -0
- uro_core-0.2.0/uro_core/adapters/postgres/migrations/019_claim_created_day.sql +6 -0
- uro_core-0.2.0/uro_core/adapters/postgres/projector.py +409 -0
- uro_core-0.2.0/uro_core/adapters/postgres/store.py +1759 -0
- uro_core-0.2.0/uro_core/authored.py +48 -0
- uro_core-0.2.0/uro_core/chronicler.py +86 -0
- uro_core-0.2.0/uro_core/domain/__init__.py +1 -0
- uro_core-0.2.0/uro_core/domain/events.py +992 -0
- uro_core-0.2.0/uro_core/domain/hashing.py +20 -0
- uro_core-0.2.0/uro_core/domain/ids.py +8 -0
- uro_core-0.2.0/uro_core/engines/__init__.py +1 -0
- uro_core-0.2.0/uro_core/engines/actor.py +84 -0
- uro_core-0.2.0/uro_core/engines/history.py +96 -0
- uro_core-0.2.0/uro_core/engines/probe.py +149 -0
- uro_core-0.2.0/uro_core/engines/rules.py +279 -0
- uro_core-0.2.0/uro_core/engines/rules_gauntlet.py +420 -0
- uro_core-0.2.0/uro_core/errors.py +39 -0
- uro_core-0.2.0/uro_core/export.py +146 -0
- uro_core-0.2.0/uro_core/memory/__init__.py +1 -0
- uro_core-0.2.0/uro_core/metering.py +24 -0
- uro_core-0.2.0/uro_core/pipeline/__init__.py +1 -0
- uro_core-0.2.0/uro_core/pipeline/encounter.py +90 -0
- uro_core-0.2.0/uro_core/pipeline/engine.py +852 -0
- uro_core-0.2.0/uro_core/pipeline/extraction.py +208 -0
- uro_core-0.2.0/uro_core/pipeline/mechanics.py +51 -0
- uro_core-0.2.0/uro_core/pipeline/plan.py +192 -0
- uro_core-0.2.0/uro_core/pipeline/prompts.py +48 -0
- uro_core-0.2.0/uro_core/pipeline/recall.py +274 -0
- uro_core-0.2.0/uro_core/ports/__init__.py +1 -0
- uro_core-0.2.0/uro_core/ports/event_store.py +151 -0
- uro_core-0.2.0/uro_core/ports/participant.py +41 -0
- uro_core-0.2.0/uro_core/ports/projections.py +157 -0
- uro_core-0.2.0/uro_core/ports/tokens.py +33 -0
- uro_core-0.2.0/uro_core/ports/vector.py +29 -0
- uro_core-0.2.0/uro_core/prompts/__init__.py +1 -0
- uro_core-0.2.0/uro_core/prompts/extractor.system.j2 +36 -0
- uro_core-0.2.0/uro_core/prompts/narrator.system.j2 +7 -0
- uro_core-0.2.0/uro_core/prompts/planner.system.j2 +12 -0
- uro_core-0.2.0/uro_core/providers/__init__.py +4 -0
- uro_core-0.2.0/uro_core/providers/adapters/__init__.py +4 -0
- uro_core-0.2.0/uro_core/providers/adapters/anthropic.py +146 -0
- uro_core-0.2.0/uro_core/providers/adapters/openai_compat.py +132 -0
- uro_core-0.2.0/uro_core/providers/adapters/stub.py +57 -0
- uro_core-0.2.0/uro_core/providers/base.py +48 -0
- uro_core-0.2.0/uro_core/providers/router.py +66 -0
- uro_core-0.2.0/uro_core/py.typed +0 -0
- uro_core-0.2.0/uro_core/rulesets/__init__.py +4 -0
- uro_core-0.2.0/uro_core/rulesets/base.py +202 -0
- uro_core-0.2.0/uro_core/rulesets/registry.py +58 -0
- uro_core-0.2.0/uro_core/rulesets/rng.py +42 -0
- uro_core-0.2.0/uro_core/rulesets/uro_basic/__init__.py +8 -0
- uro_core-0.2.0/uro_core/rulesets/uro_basic/ruleset.py +328 -0
- uro_core-0.2.0/uro_core/rulesets/uro_pbta/__init__.py +11 -0
- uro_core-0.2.0/uro_core/rulesets/uro_pbta/ruleset.py +278 -0
- uro_core-0.2.0/uro_core/session.py +268 -0
- uro_core-0.2.0/uro_core/timeline/__init__.py +1 -0
- uro_core-0.2.0/uro_core/timeline/models.py +140 -0
- uro_core-0.2.0/uro_core/worldpack/__init__.py +1 -0
- uro_core-0.2.0/uro_core/worldpack/backfill.py +73 -0
- uro_core-0.2.0/uro_core/worldpack/importer.py +64 -0
- uro_core-0.2.0/uro_core/worldpack/models.py +116 -0
- uro_core-0.2.0/uro_core/worldpack/parse.py +130 -0
- uro_core-0.2.0/uro_core/worldpack/rules.py +593 -0
- 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.
|
uro_core-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
uro_core-0.2.0/README.md
ADDED
|
@@ -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())
|