From cce40458d3396f13321cef4bf695ceed47e2c0ce Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 25 Jun 2026 23:37:38 -0700 Subject: [PATCH] feat: add RPG runtime harness --- ...ign-session-runtime-implementation-plan.md | 2911 +++++++++++++++++ ...ttachment-retrieval-implementation-plan.md | 972 ++++++ ...-25-rpg-campaign-session-runtime-design.md | 566 ++++ ...-rules-pack-attachment-retrieval-design.md | 451 +++ ...gn-RPG-campaign-session-runtime-harness.md | 63 + ...ules-lookup-and-session-context-builder.md | 79 + ...-RPG-runtime-and-run-final-verification.md | 64 + ...-attachment-and-retrieval-backed-lookup.md | 78 + ...-attachment-and-retrieval-backed-lookup.md | 117 + ...eneration-controls-for-RPG-rules-lookup.md | 52 + .../Config_Files/privilege_catalog.yaml | 130 + tldw_Server_API/app/api/v1/endpoints/rpg.py | 504 +++ .../app/api/v1/router_groups/content.py | 9 +- .../app/api/v1/router_groups/minimal.py | 9 +- .../app/api/v1/schemas/rpg_schemas.py | 236 ++ .../app/core/DB_Management/RPG_DB.py | 1152 +++++++ .../modules/implementations/rpg_module.py | 943 ++++++ .../app/core/MCP_unified/server.py | 14 + .../MCP_unified/tool_execution/runtime.py | 5 + tldw_Server_API/app/core/RPG/README.md | 136 + tldw_Server_API/app/core/RPG/__init__.py | 35 + tldw_Server_API/app/core/RPG/authority.py | 35 + tldw_Server_API/app/core/RPG/checks.py | 13 + tldw_Server_API/app/core/RPG/constants.py | 15 + tldw_Server_API/app/core/RPG/context.py | 97 + tldw_Server_API/app/core/RPG/dice.py | 102 + tldw_Server_API/app/core/RPG/errors.py | 17 + tldw_Server_API/app/core/RPG/events.py | 84 + tldw_Server_API/app/core/RPG/models.py | 152 + tldw_Server_API/app/core/RPG/proposals.py | 26 + tldw_Server_API/app/core/RPG/reducer.py | 90 + .../app/core/RPG/rules/__init__.py | 1 + .../app/core/RPG/rules/adapters.py | 317 ++ .../app/core/RPG/rules/answering.py | 158 + .../app/core/RPG/rules/content_packs.py | 125 + tldw_Server_API/app/core/RPG/rules/lookup.py | 122 + tldw_Server_API/app/core/RPG/rules/refs.py | 185 ++ .../app/core/RPG/rules/retrieval.py | 288 ++ .../app/core/RPG/rules/source_validation.py | 92 + tldw_Server_API/app/core/RPG/service.py | 578 ++++ tldw_Server_API/app/core/exceptions.py | 16 + tldw_Server_API/tests/RPG/test_rpg_api.py | 779 +++++ .../RPG/test_rpg_core_models_adapters.py | 60 + tldw_Server_API/tests/RPG/test_rpg_db.py | 428 +++ .../tests/RPG/test_rpg_dice_checks.py | 131 + .../tests/RPG/test_rpg_events_reducer.py | 137 + .../tests/RPG/test_rpg_mcp_module.py | 962 ++++++ .../tests/RPG/test_rpg_rules_answering.py | 268 ++ .../tests/RPG/test_rpg_rules_context.py | 361 ++ .../tests/RPG/test_rpg_rules_refs.py | 187 ++ .../tests/RPG/test_rpg_rules_retrieval.py | 398 +++ tldw_Server_API/tests/RPG/test_rpg_service.py | 547 ++++ .../privilege_route_registry_snapshot.json | 908 +++++ 53 files changed, 16202 insertions(+), 3 deletions(-) create mode 100644 Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md create mode 100644 Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md create mode 100644 Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md create mode 100644 Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md create mode 100644 backlog/tasks/task-12017 - Design-RPG-campaign-session-runtime-harness.md create mode 100644 backlog/tasks/task-12026 - Add-RPG-rules-lookup-and-session-context-builder.md create mode 100644 backlog/tasks/task-12028 - Document-RPG-runtime-and-run-final-verification.md create mode 100644 backlog/tasks/task-12029 - Design-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md create mode 100644 backlog/tasks/task-12030 - Implement-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md create mode 100644 backlog/tasks/task-12031 - Wire-host-level-MCP-answer-generation-controls-for-RPG-rules-lookup.md create mode 100644 tldw_Server_API/app/api/v1/endpoints/rpg.py create mode 100644 tldw_Server_API/app/api/v1/schemas/rpg_schemas.py create mode 100644 tldw_Server_API/app/core/DB_Management/RPG_DB.py create mode 100644 tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py create mode 100644 tldw_Server_API/app/core/RPG/README.md create mode 100644 tldw_Server_API/app/core/RPG/__init__.py create mode 100644 tldw_Server_API/app/core/RPG/authority.py create mode 100644 tldw_Server_API/app/core/RPG/checks.py create mode 100644 tldw_Server_API/app/core/RPG/constants.py create mode 100644 tldw_Server_API/app/core/RPG/context.py create mode 100644 tldw_Server_API/app/core/RPG/dice.py create mode 100644 tldw_Server_API/app/core/RPG/errors.py create mode 100644 tldw_Server_API/app/core/RPG/events.py create mode 100644 tldw_Server_API/app/core/RPG/models.py create mode 100644 tldw_Server_API/app/core/RPG/proposals.py create mode 100644 tldw_Server_API/app/core/RPG/reducer.py create mode 100644 tldw_Server_API/app/core/RPG/rules/__init__.py create mode 100644 tldw_Server_API/app/core/RPG/rules/adapters.py create mode 100644 tldw_Server_API/app/core/RPG/rules/answering.py create mode 100644 tldw_Server_API/app/core/RPG/rules/content_packs.py create mode 100644 tldw_Server_API/app/core/RPG/rules/lookup.py create mode 100644 tldw_Server_API/app/core/RPG/rules/refs.py create mode 100644 tldw_Server_API/app/core/RPG/rules/retrieval.py create mode 100644 tldw_Server_API/app/core/RPG/rules/source_validation.py create mode 100644 tldw_Server_API/app/core/RPG/service.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_api.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_core_models_adapters.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_db.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_dice_checks.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_events_reducer.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_mcp_module.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_rules_answering.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_rules_context.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_rules_refs.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py create mode 100644 tldw_Server_API/tests/RPG/test_rpg_service.py diff --git a/Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md b/Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md new file mode 100644 index 0000000000..e911278bfc --- /dev/null +++ b/Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md @@ -0,0 +1,2911 @@ +# RPG Campaign Session Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a generic RPG/TTRPG backend harness for campaigns, sessions, rules adapters, dice/check resolution, event proposals, rules lookup, REST APIs, and MCP tools without becoming a virtual tabletop. + +**Architecture:** Add a focused `tldw_Server_API.app.core.RPG` package backed by per-user ChaChaNotes storage through a dedicated `RPGRepository`. Session events are the source of truth; snapshots are deterministic cached projections; model-sourced changes become proposals unless session authority settings allow direct commit. + +**Tech Stack:** FastAPI, Pydantic v2, SQLite via `CharactersRAGDB`, Loguru, pytest, MCP Unified module APIs, existing AuthNZ token-scope/privilege catalog tooling. + +--- + +## Reference Inputs + +- Approved spec: `Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md` +- Adjacent runtime pattern: `tldw_Server_API/app/core/VN_Play/` +- Adjacent storage pattern: `tldw_Server_API/app/core/DB_Management/VNPlay_DB.py` +- Adjacent endpoint pattern: `tldw_Server_API/app/api/v1/endpoints/vn_play.py` +- Router registration: `tldw_Server_API/app/api/v1/router_groups/content.py` +- MCP module pattern: `tldw_Server_API/app/core/MCP_unified/modules/implementations/quizzes_module.py` +- Privilege catalog and snapshot tooling: + - `tldw_Server_API/Config_Files/privilege_catalog.yaml` + - `Helper_Scripts/update_privilege_registry_snapshot.py` + - `tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py` + +## File Structure + +Create the RPG runtime as focused files: + +- `tldw_Server_API/app/core/RPG/__init__.py`: public package exports. +- `tldw_Server_API/app/core/RPG/constants.py`: adapter keys, event type strings, schema versions, reducer version, limits. +- `tldw_Server_API/app/core/RPG/errors.py`: domain exceptions mapped by REST and MCP layers. +- `tldw_Server_API/app/core/RPG/models.py`: core dataclasses for campaigns, sessions, events, snapshots, proposals, adapters, checks, citations. +- `tldw_Server_API/app/core/RPG/rules/__init__.py`: rule adapter package exports. +- `tldw_Server_API/app/core/RPG/rules/adapters.py`: `RuleAdapter` protocol, bundled D&D 5e SRD, PF2e, and Fate adapters, registry builder. +- `tldw_Server_API/app/core/RPG/rules/content_packs.py`: small bundled citation metadata and user-rules-pack reference models. +- `tldw_Server_API/app/core/RPG/rules/lookup.py`: rules lookup service over bundled snippets and linked RAG/media references. +- `tldw_Server_API/app/core/RPG/events.py`: event envelope validation, canonical request hashes, idempotency helpers. +- `tldw_Server_API/app/core/RPG/reducer.py`: pure event-to-snapshot reducer. +- `tldw_Server_API/app/core/RPG/dice.py`: dice expression parser and roller with injectable randomness for tests. +- `tldw_Server_API/app/core/RPG/checks.py`: adapter-backed check resolution. +- `tldw_Server_API/app/core/RPG/authority.py`: direct-commit versus proposal decisions. +- `tldw_Server_API/app/core/RPG/proposals.py`: proposal validation and preview helpers. +- `tldw_Server_API/app/core/RPG/context.py`: bounded session context builder with citation diagnostics. +- `tldw_Server_API/app/core/RPG/service.py`: application service used by REST and MCP. +- `tldw_Server_API/app/core/RPG/README.md`: concise runtime design and legal-content notes. +- `tldw_Server_API/app/core/DB_Management/RPG_DB.py`: repository/schema initialization around a per-user `CharactersRAGDB`. +- `tldw_Server_API/app/api/v1/schemas/rpg_schemas.py`: API request/response models. +- `tldw_Server_API/app/api/v1/endpoints/rpg.py`: REST endpoints under `/api/v1/rpg`. +- `tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py`: optional MCP module. + +Modify integration points: + +- `tldw_Server_API/app/api/v1/router_groups/content.py`: add route-key gated RPG router. +- `tldw_Server_API/app/core/MCP_unified/server.py`: optional `MCP_ENABLE_RPG_MODULE` registration. +- `tldw_Server_API/Config_Files/privilege_catalog.yaml`: add RPG privilege scopes and endpoint IDs. +- `tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json`: regenerate after endpoint metadata is added. + +Create tests: + +- `tldw_Server_API/tests/RPG/test_rules_adapters.py` +- `tldw_Server_API/tests/RPG/test_rpg_db.py` +- `tldw_Server_API/tests/RPG/test_rpg_events_reducer.py` +- `tldw_Server_API/tests/RPG/test_rpg_dice_checks.py` +- `tldw_Server_API/tests/RPG/test_rpg_service.py` +- `tldw_Server_API/tests/RPG/test_rpg_api.py` +- `tldw_Server_API/tests/RPG/test_rpg_rules_context.py` +- `tldw_Server_API/tests/RPG/test_rpg_mcp_module.py` + +## Execution Notes + +- Keep the implementation under a Backlog.md task and update touched files, verification, and final summary as work proceeds. +- Use `source .venv/bin/activate` before Python, pytest, Bandit, or helper commands. +- Keep bundled PF2e prose out of v1 unless an explicit source/license inventory is added in the same task; mechanics metadata and citations are enough for the adapter contract. +- Do not add maps, token positions, battlemap assets, shared tabletop synchronization, or a FoundryVTT-style canvas. +- Every mutating service path must use an idempotency key or reject repeated unsafe writes with a domain error. +- Every ledger-affecting write must carry `expected_last_event_sequence`; snapshot-affecting writes must also verify `current_snapshot_version` inside the same transaction. +- Convert slots dataclasses with `dataclasses.asdict()` or dedicated serializer helpers. Do not use `obj.__dict__` for RPG dataclasses because they use `slots=True`. +- Use `CharactersRAGDB.transaction()` for SQLite writes. Do not hand-roll `BEGIN`/`COMMIT` around ChaChaNotes connections. +- REST endpoints must combine token-scope metadata with explicit permission dependencies such as `RequirePermission("rpg.sessions.manage")`; `rbac_rate_limit()` and `TokenScopeGuard()` alone are not authorization. +- REST clients must not choose privileged event sources. REST event writes are source type `user`; MCP event writes are source type `mcp`; model/import/system sources are created by internal service paths only. +- Existing MCP storage modules open user databases from `context.db_paths["chacha"]`; the RPG MCP module must follow that pattern and fail closed when `context.user_id` or the chacha DB path is absent. + +## Pre-Execution Review Amendments + +These amendments supersede any later snippet that conflicts with them. + +- Idempotency belongs to request/batch records, not event-row uniqueness. A single idempotent operation can append multiple events. +- Event append, snapshot insert, session cursor updates, idempotency response storage, and proposal status changes must be one repository transaction. +- Idempotency replay must return the stored operation response without re-reducing or writing another snapshot. +- Mixed-source event batches are rejected. Authority is evaluated from the server-derived source and event action class, not from client-provided payload fields. +- Unsupported event types are rejected before persistence through a shared event registry used by validation and the reducer. +- Task 6 cannot require endpoint tests to pass until privilege catalog entries are in place. Implement catalog entries before the full API test run, or include the catalog update in the same task. +- Task 6 must define all referenced schemas and service methods before endpoint handlers are added: `RPGRulesLookupRequest`, `RPGContextBuildRequest`, roll/proposal response schemas, `list_campaigns`, `get_session_payload`, and `list_events`. +- The V1 REST route matrix must be explicit before endpoint implementation: method, path, response model, permission, endpoint ID, idempotency requirement, and expected sequence requirement. + +### Task 1: Core RPG Models And Adapter Registry + +**Files:** +- Create: `tldw_Server_API/app/core/RPG/__init__.py` +- Create: `tldw_Server_API/app/core/RPG/constants.py` +- Create: `tldw_Server_API/app/core/RPG/errors.py` +- Create: `tldw_Server_API/app/core/RPG/models.py` +- Create: `tldw_Server_API/app/core/RPG/rules/__init__.py` +- Create: `tldw_Server_API/app/core/RPG/rules/adapters.py` +- Test: `tldw_Server_API/tests/RPG/test_rules_adapters.py` + +- [x] **Step 1: Write failing adapter contract tests** + +```python +from tldw_Server_API.app.core.RPG.rules.adapters import build_default_adapter_registry + + +def test_default_registry_exposes_version_pinned_adapters(): + registry = build_default_adapter_registry() + + assert sorted(registry.adapter_keys()) == ["dnd5e_srd", "fate", "pf2e"] + dnd = registry.get("dnd5e_srd") + assert dnd.source_version == "SRD 5.1" + assert dnd.adapter_version == "1.0.0" + assert dnd.license_summary.source_title == "Dungeons & Dragons SRD 5.1" + + +def test_pf2e_adapter_starts_metadata_only_for_rules_prose(): + pf2e = build_default_adapter_registry().get("pf2e") + + assert pf2e.status == "metadata_only" + assert pf2e.bundled_snippet_policy == "citations_only" + assert pf2e.mechanics_tags["resolution_family"] == "d20" + + +def test_fate_adapter_does_not_require_d20_fields(): + fate = build_default_adapter_registry().get("fate") + + actor_schema = fate.actor_schema() + assert "aspects" in actor_schema["properties"] + assert "stress" in actor_schema["properties"] + assert "ability_scores" not in actor_schema["required"] + assert fate.mechanics_tags["resolution_family"] == "fate" +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rules_adapters.py -v` + +Expected: FAIL because `tldw_Server_API.app.core.RPG` does not exist. + +- [x] **Step 3: Add core constants, errors, and dataclasses** + +```python +# tldw_Server_API/app/core/RPG/constants.py +RPG_ADAPTER_DND5E_SRD = "dnd5e_srd" +RPG_ADAPTER_PF2E = "pf2e" +RPG_ADAPTER_FATE = "fate" + +RPG_ADAPTER_VERSION_V1 = "1.0.0" +RPG_EVENT_SCHEMA_VERSION = "1.0.0" +RPG_SNAPSHOT_SCHEMA_VERSION = "1.0.0" +RPG_REDUCER_VERSION = "1.0.0" + +RPG_SOURCE_TYPES = ("user", "system", "mcp", "model", "import") +RPG_PROPOSAL_STATUSES = ("pending", "applied", "rejected", "expired", "conflicted") + +MAX_RPG_EVENT_PAYLOAD_BYTES = 64_000 +MAX_RPG_CONTEXT_CHARS = 24_000 +``` + +```python +# tldw_Server_API/app/core/RPG/errors.py +class RPGError(Exception): + """Base RPG domain error.""" + + +class RPGNotFoundError(RPGError): + """Requested RPG object was not found for the current user.""" + + +class RPGConflictError(RPGError): + """Write could not be applied because state or idempotency conflicted.""" + + +class RPGValidationError(RPGError): + """Input failed RPG domain validation.""" + + +class RPGPermissionError(RPGError): + """Caller cannot perform the requested RPG action.""" +``` + +```python +# tldw_Server_API/app/core/RPG/models.py +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Literal + +RPGSourceType = Literal["user", "system", "mcp", "model", "import"] +RPGProposalStatus = Literal["pending", "applied", "rejected", "expired", "conflicted"] + + +@dataclass(frozen=True, slots=True) +class RuleLicenseSummary: + source_title: str + source_url: str + license: str + license_url: str + attribution: str + + +@dataclass(frozen=True, slots=True) +class RuleCitation: + adapter_key: str + source_title: str + source_url: str + license: str + license_url: str + attribution: str + trust_level: str + content_hash: str + snippet_id: str + source_version: str + content_pack_version: str + + +@dataclass(frozen=True, slots=True) +class RuleAdapterInfo: + adapter_key: str + adapter_version: str + display_name: str + status: str + source_version: str + bundled_snippet_policy: str + license_summary: RuleLicenseSummary + mechanics_tags: dict[str, str] + + +@dataclass(frozen=True, slots=True) +class RPGCampaign: + id: int + owner_user_id: int + title: str + description: str | None + default_adapter_key: str + default_adapter_version: str + settings: dict[str, Any] + linked_rules_pack_refs: list[dict[str, Any]] + version: int + status: str + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class RPGSession: + id: int + campaign_id: int + owner_user_id: int + title: str + status: str + adapter_key: str + adapter_version: str + authority_settings: dict[str, Any] + linked_chat_id: int | None + active_rules_pack_refs: list[dict[str, Any]] + current_snapshot_version: int + last_event_sequence: int + version: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class RPGSessionEvent: + id: int + session_id: int + owner_user_id: int + sequence_number: int + event_type: str + event_payload: dict[str, Any] + source_type: RPGSourceType + source_actor_id: str | None + source_label: str | None + operation_id: int | None + event_schema_version: str + adapter_key: str + adapter_version: str + proposal_id: int | None + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class RPGSnapshotState: + scene: dict[str, Any] = field(default_factory=dict) + actors: dict[str, dict[str, Any]] = field(default_factory=dict) + resources: dict[str, dict[str, Any]] = field(default_factory=dict) + clocks: dict[str, dict[str, Any]] = field(default_factory=dict) + rolls: list[dict[str, Any]] = field(default_factory=list) + notes: list[dict[str, Any]] = field(default_factory=list) + recap: str = "" + quests: dict[str, dict[str, Any]] = field(default_factory=dict) + npcs: dict[str, dict[str, Any]] = field(default_factory=dict) + inventory: dict[str, dict[str, Any]] = field(default_factory=dict) + locations: dict[str, dict[str, Any]] = field(default_factory=dict) + factions: dict[str, dict[str, Any]] = field(default_factory=dict) + rules_references: list[dict[str, Any]] = field(default_factory=list) + unresolved_rulings: dict[str, dict[str, Any]] = field(default_factory=dict) +``` + +- [x] **Step 4: Add the adapter protocol and bundled registry** + +```python +# tldw_Server_API/app/core/RPG/rules/adapters.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + +from tldw_Server_API.app.core.RPG.constants import ( + RPG_ADAPTER_DND5E_SRD, + RPG_ADAPTER_FATE, + RPG_ADAPTER_PF2E, + RPG_ADAPTER_VERSION_V1, +) +from tldw_Server_API.app.core.RPG.errors import RPGNotFoundError +from tldw_Server_API.app.core.RPG.models import RuleAdapterInfo, RuleLicenseSummary + + +class RuleAdapter(Protocol): + adapter_key: str + adapter_version: str + display_name: str + status: str + source_version: str + bundled_snippet_policy: str + license_summary: RuleLicenseSummary + mechanics_tags: dict[str, str] + + def actor_schema(self) -> dict[str, Any]: + raise NotImplementedError + + def check_schema(self) -> dict[str, Any]: + raise NotImplementedError + + def supported_event_types(self) -> set[str]: + raise NotImplementedError + + def validate_actor(self, actor_payload: dict[str, Any]) -> dict[str, Any]: + raise NotImplementedError + + def resolve_check(self, roller: Any, payload: dict[str, Any]) -> Any: + raise NotImplementedError + + def content_pack_refs(self) -> list[dict[str, Any]]: + raise NotImplementedError + + def info(self) -> RuleAdapterInfo: + raise NotImplementedError + + +@dataclass(frozen=True, slots=True) +class StaticRuleAdapter: + adapter_key: str + adapter_version: str + display_name: str + status: str + source_version: str + bundled_snippet_policy: str + license_summary: RuleLicenseSummary + mechanics_tags: dict[str, str] + _actor_schema: dict[str, Any] + _check_schema: dict[str, Any] + + def actor_schema(self) -> dict[str, Any]: + return dict(self._actor_schema) + + def check_schema(self) -> dict[str, Any]: + return dict(self._check_schema) + + def supported_event_types(self) -> set[str]: + return { + "actor.upserted", + "clock.updated", + "faction.upserted", + "inventory.item.upserted", + "location.upserted", + "note.added", + "npc.upserted", + "quest.upserted", + "roll.recorded", + "rule.reference.added", + "ruling.added", + "scene.updated", + } + + def validate_actor(self, actor_payload: dict[str, Any]) -> dict[str, Any]: + return dict(actor_payload) + + def resolve_check(self, roller: Any, payload: dict[str, Any]) -> Any: + raise NotImplementedError("adapter-specific check resolution is implemented in Task 4") + + def content_pack_refs(self) -> list[dict[str, Any]]: + return [] + + def info(self) -> RuleAdapterInfo: + return RuleAdapterInfo( + adapter_key=self.adapter_key, + adapter_version=self.adapter_version, + display_name=self.display_name, + status=self.status, + source_version=self.source_version, + bundled_snippet_policy=self.bundled_snippet_policy, + license_summary=self.license_summary, + mechanics_tags=dict(self.mechanics_tags), + ) + + +class RuleAdapterRegistry: + def __init__(self, adapters: list[RuleAdapter]) -> None: + self._adapters = {adapter.adapter_key: adapter for adapter in adapters} + + def adapter_keys(self) -> list[str]: + return sorted(self._adapters) + + def get(self, adapter_key: str) -> RuleAdapter: + try: + return self._adapters[adapter_key] + except KeyError as exc: + raise RPGNotFoundError(f"Unknown RPG rules adapter: {adapter_key}") from exc + + def list_infos(self) -> list[RuleAdapterInfo]: + return [self._adapters[key].info() for key in self.adapter_keys()] + + +def build_default_adapter_registry() -> RuleAdapterRegistry: + return RuleAdapterRegistry( + adapters=[ + StaticRuleAdapter( + adapter_key=RPG_ADAPTER_DND5E_SRD, + adapter_version=RPG_ADAPTER_VERSION_V1, + display_name="D&D 5e SRD", + status="bundled", + source_version="SRD 5.1", + bundled_snippet_policy="short_srd_citations", + license_summary=RuleLicenseSummary( + source_title="Dungeons & Dragons SRD 5.1", + source_url="https://www.dndbeyond.com/srd", + license="Creative Commons Attribution 4.0 International", + license_url="https://creativecommons.org/licenses/by/4.0/", + attribution="Dungeons & Dragons SRD 5.1", + ), + mechanics_tags={"resolution_family": "d20"}, + _actor_schema={"properties": {"ability_scores": {"type": "object"}}, "required": []}, + _check_schema={"properties": {"dc": {"type": "integer"}}, "required": []}, + ), + StaticRuleAdapter( + adapter_key=RPG_ADAPTER_PF2E, + adapter_version=RPG_ADAPTER_VERSION_V1, + display_name="Pathfinder 2e", + status="metadata_only", + source_version="PF2e", + bundled_snippet_policy="citations_only", + license_summary=RuleLicenseSummary( + source_title="Pathfinder Second Edition", + source_url="https://2e.aonprd.com/", + license="ORC and Paizo Community Use references", + license_url="https://downloads.paizo.com/ORC_License_FINAL.pdf", + attribution="Pathfinder Second Edition rules references", + ), + mechanics_tags={"resolution_family": "d20"}, + _actor_schema={"properties": {"level": {"type": "integer"}}, "required": []}, + _check_schema={"properties": {"dc": {"type": "integer"}}, "required": []}, + ), + StaticRuleAdapter( + adapter_key=RPG_ADAPTER_FATE, + adapter_version=RPG_ADAPTER_VERSION_V1, + display_name="Fate", + status="bundled", + source_version="Fate SRD", + bundled_snippet_policy="short_srd_citations", + license_summary=RuleLicenseSummary( + source_title="Fate SRD", + source_url="https://fate-srd.com/", + license="Creative Commons Attribution 3.0 Unported", + license_url="https://creativecommons.org/licenses/by/3.0/", + attribution="Fate SRD", + ), + mechanics_tags={"resolution_family": "fate"}, + _actor_schema={ + "properties": {"aspects": {"type": "array"}, "stress": {"type": "object"}}, + "required": [], + }, + _check_schema={"properties": {"ladder_target": {"type": "integer"}}, "required": []}, + ), + ] + ) +``` + +- [x] **Step 5: Export the public package symbols** + +```python +# tldw_Server_API/app/core/RPG/__init__.py +from .models import RPGCampaign, RPGSession, RPGSessionEvent, RPGSnapshotState + +__all__ = ["RPGCampaign", "RPGSession", "RPGSessionEvent", "RPGSnapshotState"] +``` + +Task 5 adds `RPGService` to these exports after `service.py` exists. + +- [x] **Step 6: Run focused tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rules_adapters.py -v` + +Expected: PASS. + +- [x] **Step 7: Commit** + +```bash +git add tldw_Server_API/app/core/RPG tldw_Server_API/tests/RPG/test_rules_adapters.py +git commit -m "feat: add RPG rules adapter registry" +``` + +### Task 2: ChaChaNotes-Backed RPG Repository + +**Files:** +- Create: `tldw_Server_API/app/core/DB_Management/RPG_DB.py` +- Test: `tldw_Server_API/tests/RPG/test_rpg_db.py` + +- [x] **Step 1: Write failing repository tests** + +```python +import pytest + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository + + +def _campaign(repo: RPGRepository, owner_user_id: int = 42, adapter_key: str = "fate"): + return repo.create_campaign( + owner_user_id, + "Campaign", + None, + adapter_key, + "1.0.0", + {}, + [], + idempotency_key=f"campaign-{adapter_key}", + request_payload_hash=f"hash-campaign-{adapter_key}", + source_type="user", + ) + + +def _session(repo: RPGRepository, campaign_id: int, owner_user_id: int = 42, adapter_key: str = "fate"): + return repo.create_session( + owner_user_id, + campaign_id, + "Opening", + adapter_key, + "1.0.0", + {}, + None, + [], + idempotency_key=f"session-{campaign_id}-{adapter_key}", + request_payload_hash=f"hash-session-{campaign_id}-{adapter_key}", + source_type="user", + ) + + +def test_repository_creates_campaign_session_and_initial_snapshot(): + db = CharactersRAGDB(":memory:", "test-client") + repo = RPGRepository.initialized(db) + + campaign = repo.create_campaign( + owner_user_id=42, + title="Saltmarsh", + description="Coastal trouble", + default_adapter_key="dnd5e_srd", + default_adapter_version="1.0.0", + settings={}, + linked_rules_pack_refs=[], + idempotency_key="campaign-saltmarsh", + request_payload_hash="hash-campaign-saltmarsh", + source_type="user", + ) + session = repo.create_session( + owner_user_id=42, + campaign_id=campaign.id, + title="Session 1", + adapter_key="dnd5e_srd", + adapter_version="1.0.0", + authority_settings={"model_auto_commit": False}, + linked_chat_id=None, + active_rules_pack_refs=[], + idempotency_key="session-1", + request_payload_hash="hash-session-1", + source_type="user", + ) + + assert session.campaign_id == campaign.id + assert session.last_event_sequence == 0 + assert repo.get_latest_snapshot(owner_user_id=42, session_id=session.id).snapshot_version == 0 + + +def test_commit_events_assigns_sequences_and_updates_snapshot_cursor(): + db = CharactersRAGDB(":memory:", "test-client") + repo = RPGRepository.initialized(db) + campaign = _campaign(repo) + session = _session(repo, campaign.id) + + result = repo.commit_events_and_snapshot( + owner_user_id=42, + session_id=session.id, + expected_last_event_sequence=0, + base_snapshot_version=0, + events=[ + { + "event_type": "scene.updated", + "event_payload": {"scene_id": "scene-start", "summary": "At the docks"}, + "source_type": "user", + }, + { + "event_type": "note.added", + "event_payload": {"note_id": "note-1", "text": "Storm clouds gather"}, + "source_type": "user", + }, + ], + snapshot={"scene": {"scene_id": "scene-start", "summary": "At the docks"}, "notes": [{"note_id": "note-1", "text": "Storm clouds gather"}]}, + diagnostics={"applied_event_count": 2}, + idempotency_key="req-1", + request_payload_hash="hash-a", + adapter_key="fate", + adapter_version="1.0.0", + proposal_id=None, + ) + + assert [event.sequence_number for event in result.events] == [1, 2] + updated = repo.get_session(owner_user_id=42, session_id=session.id) + assert updated.last_event_sequence == 2 + assert updated.current_snapshot_version == 1 + + +def test_commit_events_replays_same_idempotency_key_with_same_hash(): + db = CharactersRAGDB(":memory:", "test-client") + repo = RPGRepository.initialized(db) + campaign = _campaign(repo) + session = _session(repo, campaign.id) + payload = [{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "A"}, "source_type": "user"}] + + first = repo.commit_events_and_snapshot(42, session.id, 0, 0, payload, {"notes": [{"note_id": "n1", "text": "A"}]}, {}, "same-key", "hash-a", "fate", "1.0.0", None) + second = repo.commit_events_and_snapshot(42, session.id, 0, 0, payload, {"notes": [{"note_id": "n1", "text": "A"}]}, {}, "same-key", "hash-a", "fate", "1.0.0", None) + + assert [event.id for event in second.events] == [event.id for event in first.events] + assert second.replayed is True + assert repo.get_session(owner_user_id=42, session_id=session.id).current_snapshot_version == 1 + + +def test_commit_events_rejects_same_idempotency_key_with_different_hash(): + db = CharactersRAGDB(":memory:", "test-client") + repo = RPGRepository.initialized(db) + campaign = _campaign(repo) + session = _session(repo, campaign.id) + payload = [{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "A"}, "source_type": "user"}] + + repo.commit_events_and_snapshot(42, session.id, 0, 0, payload, {"notes": [{"note_id": "n1", "text": "A"}]}, {}, "same-key", "hash-a", "fate", "1.0.0", None) + + with pytest.raises(Exception, match="idempotency"): + repo.commit_events_and_snapshot(42, session.id, 1, 1, payload, {"notes": [{"note_id": "n1", "text": "A"}]}, {}, "same-key", "hash-b", "fate", "1.0.0", None) + + +def test_commit_events_rejects_stale_expected_sequence(): + db = CharactersRAGDB(":memory:", "test-client") + repo = RPGRepository.initialized(db) + campaign = _campaign(repo) + session = _session(repo, campaign.id) + payload = [{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "A"}, "source_type": "user"}] + + with pytest.raises(Exception, match="stale_event_sequence"): + repo.commit_events_and_snapshot(42, session.id, 7, 0, payload, {"notes": []}, {}, "stale-key", "hash-a", "fate", "1.0.0", None) +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_db.py -v` + +Expected: FAIL because `RPGRepository` does not exist. + +- [x] **Step 3: Implement schema initialization and row mapping** + +```python +# tldw_Server_API/app/core/DB_Management/RPG_DB.py +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from loguru import logger + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.RPG.constants import RPG_REDUCER_VERSION, RPG_SNAPSHOT_SCHEMA_VERSION +from tldw_Server_API.app.core.RPG.errors import RPGConflictError, RPGNotFoundError +from tldw_Server_API.app.core.RPG.models import RPGCampaign, RPGSession, RPGSessionEvent + + +@dataclass(frozen=True, slots=True) +class RPGSnapshotRecord: + id: int + session_id: int + owner_user_id: int + snapshot_version: int + last_event_sequence: int + reducer_version: str + snapshot_schema_version: str + snapshot_json: dict[str, Any] + diagnostics_json: dict[str, Any] + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class CommitEventsResult: + events: list[RPGSessionEvent] + replayed: bool + + +class RPGRepository: + def __init__(self, db: CharactersRAGDB) -> None: + self.db = db + + @classmethod + def initialized(cls, db: CharactersRAGDB) -> "RPGRepository": + repo = cls(db) + repo.ensure_schema() + return repo + + def ensure_schema(self) -> None: + with self.db.transaction() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS rpg_campaigns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + default_adapter_key TEXT NOT NULL, + default_adapter_version TEXT NOT NULL, + settings_json TEXT NOT NULL DEFAULT '{}', + linked_rules_pack_refs_json TEXT NOT NULL DEFAULT '[]', + version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS rpg_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES rpg_campaigns(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + adapter_key TEXT NOT NULL, + adapter_version TEXT NOT NULL, + authority_settings_json TEXT NOT NULL DEFAULT '{}', + linked_chat_id INTEGER, + active_rules_pack_refs_json TEXT NOT NULL DEFAULT '[]', + current_snapshot_version INTEGER NOT NULL DEFAULT 0, + last_event_sequence INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS rpg_session_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES rpg_sessions(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL, + sequence_number INTEGER NOT NULL, + event_type TEXT NOT NULL, + event_payload_json TEXT NOT NULL, + source_type TEXT NOT NULL, + source_actor_id TEXT, + source_label TEXT, + operation_id INTEGER, + event_schema_version TEXT NOT NULL, + adapter_key TEXT NOT NULL, + adapter_version TEXT NOT NULL, + proposal_id INTEGER REFERENCES rpg_session_proposals(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + UNIQUE(owner_user_id, session_id, sequence_number), + CHECK(source_type IN ('user', 'system', 'mcp', 'model', 'import')) + ); + CREATE TABLE IF NOT EXISTS rpg_idempotency_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_user_id INTEGER NOT NULL, + session_id INTEGER REFERENCES rpg_sessions(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + operation_scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_payload_hash TEXT NOT NULL, + event_ids_json TEXT NOT NULL DEFAULT '[]', + response_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + UNIQUE(owner_user_id, source_type, operation_scope, idempotency_key), + CHECK(source_type IN ('user', 'system', 'mcp', 'model', 'import')) + ); + CREATE TABLE IF NOT EXISTS rpg_session_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES rpg_sessions(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL, + snapshot_version INTEGER NOT NULL, + last_event_sequence INTEGER NOT NULL, + reducer_version TEXT NOT NULL, + snapshot_schema_version TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + diagnostics_json TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(owner_user_id, session_id, snapshot_version) + ); + CREATE TABLE IF NOT EXISTS rpg_session_proposals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES rpg_sessions(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL, + base_event_sequence INTEGER NOT NULL, + base_snapshot_version INTEGER NOT NULL, + proposed_events_json TEXT NOT NULL, + patch_json TEXT, + rationale TEXT, + confidence REAL, + source_type TEXT NOT NULL, + source_actor_id TEXT, + model_metadata_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'pending', + review_notes TEXT, + created_at TEXT NOT NULL, + applied_at TEXT, + rejected_at TEXT, + CHECK(source_type IN ('user', 'system', 'mcp', 'model', 'import')), + CHECK(status IN ('pending', 'applied', 'rejected', 'expired', 'conflicted')) + ); + """ + ) + logger.debug("RPG repository schema ensured") +``` + +Add these repository method signatures and implement each one with parameterized SQL, JSON helpers using `json.dumps(value, sort_keys=True, separators=(",", ":"))`, and row mappers returning dataclasses from `models.py`. + +```python +def create_campaign( + self, + owner_user_id: int, + title: str, + description: str | None, + default_adapter_key: str, + default_adapter_version: str, + settings: dict[str, Any], + linked_rules_pack_refs: list[dict[str, Any]], + idempotency_key: str, + request_payload_hash: str, + source_type: str, +) -> RPGCampaign: + raise NotImplementedError + +def create_session( + self, + owner_user_id: int, + campaign_id: int, + title: str, + adapter_key: str, + adapter_version: str, + authority_settings: dict[str, Any], + linked_chat_id: int | None, + active_rules_pack_refs: list[dict[str, Any]], + idempotency_key: str, + request_payload_hash: str, + source_type: str, +) -> RPGSession: + raise NotImplementedError + +def get_session(self, owner_user_id: int, session_id: int) -> RPGSession: + raise NotImplementedError + +def get_event(self, owner_user_id: int, event_id: int) -> RPGSessionEvent: + raise NotImplementedError + +def get_latest_snapshot(self, owner_user_id: int, session_id: int) -> RPGSnapshotRecord: + raise NotImplementedError +``` + +- [x] **Step 4: Add idempotent atomic event/snapshot commit behavior** + +```python +def commit_events_and_snapshot( + self, + owner_user_id: int, + session_id: int, + expected_last_event_sequence: int, + base_snapshot_version: int, + events: list[dict[str, Any]], + snapshot: dict[str, Any], + diagnostics: dict[str, Any], + idempotency_key: str, + request_payload_hash: str, + adapter_key: str, + adapter_version: str, + proposal_id: int | None, + proposal_review_notes: str | None = None, +) -> CommitEventsResult: + if not events: + raise RPGConflictError("events_required") + source_type = events[0]["source_type"] + operation_scope = f"session:{session_id}:events" + now = datetime.now(timezone.utc).isoformat() + try: + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is not None: + if replay["request_payload_hash"] != request_payload_hash: + raise RPGConflictError("idempotency_key_conflict") + event_ids = self._from_json(replay["event_ids_json"]) + return CommitEventsResult(events=self._events_by_ids(conn, owner_user_id, event_ids), replayed=True) + + session_row = conn.execute( + """ + SELECT last_event_sequence, current_snapshot_version + FROM rpg_sessions + WHERE id = ? AND owner_user_id = ? + """, + (session_id, owner_user_id), + ).fetchone() + if session_row is None: + raise RPGNotFoundError("rpg_session_not_found") + if session_row["last_event_sequence"] != expected_last_event_sequence: + raise RPGConflictError("stale_event_sequence") + if session_row["current_snapshot_version"] != base_snapshot_version: + raise RPGConflictError("stale_snapshot_version") + + next_sequence = expected_last_event_sequence + 1 + inserted: list[RPGSessionEvent] = [] + for offset, event in enumerate(events): + sequence = next_sequence + offset + cursor = conn.execute( + """ + INSERT INTO rpg_session_events ( + session_id, owner_user_id, sequence_number, event_type, event_payload_json, + source_type, source_actor_id, source_label, operation_id, + event_schema_version, adapter_key, adapter_version, proposal_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?) + """, + ( + session_id, + owner_user_id, + sequence, + event["event_type"], + self._to_json(event["event_payload"]), + source_type, + event.get("source_actor_id"), + event.get("source_label"), + event.get("event_schema_version", "1.0.0"), + adapter_key, + adapter_version, + proposal_id, + now, + ), + ) + inserted.append(self._get_event_with_conn(conn, owner_user_id, int(cursor.lastrowid))) + + next_snapshot_version = base_snapshot_version + 1 + conn.execute( + """ + INSERT INTO rpg_session_snapshots ( + session_id, owner_user_id, snapshot_version, last_event_sequence, + reducer_version, snapshot_schema_version, snapshot_json, diagnostics_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + session_id, + owner_user_id, + next_snapshot_version, + inserted[-1].sequence_number, + RPG_REDUCER_VERSION, + RPG_SNAPSHOT_SCHEMA_VERSION, + self._to_json(snapshot), + self._to_json(diagnostics), + now, + ), + ) + update_cursor = conn.execute( + """ + UPDATE rpg_sessions + SET last_event_sequence = ?, current_snapshot_version = ?, version = version + 1, updated_at = ? + WHERE id = ? AND owner_user_id = ? AND last_event_sequence = ? AND current_snapshot_version = ? + """, + ( + inserted[-1].sequence_number, + next_snapshot_version, + now, + session_id, + owner_user_id, + expected_last_event_sequence, + base_snapshot_version, + ), + ) + if update_cursor.rowcount != 1: + raise RPGConflictError("stale_session_cursor") + if proposal_id is not None: + proposal_cursor = conn.execute( + """ + UPDATE rpg_session_proposals + SET status = 'applied', applied_at = ?, review_notes = COALESCE(review_notes, ?) + WHERE id = ? AND owner_user_id = ? AND session_id = ? AND status = 'pending' + """, + (now, proposal_review_notes, proposal_id, owner_user_id, session_id), + ) + if proposal_cursor.rowcount != 1: + raise RPGConflictError("proposal_not_pending") + idempotency_cursor = conn.execute( + """ + INSERT INTO rpg_idempotency_records ( + owner_user_id, session_id, source_type, operation_scope, idempotency_key, + request_payload_hash, event_ids_json, response_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + owner_user_id, + session_id, + source_type, + operation_scope, + idempotency_key, + request_payload_hash, + self._to_json([event.id for event in inserted]), + self._to_json({"event_ids": [event.id for event in inserted]}), + now, + ), + ) + operation_id = int(idempotency_cursor.lastrowid) + conn.execute( + f"UPDATE rpg_session_events SET operation_id = ? WHERE id IN ({','.join('?' for _ in inserted)})", + (operation_id, *[event.id for event in inserted]), + ) + return CommitEventsResult(events=inserted, replayed=False) + except sqlite3.IntegrityError as exc: + raise RPGConflictError("rpg_event_append_conflict") from exc +``` + +The implementation must keep idempotency in `rpg_idempotency_records`, not as a uniqueness constraint on `rpg_session_events`; a single idempotent request can append multiple events. `operation_scope` distinguishes campaign creation, session creation, event recording, proposal apply, and proposal reject operations so all mutating REST and MCP calls can be idempotent. Idempotent replay returns the stored response payload/event IDs and must not re-run the reducer or insert another snapshot. + +- [x] **Step 5: Run focused repository tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_db.py -v` + +Expected: PASS. + +- [x] **Step 6: Commit** + +```bash +git add tldw_Server_API/app/core/DB_Management/RPG_DB.py tldw_Server_API/tests/RPG/test_rpg_db.py +git commit -m "feat: add RPG session repository" +``` + +### Task 3: Event Validation And Deterministic Reducer + +**Files:** +- Create: `tldw_Server_API/app/core/RPG/events.py` +- Create: `tldw_Server_API/app/core/RPG/reducer.py` +- Modify: `tldw_Server_API/app/core/RPG/models.py` +- Test: `tldw_Server_API/tests/RPG/test_rpg_events_reducer.py` + +- [x] **Step 1: Write failing event and reducer tests** + +```python +import pytest + +from tldw_Server_API.app.core.RPG.events import canonical_request_hash, validate_event_envelope +from tldw_Server_API.app.core.RPG.reducer import initial_snapshot, reduce_events + + +def test_canonical_request_hash_is_stable_for_key_order(): + left = {"events": [{"event_type": "note.added", "event_payload": {"text": "A", "note_id": "n1"}}]} + right = {"events": [{"event_payload": {"note_id": "n1", "text": "A"}, "event_type": "note.added"}]} + + assert canonical_request_hash(left) == canonical_request_hash(right) + + +def test_validate_event_envelope_rejects_missing_stable_ids(): + event = {"event_type": "npc.upserted", "event_payload": {"name": "Ada"}, "source_type": "user"} + + with pytest.raises(ValueError, match="npc_id"): + validate_event_envelope(event) + + +def test_validate_event_envelope_rejects_unknown_event_type(): + event = {"event_type": "homebrew.mutates_state", "event_payload": {"id": "x"}, "source_type": "user"} + + with pytest.raises(ValueError, match="Unsupported RPG event type"): + validate_event_envelope(event) + + +def test_reducer_rebuilds_same_snapshot_from_same_events(): + events = [ + {"event_type": "scene.updated", "event_payload": {"scene_id": "s1", "summary": "Rainy docks"}, "source_type": "user"}, + {"event_type": "actor.upserted", "event_payload": {"actor_id": "pc-1", "name": "Marin"}, "source_type": "user"}, + {"event_type": "npc.upserted", "event_payload": {"npc_id": "npc-ada", "name": "Ada"}, "source_type": "user"}, + {"event_type": "quest.upserted", "event_payload": {"quest_id": "q1", "title": "Find the map"}, "source_type": "user"}, + {"event_type": "inventory.item.upserted", "event_payload": {"item_id": "map", "name": "Wet map"}, "source_type": "user"}, + {"event_type": "location.upserted", "event_payload": {"location_id": "docks", "name": "The docks"}, "source_type": "user"}, + {"event_type": "faction.upserted", "event_payload": {"faction_id": "guild", "name": "Harbor Guild"}, "source_type": "user"}, + {"event_type": "clock.updated", "event_payload": {"clock_id": "storm", "progress": 2, "segments": 6}, "source_type": "user"}, + {"event_type": "roll.recorded", "event_payload": {"roll_id": "roll-1", "total": 15}, "source_type": "user"}, + ] + + first = reduce_events(initial_snapshot(), events) + second = reduce_events(initial_snapshot(), events) + + assert first == second + assert first.scene["summary"] == "Rainy docks" + assert first.actors["pc-1"]["name"] == "Marin" + assert first.npcs["npc-ada"]["name"] == "Ada" + assert first.quests["q1"]["title"] == "Find the map" + assert first.inventory["map"]["name"] == "Wet map" + assert first.locations["docks"]["name"] == "The docks" + assert first.factions["guild"]["name"] == "Harbor Guild" + assert first.clocks["storm"]["progress"] == 2 + assert first.rolls[0]["roll_id"] == "roll-1" +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_events_reducer.py -v` + +Expected: FAIL because event validation and reducer modules do not exist. + +- [x] **Step 3: Implement canonical hashes and payload validators** + +```python +# tldw_Server_API/app/core/RPG/events.py +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from tldw_Server_API.app.core.RPG.constants import MAX_RPG_EVENT_PAYLOAD_BYTES, RPG_EVENT_SCHEMA_VERSION, RPG_SOURCE_TYPES + +_REQUIRED_EVENT_IDS = { + "actor.upserted": "actor_id", + "npc.upserted": "npc_id", + "quest.upserted": "quest_id", + "inventory.item.upserted": "item_id", + "location.upserted": "location_id", + "faction.upserted": "faction_id", + "clock.updated": "clock_id", + "ruling.added": "ruling_id", + "note.added": "note_id", + "roll.recorded": "roll_id", + "rule.reference.added": "reference_id", + "scene.updated": "scene_id", +} +SUPPORTED_EVENT_TYPES = frozenset(_REQUIRED_EVENT_IDS) + + +def canonical_request_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def validate_event_envelope(event: dict[str, Any]) -> dict[str, Any]: + event_type = str(event.get("event_type") or "").strip() + source_type = str(event.get("source_type") or "").strip() + payload = event.get("event_payload") + if not event_type: + raise ValueError("event_type is required") + if event_type not in SUPPORTED_EVENT_TYPES: + raise ValueError(f"Unsupported RPG event type: {event_type}") + if source_type not in RPG_SOURCE_TYPES: + raise ValueError("source_type is invalid") + if not isinstance(payload, dict): + raise ValueError("event_payload must be an object") + encoded_size = len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) + if encoded_size > MAX_RPG_EVENT_PAYLOAD_BYTES: + raise ValueError("event_payload is too large") + required_id = _REQUIRED_EVENT_IDS.get(event_type) + if required_id and not str(payload.get(required_id) or "").strip(): + raise ValueError(f"{required_id} is required for {event_type}") + normalized = dict(event) + normalized["event_type"] = event_type + normalized["source_type"] = source_type + normalized["event_payload"] = dict(payload) + normalized.setdefault("event_schema_version", RPG_EVENT_SCHEMA_VERSION) + return normalized +``` + +- [x] **Step 4: Implement pure reducer functions** + +```python +# tldw_Server_API/app/core/RPG/reducer.py +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +from tldw_Server_API.app.core.RPG.models import RPGSnapshotState + + +def initial_snapshot() -> RPGSnapshotState: + return RPGSnapshotState() + + +def reduce_event(snapshot: RPGSnapshotState, event: dict[str, Any]) -> RPGSnapshotState: + event_type = event["event_type"] + payload = event["event_payload"] + if event_type == "scene.updated": + return replace(snapshot, scene={**snapshot.scene, **payload}) + if event_type == "actor.upserted": + actors = dict(snapshot.actors) + actors[payload["actor_id"]] = {**actors.get(payload["actor_id"], {}), **payload} + return replace(snapshot, actors=actors) + if event_type == "npc.upserted": + npcs = dict(snapshot.npcs) + npcs[payload["npc_id"]] = {**npcs.get(payload["npc_id"], {}), **payload} + return replace(snapshot, npcs=npcs) + if event_type == "quest.upserted": + quests = dict(snapshot.quests) + quests[payload["quest_id"]] = {**quests.get(payload["quest_id"], {}), **payload} + return replace(snapshot, quests=quests) + if event_type == "inventory.item.upserted": + inventory = dict(snapshot.inventory) + inventory[payload["item_id"]] = {**inventory.get(payload["item_id"], {}), **payload} + return replace(snapshot, inventory=inventory) + if event_type == "location.upserted": + locations = dict(snapshot.locations) + locations[payload["location_id"]] = {**locations.get(payload["location_id"], {}), **payload} + return replace(snapshot, locations=locations) + if event_type == "faction.upserted": + factions = dict(snapshot.factions) + factions[payload["faction_id"]] = {**factions.get(payload["faction_id"], {}), **payload} + return replace(snapshot, factions=factions) + if event_type == "clock.updated": + clocks = dict(snapshot.clocks) + clocks[payload["clock_id"]] = {**clocks.get(payload["clock_id"], {}), **payload} + return replace(snapshot, clocks=clocks) + if event_type == "roll.recorded": + return replace(snapshot, rolls=[*snapshot.rolls, dict(payload)]) + if event_type == "note.added": + return replace(snapshot, notes=[*snapshot.notes, dict(payload)]) + if event_type == "rule.reference.added": + return replace(snapshot, rules_references=[*snapshot.rules_references, dict(payload)]) + if event_type == "ruling.added": + rulings = dict(snapshot.unresolved_rulings) + rulings[payload["ruling_id"]] = dict(payload) + return replace(snapshot, unresolved_rulings=rulings) + raise ValueError(f"Unsupported RPG event type: {event_type}") + + +def reduce_events(snapshot: RPGSnapshotState, events: list[dict[str, Any]]) -> RPGSnapshotState: + current = snapshot + for event in events: + current = reduce_event(current, event) + return current +``` + +- [x] **Step 5: Run focused reducer tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_events_reducer.py -v` + +Expected: PASS. + +- [x] **Step 6: Commit** + +```bash +git add tldw_Server_API/app/core/RPG/events.py tldw_Server_API/app/core/RPG/reducer.py tldw_Server_API/app/core/RPG/models.py tldw_Server_API/tests/RPG/test_rpg_events_reducer.py +git commit -m "feat: add RPG event reducer" +``` + +### Task 4: Dice And Check Resolution + +**Files:** +- Create: `tldw_Server_API/app/core/RPG/dice.py` +- Create: `tldw_Server_API/app/core/RPG/checks.py` +- Modify: `tldw_Server_API/app/core/RPG/models.py` +- Test: `tldw_Server_API/tests/RPG/test_rpg_dice_checks.py` + +- [x] **Step 1: Write failing dice and check tests** + +```python +from tldw_Server_API.app.core.RPG.checks import resolve_check +from tldw_Server_API.app.core.RPG.dice import DiceRoller +from tldw_Server_API.app.core.RPG.rules.adapters import build_default_adapter_registry + + +def test_dice_roller_supports_basic_dice_and_modifier(): + roller = DiceRoller(randbelow=lambda sides: 3) + + result = roller.roll("2d6+1") + + assert result.total == 9 + assert result.rolls == [4, 4] + assert result.modifier == 1 + + +def test_dnd_check_uses_d20_total_against_dc(): + registry = build_default_adapter_registry() + roller = DiceRoller(randbelow=lambda sides: 14) + + result = resolve_check( + adapter=registry.get("dnd5e_srd"), + roller=roller, + payload={"roll_expression": "1d20+5", "dc": 18, "check_label": "Strength"}, + ) + + assert result.total == 20 + assert result.outcome == "success" + + +def test_fate_check_uses_fate_dice_total_against_ladder_target(): + registry = build_default_adapter_registry() + roller = DiceRoller(fate_values=[-1, 0, 1, 1]) + + result = resolve_check( + adapter=registry.get("fate"), + roller=roller, + payload={"skill_bonus": 2, "ladder_target": 3, "check_label": "Careful"}, + ) + + assert result.total == 3 + assert result.outcome == "tie" +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_dice_checks.py -v` + +Expected: FAIL because dice and check modules do not exist. + +- [x] **Step 3: Add dice result dataclass and roller** + +```python +# tldw_Server_API/app/core/RPG/models.py +@dataclass(frozen=True, slots=True) +class DiceRollResult: + expression: str + rolls: list[int] + modifier: int + total: int + kept: list[int] + + +@dataclass(frozen=True, slots=True) +class CheckResult: + adapter_key: str + check_label: str + total: int + target: int | None + outcome: str + roll: DiceRollResult | None + details: dict[str, Any] +``` + +```python +# tldw_Server_API/app/core/RPG/dice.py +from __future__ import annotations + +import re +import secrets +from collections.abc import Callable + +from tldw_Server_API.app.core.RPG.models import DiceRollResult + +_DICE_RE = re.compile(r"^(?P[1-9][0-9]?)d(?P[1-9][0-9]*)(?P[+-][0-9]+)?$") + + +class DiceRoller: + def __init__( + self, + randbelow: Callable[[int], int] | None = None, + fate_values: list[int] | None = None, + ) -> None: + self._randbelow = randbelow or secrets.randbelow + self._fate_values = list(fate_values or []) + + def roll(self, expression: str) -> DiceRollResult: + match = _DICE_RE.match(expression.strip().lower()) + if not match: + raise ValueError("Unsupported dice expression") + count = int(match.group("count")) + sides = int(match.group("sides")) + modifier = int(match.group("mod") or 0) + rolls = [self._randbelow(sides) + 1 for _ in range(count)] + total = sum(rolls) + modifier + return DiceRollResult(expression=expression, rolls=rolls, modifier=modifier, total=total, kept=rolls) + + def roll_fate(self) -> list[int]: + if self._fate_values: + values = self._fate_values[:4] + if len(values) != 4: + raise ValueError("Fate checks require four fate dice") + return values + return [self._randbelow(3) - 1 for _ in range(4)] +``` + +- [x] **Step 4: Add adapter-aware check resolver** + +```python +# tldw_Server_API/app/core/RPG/checks.py +from __future__ import annotations + +from typing import Any + +from tldw_Server_API.app.core.RPG.dice import DiceRoller +from tldw_Server_API.app.core.RPG.models import CheckResult +from tldw_Server_API.app.core.RPG.rules.adapters import RuleAdapter + + +def resolve_check(adapter: RuleAdapter, roller: DiceRoller, payload: dict[str, Any]) -> CheckResult: + return adapter.resolve_check(roller, payload) +``` + +Implement the actual D20 and Fate resolution helpers on the bundled adapter classes or resolver callbacks registered by `StaticRuleAdapter`; core check orchestration must not branch on `mechanics_tags["resolution_family"]`. + +- [x] **Step 5: Run focused dice/check tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_dice_checks.py -v` + +Expected: PASS. + +- [x] **Step 6: Commit** + +```bash +git add tldw_Server_API/app/core/RPG/dice.py tldw_Server_API/app/core/RPG/checks.py tldw_Server_API/app/core/RPG/models.py tldw_Server_API/tests/RPG/test_rpg_dice_checks.py +git commit -m "feat: add RPG dice and check resolution" +``` + +### Task 5: RPG Service, Authority Policy, And Proposals + +**Files:** +- Create: `tldw_Server_API/app/core/RPG/authority.py` +- Create: `tldw_Server_API/app/core/RPG/proposals.py` +- Create: `tldw_Server_API/app/core/RPG/service.py` +- Modify: `tldw_Server_API/app/core/RPG/__init__.py` +- Modify: `tldw_Server_API/app/core/DB_Management/RPG_DB.py` +- Test: `tldw_Server_API/tests/RPG/test_rpg_service.py` + +- [x] **Step 1: Write failing service authority tests** + +```python +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RPG.service import RPGService + + +def _service(): + repo = RPGRepository.initialized(CharactersRAGDB(":memory:", "test-client")) + return RPGService(repo=repo, owner_user_id=42) + + +def test_model_events_create_pending_proposal_by_default(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-model") + session = service.create_session(campaign.id, "Opening", adapter_key="fate", idempotency_key="session-model") + + result = service.record_events( + session_id=session.id, + events=[{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "Suggested"}}], + source_type="model", + expected_last_event_sequence=0, + idempotency_key="model-1", + ) + + assert result.committed_events == [] + assert result.proposal is not None + assert result.proposal.status == "pending" + + +def test_user_events_commit_and_update_snapshot(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-user") + session = service.create_session(campaign.id, "Opening", adapter_key="fate", idempotency_key="session-user") + + result = service.record_events( + session_id=session.id, + events=[{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "Observed"}}], + source_type="user", + expected_last_event_sequence=0, + idempotency_key="user-1", + ) + + assert [event.sequence_number for event in result.committed_events] == [1] + assert service.get_snapshot(session.id).snapshot.notes[0]["text"] == "Observed" + + +def test_apply_proposal_is_atomic_and_advances_snapshot_once(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-proposal") + session = service.create_session(campaign.id, "Opening", adapter_key="fate", idempotency_key="session-proposal") + proposed = service.record_events( + session_id=session.id, + events=[ + {"event_type": "npc.upserted", "event_payload": {"npc_id": "npc-1", "name": "Ada"}}, + {"event_type": "quest.upserted", "event_payload": {"quest_id": "q1", "title": "Find Ada"}}, + ], + source_type="model", + expected_last_event_sequence=0, + idempotency_key="model-2", + ).proposal + + applied = service.apply_proposal( + session_id=session.id, + proposal_id=proposed.id, + expected_last_event_sequence=0, + idempotency_key="proposal-apply-1", + review_notes="accepted", + ) + + assert [event.sequence_number for event in applied.committed_events] == [1, 2] + snapshot = service.get_snapshot(session.id).snapshot + assert snapshot.npcs["npc-1"]["name"] == "Ada" + assert snapshot.quests["q1"]["title"] == "Find Ada" +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_service.py -v` + +Expected: FAIL because `RPGService` does not exist. + +- [x] **Step 3: Implement authority decisions** + +```python +# tldw_Server_API/app/core/RPG/authority.py +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class AuthorityDecision: + action: str + reason: str + + +def decide_authority(source_type: str, event_type: str, authority_settings: dict[str, object]) -> AuthorityDecision: + if source_type == "user": + return AuthorityDecision(action="commit", reason="user_direct_commit") + if source_type == "system": + return AuthorityDecision(action="commit", reason="internal_system_commit") + if source_type == "import" and authority_settings.get("import_auto_commit") is True: + return AuthorityDecision(action="commit", reason="import_auto_commit_enabled") + if source_type == "mcp" and authority_settings.get("mcp_auto_commit") is True: + return AuthorityDecision(action="commit", reason="mcp_auto_commit_enabled") + if source_type == "model" and authority_settings.get("model_auto_commit") is True: + allowed = set(authority_settings.get("model_auto_commit_event_types") or []) + if event_type in allowed: + return AuthorityDecision(action="commit", reason="model_event_type_auto_commit_enabled") + return AuthorityDecision(action="proposal", reason=f"{source_type}_{event_type}_requires_review") +``` + +- [x] **Step 4: Implement service result types and orchestration** + +```python +# tldw_Server_API/app/core/RPG/service.py +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RPG.authority import decide_authority +from tldw_Server_API.app.core.RPG.events import canonical_request_hash, validate_event_envelope +from tldw_Server_API.app.core.RPG.models import RPGCampaign, RPGSession, RPGSessionEvent, RPGSnapshotState +from tldw_Server_API.app.core.RPG.reducer import reduce_events +from tldw_Server_API.app.core.RPG.rules.adapters import RuleAdapterRegistry, build_default_adapter_registry + + +@dataclass(frozen=True, slots=True) +class RPGServiceProposal: + id: int + session_id: int + status: str + proposed_events: list[dict[str, Any]] + + +@dataclass(frozen=True, slots=True) +class RecordEventsResult: + committed_events: list[RPGSessionEvent] + proposal: RPGServiceProposal | None + + +@dataclass(frozen=True, slots=True) +class SnapshotResult: + snapshot_version: int + last_event_sequence: int + snapshot: RPGSnapshotState + diagnostics: dict[str, Any] + + +class RPGService: + def __init__( + self, + repo: RPGRepository, + owner_user_id: int, + adapter_registry: RuleAdapterRegistry | None = None, + ) -> None: + self.repo = repo + self.owner_user_id = owner_user_id + self.adapter_registry = adapter_registry or build_default_adapter_registry() + + def create_campaign(self, title: str, description: str | None, default_adapter_key: str, idempotency_key: str) -> RPGCampaign: + adapter = self.adapter_registry.get(default_adapter_key) + request_hash = canonical_request_hash({"title": title, "description": description, "default_adapter_key": adapter.adapter_key}) + return self.repo.create_campaign( + owner_user_id=self.owner_user_id, + title=title, + description=description, + default_adapter_key=adapter.adapter_key, + default_adapter_version=adapter.adapter_version, + settings={}, + linked_rules_pack_refs=[], + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type="user", + ) + + def create_session(self, campaign_id: int, title: str, adapter_key: str, idempotency_key: str) -> RPGSession: + adapter = self.adapter_registry.get(adapter_key) + request_hash = canonical_request_hash({"campaign_id": campaign_id, "title": title, "adapter_key": adapter.adapter_key}) + return self.repo.create_session( + owner_user_id=self.owner_user_id, + campaign_id=campaign_id, + title=title, + adapter_key=adapter.adapter_key, + adapter_version=adapter.adapter_version, + authority_settings={"model_auto_commit": False, "mcp_auto_commit": False}, + linked_chat_id=None, + active_rules_pack_refs=[], + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type="user", + ) + + def record_events( + self, + session_id: int, + events: list[dict[str, Any]], + source_type: str, + expected_last_event_sequence: int, + idempotency_key: str, + ) -> RecordEventsResult: + if not idempotency_key: + raise RPGConflictError("idempotency_key_required") + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + source_actor_id = f"{source_type}:{self.owner_user_id}" if source_type in {"user", "mcp"} else None + normalized = [ + validate_event_envelope({**event, "source_type": source_type, "source_actor_id": source_actor_id}) + for event in events + ] + if {event["source_type"] for event in normalized} != {source_type}: + raise RPGConflictError("mixed_source_batch") + decisions = [ + decide_authority(source_type, event["event_type"], session.authority_settings) + for event in normalized + ] + request_hash = canonical_request_hash( + { + "events": normalized, + "expected_last_event_sequence": expected_last_event_sequence, + "source_type": source_type, + } + ) + if any(decision.action == "proposal" for decision in decisions): + current = self.repo.get_latest_snapshot(owner_user_id=self.owner_user_id, session_id=session_id) + if expected_last_event_sequence != session.last_event_sequence: + raise RPGConflictError("stale_event_sequence") + proposal = self.repo.create_proposal( + owner_user_id=self.owner_user_id, + session_id=session_id, + base_event_sequence=session.last_event_sequence, + base_snapshot_version=current.snapshot_version, + proposed_events=normalized, + source_type=source_type, + source_actor_id=normalized[0].get("source_actor_id"), + model_metadata={}, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + ) + return RecordEventsResult(committed_events=[], proposal=RPGServiceProposal(proposal.id, session_id, proposal.status, normalized)) + return self._commit_validated_events( + session=session, + normalized=normalized, + expected_last_event_sequence=expected_last_event_sequence, + idempotency_key=idempotency_key, + request_hash=request_hash, + proposal_id=None, + ) +``` + +Add these service methods in the same file: + +```python +def apply_proposal( + self, + session_id: int, + proposal_id: int, + expected_last_event_sequence: int, + idempotency_key: str, + review_notes: str | None = None, +) -> RecordEventsResult: + if not idempotency_key: + raise ValueError("Idempotency-Key is required") + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + proposal = self.repo.get_proposal(owner_user_id=self.owner_user_id, proposal_id=proposal_id) + if proposal.session_id != session_id: + raise RPGConflictError("proposal_session_mismatch") + if proposal.status != "pending": + raise RPGConflictError("proposal_not_pending") + if expected_last_event_sequence != proposal.base_event_sequence: + raise RPGConflictError("stale_event_sequence") + if proposal.base_event_sequence != session.last_event_sequence: + self.repo.mark_proposal_conflicted(self.owner_user_id, proposal_id) + raise RPGConflictError("proposal_base_sequence_conflict") + normalized = [validate_event_envelope(event) for event in proposal.proposed_events] + request_hash = canonical_request_hash({"proposal_id": proposal_id, "expected_last_event_sequence": expected_last_event_sequence, "events": normalized}) + return self._commit_validated_events( + session=session, + normalized=normalized, + expected_last_event_sequence=proposal.base_event_sequence, + idempotency_key=idempotency_key, + request_hash=request_hash, + proposal_id=proposal_id, + proposal_review_notes=review_notes, + ) + + +def reject_proposal(self, session_id: int, proposal_id: int, idempotency_key: str, review_notes: str | None = None) -> RPGServiceProposal: + if not idempotency_key: + raise ValueError("Idempotency-Key is required") + proposal = self.repo.get_proposal(owner_user_id=self.owner_user_id, proposal_id=proposal_id) + if proposal.session_id != session_id: + raise RPGConflictError("proposal_session_mismatch") + proposal = self.repo.mark_proposal_rejected(self.owner_user_id, proposal_id, idempotency_key, review_notes) + return RPGServiceProposal(proposal.id, session_id, proposal.status, proposal.proposed_events) + + +def get_snapshot(self, session_id: int) -> SnapshotResult: + record = self.repo.get_latest_snapshot(owner_user_id=self.owner_user_id, session_id=session_id) + return SnapshotResult( + snapshot_version=record.snapshot_version, + last_event_sequence=record.last_event_sequence, + snapshot=RPGSnapshotState(**record.snapshot_json), + diagnostics=record.diagnostics_json, + ) + + +def _commit_validated_events( + self, + session: RPGSession, + normalized: list[dict[str, Any]], + expected_last_event_sequence: int, + idempotency_key: str, + request_hash: str, + proposal_id: int | None, + proposal_review_notes: str | None = None, +) -> RecordEventsResult: + current = self.repo.get_latest_snapshot(owner_user_id=self.owner_user_id, session_id=session.id) + next_snapshot = reduce_events(RPGSnapshotState(**current.snapshot_json), normalized) + committed = self.repo.commit_events_and_snapshot( + owner_user_id=self.owner_user_id, + session_id=session.id, + expected_last_event_sequence=expected_last_event_sequence, + base_snapshot_version=current.snapshot_version, + events=normalized, + snapshot=asdict(next_snapshot), + diagnostics={"applied_event_count": len(normalized)}, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + adapter_key=session.adapter_key, + adapter_version=session.adapter_version, + proposal_id=proposal_id, + proposal_review_notes=proposal_review_notes, + ) + return RecordEventsResult(committed_events=committed.events, proposal=None) +``` + +- [x] **Step 5: Add repository proposal and snapshot methods** + +Add these methods to `RPGRepository`. Each method filters by `owner_user_id` and raises `RPGNotFoundError` when a row is absent. + +```python +def create_proposal( + self, + owner_user_id: int, + session_id: int, + base_event_sequence: int, + base_snapshot_version: int, + proposed_events: list[dict[str, Any]], + source_type: str, + source_actor_id: str | None, + model_metadata: dict[str, Any], + idempotency_key: str, + request_payload_hash: str, +) -> RPGProposalRecord: + raise NotImplementedError + +def get_proposal(self, owner_user_id: int, proposal_id: int) -> RPGProposalRecord: + raise NotImplementedError + +def mark_proposal_applied(self, owner_user_id: int, proposal_id: int, review_notes: str | None) -> RPGProposalRecord: + raise NotImplementedError + +def mark_proposal_rejected(self, owner_user_id: int, proposal_id: int, idempotency_key: str, review_notes: str | None) -> RPGProposalRecord: + raise NotImplementedError + +def mark_proposal_conflicted(self, owner_user_id: int, proposal_id: int) -> RPGProposalRecord: + raise NotImplementedError + +def save_snapshot(self, owner_user_id: int, session_id: int, snapshot_version: int, last_event_sequence: int, snapshot: dict[str, Any], diagnostics: dict[str, Any]) -> RPGSnapshotRecord: + raise NotImplementedError + +def commit_events_and_snapshot( + self, + owner_user_id: int, + session_id: int, + expected_last_event_sequence: int, + base_snapshot_version: int, + events: list[dict[str, Any]], + snapshot: dict[str, Any], + diagnostics: dict[str, Any], + idempotency_key: str, + request_payload_hash: str, + adapter_key: str, + adapter_version: str, + proposal_id: int | None, + proposal_review_notes: str | None = None, +) -> CommitEventsResult: + raise NotImplementedError +``` + +`commit_events_and_snapshot()` must perform all of these operations in one `CharactersRAGDB.transaction()` block: idempotency replay lookup, `expected_last_event_sequence` and `base_snapshot_version` validation, event inserts, snapshot insert, `rpg_sessions.last_event_sequence` and `current_snapshot_version` update, proposal status update when applicable, idempotency response insert, and event `operation_id` backfill. If any step fails, no event rows from that request may remain. + +`create_campaign()`, `create_session()`, `create_proposal()`, and `mark_proposal_rejected()` must also use `rpg_idempotency_records` with operation scopes such as `campaigns`, `campaign:{campaign_id}:sessions`, `session:{session_id}:proposals`, and `proposal:{proposal_id}:reject`. Replays return the stored response JSON and conflicting payload hashes raise `RPGConflictError("idempotency_key_conflict")`. + +- [x] **Step 6: Run focused service tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_service.py -v` + +Expected: PASS. + +- [x] **Step 7: Commit** + +```bash +git add tldw_Server_API/app/core/RPG/authority.py tldw_Server_API/app/core/RPG/proposals.py tldw_Server_API/app/core/RPG/service.py tldw_Server_API/app/core/RPG/__init__.py tldw_Server_API/app/core/DB_Management/RPG_DB.py tldw_Server_API/tests/RPG/test_rpg_service.py +git commit -m "feat: add RPG service authority flow" +``` + +### Task 6: REST Schemas, Endpoints, And Router Registration + +**Files:** +- Create: `tldw_Server_API/app/api/v1/schemas/rpg_schemas.py` +- Create: `tldw_Server_API/app/api/v1/endpoints/rpg.py` +- Modify: `tldw_Server_API/app/api/v1/router_groups/content.py` +- Modify: `tldw_Server_API/Config_Files/privilege_catalog.yaml` +- Test: `tldw_Server_API/tests/RPG/test_rpg_api.py` + +Before endpoint code is written, create a route matrix in the task notes and keep implementation aligned with it. This is the target RPG API surface; Task 6 implements only rows backed by the current service layer, while later rules/context and final-hardening tasks add the remaining handlers. + +| Method | Path | Permission | Endpoint ID | Idempotency | Expected Sequence | +| --- | --- | --- | --- | --- | --- | +| `GET` | `/api/v1/rpg/rules/adapters` | `rpg.rules.read` | `rpg.rules.read` | no | no | +| `GET` | `/api/v1/rpg/rules/adapters/{adapter_key}` | `rpg.rules.read` | `rpg.rules.read` | no | no | +| `POST` | `/api/v1/rpg/rules/lookup` | `rpg.rules.read` | `rpg.rules.read` | no | no | +| `POST` | `/api/v1/rpg/campaigns` | `rpg.campaigns.manage` | `rpg.campaigns.manage` | header required | no | +| `GET` | `/api/v1/rpg/campaigns` | `rpg.campaigns.read` | `rpg.campaigns.read` | no | no | +| `GET` | `/api/v1/rpg/campaigns/{campaign_id}` | `rpg.campaigns.read` | `rpg.campaigns.read` | no | no | +| `PATCH` | `/api/v1/rpg/campaigns/{campaign_id}` | `rpg.campaigns.manage` | `rpg.campaigns.manage` | header required | no | +| `DELETE` | `/api/v1/rpg/campaigns/{campaign_id}` | `rpg.campaigns.manage` | `rpg.campaigns.manage` | header required | no | +| `POST` | `/api/v1/rpg/campaigns/{campaign_id}/sessions` | `rpg.sessions.manage` | `rpg.sessions.manage` | header required | no | +| `GET` | `/api/v1/rpg/sessions/{session_id}` | `rpg.sessions.read` | `rpg.sessions.read` | no | no | +| `PATCH` | `/api/v1/rpg/sessions/{session_id}` | `rpg.sessions.manage` | `rpg.sessions.manage` | header required | yes | +| `GET` | `/api/v1/rpg/sessions/{session_id}/events` | `rpg.sessions.read` | `rpg.sessions.read` | no | no | +| `GET` | `/api/v1/rpg/sessions/{session_id}/snapshot` | `rpg.sessions.read` | `rpg.sessions.read` | no | no | +| `POST` | `/api/v1/rpg/sessions/{session_id}/snapshot/rebuild` | `rpg.snapshots.admin` | `rpg.snapshots.admin` | header required | yes | +| `POST` | `/api/v1/rpg/sessions/{session_id}/events` | `rpg.sessions.manage` | `rpg.sessions.manage` | header required | yes | +| `POST` | `/api/v1/rpg/sessions/{session_id}/rolls` | `rpg.sessions.manage` | `rpg.sessions.manage` | header required | yes when recording | +| `POST` | `/api/v1/rpg/sessions/{session_id}/proposals` | `rpg.sessions.manage` | `rpg.sessions.manage` | header required | yes | +| `POST` | `/api/v1/rpg/sessions/{session_id}/proposals/{proposal_id}/apply` | `rpg.proposals.review` | `rpg.proposals.review` | header required | yes | +| `POST` | `/api/v1/rpg/sessions/{session_id}/proposals/{proposal_id}/reject` | `rpg.proposals.review` | `rpg.proposals.review` | header required | no | +| `POST` | `/api/v1/rpg/sessions/{session_id}/rules/lookup` | `rpg.rules.read` | `rpg.rules.read` | no | no | +| `POST` | `/api/v1/rpg/sessions/{session_id}/context` | `rpg.sessions.read` | `rpg.sessions.read` | no | no | + +The global `/api/v1/rpg/rules/lookup` route remains deferred; the current implementation uses session-scoped lookup so it can respect the active adapter and linked rule pack refs. + +- [x] **Step 1: Write failing API tests** + +```python +from fastapi.testclient import TestClient + +from tldw_Server_API.app.main import app + + +def test_rpg_adapters_endpoint_lists_default_adapters(): + client = TestClient(app) + + response = client.get("/api/v1/rpg/rules/adapters", headers={"X-API-KEY": "test-api-key-12345"}) + + assert response.status_code == 200 + keys = [item["adapter_key"] for item in response.json()["adapters"]] + assert keys == ["dnd5e_srd", "fate", "pf2e"] + + +def test_create_campaign_session_and_record_user_event(): + client = TestClient(app) + auth_headers = {"X-API-KEY": "test-api-key-12345"} + + campaign = client.post( + "/api/v1/rpg/campaigns", + headers={**auth_headers, "Idempotency-Key": "api-campaign-1"}, + json={"title": "Campaign", "default_adapter_key": "fate"}, + ) + assert campaign.status_code == 201 + campaign_id = campaign.json()["id"] + + session = client.post( + f"/api/v1/rpg/campaigns/{campaign_id}/sessions", + headers={**auth_headers, "Idempotency-Key": "api-session-1"}, + json={"title": "Opening", "adapter_key": "fate"}, + ) + assert session.status_code == 201 + session_id = session.json()["id"] + + event_response = client.post( + f"/api/v1/rpg/sessions/{session_id}/events", + headers={**auth_headers, "Idempotency-Key": "api-event-1"}, + json={ + "expected_last_event_sequence": 0, + "events": [ + {"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "At the docks"}} + ] + }, + ) + + assert event_response.status_code == 200 + assert event_response.json()["committed_events"][0]["sequence_number"] == 1 +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py -v` + +Expected: FAIL because the RPG router is not registered. + +- [x] **Step 3: Add Pydantic schemas** + +```python +# tldw_Server_API/app/api/v1/schemas/rpg_schemas.py +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class RPGCampaignCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + title: str = Field(min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=4000) + default_adapter_key: str = Field(min_length=1, max_length=80) + + +class RPGCampaignResponse(BaseModel): + id: int + title: str + description: str | None + default_adapter_key: str + default_adapter_version: str + status: str + version: int + + +class RPGSessionCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + title: str = Field(min_length=1, max_length=200) + adapter_key: str = Field(min_length=1, max_length=80) + linked_chat_id: int | None = None + + +class RPGSessionResponse(BaseModel): + id: int + campaign_id: int + title: str + status: str + adapter_key: str + adapter_version: str + current_snapshot_version: int + last_event_sequence: int + version: int + + +class RPGEventInput(BaseModel): + model_config = ConfigDict(extra="forbid") + event_type: str = Field(min_length=1, max_length=120) + event_payload: dict[str, Any] + + +class RPGRecordEventsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_last_event_sequence: int = Field(ge=0) + events: list[RPGEventInput] = Field(min_length=1, max_length=20) + + +class RPGRecordEventsResponse(BaseModel): + committed_events: list[dict[str, Any]] + proposal: dict[str, Any] | None + + +class RPGRulesLookupRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + query: str = Field(min_length=1, max_length=500) + + +class RPGContextBuildRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + query: str | None = Field(default=None, max_length=500) + max_chars: int = Field(default=24000, ge=1000, le=24000) + + +class RPGProposalApplyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_last_event_sequence: int = Field(ge=0) + review_notes: str | None = Field(default=None, max_length=2000) + + +class RPGProposalRejectRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + review_notes: str | None = Field(default=None, max_length=2000) +``` + +- [x] **Step 4: Add endpoints with token scope metadata** + +```python +# tldw_Server_API/app/api/v1/endpoints/rpg.py +from __future__ import annotations + +from dataclasses import asdict + +from fastapi import APIRouter, Depends, Header, HTTPException, status + +from tldw_Server_API.app.api.v1.API_Deps.ChaCha_Notes_DB_Deps import get_chacha_db_for_user +from tldw_Server_API.app.api.v1.API_Deps.auth_deps import RequirePermission, TokenScopeGuard, User, get_request_user, rbac_rate_limit +from tldw_Server_API.app.api.v1.schemas.rpg_schemas import ( + RPGCampaignCreateRequest, + RPGCampaignResponse, + RPGContextBuildRequest, + RPGProposalApplyRequest, + RPGProposalRejectRequest, + RPGRecordEventsRequest, + RPGRecordEventsResponse, + RPGRulesLookupRequest, + RPGSessionCreateRequest, + RPGSessionResponse, +) +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RPG.errors import RPGConflictError, RPGNotFoundError, RPGValidationError +from tldw_Server_API.app.core.RPG.service import RPGService + +router = APIRouter(prefix="/rpg", tags=["rpg"]) + + +def _owner_user_id(current_user: User) -> int: + if current_user.id_int is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_user_id") + return current_user.id_int + + +def _service( + db: CharactersRAGDB = Depends(get_chacha_db_for_user), + current_user: User = Depends(get_request_user), +) -> RPGService: + return RPGService(repo=RPGRepository.initialized(db), owner_user_id=_owner_user_id(current_user)) + + +def _http_error(exc: Exception) -> HTTPException: + if isinstance(exc, RPGNotFoundError): + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) + if isinstance(exc, RPGConflictError): + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + if isinstance(exc, (RPGValidationError, ValueError)): + return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="rpg_internal_error") + + +@router.get( + "/rules/adapters", + dependencies=[ + Depends(rbac_rate_limit("rpg.rules.read")), + Depends(RequirePermission("rpg.rules.read")), + Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.rules.read")), + ], +) +def list_adapters(service: RPGService = Depends(_service)) -> dict[str, object]: + return {"adapters": [asdict(adapter) for adapter in service.adapter_registry.list_infos()]} + + +@router.post( + "/campaigns", + response_model=RPGCampaignResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[ + Depends(rbac_rate_limit("rpg.campaigns.manage")), + Depends(RequirePermission("rpg.campaigns.manage")), + Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.campaigns.manage")), + ], +) +def create_campaign( + request: RPGCampaignCreateRequest, + idempotency_key: str = Header(alias="Idempotency-Key"), + service: RPGService = Depends(_service), +) -> RPGCampaignResponse: + try: + campaign = service.create_campaign(request.title, request.description, request.default_adapter_key, idempotency_key=idempotency_key) + return RPGCampaignResponse.model_validate(asdict(campaign)) + except Exception as exc: + raise _http_error(exc) from exc +``` + +Add these route handlers in the same file with the same `_service` and `_http_error` helpers: + +```python +@router.get("/campaigns", dependencies=[Depends(rbac_rate_limit("rpg.campaigns.read")), Depends(RequirePermission("rpg.campaigns.read")), Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.campaigns.read"))]) +def list_campaigns(service: RPGService = Depends(_service)) -> dict[str, object]: + return {"campaigns": [asdict(campaign) for campaign in service.list_campaigns()]} + + +@router.post("/campaigns/{campaign_id}/sessions", response_model=RPGSessionResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(rbac_rate_limit("rpg.sessions.manage")), Depends(RequirePermission("rpg.sessions.manage")), Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.sessions.manage"))]) +def create_session(campaign_id: int, request: RPGSessionCreateRequest, idempotency_key: str = Header(alias="Idempotency-Key"), service: RPGService = Depends(_service)) -> RPGSessionResponse: + session = service.create_session(campaign_id=campaign_id, title=request.title, adapter_key=request.adapter_key, idempotency_key=idempotency_key) + return RPGSessionResponse.model_validate(asdict(session)) + + +@router.get("/sessions/{session_id}", dependencies=[Depends(rbac_rate_limit("rpg.sessions.read")), Depends(RequirePermission("rpg.sessions.read")), Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.sessions.read"))]) +def get_session(session_id: int, service: RPGService = Depends(_service)) -> dict[str, object]: + return service.get_session_payload(session_id) + + +@router.get("/sessions/{session_id}/events", dependencies=[Depends(rbac_rate_limit("rpg.sessions.read")), Depends(RequirePermission("rpg.sessions.read")), Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.sessions.read"))]) +def list_events(session_id: int, service: RPGService = Depends(_service)) -> dict[str, object]: + return {"events": [asdict(event) for event in service.list_events(session_id)]} + + +@router.post("/sessions/{session_id}/events", response_model=RPGRecordEventsResponse, dependencies=[Depends(rbac_rate_limit("rpg.sessions.manage")), Depends(RequirePermission("rpg.sessions.manage")), Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.sessions.manage"))]) +def record_events(session_id: int, request: RPGRecordEventsRequest, idempotency_key: str = Header(alias="Idempotency-Key"), service: RPGService = Depends(_service)) -> RPGRecordEventsResponse: + result = service.record_events(session_id=session_id, events=[event.model_dump() for event in request.events], source_type="user", expected_last_event_sequence=request.expected_last_event_sequence, idempotency_key=idempotency_key) + return RPGRecordEventsResponse(committed_events=[asdict(event) for event in result.committed_events], proposal=asdict(result.proposal) if result.proposal else None) + + +@router.post("/sessions/{session_id}/rules/lookup", dependencies=[Depends(rbac_rate_limit("rpg.rules.read")), Depends(RequirePermission("rpg.rules.read")), Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.rules.read"))]) +def lookup_rules(session_id: int, request: RPGRulesLookupRequest, service: RPGService = Depends(_service)) -> dict[str, object]: + return asdict(service.lookup_rules(session_id=session_id, query=request.query)) + + +@router.post("/sessions/{session_id}/context", dependencies=[Depends(rbac_rate_limit("rpg.sessions.read")), Depends(RequirePermission("rpg.sessions.read")), Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id="rpg.sessions.read"))]) +def build_context(session_id: int, request: RPGContextBuildRequest, service: RPGService = Depends(_service)) -> dict[str, object]: + return asdict(service.build_context(session_id=session_id, query=request.query, max_chars=request.max_chars)) +``` + +Add the route handlers backed by the current service slice with the same dependency style. Do not add placeholder routes for rules/context/session-read rows until the backing service behavior exists. All write handlers must require the `Idempotency-Key` header and the matching explicit permission dependency. + +- [x] **Step 5: Register the router** + +```python +# tldw_Server_API/app/api/v1/router_groups/content.py +rpg_spec = ImportedRouterSpec( + import_path="tldw_Server_API.app.api.v1.endpoints.rpg", + log_name="rpg", + prefix=f"{API_V1_PREFIX}", + tags=("rpg",), + route_key="rpg", +) +append_imported_router_spec(specs, rpg_spec) +``` + +Add this spec near adjacent content runtimes, before the VN route group tail. + +- [x] **Step 6: Add privilege catalog entries needed by the RPG router** + +Add the catalog entries from Task 7 before expecting endpoint tests to pass. Include `rpg.snapshots.admin` for snapshot rebuild. Run `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v`. + +- [x] **Step 7: Run focused API tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py -v` + +Expected: PASS. + +- [x] **Step 8: Commit** + +```bash +git add tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/router_groups/content.py tldw_Server_API/Config_Files/privilege_catalog.yaml tldw_Server_API/tests/RPG/test_rpg_api.py +git commit -m "feat: expose RPG REST runtime" +``` + +### Task 7: RPG Privilege Catalog And Route Snapshot + +**Files:** +- Modify: `tldw_Server_API/Config_Files/privilege_catalog.yaml` +- Modify: `tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json` +- Test: `tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py` + +- [x] **Step 1: Run privilege sync tests before catalog edits** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` + +Original expectation: FAIL with missing `rpg.*` endpoint IDs after Task 6 is complete. Actual follow-up status: Task 6 already added the concrete RPG endpoint scope entries, so this step served as a baseline check before adding the generic `rpg` token scope and regenerating the route snapshot. + +- [x] **Step 2: Add RPG scopes and endpoint IDs to the catalog** + +Add these entries under `scopes:` in `tldw_Server_API/Config_Files/privilege_catalog.yaml`: + +```yaml + - id: rpg + description: Generic token scope bucket for RPG campaign and session APIs. + resource_tags: + - rpg + - ttrpg + sensitivity_tier: moderate + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg + - id: rpg.rules.read + description: Read RPG rules adapter metadata and cited rules lookup results. + resource_tags: + - rpg + - rules + - read + sensitivity_tier: low + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + - viewer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-rules-read + - id: rpg.campaigns.read + description: Read RPG campaign metadata owned by the current user or scope. + resource_tags: + - rpg + - campaigns + - read + sensitivity_tier: moderate + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + - viewer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-campaigns-read + - id: rpg.campaigns.manage + description: Create and update RPG campaigns. + resource_tags: + - rpg + - campaigns + - write + sensitivity_tier: high + rate_limit_class: elevated + default_roles: + - admin + - developer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-campaigns-manage + - id: rpg.sessions.read + description: Read RPG sessions, events, snapshots, and prompt context diagnostics. + resource_tags: + - rpg + - sessions + - read + sensitivity_tier: moderate + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + - viewer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-sessions-read + - id: rpg.sessions.manage + description: Create RPG sessions, append trusted events, and record dice/check results. + resource_tags: + - rpg + - sessions + - write + sensitivity_tier: high + rate_limit_class: elevated + default_roles: + - admin + - developer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-sessions-manage + - id: rpg.proposals.review + description: Apply or reject RPG state-change proposals. + resource_tags: + - rpg + - proposals + - review + sensitivity_tier: restricted + rate_limit_class: admin + default_roles: + - admin + - developer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-proposals-review + - id: rpg.snapshots.admin + description: Rebuild RPG session snapshots from the event ledger. + resource_tags: + - rpg + - snapshots + - admin + sensitivity_tier: restricted + rate_limit_class: admin + default_roles: + - admin + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-snapshots-admin +``` + +- [x] **Step 3: Regenerate route registry snapshot** + +Run: `source .venv/bin/activate && python Helper_Scripts/update_privilege_registry_snapshot.py` + +Expected: `tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json` is updated and includes RPG routes with their endpoint IDs. + +- [x] **Step 4: Run privilege catalog tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` + +Expected: PASS. + +- [x] **Step 5: Commit** + +```bash +git add tldw_Server_API/Config_Files/privilege_catalog.yaml tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json +git commit -m "feat: register RPG privileges" +``` + +### Task 8: Rules Lookup And Session Context Builder + +**Files:** +- Create: `tldw_Server_API/app/core/RPG/rules/content_packs.py` +- Create: `tldw_Server_API/app/core/RPG/rules/lookup.py` +- Create: `tldw_Server_API/app/core/RPG/context.py` +- Modify: `tldw_Server_API/app/core/RPG/service.py` +- Test: `tldw_Server_API/tests/RPG/test_rpg_rules_context.py` + +- [x] **Step 1: Write failing rules lookup and context tests** + +```python +from tldw_Server_API.app.core.RPG.context import SessionContextBuilder +from tldw_Server_API.app.core.RPG.models import RPGSnapshotState +from tldw_Server_API.app.core.RPG.rules.lookup import RulesLookupService + + +def test_rules_lookup_returns_citations_without_pf2e_prose(): + lookup = RulesLookupService() + + result = lookup.lookup(adapter_key="pf2e", query="dying condition", linked_rules_pack_refs=[]) + + assert result.query == "dying condition" + assert result.results + assert all(item.text == "" for item in result.results) + assert all(item.citation.adapter_key == "pf2e" for item in result.results) + + +def test_context_builder_includes_snapshot_and_rule_citations_with_budget(): + builder = SessionContextBuilder(max_chars=500) + snapshot = RPGSnapshotState( + scene={"summary": "Rain at the old docks"}, + npcs={"npc-1": {"npc_id": "npc-1", "name": "Ada"}}, + unresolved_rulings={"r1": {"ruling_id": "r1", "question": "How does stress clear"}}, + ) + + context = builder.build( + adapter_key="fate", + session_title="Opening", + snapshot=snapshot, + rules_results=[], + ) + + assert "Opening" in context.text + assert "Rain at the old docks" in context.text + assert context.diagnostics["truncated"] is False + assert context.diagnostics["rules_result_count"] == 0 +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_context.py -v` + +Expected: FAIL because lookup and context modules do not exist. + +- [x] **Step 3: Implement content pack citations and lookup result types** + +```python +# tldw_Server_API/app/core/RPG/rules/content_packs.py +from __future__ import annotations + +from dataclasses import dataclass + +from tldw_Server_API.app.core.RPG.models import RuleCitation + + +@dataclass(frozen=True, slots=True) +class RuleLookupItem: + text: str + citation: RuleCitation + score: float + + +@dataclass(frozen=True, slots=True) +class RuleLookupResult: + query: str + results: list[RuleLookupItem] + diagnostics: dict[str, object] + + +PF2E_CITATIONS = [ + RuleCitation( + adapter_key="pf2e", + source_title="Archives of Nethys Pathfinder 2e", + source_url="https://2e.aonprd.com/", + license="ORC and Paizo Community Use references", + license_url="https://downloads.paizo.com/ORC_License_FINAL.pdf", + attribution="Pathfinder Second Edition rules references", + trust_level="reference", + content_hash="citation-only", + snippet_id="pf2e-citation-index", + source_version="PF2e", + content_pack_version="1.0.0", + ) +] +``` + +```python +# tldw_Server_API/app/core/RPG/rules/lookup.py +from __future__ import annotations + +from tldw_Server_API.app.core.RPG.rules.content_packs import PF2E_CITATIONS, RuleLookupItem, RuleLookupResult + + +class RulesLookupService: + def lookup(self, adapter_key: str, query: str, linked_rules_pack_refs: list[dict[str, object]]) -> RuleLookupResult: + if adapter_key == "pf2e": + return RuleLookupResult( + query=query, + results=[RuleLookupItem(text="", citation=citation, score=0.5) for citation in PF2E_CITATIONS], + diagnostics={"bundled_policy": "citations_only", "linked_rules_pack_count": len(linked_rules_pack_refs)}, + ) + return RuleLookupResult( + query=query, + results=[], + diagnostics={"bundled_policy": "no_match", "linked_rules_pack_count": len(linked_rules_pack_refs)}, + ) +``` + +- [x] **Step 4: Implement bounded context builder** + +```python +# tldw_Server_API/app/core/RPG/context.py +from __future__ import annotations + +from dataclasses import dataclass + +from tldw_Server_API.app.core.RPG.models import RPGSnapshotState +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupItem + + +@dataclass(frozen=True, slots=True) +class SessionContext: + text: str + diagnostics: dict[str, object] + + +class SessionContextBuilder: + def __init__(self, max_chars: int) -> None: + self.max_chars = max_chars + + def build( + self, + adapter_key: str, + session_title: str, + snapshot: RPGSnapshotState, + rules_results: list[RuleLookupItem], + ) -> SessionContext: + lines = [ + f"RPG session: {session_title}", + f"Rules adapter: {adapter_key}", + f"Scene: {snapshot.scene.get('summary', '')}", + f"NPCs: {', '.join(sorted(npc.get('name', npc_id) for npc_id, npc in snapshot.npcs.items()))}", + f"Open rulings: {len(snapshot.unresolved_rulings)}", + ] + if rules_results: + lines.append("Rules citations:") + for item in rules_results: + citation = item.citation + lines.append(f"- {citation.source_title}: {citation.source_url}") + text = "\n".join(line for line in lines if line.strip()) + truncated = len(text) > self.max_chars + if truncated: + text = text[: self.max_chars] + return SessionContext( + text=text, + diagnostics={"truncated": truncated, "rules_result_count": len(rules_results)}, + ) +``` + +- [x] **Step 5: Wire lookup and context methods into service** + +Add these methods to `RPGService`: + +```python +def lookup_rules(self, session_id: int, query: str) -> RuleLookupResult: + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + lookup = RulesLookupService() + return lookup.lookup( + adapter_key=session.adapter_key, + query=query, + linked_rules_pack_refs=session.active_rules_pack_refs, + ) + + +def build_context(self, session_id: int, query: str | None = None, max_chars: int = MAX_RPG_CONTEXT_CHARS) -> SessionContext: + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + snapshot = self.get_snapshot(session_id).snapshot + rules = self.lookup_rules(session_id, query).results if query else [] + return SessionContextBuilder(max_chars=max_chars).build( + adapter_key=session.adapter_key, + session_title=session.title, + snapshot=snapshot, + rules_results=rules, + ) +``` + +Also expose the session-scoped lookup/context behavior through the REST router and regenerate the privilege route registry snapshot, so AuthNZ metadata includes `/api/v1/rpg/sessions/{session_id}/rules/lookup` and `/api/v1/rpg/sessions/{session_id}/context`. + +- [x] **Step 6: Run focused rules/context tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_context.py -v` + +Expected: PASS. + +- [x] **Step 7: Commit** + +```bash +git add tldw_Server_API/app/core/RPG/rules/content_packs.py tldw_Server_API/app/core/RPG/rules/lookup.py tldw_Server_API/app/core/RPG/context.py tldw_Server_API/app/core/RPG/service.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py +git commit -m "feat: add RPG rules context builder" +``` + +### Task 9: MCP RPG Module And Optional Registration + +**Files:** +- Create: `tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py` +- Modify: `tldw_Server_API/app/core/MCP_unified/server.py` +- Test: `tldw_Server_API/tests/RPG/test_rpg_mcp_module.py` + +- [x] **Step 1: Write failing MCP module tests** + +```python +import pytest + +from tldw_Server_API.app.core.MCP_unified.modules.base import ModuleConfig +from tldw_Server_API.app.core.MCP_unified.modules.implementations.rpg_module import RPGModule + + +@pytest.mark.asyncio +async def test_rpg_module_lists_read_and_write_tools(): + module = RPGModule(ModuleConfig(name="rpg")) + + tools = await module.get_tools() + tool_by_name = {tool["name"]: tool for tool in tools} + + assert tool_by_name["rpg.adapters.list"]["annotations"]["readOnlyHint"] is True + assert tool_by_name["rpg.events.record"]["metadata"]["category"] == "management" + assert "rpg.proposals.apply" in tool_by_name + + +@pytest.mark.asyncio +async def test_rpg_module_lists_adapters_without_database_context(): + module = RPGModule(ModuleConfig(name="rpg")) + + result = await module.execute_tool("rpg.adapters.list", {}, context=None) + + assert [item["adapter_key"] for item in result["adapters"]] == ["dnd5e_srd", "fate", "pf2e"] + + +@pytest.mark.asyncio +async def test_rpg_database_tools_fail_closed_without_user_context(): + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(ValueError, match="authenticated user context"): + await module.execute_tool("rpg.sessions.get", {"session_id": 1}, context=None) +``` + +Add protocol-level MCP authorization tests that drive `MCPProtocol.process_request`, not just module methods: +- no RPG permission: `tools/call` for `rpg.sessions.get` is denied and `tools/list` does not expose executable RPG tools. +- read-only RPG permission: read tools execute, write tools such as `rpg.events.record` are denied. +- exact write permission: `rpg.events.record` executes when the context has that tool permission and an idempotency key. +- wildcard RPG permission: `rpg.*` or the configured wildcard form exposes read and write tools as executable. +- `allowed_tools` filtering: context metadata with an unrelated allow-list hides or denies all RPG tools, and `allowed_tools=["rpg.sessions.get"]` leaves only that read tool executable. + +- [x] **Step 2: Run the tests to verify they fail** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_mcp_module.py -v` + +Expected: FAIL because `RPGModule` does not exist. + +- [x] **Step 3: Implement MCP tool definitions and read-only adapter listing** + +```python +# tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from loguru import logger + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RPG.service import RPGService +from tldw_Server_API.app.core.RPG.rules.adapters import build_default_adapter_registry + +from ..base import BaseModule, create_tool_definition + + +class RPGModule(BaseModule): + async def on_initialize(self) -> None: + logger.info("Initializing RPG MCP module: {}", self.name) + + async def on_shutdown(self) -> None: + logger.info("Shutting down RPG MCP module: {}", self.name) + + async def check_health(self) -> dict[str, bool]: + return {"initialized": True, "adapter_registry": True} + + async def get_tools(self) -> list[dict[str, Any]]: + return [ + create_tool_definition( + name="rpg.adapters.list", + description="List bundled RPG rules adapters.", + parameters={"properties": {}}, + metadata={"category": "retrieval", "readOnlyHint": True}, + ), + create_tool_definition( + name="rpg.sessions.get", + description="Get RPG session state and current snapshot.", + parameters={"properties": {"session_id": {"type": "integer"}}, "required": ["session_id"]}, + metadata={"category": "retrieval", "readOnlyHint": True}, + ), + create_tool_definition( + name="rpg.events.list", + description="List RPG session events.", + parameters={"properties": {"session_id": {"type": "integer"}, "limit": {"type": "integer", "minimum": 1, "maximum": 200}}}, + metadata={"category": "retrieval", "readOnlyHint": True}, + ), + create_tool_definition( + name="rpg.events.record", + description="Record trusted RPG session events or create a proposal based on authority settings.", + parameters={"properties": {"session_id": {"type": "integer"}, "expected_last_event_sequence": {"type": "integer", "minimum": 0}, "events": {"type": "array"}, "idempotency_key": {"type": "string", "minLength": 1}}, "required": ["session_id", "expected_last_event_sequence", "events", "idempotency_key"]}, + metadata={"category": "management", "auth_required": True}, + ), + create_tool_definition( + name="rpg.roll", + description="Resolve an RPG dice/check roll and record it when requested.", + parameters={"properties": {"session_id": {"type": "integer"}, "check": {"type": "object"}, "record": {"type": "boolean", "default": True}}, "required": ["session_id", "check"]}, + metadata={"category": "management", "auth_required": True}, + ), + create_tool_definition( + name="rpg.rules.lookup", + description="Look up cited RPG rules references for a session.", + parameters={"properties": {"session_id": {"type": "integer"}, "query": {"type": "string", "maxLength": 500}}, "required": ["session_id", "query"]}, + metadata={"category": "retrieval", "readOnlyHint": True}, + ), + create_tool_definition( + name="rpg.context.build", + description="Build bounded RPG session context with citation diagnostics.", + parameters={"properties": {"session_id": {"type": "integer"}, "query": {"type": "string"}, "max_chars": {"type": "integer", "minimum": 1000, "maximum": 24000}}, "required": ["session_id"]}, + metadata={"category": "retrieval", "readOnlyHint": True}, + ), + create_tool_definition( + name="rpg.proposals.apply", + description="Apply a pending RPG proposal atomically.", + parameters={"properties": {"session_id": {"type": "integer"}, "proposal_id": {"type": "integer"}, "expected_last_event_sequence": {"type": "integer", "minimum": 0}, "review_notes": {"type": "string"}, "idempotency_key": {"type": "string", "minLength": 1}}, "required": ["session_id", "proposal_id", "expected_last_event_sequence", "idempotency_key"]}, + metadata={"category": "management", "auth_required": True}, + ), + create_tool_definition( + name="rpg.proposals.reject", + description="Reject a pending RPG proposal.", + parameters={"properties": {"session_id": {"type": "integer"}, "proposal_id": {"type": "integer"}, "review_notes": {"type": "string"}, "idempotency_key": {"type": "string", "minLength": 1}}, "required": ["session_id", "proposal_id", "idempotency_key"]}, + metadata={"category": "management", "auth_required": True}, + ), + ] + + async def get_resources(self) -> list[dict[str, Any]]: + return [] + + async def get_prompts(self) -> list[dict[str, Any]]: + return [] + + async def execute_tool(self, tool_name: str, arguments: dict[str, Any], context: Any = None) -> Any: + if tool_name == "rpg.adapters.list": + registry = build_default_adapter_registry() + return {"adapters": [asdict(info) for info in registry.list_infos()]} + raise ValueError(f"Unknown RPG tool: {tool_name}") +``` + +Add database-backed tool execution in `execute_tool` after `rpg.adapters.list`: + +```python +def _service_for_context(self, context: Any) -> RPGService: + if context is None or not str(getattr(context, "user_id", "") or "").strip(): + raise ValueError("RPG MCP tools require an authenticated user context") + db_paths = getattr(context, "db_paths", None) + if not isinstance(db_paths, dict) or not db_paths.get("chacha"): + raise ValueError("ChaChaNotes DB path not available in context") + owner_user_id = int(str(context.user_id)) + db = CharactersRAGDB(db_path=db_paths["chacha"], client_id=f"mcp_rpg_{self.config.name}") + return RPGService(repo=RPGRepository.initialized(db), owner_user_id=owner_user_id) + + +async def execute_tool(self, tool_name: str, arguments: dict[str, Any], context: Any = None) -> Any: + if tool_name == "rpg.adapters.list": + registry = build_default_adapter_registry() + return {"adapters": [asdict(info) for info in registry.list_infos()]} + service = self._service_for_context(context) + if tool_name == "rpg.sessions.get": + return service.get_session_payload(int(arguments["session_id"])) + if tool_name == "rpg.events.list": + events = service.list_events(int(arguments["session_id"]), limit=int(arguments.get("limit") or 100)) + return {"events": [asdict(event) for event in events]} + if tool_name == "rpg.events.record": + result = service.record_events(int(arguments["session_id"]), list(arguments["events"]), source_type="mcp", expected_last_event_sequence=int(arguments["expected_last_event_sequence"]), idempotency_key=str(arguments["idempotency_key"])) + return {"committed_events": [asdict(event) for event in result.committed_events], "proposal": asdict(result.proposal) if result.proposal else None} + if tool_name == "rpg.rules.lookup": + return asdict(service.lookup_rules(int(arguments["session_id"]), str(arguments["query"]))) + if tool_name == "rpg.context.build": + return asdict(service.build_context(int(arguments["session_id"]), arguments.get("query"), int(arguments.get("max_chars") or 24000))) + if tool_name == "rpg.proposals.apply": + result = service.apply_proposal(int(arguments["session_id"]), int(arguments["proposal_id"]), int(arguments["expected_last_event_sequence"]), str(arguments["idempotency_key"]), arguments.get("review_notes")) + return {"committed_events": [asdict(event) for event in result.committed_events]} + if tool_name == "rpg.proposals.reject": + return asdict(service.reject_proposal(int(arguments["session_id"]), int(arguments["proposal_id"]), str(arguments["idempotency_key"]), arguments.get("review_notes"))) + raise ValueError(f"Unknown RPG tool: {tool_name}") +``` + +- [x] **Step 4: Add optional server registration** + +```python +# tldw_Server_API/app/core/MCP_unified/server.py +if self._env_flag_enabled("MCP_ENABLE_RPG_MODULE"): + if not any(m.get("id") == "rpg" for m in modules_to_load if isinstance(m, dict)): + modules_to_load.append({ + "id": "rpg", + "class": "tldw_Server_API.app.core.MCP_unified.modules.implementations.rpg_module:RPGModule", + "enabled": True, + "name": "RPG", + "version": "1.0.0", + "department": "management", + "settings": {}, + }) + logger.info("MCP_ENABLE_RPG_MODULE=true; queuing RPGModule for registration") +``` + +Place this block beside other optional modules, before the final registration loop. + +- [x] **Step 5: Add tests for registered tool metadata** + +Add this registry test: + +```python +@pytest.mark.asyncio +async def test_rpg_module_write_tool_classification(): + module = RPGModule(ModuleConfig(name="rpg")) + tools = {tool["name"]: tool for tool in await module.get_tools()} + + assert module.is_write_tool_def(tools["rpg.events.record"]) is True + assert module.is_write_tool_def(tools["rpg.context.build"]) is False +``` + +- [x] **Step 6: Run focused MCP tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_mcp_module.py tldw_Server_API/app/core/MCP_unified/tests/test_idempotency_and_category.py -v` + +Expected: PASS. + +- [x] **Step 7: Commit** + +```bash +git add tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py tldw_Server_API/app/core/MCP_unified/server.py tldw_Server_API/tests/RPG/test_rpg_mcp_module.py +git commit -m "feat: add RPG MCP module" +``` + +Status note: Task 9 implemented an optional RPG MCP module with read-only adapter/session/rules/context tools, write-classified event/proposal tools, authenticated ChaCha DB context binding, idempotency enforcement, fail-closed direct validation, protocol authorization coverage, and `MCP_ENABLE_RPG_MODULE` registration. Final verification passed: `python -m pytest tldw_Server_API/tests/RPG tldw_Server_API/app/core/MCP_unified/tests/test_idempotency_and_category.py tldw_Server_API/app/core/MCP_unified/tests/test_web_search_module_registration.py -q` reported 75 passed; `python -m compileall -q ...` passed; `python -m bandit -r ... -f json -o /tmp/bandit_rpg_mcp.json` reported 0 results/errors/skips; `git diff --check` passed. + +### Task 10: Documentation, Regression Checks, And Security Scan + +**Files:** +- Create: `tldw_Server_API/app/core/RPG/README.md` + +- [x] **Step 1: Write the RPG runtime README** + +```markdown +# RPG Runtime + +The RPG runtime is a backend harness for tabletop roleplaying sessions. It stores campaigns, sessions, append-only events, cached snapshots, and reviewable state-change proposals in each user's ChaChaNotes database. + +The runtime is not a virtual tabletop. It does not manage maps, token positions, lighting, walls, or live shared tabletop rendering. + +Rules adapters provide mechanics metadata and check resolution for D&D 5e SRD, Pathfinder 2e, and Fate. Rules prose is citation-first and license-aware; user-provided rules packs are referenced through existing ingestion and retrieval systems rather than copied into RPG tables. + +State changes from users, imports, and trusted system operations can commit directly. Model-sourced changes become proposals unless the session explicitly enables auto-commit. Proposal application validates base sequence and appends all accepted events atomically. +``` + +- [x] **Step 2: Run the focused RPG suite** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG -v` + +Expected: PASS. + +- [x] **Step 3: Run adjacent regression tests** + +Run: `source .venv/bin/activate && python -m pytest tldw_Server_API/tests/VN_Play/test_vn_play_db.py tldw_Server_API/app/core/MCP_unified/tests/test_idempotency_and_category.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` + +Expected: PASS. + +- [x] **Step 4: Run Bandit on touched code** + +Run: `source .venv/bin/activate && python -m bandit -r tldw_Server_API/app/core/RPG tldw_Server_API/app/core/DB_Management/RPG_DB.py tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py -f json -o /tmp/bandit_rpg_runtime.json` + +Expected: exit code 0 or only non-actionable findings documented in the Backlog task with rationale. + +- [x] **Step 5: Run formatting and import sanity checks** + +Run: `source .venv/bin/activate && python -m compileall -q tldw_Server_API/app/core/RPG tldw_Server_API/app/core/DB_Management/RPG_DB.py tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py` + +Expected: PASS with no output. + +- [x] **Step 6: Update Backlog task final summary** + +Record: + +- Files created and modified. +- Test commands and outcomes. +- Bandit output path and outcome. +- Any deliberate skips with exact reason. +- Confirmation that no virtual tabletop canvas/map/token features were added. + +- [x] **Step 7: Final commit** + +```bash +git add tldw_Server_API/app/core/RPG/README.md +git commit -m "docs: document RPG runtime" +``` + +Status note: Task 10 added `tldw_Server_API/app/core/RPG/README.md` documenting the backend harness scope, non-VTT boundary, per-user ChaCha storage model, event/snapshot/idempotency behavior, current adapter/rules lookup limits, authority/proposal model, REST/MCP surfaces, concrete input limits, and current non-goals. A subagent documentation review identified over-broad sequence and rules-pack wording plus missing concrete limits; those were corrected before closeout. Verification passed: `python -m pytest tldw_Server_API/tests/RPG -q` reported 59 passed; `python -m pytest tldw_Server_API/tests/VN_Play/test_vn_play_db.py tldw_Server_API/app/core/MCP_unified/tests/test_idempotency_and_category.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -q` reported 47 passed; `python -m compileall -q ...` passed; `python -m bandit -r ... -f json -o /tmp/bandit_rpg_runtime.json` reported 0 results/errors/skips; `git diff --check` passed. No virtual tabletop canvas, map, token, wall, lighting, or live renderer features were added. + +## Self-Review Checklist + +- Spec coverage: + - Generic, non-Foundry RPG harness: Tasks 1, 5, 6, 9, 10. + - D&D 5e SRD, Pathfinder 2e, Fate adapters: Tasks 1, 4, 8. + - Hybrid persistence in per-user ChaChaNotes plus rules references: Tasks 2, 8. + - RPG sessions as source of truth, chats optional: Tasks 2, 5, 6. + - Append-only events, cached snapshots, idempotency, optimistic sequence checks: Tasks 2, 3, 5. + - Mixed authority and proposals: Task 5. + - REST and MCP surfaces: Tasks 6, 9. + - Privilege catalog coverage: Task 7. + - Legal conservatism for rules prose: Tasks 1, 8, 10. + +- Type consistency: + - `RPGRepository.initialized(db)` is introduced in Task 2 and reused in Tasks 5, 6, and 9. + - `RPGService` is introduced in Task 5 and reused by REST, context, and MCP tasks. + - `RPGSnapshotState`, `RPGSessionEvent`, `RuleAdapterRegistry`, `DiceRoller`, and `CheckResult` keep the same names across all tasks. + - Endpoint IDs in Task 6 match catalog IDs in Task 7. + +- Verification scope: + - Focused RPG tests cover adapters, storage, reducer, dice/checks, service authority, REST, context, and MCP. + - Adjacent regressions cover VN Play storage, MCP idempotency/category behavior, and privilege endpoint synchronization. + - Bandit scans every touched Python path in the new feature. diff --git a/Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md b/Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md new file mode 100644 index 0000000000..113327c75a --- /dev/null +++ b/Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md @@ -0,0 +1,972 @@ +# RPG Rules-Pack Attachment Retrieval Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let RPG campaigns and sessions attach user-owned media items or media collections as rules references, then use scoped retrieval to augment RPG rules lookup and session context with citations and optional grounded answers. + +**Architecture:** Keep rules content in existing Media/RAG stores, store only normalized source references in RPG campaign/session JSON columns, validate every dereference through existing ownership/readability checks, and run lookup through injectable async retrieval and answer-generation adapters shared by REST and MCP. + +**Tech Stack:** FastAPI, Pydantic v2, SQLite through `CharactersRAGDB`, existing Media DB and Collections DB dependencies, existing RAG retrieval executor surfaces, async chat generation through `perform_chat_api_call_async`, Loguru, pytest, Bandit, MCP Unified module APIs. + +--- + +## Reference Inputs + +- Approved design: `Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md` +- Existing RPG runtime plan: `Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md` +- Backlog task for design and handoff: `TASK-12029` +- Current repository storage: `tldw_Server_API/app/core/DB_Management/RPG_DB.py` +- Current RPG service: `tldw_Server_API/app/core/RPG/service.py` +- Current rules lookup: `tldw_Server_API/app/core/RPG/rules/lookup.py` +- Current rules content models: `tldw_Server_API/app/core/RPG/rules/content_packs.py` +- Current context builder: `tldw_Server_API/app/core/RPG/context.py` +- Current REST endpoint: `tldw_Server_API/app/api/v1/endpoints/rpg.py` +- Current REST schemas: `tldw_Server_API/app/api/v1/schemas/rpg_schemas.py` +- Current MCP module: `tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py` +- RAG request schema reference: `tldw_Server_API/app/api/v1/schemas/rag_schemas_unified.py` +- RAG scoped retrieval reference: `tldw_Server_API/app/core/RAG/rag_service/retrieval_executor.py` +- RAG collection readiness reference: `tldw_Server_API/app/api/v1/endpoints/rag_unified.py` +- Chat generation reference: `tldw_Server_API/app/core/Chat/chat_service.py` + +## Public Contract + +Rules refs use this server-owned shape when serialized into `linked_rules_pack_refs_json` or `active_rules_pack_refs_json`: + +```json +{ + "ref_id": "media_item:42", + "source_type": "media_item", + "source_id": 42, + "display_name": "Player Handbook SRD Notes", + "enabled": true, + "created_at": "2026-06-25T00:00:00Z", + "updated_at": "2026-06-25T00:00:00Z", + "metadata": { + "source_label": "user" + } +} +``` + +Supported `source_type` values for this implementation are `media_item` and `media_collection`. A disabled ref stays attached and is skipped during retrieval. A readable collection with no ready media items remains a valid ref and produces diagnostics instead of a hard lookup failure. + +Rules lookup response shape: + +```json +{ + "query": "How does advantage work?", + "mode": "lookup", + "results": [ + { + "origin": "user_provided", + "text": "short retrieved snippet", + "citation": { + "source_type": "media_item", + "source_id": 42, + "source_title": "Player Handbook SRD Notes", + "source_url": null, + "license": null, + "attribution": null, + "trust_level": "user_provided", + "content_hash": "sha256:abc123def456", + "snippet_id": "media:42:chunk:7" + }, + "score": 0.78 + } + ], + "answer": null, + "answer_status": "not_requested", + "answer_citation_ids": [], + "diagnostics": { + "linked_rules_pack_count": 2, + "enabled_rules_pack_count": 1, + "ready_media_item_count": 1, + "retrieval_result_count": 1, + "bundled_citation_count": 1, + "skipped_refs": [] + } +} +``` + +`mode="answer"` runs the same lookup first. If no user-provided snippets are returned, answer generation is skipped and `answer_status` is `no_evidence`. If snippets exist, generation uses the existing async chat service and only cites `snippet_id` values returned by lookup. + +## File Structure + +Create: + +- `tldw_Server_API/app/core/RPG/rules/refs.py`: typed ref normalization, server timestamp preservation, replacement-list validation, result dataclasses. +- `tldw_Server_API/app/core/RPG/rules/retrieval.py`: async protocols and concrete retrieval adapter for scoped media/collection lookups. +- `tldw_Server_API/app/core/RPG/rules/answering.py`: grounded answer generator over lookup snippets using `perform_chat_api_call_async`. +- `tldw_Server_API/tests/RPG/test_rpg_rules_refs.py`: ref normalization, timestamp, duplicate, and replacement semantics. +- `tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py`: retrieval adapter and source validation behavior. +- `tldw_Server_API/tests/RPG/test_rpg_rules_answering.py`: answer generation statuses, citation filtering, provider errors. + +Modify: + +- `tldw_Server_API/app/core/RPG/models.py`: add typed lookup item fields and serialize-friendly citation fields while preserving public dataclass compatibility. +- `tldw_Server_API/app/core/RPG/rules/content_packs.py`: extend `RuleLookupItem` and `RuleLookupResult` for `origin`, answer fields, and diagnostics. +- `tldw_Server_API/app/core/RPG/rules/lookup.py`: merge bundled citation-only entries with scoped retrieval results. +- `tldw_Server_API/app/core/RPG/context.py`: use lookup-mode retrieved snippets in bounded session context. +- `tldw_Server_API/app/core/RPG/service.py`: expose campaign/session ref list and replace operations, async lookup, and async context build. +- `tldw_Server_API/app/core/DB_Management/RPG_DB.py`: add public campaign getter and optimistic whole-list ref replacement methods. +- `tldw_Server_API/app/api/v1/schemas/rpg_schemas.py`: add rules-pack ref request/response schemas and lookup `mode`. +- `tldw_Server_API/app/api/v1/endpoints/rpg.py`: add campaign/session ref endpoints, wire media dependencies, convert lookup/context handlers to async. +- `tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py`: add ref-management tools and update lookup schema. +- `tldw_Server_API/Config_Files/privilege_catalog.yaml`: add route and MCP privilege metadata for new endpoints/tools when route snapshots require it. +- `tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json`: regenerate if endpoint metadata changes. +- `tldw_Server_API/app/core/RPG/README.md`: document user-provided rules refs, lookup behavior, and licensing stance. + +Update existing tests: + +- `tldw_Server_API/tests/RPG/test_rpg_db.py` +- `tldw_Server_API/tests/RPG/test_rpg_service.py` +- `tldw_Server_API/tests/RPG/test_rpg_rules_context.py` +- `tldw_Server_API/tests/RPG/test_rpg_api.py` +- `tldw_Server_API/tests/RPG/test_rpg_mcp_module.py` + +## Implementation Notes + +- Use `source .venv/bin/activate` before Python, pytest, helper scripts, or Bandit. +- Use a separate Backlog.md implementation task before runtime code edits begin; link this plan and `TASK-12029`. +- Whole-list replacement is the only write form for rules refs in this implementation. +- Ref writes require `expected_version` matching `RPGCampaign.version` or `RPGSession.version`. +- Repository writes increment the campaign/session `version` and `updated_at` in the same transaction. +- Idempotency scopes: + - Campaign refs: `campaign:{campaign_id}:rules_pack_refs` + - Session refs: `session:{session_id}:rules_pack_refs` +- Idempotency replays return the stored response when request hash matches and raise `RPGConflictError("idempotency_key_payload_mismatch")` when it differs. +- Server code owns `created_at` and `updated_at`; client-supplied timestamp fields are ignored. +- Preserve `created_at` for refs whose identity and source ID match an existing ref during replacement. +- Do not copy extracted rules prose into RPG tables. +- Do not add broad RAG fallback, web fallback, or cross-user source discovery. +- REST auth must include both RPG permissions and `media.read` on endpoints that dereference or retrieve attached media sources. +- Built-in citations stay citation-only with score `0.0`; generated answers never cite bundled citation-only rows as evidence. + +--- + +### Task 1: Add Rules-Pack Ref Model and Repository Replacement + +**Files:** + +- Create: `tldw_Server_API/app/core/RPG/rules/refs.py` +- Modify: `tldw_Server_API/app/core/DB_Management/RPG_DB.py` +- Modify: `tldw_Server_API/app/core/RPG/models.py` +- Create: `tldw_Server_API/tests/RPG/test_rpg_rules_refs.py` +- Modify: `tldw_Server_API/tests/RPG/test_rpg_db.py` + +- [ ] **Step 1: Write failing unit tests for ref normalization** + +Add `test_rpg_rules_refs.py` with these cases: + +- `normalize_rules_pack_refs_accepts_media_item_and_collection` +- `normalize_rules_pack_refs_rejects_unknown_source_type` +- `normalize_rules_pack_refs_rejects_non_positive_source_id` +- `normalize_rules_pack_refs_rejects_duplicate_ref_identity` +- `normalize_rules_pack_refs_ignores_client_timestamps` +- `normalize_rules_pack_refs_preserves_created_at_for_existing_ref` +- `normalize_rules_pack_refs_updates_updated_at_for_existing_ref` +- `normalize_rules_pack_refs_limits_metadata_to_json_object` + +Run: + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_refs.py -v +``` + +Confirm these tests fail because `refs.py` does not exist. + +- [ ] **Step 2: Implement typed ref normalization** + +Add these dataclasses and helpers in `rules/refs.py`: + +```python +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Literal + +RulesPackSourceType = Literal["media_item", "media_collection"] + + +@dataclass(frozen=True, slots=True) +class RulesPackRef: + ref_id: str + source_type: RulesPackSourceType + source_id: int + display_name: str + enabled: bool + created_at: datetime + updated_at: datetime + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RulesPackRefReplacementResult: + refs: list[RulesPackRef] + version: int + replayed: bool = False +``` + +Implementation requirements: + +- `normalize_rules_pack_ref_payloads(payloads, existing_refs, now)` accepts a list of dictionaries and returns `list[RulesPackRef]`. +- `source_type` must be exactly `media_item` or `media_collection`. +- `source_id` must be an integer greater than zero. +- `ref_id` is server-derived as `f"{source_type}:{source_id}"`. +- Duplicate `ref_id` values raise `RPGValidationError("duplicate_rules_pack_ref")`. +- `display_name` defaults to `ref_id` when omitted or blank. +- `enabled` defaults to `True`. +- `metadata` defaults to `{}` and must be a JSON object. +- The helper must serialize refs through `rules_pack_ref_to_dict(ref)` using ISO-8601 UTC strings. +- The helper must deserialize existing dicts through `rules_pack_ref_from_dict(data)`. + +- [ ] **Step 3: Write failing repository tests for whole-list replacement** + +In `test_rpg_db.py`, add these cases: + +- `test_get_campaign_returns_owner_scoped_campaign` +- `test_replace_campaign_rules_pack_refs_requires_expected_version` +- `test_replace_campaign_rules_pack_refs_increments_version` +- `test_replace_campaign_rules_pack_refs_replays_idempotency_key` +- `test_replace_campaign_rules_pack_refs_rejects_idempotency_payload_mismatch` +- `test_replace_session_rules_pack_refs_requires_expected_version` +- `test_replace_session_rules_pack_refs_increments_version` +- `test_replace_session_rules_pack_refs_replays_idempotency_key` + +Run: + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_db.py -v +``` + +Confirm the new tests fail because the repository methods are missing. + +- [ ] **Step 4: Add repository methods** + +Add public methods to `RPGRepository` with these signatures: + +```text +get_campaign(owner_user_id: int, campaign_id: int) -> RPGCampaign +replace_campaign_rules_pack_refs(owner_user_id: int, campaign_id: int, rules_pack_refs: list[dict[str, Any]], expected_version: int, idempotency_key: str, request_payload_hash: str, source_type: str) -> RulesPackRefReplacementResult +replace_session_rules_pack_refs(owner_user_id: int, session_id: int, rules_pack_refs: list[dict[str, Any]], expected_version: int, idempotency_key: str, request_payload_hash: str, source_type: str) -> RulesPackRefReplacementResult +``` + +Behavior: + +- Call `_validate_source_type(source_type)` before idempotency logic. +- Load the current campaign/session inside the same transaction. +- Compare `expected_version` to the row `version`. +- Normalize new refs with the current row refs and `now`. +- Update only the JSON ref column, `version = version + 1`, and `updated_at = now`. +- Use `WHERE owner_user_id = ? AND id = ? AND version = ?` and require `rowcount == 1`. +- Store response JSON with `refs` and `version`. +- Replay returns `RulesPackRefReplacementResult(refs=stored_refs, version=stored_version, replayed=True)`. +- Missing owner-scoped rows raise `RPGNotFoundError`. +- Stale versions raise `RPGConflictError("stale_rules_pack_ref_version")`. + +- [ ] **Step 5: Run task verification** + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_refs.py tldw_Server_API/tests/RPG/test_rpg_db.py -v +``` + +--- + +### Task 2: Add Service-Level Source Validation and Session Copy Semantics + +**Files:** + +- Modify: `tldw_Server_API/app/core/RPG/service.py` +- Modify: `tldw_Server_API/app/core/RPG/rules/refs.py` +- Modify: `tldw_Server_API/tests/RPG/test_rpg_service.py` + +- [ ] **Step 1: Write failing service tests** + +Add these service tests with fake media and collection validators: + +- `test_create_session_copies_campaign_rules_refs_when_request_omits_refs` +- `test_create_session_uses_explicit_empty_rules_refs` +- `test_replace_campaign_rules_pack_refs_validates_each_enabled_source` +- `test_replace_session_rules_pack_refs_validates_each_enabled_source` +- `test_replace_rules_pack_refs_allows_disabled_unreadable_source_without_dereference` +- `test_replace_rules_pack_refs_rejects_unreadable_media_item` +- `test_replace_rules_pack_refs_allows_empty_readable_collection` +- `test_list_campaign_rules_pack_refs_returns_current_version` +- `test_list_session_rules_pack_refs_returns_current_version` + +Run: + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_service.py -v +``` + +Confirm the new tests fail because the service methods and validator protocol are missing. + +- [ ] **Step 2: Add source validation protocols** + +Add service-facing protocol/dataclasses in `rules/refs.py`: + +```python +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class RulesPackSourceValidation: + ref_id: str + readable: bool + display_name: str | None + ready_media_ids: list[int] = field(default_factory=list) + + +class RulesPackSourceValidator(Protocol): + async def validate_media_item(self, owner_user_id: int, media_id: int) -> RulesPackSourceValidation: + raise NotImplementedError + + async def validate_media_collection(self, owner_user_id: int, collection_id: int) -> RulesPackSourceValidation: + raise NotImplementedError +``` + +Task 3 adds the REST validator adapter and Task 6 wires MCP construction. Unit tests can inject fakes. + +- [ ] **Step 3: Add service methods** + +Extend `RPGService` constructor to accept optional `rules_source_validator`. Add service methods with these signatures: + +```text +list_campaign_rules_pack_refs(campaign_id: int) -> RulesPackRefReplacementResult +list_session_rules_pack_refs(session_id: int) -> RulesPackRefReplacementResult +replace_campaign_rules_pack_refs(campaign_id: int, refs: list[dict[str, Any]], expected_version: int, idempotency_key: str, source_type: str = "user") -> RulesPackRefReplacementResult +replace_session_rules_pack_refs(session_id: int, refs: list[dict[str, Any]], expected_version: int, idempotency_key: str, source_type: str = "user") -> RulesPackRefReplacementResult +``` + +Behavior: + +- Normalize before validation to get stable `ref_id` values. +- Validate enabled refs only. +- Media item validation rejects unreadable, deleted, trash, or missing items through `RPGValidationError("rules_pack_source_unreadable")`. +- Media collection validation rejects unreadable or missing collections through the same domain error. +- Empty readable collections pass validation. +- Request hashes must include normalized refs and `expected_version`. +- Use repository replacement methods after validation passes. + +- [ ] **Step 4: Update session creation copy behavior** + +Change `RPGService.create_session()` so: + +- If the request omits `active_rules_pack_refs`, it loads the campaign and copies `campaign.linked_rules_pack_refs`. +- If the request supplies `active_rules_pack_refs=[]`, the session starts with no refs. +- If the request supplies a non-empty list, the service validates and stores that list. +- Existing tests for session creation continue to pass. + +- [ ] **Step 5: Run task verification** + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_service.py -v +``` + +--- + +### Task 3: Add REST Schemas, Endpoints, and Authorization Contract + +**Files:** + +- Modify: `tldw_Server_API/app/api/v1/schemas/rpg_schemas.py` +- Modify: `tldw_Server_API/app/api/v1/endpoints/rpg.py` +- Modify: `tldw_Server_API/Config_Files/privilege_catalog.yaml` +- Modify: `tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json` +- Modify: `tldw_Server_API/tests/RPG/test_rpg_api.py` + +- [x] **Step 1: Write failing REST schema and endpoint tests** + +Add or update API tests: + +- `test_campaign_rules_pack_refs_get_requires_campaign_read_and_media_read` +- `test_campaign_rules_pack_refs_put_requires_campaign_manage_and_media_read` +- `test_session_rules_pack_refs_get_requires_session_read_and_media_read` +- `test_session_rules_pack_refs_put_requires_session_manage_and_media_read` +- `test_replace_campaign_rules_pack_refs_returns_version_and_refs` +- `test_replace_session_rules_pack_refs_returns_version_and_refs` +- `test_replace_rules_pack_refs_rejects_stale_version_with_409` +- `test_replace_rules_pack_refs_replays_idempotency_key` +- `test_rules_lookup_accepts_lookup_mode` +- `test_rules_lookup_accepts_answer_mode` +- `test_rules_lookup_rejects_unknown_mode` +- `test_endpoint_scope_catalog_includes_rules_pack_routes` + +Run: + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v +``` + +Confirm the new tests fail because schemas/routes/scope metadata are missing. + +- [x] **Step 2: Add Pydantic schemas** + +Add: + +```python +class RPGRulesPackRefInput(BaseModel): + source_type: Literal["media_item", "media_collection"] + source_id: int = Field(gt=0) + display_name: str | None = None + enabled: bool = True + metadata: dict[str, Any] = Field(default_factory=dict) + + +class RPGRulesPackRefsReplaceRequest(BaseModel): + refs: list[RPGRulesPackRefInput] = Field(default_factory=list, max_length=50) + expected_version: int = Field(ge=1) + idempotency_key: str = Field(min_length=8, max_length=128) + + +class RPGRulesPackRefResponse(BaseModel): + ref_id: str + source_type: Literal["media_item", "media_collection"] + source_id: int + display_name: str + enabled: bool + created_at: datetime + updated_at: datetime + metadata: dict[str, Any] + + +class RPGRulesPackRefsResponse(BaseModel): + refs: list[RPGRulesPackRefResponse] + version: int + replayed: bool = False +``` + +Update `RPGRulesLookupRequest`: + +```python +class RPGRulesLookupRequest(BaseModel): + query: str = Field(min_length=1, max_length=1000) + mode: Literal["lookup", "answer"] = "lookup" + provider: str | None = None + model: str | None = None + temperature: float = Field(default=0.2, ge=0, le=2) + max_tokens: int = Field(default=600, ge=64, le=2000) +``` + +- [x] **Step 3: Add concrete media/collection validator for REST** + +In `endpoints/rpg.py`, create a small adapter class that receives the authenticated user's media DB and collections DB: + +- `validate_media_item()` calls `media_db.get_media_by_id(media_id, include_deleted=False, include_trash=False)`. +- `validate_media_collection()` loads the collection through the same read path used by collections endpoints and resolves ready media IDs using statuses `completed` and `skipped_existing`. +- The validator returns a display name when the source has a title/name. +- The validator must not return media IDs from unreadable, deleted, trash, or missing items. + +Keep this adapter in the endpoint layer unless shared MCP construction needs the same concrete class; if both REST and MCP need it, move it to `tldw_Server_API/app/core/RPG/rules/retrieval.py`. + +- [x] **Step 4: Add endpoints** + +Add constants: + +```python +RPG_CAMPAIGNS_READ = "rpg.campaigns.read" +MEDIA_READ = "media.read" +``` + +Add routes: + +- `GET /api/v1/rpg/campaigns/{campaign_id}/rules-packs` +- `PUT /api/v1/rpg/campaigns/{campaign_id}/rules-packs` +- `GET /api/v1/rpg/sessions/{session_id}/rules-packs` +- `PUT /api/v1/rpg/sessions/{session_id}/rules-packs` + +Endpoint dependency requirements: + +- Campaign GET: `RequirePermission(RPG_CAMPAIGNS_READ)` and `RequirePermission(MEDIA_READ)` +- Campaign PUT: `RequirePermission(RPG_CAMPAIGNS_MANAGE)` and `RequirePermission(MEDIA_READ)` +- Session GET: `RequirePermission(RPG_SESSIONS_READ)` and `RequirePermission(MEDIA_READ)` +- Session PUT: `RequirePermission(RPG_SESSIONS_MANAGE)` and `RequirePermission(MEDIA_READ)` +- Lookup endpoint: keep `RequirePermission(RPG_RULES_READ)` and add `RequirePermission(MEDIA_READ)` + +Convert lookup and context endpoints that touch async service methods to `async def`. + +- [x] **Step 5: Update privilege catalog and snapshot** + +Run the project helper after endpoint metadata is added: + +```bash +source .venv/bin/activate && python Helper_Scripts/update_privilege_registry_snapshot.py +``` + +Then run: + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v +``` + +- [x] **Step 6: Run task verification** + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v +``` + +--- + +### Task 4: Add Scoped Retrieval-Backed Lookup + +**Files:** + +- Create: `tldw_Server_API/app/core/RPG/rules/retrieval.py` +- Create: `tldw_Server_API/app/core/RPG/rules/source_validation.py` +- Modify: `tldw_Server_API/app/core/RPG/rules/content_packs.py` +- Modify: `tldw_Server_API/app/core/RPG/rules/lookup.py` +- Modify: `tldw_Server_API/app/core/RPG/service.py` +- Modify: `tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py` +- Create: `tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py` +- Modify: `tldw_Server_API/tests/RPG/test_rpg_rules_context.py` +- Modify: `tldw_Server_API/tests/RPG/test_rpg_mcp_module.py` + +- [x] **Step 1: Write failing lookup and retrieval tests** + +Add retrieval tests: + +- `test_retrieval_skips_disabled_refs` +- `test_retrieval_resolves_media_item_to_allowed_media_ids` +- `test_retrieval_resolves_collection_ready_items_only` +- `test_retrieval_reports_empty_collection_without_error` +- `test_retrieval_reports_no_ready_sources_without_broad_fallback` +- `test_retrieval_passes_allowed_media_ids_to_executor` +- `test_retrieval_maps_documents_to_user_provided_lookup_items` +- `test_retrieval_uses_stable_snippet_ids` + +Update context/lookup tests: + +- `test_lookup_returns_user_results_before_bundled_citations` +- `test_lookup_keeps_bundled_citations_score_zero` +- `test_lookup_returns_diagnostics_for_skipped_refs` +- `test_lookup_does_not_call_retriever_when_query_is_blank` + +Run: + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py -v +``` + +Confirm the new tests fail because retrieval-backed lookup is missing. + +- [x] **Step 2: Extend lookup dataclasses** + +In `content_packs.py`, update or add serializable dataclasses: + +```python +@dataclass(frozen=True, slots=True) +class RuleLookupCitation: + source_type: str + source_id: int | None + source_title: str + source_url: str | None + license: str | None + license_url: str | None + attribution: str | None + trust_level: str + content_hash: str + snippet_id: str + + +@dataclass(frozen=True, slots=True) +class RuleLookupItem: + origin: Literal["user_provided", "bundled_citation"] + text: str + citation: RuleLookupCitation + score: float + + +@dataclass(frozen=True, slots=True) +class RuleLookupResult: + query: str + mode: Literal["lookup", "answer"] + results: list[RuleLookupItem] + answer: str | None + answer_status: str + answer_citation_ids: list[str] + diagnostics: dict[str, Any] +``` + +Update bundled citation creation to map existing `RuleCitation` into `RuleLookupCitation` with `source_type="bundled_rules_citation"` and `source_id=None`. + +- [x] **Step 3: Add retrieval protocols and concrete adapter** + +Create: + +```python +class RulesRetriever(Protocol): + async def retrieve( + self, + *, + owner_user_id: int, + query: str, + refs: list[RulesPackRef], + max_results: int, + ) -> RulesRetrievalResult: + raise NotImplementedError +``` + +Concrete adapter requirements: + +- Validate and resolve refs through `RulesPackSourceValidator`. +- Build a deduplicated list of ready media IDs. +- Return early with diagnostics when no media IDs are ready. +- Call the existing retrieval executor with `source="media_db"` semantics and `allowed_media_ids` set to resolved IDs. +- Do not pass note IDs, web sources, or provider-generated synthetic content. +- Map retrieved documents to `RuleLookupItem(origin="user_provided", text=chunk_text, score=score)`. +- Use `snippet_id` from the retriever when present; otherwise derive `f"media:{media_id}:chunk:{chunk_index}"`. +- Truncate single snippet text to a conservative bound, such as 1,500 characters, before returning to lookup. + +- [x] **Step 4: Update `RulesLookupService`** + +Constructor: + +```python +class RulesLookupService: + def __init__( + self, + *, + retriever: RulesRetriever | None = None, + answer_generator: RulesAnswerGenerator | None = None, + ) -> None: + self._retriever = retriever + self._answer_generator = answer_generator +``` + +Lookup method: + +```python +async def lookup( + self, + *, + owner_user_id: int, + adapter_key: str, + query: str, + linked_rules_pack_refs: list[dict[str, Any]], + mode: Literal["lookup", "answer"] = "lookup", + answer_options: RulesAnswerOptions | None = None, +) -> RuleLookupResult: + raise NotImplementedError +``` + +Behavior: + +- Blank query raises `RPGValidationError("rules_query_required")`. +- Normalize refs and count linked/enabled refs. +- User-provided retrieval results come before bundled citations. +- Bundled citations are always included when an adapter has citation metadata. +- Retrieval failures produce `answer_status="retrieval_error"` only for answer mode; lookup mode returns bundled citations plus diagnostics unless the error is a validation/auth error. +- Validation/auth errors from unreadable refs propagate as domain errors. +- Diagnostics include linked count, enabled count, ready media count, retrieval result count, bundled citation count, and skipped refs. + +- [x] **Step 5: Update service async lookup path** + +Change `RPGService.lookup_rules()` to `async def` and pass `owner_user_id`, `mode`, and answer options to `RulesLookupService`. + +Update REST and MCP call sites after this change. Temporary test fakes can call `await service.lookup_rules(session_id=1, query="advantage", mode="lookup")`. + +- [x] **Step 6: Run task verification** + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_service.py -v +``` + +Task 4 verification completed 2026-06-25: + +- RED: focused retrieval/context pytest failed during collection because `rules.retrieval` and `RuleLookupCitation` were missing. +- GREEN: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_service.py -v` -> 35 passed, 82 existing warnings. +- API async call-site check: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` -> 27 passed, 76 existing warnings. +- Bandit: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m bandit -r -f json -o /tmp/bandit_task12030_task4.json` -> 0 findings. + +Task 4 post-review hardening completed 2026-06-25: + +- Added shared source validation for REST/MCP, MCP retrieval wiring for attached rules refs, executor result scope filtering, diagnostics/log redaction, and lazy MCP media binding for rules lookup/context only. +- RED/GREEN post-review checks covered out-of-scope documents, redacted warning logs, MCP attached-media lookup, and non-rules MCP tools avoiding media DB opens. +- Final focused verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/RPG/test_rpg_mcp_module.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` -> 64 passed, 176 existing warnings. +- Bandit: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m bandit -r tldw_Server_API/app/core/RPG tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py -f json -o /tmp/bandit_task12030_task4.json` -> 0 findings, 0 errors. + +--- + +### Task 5: Add Grounded Answer Mode and Async Context Building + +**Files:** + +- Create: `tldw_Server_API/app/core/RPG/rules/answering.py` +- Modify: `tldw_Server_API/app/core/RPG/rules/lookup.py` +- Modify: `tldw_Server_API/app/core/RPG/context.py` +- Modify: `tldw_Server_API/app/core/RPG/service.py` +- Modify: `tldw_Server_API/app/api/v1/endpoints/rpg.py` +- Create: `tldw_Server_API/tests/RPG/test_rpg_rules_answering.py` +- Modify: `tldw_Server_API/tests/RPG/test_rpg_rules_context.py` +- Modify: `tldw_Server_API/tests/RPG/test_rpg_api.py` + +- [x] **Step 1: Write failing answer-mode tests** + +Add: + +- `test_answer_mode_returns_not_requested_for_lookup_mode` +- `test_answer_mode_returns_no_evidence_without_user_snippets` +- `test_answer_mode_calls_chat_service_with_grounded_prompt` +- `test_answer_mode_extracts_openai_content` +- `test_answer_mode_filters_unknown_citation_ids` +- `test_answer_mode_returns_generation_error_on_provider_failure` +- `test_answer_mode_uses_request_provider_and_model` +- `test_answer_mode_uses_default_temperature_and_token_bounds` + +Run: + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_answering.py -v +``` + +Confirm the tests fail because `answering.py` is missing. + +- [x] **Step 2: Implement answer generator** + +Create: + +```python +@dataclass(frozen=True, slots=True) +class RulesAnswerOptions: + provider: str | None = None + model: str | None = None + temperature: float = 0.2 + max_tokens: int = 600 + + +@dataclass(frozen=True, slots=True) +class RulesAnswerResult: + answer: str | None + answer_status: str + citation_ids: list[str] + + +class RulesAnswerGenerator: + async def generate( + self, + *, + query: str, + evidence: list[RuleLookupItem], + options: RulesAnswerOptions, + ) -> RulesAnswerResult: + raise NotImplementedError +``` + +Concrete generator requirements: + +- Use `perform_chat_api_call_async`. +- Use `tldw_Server_API.app.core.Workflows.adapters._common.extract_openai_content`. +- Prompt the model to answer only from provided snippets and return JSON with `answer` and `citation_ids`. +- Pass: + - `messages=[{"role": "user", "content": user_prompt}]` + - `system_message=` + - `api_provider=options.provider` + - `model=options.model` + - `temperature=options.temperature` + - `max_tokens=options.max_tokens` + - `stream=False` +- Parse JSON when returned; post-review hardening treats non-JSON/plain text as `generation_error` instead of synthesizing citations. +- Filter citation IDs to the evidence `snippet_id` set. +- Return `generation_error` when the chat service raises a `ChatAPIError` or `ChatProviderError`. + +- [x] **Step 3: Wire answer mode into lookup** + +In `RulesLookupService.lookup()`: + +- `mode="lookup"` sets `answer=None`, `answer_status="not_requested"`, and `answer_citation_ids=[]`. +- `mode="answer"` with no user-provided evidence sets `answer_status="no_evidence"`. +- `mode="answer"` with evidence calls the generator. +- Generator result statuses pass through as `answered` or `generation_error`. +- Generated answer text is not included in context builder output. + +- [x] **Step 4: Convert context builder to async evidence inclusion** + +Change: + +```python +async def build_context(self, session_id: int, query: str | None = None, max_chars: int = MAX_RPG_CONTEXT_CHARS) -> SessionContext +``` + +Context behavior: + +- Call rules lookup with `mode="lookup"`. +- Include user-provided snippets within existing context bounds. +- Include bundled citations as citation lines only. +- Include diagnostics with retrieval result count and skipped refs. +- If lookup fails due to source validation, include a diagnostics entry and continue with session state context. +- Do not call answer generation. + +- [x] **Step 5: Update service and REST context endpoints** + +Change `RPGService.build_context()` to async and update REST/MCP call sites to await it. + +Add API tests: + +- `test_context_endpoint_includes_retrieved_rules_snippets` +- `test_context_endpoint_does_not_generate_answer` +- `test_context_endpoint_reports_rules_lookup_diagnostics` + +- [x] **Step 6: Run task verification** + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_answering.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_api.py -v +``` + +Task 5 verification completed 2026-06-25: + +- RED: focused answer-mode pytest failed before `rules/answering.py` existed; focused context/API diagnostics tests failed before lookup diagnostics and validation fallback were wired. +- GREEN: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_answering.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_api.py -v` -> 54 passed, 133 existing warnings after post-review hardening. +- Post-review hardening: answer mode now requires `chat.completions` permission plus the shared token-scope, LLM-budget, and chat rate-limit guard path; malformed/non-JSON model output returns `generation_error` without fabricated citations; unexpected answer-generator failures are sanitized to `generation_error`. +- Broader regression: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_answering.py tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/RPG/test_rpg_mcp_module.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` -> 81 passed, 213 existing warnings. +- Bandit: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m bandit -r tldw_Server_API/app/core/RPG tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py -f json -o /tmp/bandit_task12030_task5.json` -> 0 findings, 0 errors. +- Reviews: spec re-review and quality re-review reported no blockers after the stricter JSON and bearer-token guard fixes. + +--- + +### Task 6: Add MCP Tooling, Documentation, and Final Verification + +**Files:** + +- Modify: `tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py` +- Modify: `tldw_Server_API/app/core/RPG/README.md` +- Modify: `tldw_Server_API/tests/RPG/test_rpg_mcp_module.py` +- Modify: `backlog/tasks/task-12029 - Design-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md` +- Modify: implementation Backlog.md task created before code execution + +- [x] **Step 1: Write failing MCP tests** + +Add: + +- `test_rpg_mcp_tool_list_includes_rules_pack_ref_tools` +- `test_rpg_mcp_rules_pack_ref_tools_have_read_write_metadata` +- `test_rpg_mcp_rules_pack_ref_replace_validates_expected_version` +- `test_rpg_mcp_rules_lookup_accepts_answer_mode` +- `test_rpg_mcp_context_build_awaits_async_service` +- `test_rpg_mcp_read_tools_require_media_read_for_attached_refs` +- `test_rpg_mcp_write_tools_require_media_read_for_source_validation` + +Run: + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_mcp_module.py -v +``` + +Confirm tests fail because MCP tools are missing. + +- [x] **Step 2: Add MCP tools** + +Add tool names: + +- `rpg.campaigns.rules_packs.get` +- `rpg.campaigns.rules_packs.replace` +- `rpg.sessions.rules_packs.get` +- `rpg.sessions.rules_packs.replace` + +Tool metadata: + +- GET tools are read tools with `rpg.campaigns.read` or `rpg.sessions.read` and `media.read`. +- Replace tools are write tools with `rpg.campaigns.manage` or `rpg.sessions.manage` and `media.read`. +- Replace schemas include `expected_version`, `idempotency_key`, and `refs`. +- Lookup schema includes `mode`, `provider`, `model`, `temperature`, and `max_tokens`. + +Service construction: + +- Use the same ChaChaNotes DB path handling as current RPG tools. +- Inject media/collection validation only when MCP context exposes user media and collection DB paths. +- If MCP context lacks media DB access, ref dereference tools return a structured error rather than silently treating refs as empty. + +- [x] **Step 3: Update README** + +Add a concise section to `tldw_Server_API/app/core/RPG/README.md`: + +- Ref source types and ownership behavior. +- Campaign-to-session copy behavior. +- Whole-list replacement and version/idempotency expectations. +- Lookup/answer mode behavior. +- Licensing/privacy statement: user-provided rules content stays in user media stores; bundled adapters remain mechanics metadata and citations. + +- [x] **Step 4: Run focused RPG suite** + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG -v +``` + +- [x] **Step 5: Run privilege catalog checks** + +```bash +source .venv/bin/activate && python -m pytest tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v +``` + +- [x] **Step 6: Run Bandit on touched Python scope** + +```bash +source .venv/bin/activate && python -m bandit -r tldw_Server_API/app/core/RPG tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py -f json -o /tmp/bandit_task_12029_rpg_rules_packs.json +``` + +Open the JSON report and fix any new finding in touched code before marking the implementation task complete. + +- [x] **Step 7: Run formatting and diff checks** + +```bash +git diff --check +``` + +If the project environment has Ruff installed: + +```bash +source .venv/bin/activate && python -m ruff check tldw_Server_API/app/core/RPG tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py tldw_Server_API/tests/RPG +``` + +- [x] **Step 8: Update Backlog.md records** + +Record in the implementation task: + +- Plan link. +- Modified files. +- Focused pytest commands and results. +- Privilege catalog check result. +- Bandit report path and result. +- Known skips with reason. +- Final summary. + +Record in `TASK-12029`: + +- This plan path. +- The implementation task ID. +- Final summary that design and implementation planning are complete. + +**Status:** Complete after post-review hardening. + +**Post-review hardening:** + +- Enforced declared RPG/media domain permissions inside the RPG MCP module from authenticated context metadata before database construction. +- Made MCP `mode="answer"` fail closed unless `chat.completions` is present and trusted host metadata indicates chat-generation token-scope, budget, and rate-limit controls already ran. +- Closed partially constructed MCP service resources on construction failure. +- Kept rules-pack replacement on the source-validation path only, without constructing a retrieval engine. +- Aligned REST/MCP lookup input limits and clarified REST/MCP idempotency wording in the RPG README. + +**Verification:** + +- Initial post-review RED checks failed as expected for missing permission enforcement, missing answer governance, missing cleanup, and unnecessary replacement retriever construction. +- Focused review-fix checks: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_mcp_module.py::test_rpg_module_rules_lookup_uses_attached_media_refs tldw_Server_API/tests/RPG/test_rpg_mcp_module.py::test_rpg_mcp_tool_requires_declared_domain_permissions tldw_Server_API/tests/RPG/test_rpg_mcp_module.py::test_protocol_denies_rpg_tool_when_domain_permission_missing tldw_Server_API/tests/RPG/test_rpg_mcp_module.py::test_rpg_mcp_answer_mode_requires_chat_permissions_and_generation_controls tldw_Server_API/tests/RPG/test_rpg_mcp_module.py::test_rpg_mcp_session_rules_pack_replace_succeeds_and_replays tldw_Server_API/tests/RPG/test_rpg_mcp_module.py::test_rpg_mcp_rules_pack_replace_does_not_construct_retriever tldw_Server_API/tests/RPG/test_rpg_mcp_module.py::test_rpg_mcp_service_construction_closes_opened_resources_on_failure -v` -> 7 passed, 51 warnings. +- Full MCP module: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_mcp_module.py -v` -> 28 passed, 119 warnings. +- Full RPG suite after final lint cleanup: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG -v` -> 174 passed, 424 warnings. +- RPG endpoint scope catalog: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py::test_rpg_endpoint_scopes_are_cataloged -v` -> 1 passed, 16 warnings. +- Ruff: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m ruff check tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py tldw_Server_API/app/core/RPG/__init__.py tldw_Server_API/app/core/RPG/dice.py tldw_Server_API/app/core/RPG/rules/lookup.py tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/RPG/test_rpg_events_reducer.py tldw_Server_API/tests/RPG/test_rpg_mcp_module.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py` -> All checks passed. +- Bandit: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m bandit tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py tldw_Server_API/app/core/RPG/__init__.py tldw_Server_API/app/core/RPG/dice.py tldw_Server_API/app/core/RPG/rules/lookup.py tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/RPG/test_rpg_events_reducer.py tldw_Server_API/tests/RPG/test_rpg_mcp_module.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py -f json -o /tmp/bandit_task12030_task6_post_review.json` -> 0 findings, 0 errors. +- Diff check: `git diff --check` -> clean. + +--- + +## Review Checklist Before Runtime Code Starts + +- [x] The implementation task exists in Backlog.md and links to this plan. +- [x] Task 1 starts with failing tests before repository changes. +- [x] Ref writes use existing `version` fields and do not add new DB tables. +- [x] Media and collection refs are validated through user-scoped DB access. +- [x] Lookup never falls back to unscoped RAG or web search. +- [x] Answer mode uses existing chat provider governance and never fabricates citation IDs. +- [x] Context builder uses lookup evidence only and does not call answer generation. +- [x] MCP and REST surfaces expose the same semantics. +- [x] Bandit scope is limited to touched Python files and every new finding is fixed. diff --git a/Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md b/Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md new file mode 100644 index 0000000000..8b9f6cf344 --- /dev/null +++ b/Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md @@ -0,0 +1,566 @@ +# RPG Campaign Session Runtime Design + +Date: 2026-06-25 +Status: Approved for implementation planning +Backlog task: TASK-12017 + +## Context + +The goal is to add a generic RPG/TTRPG harness to `tldw_server` that can support tabletop roleplaying games as a backend reference, tool, orchestrator, and optional DM assistant. The module must not become a FoundryVTT-style virtual tabletop. It should manage campaign/session state, rules context, dice/check resolution, and future orchestration hooks while leaving canvas maps, token movement, and live tabletop rendering out of scope. + +The user selected a full-orchestrator long-term direction, with the first implementation slice focused on the core campaign/session runtime. The design uses Marinara Engine as an external reference for typed game state, rules/check helpers, mode separation, prompt/context building, and agent-ready orchestration boundaries, but adapts those ideas to the existing tldw_server FastAPI, ChaChaNotes, RAG, and MCP architecture. + +Relevant local precedents: + +- `tldw_Server_API/app/core/VN_Play/` for session runtime, event/state handling, idempotency, and replay-sensitive APIs. +- `tldw_Server_API/app/core/Character_Chat/` for character/world-book/dictionary context and prompt assembly boundaries. +- `tldw_Server_API/app/core/Chat_Workflows/` for a dedicated interactive runtime that sits beside generic Workflows rather than forcing a user-facing flow into the automation engine. +- `tldw_Server_API/app/core/MCP_unified/` for tool registration, RBAC, idempotency, and read/write tool governance. +- `tldw_Server_API/app/api/v1/router_groups/content.py` for route-key gated feature registration. + +External references reviewed: + +- Marinara Engine: `https://github.com/Pasta-Devs/Marinara-Engine/tree/main` +- D&D SRD: `https://www.dndbeyond.com/srd` +- ORC License: `https://downloads.paizo.com/ORC_License_FINAL.pdf` +- Archives of Nethys license page: `https://2e.aonprd.com/Licenses.aspx` +- Fate SRD: `https://fate-srd.com/` + +## Product Decisions + +1. The long-term product target is a full RPG orchestrator, not a lightweight dice toolkit. +2. The first implementation slice is the core campaign/session runtime. +3. V1 supports named rules adapters for D&D 5e SRD, Pathfinder 2e, and Fate. +4. V1 may bundle minimal open-content snippets with attribution and must also support user-ingested rules packs through existing retrieval systems. +5. Persistence is hybrid: RPG campaign/session data lives in per-user ChaChaNotes-backed storage, while larger rules-pack content is referenced through existing ingestion/RAG identifiers. +6. RPG sessions are primary domain objects. They may link to chats, but chat transcripts are not the RPG source of truth. +7. V1 snapshots model a play-ready core: scene, party/actors, resources, clocks, notes, recap, quests, NPCs, inventory, locations, factions, linked rules references, and unresolved rulings. +8. V1 exposes both REST APIs and MCP tools. +9. Authority is mixed: deterministic/manual actions may commit directly, while model-derived state changes become proposals unless a session explicitly enables auto-commit. +10. Rules adapters and snippet packs are version-pinned. `dnd5e_srd` in v1 means the D&D SRD 5.1 ruleset, not a moving "latest SRD" alias. SRD 5.2.1 / 5.5e support should be a separate adapter or content-pack key. + +## Goals + +- Provide a replayable, auditable campaign/session runtime. +- Support generic RPG systems without hardcoding d20 assumptions into the core model. +- Ship enough adapter behavior for D&D 5e SRD, Pathfinder 2e, and Fate to prove the adapter boundary. +- Support dice/check resolution, event append, snapshot rebuild, rules lookup, and context assembly. +- Enable MCP agents to inspect and assist sessions without bypassing authority policy. +- Keep rules content legally conservative and source-attributed. +- Make future GM orchestration, NPC tracking, quest tracking, combat assistance, and summarization straightforward without implementing those full agents in the first slice. + +## Non-Goals + +- No canvas map, battlemap, token movement, lighting, walls, live positioning, or asset-heavy tabletop rendering in v1. +- No multiplayer real-time shared tabletop semantics in v1. +- No full GM loop, combat tracker, map engine, or autonomous quest/NPC agent suite in the first implementation slice. +- No bundled long-form copyrighted rules text. +- No dependence on external LLM calls for core state correctness. +- No rewrite of Character Chat, VN Play, RAG, or MCP Unified. + +## Recommended Architecture + +Introduce a dedicated backend module: + +- Core package: `tldw_Server_API/app/core/RPG/` +- API endpoint: `tldw_Server_API/app/api/v1/endpoints/rpg.py` +- API schemas: `tldw_Server_API/app/api/v1/schemas/rpg_schemas.py` +- Persistence repository: a focused `RPG_DB.py`/repository initialized from the per-user ChaChaNotes DB, similar to VN Play, rather than broad RPG SQL added directly into `ChaChaNotes_DB.py`. +- Router key: `rpg`, mounted under `/api/v1/rpg`. + +The source of truth is an append-only session ledger. Cached snapshots are derived state. The service appends validated typed events transactionally and then runs a deterministic reducer to update the cached snapshot. If event append and snapshot update cannot both succeed, the transaction rolls back. + +Primary service components: + +- `RPGService`: endpoint-facing orchestration for campaigns, sessions, events, rolls, proposals, snapshots, rules lookup, and context building. +- `RPGRepository`: durable per-user storage for campaigns, sessions, events, snapshots, proposals, idempotency records, and rules-pack links. +- `RuleAdapter`: mechanics and resolver behavior for each system. +- `RuleAdapterRegistry`: discovers bundled adapters and validates adapter/version selection. +- `RuleContentPack`: small bundled snippets with source, license, citation, and attribution metadata. +- `EventReducer`: pure deterministic reducer from prior snapshot plus typed events to next snapshot. +- `AuthorityPolicy`: decides direct commit vs proposal for each source/action/session setting. +- `ProposalQueue`: stores model/client-derived typed event proposals before commit. +- `DiceRoller` / `CheckResolver`: deterministic, testable dice and check helpers with explicit provenance. +- `SessionContextBuilder`: bounded prompt-ready session summary with cited rules excerpts and inclusion/exclusion diagnostics. + +The RPG module should reuse existing services rather than duplicating them: + +- AuthNZ and API-key scope dependencies for user identity and REST permission checks. +- ChaChaNotes per-user database resolution for local user data. +- RAG/media identifiers for linked user-ingested rules packs. +- MCP Unified for tool discovery, RBAC, idempotency, and execution boundaries. +- Existing logging patterns through Loguru. + +## Rules Adapter Contract + +The common adapter contract must be broad enough for both d20 and non-d20 games. It must not assume ability scores, proficiency, armor class, DCs, or initiative as universal concepts. + +Each `RuleAdapter` exposes: + +- `adapter_key`, `adapter_version`, display name, status, and license summary. +- Mechanics schema for actors, resources, tags/aspects, conditions, clocks/tracks, checks, and roll expressions. +- Adapter-specific validation for character/actor state. +- Dice/check resolver functions. +- Supported event type extensions, if any. +- Minimal `RuleContentPack` references for bundled snippets. +- Citation metadata and attribution requirements. + +V1 bundled adapters: + +- `dnd5e_srd`: D&D SRD 5.1-oriented mechanics and citations. The adapter and bundled content pack must pin source version metadata and must not silently track D&D Beyond's latest SRD page. SRD 5.2.1 / 5.5e support should use a distinct key such as `dnd_srd_5_2_1`. +- `pf2e`: Pathfinder 2e-oriented mechanics with conservative content handling. V1 should start with mechanics metadata and citation links. Bundled PF2e prose requires an explicit source/license inventory before inclusion; when uncertain, ship metadata and citation links rather than text. +- `fate`: Fate-oriented mechanics covering aspects/tags, stress, consequences, approaches/skills where configured, and Fate-style outcomes. + +The Fate adapter is a contract test for non-d20 assumptions. Core code must support Fate-style aspects, stress/consequences, and ladder-like outcomes without forcing d20 fields. + +## Rules Content And Licensing + +Rules content is split into two layers: + +1. Mechanics metadata and resolver behavior: structured implementation data needed to run checks and validate state. +2. Snippet packs and rules references: human-readable reference content used for lookup and context. + +Bundled snippets must be short, source-attributed, and license-aware. Each snippet or citation object must include: + +- `adapter_key` +- `source_title` +- `source_url` +- `license` +- `license_url` +- `attribution` +- `trust_level` +- `content_hash` +- `snippet_id` +- `source_version` +- `content_pack_version` + +User-ingested rules packs are not copied into RPG tables. RPG records store references to existing ingestion/RAG/media identifiers, selected trust levels, and campaign/session binding metadata. Rule lookup must treat retrieved rule text as quoted reference material, not instructions. Context builders must isolate rules excerpts from system/developer instructions and include citations in returned diagnostics. + +## Data Model + +Campaigns are top-level containers. + +Suggested `rpg_campaigns` fields: + +- `id` +- `owner_user_id` +- optional future `workspace_id` / `shared_scope` +- `title` +- `description` +- `default_adapter_key` +- `default_adapter_version` +- `settings_json` +- `linked_rules_pack_refs_json` +- `version` +- `status` (`active`, `archived`, `deleted`) +- `created_at` +- `updated_at` + +Sessions belong to campaigns and are source-of-truth play records. + +Suggested `rpg_sessions` fields: + +- `id` +- `campaign_id` +- `owner_user_id` +- optional future `workspace_id` / `shared_scope` +- `title` +- `status` (`active`, `paused`, `completed`, `archived`, `deleted`) +- `adapter_key` +- `adapter_version` +- `authority_settings_json` +- `linked_chat_id` +- `active_rules_pack_refs_json` +- `current_snapshot_version` +- `last_event_sequence` +- `version` +- `created_at` +- `updated_at` + +Events are append-only and typed. + +Suggested `rpg_session_events` fields: + +- `id` +- `session_id` +- `owner_user_id` +- `sequence_number` +- `event_type` +- `event_payload_json` +- `source_type` (`user`, `system`, `mcp`, `model`, `import`) +- `source_actor_id` +- `source_label` +- `idempotency_key` +- `request_payload_hash` +- `event_schema_version` +- `adapter_key` +- `adapter_version` +- optional `proposal_id` +- `created_at` + +Uniqueness should include `(owner_user_id, session_id, source_type, idempotency_key)` for mutating operations that provide idempotency keys. Sequence numbers are unique per session. + +Snapshots are cached derived state. + +Suggested `rpg_session_snapshots` fields: + +- `id` +- `session_id` +- `owner_user_id` +- `snapshot_version` +- `last_event_sequence` +- `reducer_version` +- `snapshot_schema_version` +- `snapshot_json` +- `diagnostics_json` +- `created_at` + +Snapshot JSON contains the play-ready core: + +- scene +- party / actors +- resources +- clocks / tracks +- notes +- recap +- quests +- NPCs +- inventory +- locations +- factions +- rules references +- unresolved rulings + +Every mutable entity inside snapshot JSON must have a stable ID. NPCs, quests, actors, locations, factions, inventory items, clocks, and rulings must not be updated by name matching alone. + +Proposals hold model/client-derived typed event proposals before commit. + +Suggested `rpg_session_proposals` fields: + +- `id` +- `session_id` +- `owner_user_id` +- `base_event_sequence` +- `base_snapshot_version` +- `proposed_events_json` +- optional preview `patch_json` +- `rationale` +- `confidence` +- `source_type` +- `source_actor_id` +- `model_metadata_json` +- `status` (`pending`, `applied`, `rejected`, `expired`, `conflicted`) +- `review_notes` +- `created_at` +- `applied_at` +- `rejected_at` + +Proposal commits append validated typed events. Proposals may show a patch preview, but no proposal applies arbitrary snapshot patches directly. + +Proposal apply may append multiple events. It must be one atomic operation: validate the proposal status and base sequence, validate every proposed event, append all accepted events, then run the reducer once. Partial proposal commits are not allowed. + +Roll/check events require explicit provenance: + +- server-rolled +- user-entered +- imported +- model-suggested + +Server rolls should store expression, normalized formula, result parts, total, random source metadata where practical, adapter interpretation, rule references, and citation metadata. Committed roll results are the replay authority. Do not store reusable RNG state or seeds that would make future rolls predictable. + +## Optimistic Concurrency And Idempotency + +Every mutating request must include an idempotency key. REST may accept either a request field or `Idempotency-Key` header, but the service should normalize to one internal value. MCP tools provide the key as an argument. + +Every ledger-affecting write must include `expected_last_event_sequence` or an equivalent base snapshot/event version. Stale writes return conflict and do not mutate state. + +Idempotent replay with the same payload returns the original result. Reusing the same key with a different normalized payload returns `rpg_idempotency_key_conflict`. + +## REST API Surface + +All routes are under `/api/v1/rpg`. + +Rules routes: + +- `GET /rules/adapters` +- `GET /rules/adapters/{adapter_key}` +- `POST /rules/lookup` + +Campaign routes: + +- `POST /campaigns` +- `GET /campaigns` +- `GET /campaigns/{campaign_id}` +- `PATCH /campaigns/{campaign_id}` +- `DELETE /campaigns/{campaign_id}` for soft delete/archive only + +Session routes: + +- `POST /campaigns/{campaign_id}/sessions` +- `GET /sessions/{session_id}` +- `PATCH /sessions/{session_id}` +- `GET /sessions/{session_id}/events` +- `POST /sessions/{session_id}/events` +- `GET /sessions/{session_id}/snapshot` +- `POST /sessions/{session_id}/snapshot/rebuild` +- `POST /sessions/{session_id}/rolls` +- `POST /sessions/{session_id}/proposals` +- `POST /sessions/{session_id}/proposals/{proposal_id}/apply` +- `POST /sessions/{session_id}/proposals/{proposal_id}/reject` + +Permission families: + +- `rpg.read`: read campaigns, sessions, events, snapshots, adapters, and permitted rules lookups. +- `rpg.write`: create/update campaigns and sessions, append deterministic events, commit rolls, create proposals, apply/reject proposals. +- `rpg.admin`: repair/admin operations such as snapshot rebuild and future hard-delete operations. +- `rpg.rules.write`: future rules-pack binding or adapter authoring operations if they become mutable. + +`POST /sessions/{session_id}/rolls` supports simulation and commit: + +- `commit: false` returns a result without appending an event. +- `commit: true` requires idempotency and expected sequence, then appends a roll/check event. + +`POST /rules/lookup` supports explicit source filters: + +- `adapter_key` +- `campaign_id` +- `session_id` +- `include_bundled` +- `include_user_packs` +- `trust_levels` +- `max_results` + +Rules lookup must not accidentally search unrelated user-ingested content. It only searches bundled snippets and rules packs linked through campaign/session scope unless the request explicitly uses another allowed scope. + +`POST /sessions/{session_id}/snapshot/rebuild` is admin/test-repair scoped in v1, not a normal user write path. + +Deletes are soft archive/delete with expected version. Hard delete is deferred or admin-only. + +## MCP Tool Surface + +MCP exposes a smaller, safer tool set over the same service behavior. + +Read tools: + +- `rpg.list_rules_adapters` +- `rpg.get_session_state` +- `rpg.list_recent_events` +- `rpg.lookup_rule` + +Deterministic write tools: + +- `rpg.roll_check` +- `rpg.record_event` + +Proposal tools: + +- `rpg.create_proposal` +- `rpg.apply_proposal` +- `rpg.reject_proposal` + +Context tool: + +- `rpg.build_session_context` + +Tool permissions follow existing MCP naming: + +- `tools.execute:rpg.get_session_state` +- `tools.execute:rpg.record_event` +- `tools.execute:rpg.apply_proposal` +- and equivalent per-tool permissions. + +Read and write tools are intentionally separate. Read-only tool access must not imply write access. Write tools require per-tool RBAC permission, idempotency key, source metadata, and expected event sequence when they can mutate session state. + +`rpg.record_event` must enforce authority policy. If the caller uses `source_type=model`, the tool routes to proposal behavior unless the session explicitly enables auto-commit for that source/action. + +`rpg.build_session_context` accepts a token or character budget and returns: + +- compact session state summary +- recent event digest +- selected open rulings +- cited bundled and user rules excerpts +- included/excluded diagnostics +- truncation and trust warnings + +## Authority Policy + +The service owns all write decisions. Endpoint and MCP layers do not decide whether a model-derived change may mutate state. + +Default behavior: + +- Deterministic/manual events may commit directly when the caller has write permission and concurrency checks pass. +- Server-side committed rolls append direct roll/check events. +- Model-derived changes become proposals by default. +- Auto-commit is disabled by default and may be enabled per session for specific source/action classes. +- Applying a proposal appends normal typed events with proposal lineage. +- Rejected proposals never mutate events or snapshots. + +Authority policy must also protect MCP tools. A model-facing tool cannot bypass proposal review simply by calling a direct write tool with model-sourced metadata. + +## Service Behavior + +The endpoint layer authenticates, validates Pydantic schemas, and translates service errors. The RPG service owns domain behavior. + +Service responsibilities: + +- Resolve campaign/session ownership and permissions. +- Validate adapter keys and versions. +- Validate event payloads against registered event types. +- Enforce idempotency for every mutation. +- Enforce optimistic concurrency. +- Apply authority policy. +- Append typed events transactionally. +- Apply multi-event proposal commits atomically with a single concurrency check and no partial writes. +- Run the reducer after successful event append. +- Rebuild snapshots from the ledger for admin/test repair flows. +- Resolve rules lookup through bundled packs and linked user rules packs. +- Build bounded context with citations and diagnostics. + +Reducer behavior: + +- Deterministic and side-effect-free. +- Accepts prior snapshot plus typed events. +- Returns next snapshot plus diagnostics. +- Does not call external services. +- Maintains stable entity IDs. +- Handles unknown future event versions through explicit compatibility logic or fails with a stable error. + +Long campaigns should use checkpointed/cached snapshots. V1 can rebuild from the full ledger for correctness, but the design should preserve a strategy of replaying from the latest valid snapshot checkpoint for performance once ledgers grow large. + +## Error Handling + +Use stable error codes rather than raw strings. + +Suggested codes: + +- `rpg_campaign_not_found` +- `rpg_session_not_found` +- `rpg_rules_adapter_not_found` +- `rpg_rules_adapter_version_conflict` +- `rpg_invalid_event_type` +- `rpg_invalid_event_payload` +- `rpg_stale_event_sequence` +- `rpg_idempotency_key_required` +- `rpg_idempotency_key_conflict` +- `rpg_proposal_not_found` +- `rpg_proposal_not_applicable` +- `rpg_authority_policy_requires_proposal` +- `rpg_rules_lookup_scope_invalid` +- `rpg_snapshot_rebuild_forbidden` +- `rpg_snapshot_rebuild_failed` + +HTTP mapping: + +- `400`: malformed request, invalid event type/payload, invalid lookup scope. +- `401` / `403`: existing auth and permission dependencies. +- `404`: missing campaign/session/proposal in the current user's scope. +- `409`: stale sequence, adapter version conflict, idempotency conflict, proposal conflict, or proposal no longer applicable. +- `422`: schema-valid request with semantically invalid domain fields if existing route style prefers that split. +- `500`: unexpected failures after structured logging with context. + +## Testing Strategy + +Testing should validate deterministic state behavior and security boundaries, not model quality. + +Core unit tests: + +- Adapter registry lists `dnd5e_srd`, `pf2e`, and `fate`. +- D&D adapter tests verify SRD 5.1 source-version pinning and prevent silent movement to SRD 5.2.1 / 5.5e content. +- Each adapter exposes required metadata, license details, mechanics schema, and snippet attribution. +- Bundled snippets fail validation without source title, URL, license, license URL, attribution, trust level, adapter key, source version, and content-pack version. +- PF2e bundled-prose tests require an explicit source/license inventory; mechanics-only PF2e metadata may ship without bundled prose. +- D&D/PF2e dice/check helpers work for expected d20-style patterns. +- Fate tests prove aspects/tags, stress, consequences, and non-d20 outcomes work through the common adapter contract. +- Event validation rejects unknown event types and malformed payloads. +- Reducer produces stable snapshots from known event ledgers. +- Reducer property/invariant tests cover determinism, unique stable entity IDs, rebuild equality, proposal isolation, and rejected proposal non-mutation. +- Reducer rebuild from scratch matches incrementally cached snapshots. +- Old snapshot/reducer versions are detected and routed to rebuild/compatibility behavior. +- Proposals cannot mutate snapshots until applied. +- Applying proposals appends typed events and preserves audit/source metadata. +- Applying multi-event proposals is atomic: all events commit and reduce together, or none do. +- Authority policy routes model-derived writes to proposals unless auto-commit is enabled. +- Idempotency replay returns the original result and conflicting payloads fail. +- Stale expected sequence fails with conflict. + +API tests: + +- Campaign/session CRUD is owner-scoped and soft-delete/archive aware. +- Mutating endpoints require idempotency. +- REST permission matrix covers `rpg.read`, `rpg.write`, and `rpg.admin`. +- AuthNZ/privilege registry tests cover the new RPG permission names and ensure they are grantable. +- Event append updates snapshot version and last event sequence. +- Roll simulation does not append an event; committed roll does. +- Rules lookup respects adapter, campaign, session, source, and trust filters. +- Rules lookup uses deterministic fixtures and does not require live embeddings or external content. +- Snapshot rebuild is denied without admin/test repair permission. +- Error codes map to expected HTTP statuses. + +MCP tests: + +- Read tools do not require write permissions. +- Write tools require per-tool `tools.execute:` permission. +- Read-only users cannot append events through MCP. +- `record_event` routes model-sourced writes to proposals unless auto-commit is enabled. +- `build_session_context` respects budgets and returns inclusion/exclusion diagnostics. + +Security and verification: + +- Run focused unit/API/MCP tests for touched RPG paths. +- Run Bandit on touched backend paths before completion. +- Use deterministic fixtures for rules snippets and user rules-pack lookup. +- Do not require live external providers for tests. + +## Rollout Plan + +Phase 1: Core ledger and adapters + +- Add RPG package structure, repository, schemas, service, and route registration. +- Add AuthNZ privilege/permission registry entries for `rpg.read`, `rpg.write`, `rpg.admin`, and future-facing `rpg.rules.write`. +- Implement campaign/session CRUD. +- Implement adapter registry for D&D SRD 5.1, Pathfinder 2e, and Fate. +- Implement typed event append, reducer, cached snapshots, idempotency, and optimistic concurrency. +- Implement dice/check helpers and roll simulation/commit. + +Phase 2: Proposals and rules lookup + +- Implement authority policy and proposal queue. +- Add bundled snippet packs with license validation. +- Add linked user rules-pack lookup through existing retrieval abstractions. +- Add proposal apply/reject and citation-aware rule lookup tests. + +Phase 3: MCP tools and context builder + +- Register read/write/proposal/context tools through MCP Unified. +- Add per-tool RBAC tests. +- Implement bounded session context assembly with citations and diagnostics. + +Phase 4: Reference docs and examples + +- Add API examples and adapter authoring notes. +- Add minimal sample campaigns for all three systems. +- Document non-goals and the distinction from a VTT. + +## Future Work + +Future specs can build on this runtime with: + +- D&D SRD 5.2.1 / 5.5e adapter or content-pack support under a separate key. +- GM orchestration loop. +- Quest/NPC/location/faction tracker agents. +- Combat assistant and initiative/encounter helpers. +- Session summarizer and recap automation. +- Rules-pack import UX. +- Export/import bundles, possibly integrating with Chatbooks. +- Optional WebUI inspection and setup surfaces. + +These are intentionally deferred so the first implementation remains focused on the ledger, snapshots, rules adapters, authority policy, and tool/API contract. + +## Self-Review Checklist + +- No unresolved markers or implementation blockers remain in this spec. +- The first implementation slice is bounded to core campaign/session runtime. +- The design preserves the user's long-term full-orchestrator direction. +- REST and MCP write paths both enforce idempotency, optimistic concurrency, and authority policy. +- Rules content has explicit source/license/attribution constraints. +- Non-goals prevent v1 from drifting into a VTT. diff --git a/Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md b/Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md new file mode 100644 index 0000000000..1681af1155 --- /dev/null +++ b/Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md @@ -0,0 +1,451 @@ +# RPG Rules-Pack Attachment And Retrieval Design + +Date: 2026-06-25 +Status: Approved design, pending implementation planning +Backlog task: TASK-12029 + +## Context + +The RPG runtime currently provides citation-only bundled rule references, campaign/session state, deterministic snapshots, REST endpoints, and an optional MCP module. Campaigns and sessions already have JSON rules-pack reference fields, but creation paths initialize them empty and lookup currently returns only bundled adapter citations. + +This design adds user-provided rules-pack attachment and retrieval-backed lookup without turning RPG into a virtual tabletop and without copying long-form rules prose into RPG tables. It reuses existing ingestion, Media DB, media collections, and RAG retrieval boundaries where possible. + +The approved direction is hybrid: + +- Store direct references to existing media items and media collections now. +- Reserve a registry-compatible reference shape for future first-class reusable rules packs. +- Copy campaign references to a session when the session is created, then let the session diverge independently. +- Keep lookup snippet/citation-first, with opt-in generated answer mode. +- If attached retrieval misses, return bundled citation-only references only. Do not search broader user content or the web. + +## Goals + +- Let users attach, list, replace, and remove campaign/session rules references. +- Keep RPG tables free of user-provided rules prose. +- Blend citation-only bundled adapter references with retrieved user snippets in a clear, attributed response. +- Support opt-in generated answers grounded only in attached user snippets. +- Include retrieved snippets in session context within existing bounds and diagnostics. +- Mirror REST and MCP behavior. +- Preserve AuthNZ, idempotency, optimistic concurrency, privacy, and licensing boundaries. + +## Non-Goals + +- No first-class reusable rules-pack registry in this task. +- No bundled long-form rules prose for D&D, Pathfinder, Fate, or other systems. +- No automatic web fallback. +- No lookup across un-attached user documents. +- No change to RPG being a backend orchestration/reference harness rather than a FoundryVTT-like tabletop. + +## Reference Model + +Use the existing JSON fields as the first storage boundary: + +- `rpg_campaigns.linked_rules_pack_refs_json` stores campaign defaults. +- `rpg_sessions.active_rules_pack_refs_json` stores session-active references. + +Session creation copies the current campaign list into the session. Later campaign edits do not affect existing sessions. Later session edits do not affect the campaign. + +Each reference is normalized before persistence: + +```json +{ + "source_type": "media_collection", + "source_id": "7", + "label": "Fate SRD local copy", + "enabled": true, + "adapter_keys": ["fate"], + "source_policy": "user_provided", + "snapshot_policy": "live", + "created_at": "2026-06-25T00:00:00Z", + "updated_at": "2026-06-25T00:00:00Z" +} +``` + +The server owns `created_at` and `updated_at`. Write requests may omit them. On replacement, unchanged refs preserve `created_at`; refs whose metadata changes receive a new `updated_at`. Timestamps are not part of client-provided identity. + +Initial `source_type` values: + +- `media_item` +- `media_collection` + +Reserved future `source_type` value: + +- `rules_pack` + +`source_id` is stored as a string for future compatibility, but validated according to `source_type`. `adapter_keys` are advisory metadata for filtering and diagnostics, not a hard storage gate. A user can attach any readable rules source to any RPG adapter. + +`media_collection` references are live. Lookup resolves ready collection items at query time. Users who need reproducible pinned behavior should attach individual `media_item` references instead. Attaching a readable collection that currently has no ready items is valid; lookup returns no user evidence and a diagnostic until ready items exist. + +Removing a reference means replacing the stored list without that reference. There is no separate tombstone table in the first implementation. + +## Attachment Writes + +The first implementation uses whole-list replacement because it is deterministic and easy to make idempotent. + +REST write request shape: + +```json +{ + "expected_version": 3, + "refs": [ + { + "source_type": "media_item", + "source_id": "42", + "label": "House rules", + "enabled": true, + "adapter_keys": ["dnd5e_srd"] + } + ] +} +``` + +Write behavior: + +- Require `Idempotency-Key`. +- Require `expected_version`. +- Normalize refs before hashing or persistence. +- Reject unsupported source types. +- Reject duplicate refs with the same `(source_type, source_id)`. +- Enforce a small maximum ref count per object. +- Validate referenced media or collection ownership/readability using the same content-scope and media-read posture as existing media/RAG access. +- Update the parent campaign/session `version` and `updated_at` on success. +- Return the updated normalized refs plus the new version. + +Idempotency scopes are object-specific: + +- `campaign:{campaign_id}:rules_pack_refs` +- `session:{session_id}:rules_pack_refs` + +Conflicts and validation: + +- `409` for stale `expected_version`. +- `409` for idempotency replay with a different normalized request hash. +- `400` for invalid ref shape, unsupported source type, duplicate refs, or unreadable references visible as invalid input. +- `404` for missing campaign/session, or for fail-closed source visibility cases where the caller should not learn whether the referenced source exists. + +## REST Surface + +Add these endpoints under `/api/v1/rpg`: + +- `GET /campaigns/{campaign_id}/rules-packs` +- `PUT /campaigns/{campaign_id}/rules-packs` +- `GET /sessions/{session_id}/rules-packs` +- `PUT /sessions/{session_id}/rules-packs` + +Extend existing lookup: + +- `POST /sessions/{session_id}/rules/lookup` + +Lookup request gains: + +- `mode`: `"lookup"` or `"answer"`, default `"lookup"` + +Retrieval bounds remain service-controlled in the first implementation. Caller-tunable lookup limits can be added later if the response contract proves stable. + +GET ref responses return: + +- normalized `refs` +- the parent campaign or session `version` + +Permission mapping: + +- Campaign ref read: `rpg.campaigns.read` +- Campaign ref write: `rpg.campaigns.manage` +- Session ref read: `rpg.sessions.read` +- Session ref write: `rpg.sessions.manage` +- Rules lookup and adapter metadata: `rpg.rules.read` +- Any operation that dereferences attached media items, resolves media collections, retrieves snippets, or generates an answer from attached media requires media read access as well. If implementation keeps static FastAPI dependencies, add `media.read` to the affected endpoints. If implementation uses conditional checks, fail closed before exposing user-provided source metadata or snippets. + +Privilege catalog tests should confirm every endpoint scope used by the RPG endpoint remains cataloged. + +## MCP Surface + +Extend the RPG MCP module in parallel with REST: + +- Add read tools for campaign/session rules-pack refs. +- Add write tools for replacing campaign/session rules-pack refs. +- Extend `rpg.rules.lookup` with optional `mode`. + +MCP write tools require: + +- `expected_version` +- `idempotencyKey` or `idempotency_key` +- full replacement `refs` + +MCP write tool metadata must keep the current governance posture: + +- `readOnlyHint: false` +- `is_write: true` +- `mutates_state: true` +- `requires_confirmation: true` +- `agent_write_policy: "approval_required"` +- `governance_preflight_required: true` + +Read tools remain `readOnlyHint: true`. + +## Retrieval Integration + +Add an async injectable RPG rules retrieval adapter. The adapter receives: + +- `query` +- normalized enabled refs +- owner/user context +- Media DB and collection access dependencies +- retrieval limits + +The adapter returns a canonical evidence list rather than a full RAG answer. It should call existing retrieval components, not the public REST endpoint. + +The RPG service boundary must expose an async lookup path for retrieval-backed behavior. Implementation can either convert `lookup_rules` and query-backed `build_context` to async or add explicit async variants. REST endpoints that can retrieve snippets must be async and await the service. MCP already executes async tools and should await the same service path. Do not bridge by calling `asyncio.run()` or equivalent inside an active server event loop. + +Initial retrieval behavior: + +- `media_item`: validate that the item is readable and not deleted/trash, then pass IDs as `include_media_ids` or `allowed_media_ids`. +- `media_collection`: validate that the collection is readable, resolve ready media IDs using existing collection readiness semantics, then pass those IDs. +- `sources`: force `["media_db"]`. +- `search_mode`: default to `"hybrid"`, with a small capped `top_k`. +- Web fallback: forced off. +- Broad user-content fallback: forced off. +- Empty attached refs, disabled refs, collections with no ready items, or media with no retrievable chunks return no user evidence plus diagnostics. They are not hard lookup failures. +- Retrieval errors: captured in diagnostics; do not fail lookup unless the session cannot be loaded or request validation fails. + +The adapter must be mockable for unit tests so the RPG suite does not require real embeddings, Chroma, or an external model. + +## Rules Lookup Response + +Lookup always returns ranked, attributed evidence and references. + +Response shape should include: + +- `query` +- `mode` +- `results` +- `answer` +- `answer_status` +- `answer_citation_ids` +- `diagnostics` + +`answer` is present only when `mode="answer"` and a generated answer was successfully grounded in attached user snippets. + +Suggested `answer_status` values: + +- `not_requested` +- `answered` +- `no_attached_evidence` +- `generation_failed` +- `disabled_by_config` + +Result origins: + +- `user_provided`: retrieved from attached user sources +- `bundled_citation`: bundled adapter citation metadata only + +User-provided result items include: + +- `id`, such as `user:media:42:chunk-8` +- `text` +- `score` +- `origin` +- `source_type` +- `source_id` +- `chunk_id`, if present +- `source_title`, if available +- `source_url` or a safe source label, if available +- `license` and `attribution`, if known +- `trust_level: "user_provided"` + +Bundled citation items include: + +- `id`, such as `bundled:fate:1` +- empty `text` +- `score: 0.0` +- `origin: "bundled_citation"` +- existing adapter citation metadata + +Ranking: + +1. Qualifying user snippets, sorted by retrieval score. +2. Bundled citation-only references for the active adapter. + +Bundled citation scores do not compete with user retrieval scores. + +If no attached user snippets are usable, lookup returns bundled citation-only references and diagnostics. It does not search unrelated user content or web sources. + +## Generated Answer Mode + +`mode="answer"` adds synthesis only when attached user snippets exist. + +Generated answers must be grounded in the retrieved snippets: + +- Use retrieved snippets as the only rules evidence. +- Include machine-readable `answer_citation_ids`. +- Avoid making official-source claims for bundled citation-only adapters. +- Degrade to snippets only when generation fails, is disabled, or cannot stay grounded. +- Never generate an answer from bundled citation-only references alone. + +This mode may use existing LLM generation facilities, but implementation should keep the generator injectable and testable with a fake. + +Answer generation must reuse existing provider configuration, rate-limit, and billing/quota posture. It must not introduce a hidden provider path or bypass configured model governance. + +## Context Builder + +The session context builder keeps session state primary. + +When a context request includes a query: + +- Call rules lookup in `mode="lookup"`. +- Include retrieved user snippets, not generated answers. +- Include bundled citation references only if space remains. + +Context ordering: + +1. Session header and adapter. +2. Scene and core snapshot state. +3. Recent notes and open rulings. +4. `Rules excerpts:` with bounded user snippets and compact citation handles. +5. `Rules references:` with bundled citation-only references if there is room. + +Rules excerpts use stable handles matching lookup result IDs, such as: + +- `user:media:42:chunk-8` +- `bundled:fate:1` + +Context budget behavior: + +- Preserve existing `max_chars` bounds. +- Cap each snippet. +- Bound the total rules section after session state has been admitted. +- Preserve source title and citation handle even when snippet text is truncated. + +Diagnostics add: + +- `rules_user_snippet_count` +- `rules_bundled_citation_count` +- `rules_omitted_count` +- `rules_truncated` +- `rules_retrieval_errors` +- `rules_omission_reasons`, such as `budget_exhausted`, `no_attached_refs`, `retrieval_error`, and `no_results` + +Retrieval failures degrade to session state plus diagnostics. Context build should not fail the whole request unless the session itself cannot be loaded or the input is invalid. + +## Privacy And Licensing + +Bundled adapters remain mechanics metadata plus citation-only references. They must not bundle long-form rules prose in this task. + +User snippets are retrieved only from user-attached sources. They are user-provided excerpts, not bundled content. RPG tables persist references and metadata only, not retrieved prose. + +User-facing lookup/context responses may include source titles and attribution metadata when available. Logs and low-level diagnostics should avoid snippet text and unnecessary titles. Log counts, source types, opaque IDs, and error categories instead. + +Generated answers must expose machine-readable provenance with `answer_citation_ids` and should not imply that user-provided excerpts are official bundled rules content. + +## Backward Compatibility + +Existing campaigns and sessions with empty or older JSON ref lists must continue to load. + +Rules lookup without attached refs keeps the current bundled citation-only behavior, with richer diagnostics and response fields added in a backward-compatible way where possible. + +Existing RPG tests for adapter listing, campaign/session creation, event recording, context building, and MCP tool governance should continue to pass. + +## Failure Modes + +Expected failure modes: + +- Invalid source type: `400` +- Duplicate refs: `400` +- Missing or malformed `expected_version`: validation error +- Missing REST idempotency header: request validation error +- Missing MCP/service idempotency key: write validation error before mutation +- Stale campaign/session version: `409` +- Idempotency key reuse with different normalized payload: `409` +- Missing campaign/session: `404` +- Missing, deleted, trashed, or unreadable media-item refs during attachment writes: fail closed through validation, using `400` or `404` according to visibility +- Missing or unreadable media collection refs during attachment writes: fail closed through validation, using `400` or `404` according to visibility +- Readable collections with no ready items at lookup time: no user evidence, diagnostic only +- Retrieval unavailable or partial failure: lookup/context returns bundled citations and diagnostics +- Generation unavailable or failed: `answer_status="generation_failed"` and snippets still return + +## Test And Verification Plan + +Repository tests: + +- Campaign ref replacement. +- Session ref replacement. +- Optimistic version conflicts. +- Idempotency replay and hash mismatch. +- Duplicate normalization. +- Session creation copying campaign refs. +- Backward compatibility for empty or old JSON lists. + +Service tests: + +- Lookup with no refs returns bundled citations. +- Lookup with attached media refs returns user snippets plus bundled references. +- Lookup with attached collection refs resolves ready media IDs. +- Lookup does not search broad user content. +- Retrieval errors degrade to diagnostics. +- `mode="answer"` returns no answer without snippets. +- `mode="answer"` includes `answer_citation_ids` when answered. +- Generation failure degrades to snippets only. + +Context tests: + +- User snippets appear under rules excerpts. +- Bundled citations appear only as references. +- Citation handles match lookup IDs. +- Truncation preserves attribution handles. +- Omission reasons are diagnostic and stable. +- Retrieval failures do not fail context build. + +REST tests: + +- Read permissions for campaign/session ref listing. +- Write permissions for replacement. +- Request validation. +- Idempotency. +- Stale version conflicts. +- Missing/malformed refs. +- Response schemas and version returns. +- Lookup `mode` behavior. + +MCP tests: + +- New tool schemas. +- Read/write metadata. +- Required write arguments. +- Optimistic version input validation. +- Idempotency key aliases. +- Execution behavior with fake retrieval. +- Async lookup path works from MCP without event-loop bridging issues. + +Retrieval and authorization tests: + +- Fake async retriever path. +- Missing media item. +- Deleted media item. +- Trashed media item. +- Missing media collection. +- Collection with no ready items. +- Source not readable by the user. +- Privacy-safe diagnostics without snippet text. +- Missing media-read permission on attached-source dereference. + +AuthNZ and catalog tests: + +- RPG endpoint scope catalog sync. +- No uncataloged newly introduced scopes. + +Verification commands: + +- Focused RPG pytest suite. +- Relevant AuthNZ privilege catalog tests. +- Bandit on touched RPG/API/MCP/AuthNZ paths. For a design-only change, record that Bandit is not applicable to code behavior if no Python code changed. + +## Implementation Slices + +Implementation should be split after this design into separate tasks: + +1. Repository and schema support for normalized ref replacement and session-copy behavior. +2. REST schemas/endpoints and permission tests. +3. Async retrieval adapter and service lookup response changes. +4. Context builder snippet inclusion and diagnostics. +5. MCP tool surface updates. +6. Documentation and regression verification. diff --git a/backlog/tasks/task-12017 - Design-RPG-campaign-session-runtime-harness.md b/backlog/tasks/task-12017 - Design-RPG-campaign-session-runtime-harness.md new file mode 100644 index 0000000000..75e71ffb3d --- /dev/null +++ b/backlog/tasks/task-12017 - Design-RPG-campaign-session-runtime-harness.md @@ -0,0 +1,63 @@ +--- +id: TASK-12017 +title: Design RPG campaign/session runtime harness +status: Done +created_date: 2026-06-25 02:10 +labels: +- design +- rpg +- ttrpg +- backend +documentation: +- Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md +modified_files: +- Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md +- backlog/tasks/task-12017 - Design-RPG-campaign-session-runtime-harness.md +updated_date: 2026-06-25 15:07 +--- + +## Description + + +Brainstorm and document a backend-first RPG/TTRPG harness design for tldw_server. Scope: core campaign/session runtime, append-only event ledger, cached snapshots, rules adapters for D&D 5e SRD/PF2e/Fate, hybrid rules-pack retrieval, REST and MCP surfaces, authority policy, testing, and rollout. No implementation code in this task. + + +## Acceptance Criteria + +- [x] #1 Approved design is written to Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md +- [x] #2 Spec includes architecture, data model, REST/MCP API, service behavior, error handling, testing, rollout, non-goals, and licensing constraints +- [x] #3 Spec self-review finds no placeholders, contradictions, or ambiguous core requirements + + +## Implementation Plan + + +1. Capture approved brainstorming decisions in a design spec. +2. Self-review for placeholders, contradictions, scope creep, and ambiguity. +3. Run lightweight documentation verification. +4. Commit the Backlog task update and design document. + + +## Implementation Notes + + +Spec written to Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md. Self-review performed for unresolved markers/placeholders, contradictions, ambiguous scope, and whitespace issues. Verification: `rg -n "TBD|TODO|FIXME|placeholder|unclear|\?\?\?|\[ \]" Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md` returned no matches; `git diff --check -- Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md "backlog/tasks/task-12017 - Design-RPG-campaign-session-runtime-harness.md"` passed. Bandit skipped because this task only adds documentation and Backlog metadata, with no Python code changes. +Follow-up design review amendments added: pinned `dnd5e_srd` to SRD 5.1, deferred SRD 5.2.1/5.5e to a separate key, tightened PF2e bundled prose requirements, clarified the `RPG_DB.py` repository boundary, required atomic multi-event proposal apply, tightened committed roll randomness provenance, and added AuthNZ privilege registry rollout/testing coverage. Verification repeated: unresolved-marker scan returned no matches and `git diff --check` passed for the spec/task scope. +Status corrected to Done after confirming acceptance criteria, Definition of Done, design spec, final summary, and verification notes were already complete. Follow-on implementation tasks TASK-12018 through TASK-12028 completed the approved runtime plan. + + +## Final Summary + + +Documented and amended the approved RPG/TTRPG campaign/session runtime design. The spec defines the backend-first architecture, append-only event ledger, cached snapshots, pinned D&D SRD 5.1/PF2e/Fate rules adapter boundary, conservative rules-content licensing constraints, REST and MCP surfaces, authority policy, service behavior, error handling, testing strategy, permission rollout, and phased implementation path. No implementation code was changed. + + +## Definition of Done + +- [x] #1 Acceptance criteria completed +- [x] #2 Tests or verification recorded +- [x] #3 Documentation updated when relevant +- [x] #4 Bandit run for touched code when applicable or document non-code/environment skip +- [x] #5 Final summary added +- [x] #6 Known skips or blockers documented + diff --git a/backlog/tasks/task-12026 - Add-RPG-rules-lookup-and-session-context-builder.md b/backlog/tasks/task-12026 - Add-RPG-rules-lookup-and-session-context-builder.md new file mode 100644 index 0000000000..44de399cf5 --- /dev/null +++ b/backlog/tasks/task-12026 - Add-RPG-rules-lookup-and-session-context-builder.md @@ -0,0 +1,79 @@ +--- +id: TASK-12026 +title: Add RPG rules lookup and session context builder +status: Done +created_date: 2026-06-25 04:14 +labels: +- rpg +- ttrpg +- backend +- api +- implementation +priority: high +references: +- TASK-12018 +- TASK-12024 +- TASK-12025 +documentation: +- Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md +modified_files: +- Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md +- tldw_Server_API/app/core/RPG/models.py +- tldw_Server_API/app/core/RPG/rules/content_packs.py +- tldw_Server_API/app/core/RPG/rules/lookup.py +- tldw_Server_API/app/core/RPG/context.py +- tldw_Server_API/app/core/RPG/service.py +- tldw_Server_API/app/api/v1/schemas/rpg_schemas.py +- tldw_Server_API/app/api/v1/endpoints/rpg.py +- tldw_Server_API/tests/RPG/test_rpg_rules_context.py +- tldw_Server_API/tests/RPG/test_rpg_api.py +- tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json +updated_date: 2026-06-25 04:22 +--- + +## Description + + +Implement the rules lookup and bounded session context slice for the RPG runtime, including citation-only bundled lookup behavior, context text/diagnostics, service methods, REST schemas/endpoints for rules lookup and context building, tests, and plan updates. + + +## Acceptance Criteria + +- [x] #1 Rules lookup returns citation metadata without bundled PF2E prose +- [x] #2 Session context builder includes session/snapshot/rules data and respects max character budget +- [x] #3 RPGService exposes lookup_rules and build_context using owner-scoped session state +- [x] #4 REST rules/context endpoints work with focused API tests +- [x] #5 Focused rules/context/API tests, compileall, Bandit, and diff checks pass + + +## Implementation Plan + + +1. Write failing core and API tests for citation-only rules lookup, bounded session context, service methods, and REST endpoints. +2. Implement citation/result dataclasses and lookup service with citation-only bundled results. +3. Implement a bounded, incremental context builder and wire owner-scoped service methods. +4. Add typed REST schemas and session-scoped lookup/context endpoints; regenerate the privilege route registry snapshot. +5. Run focused RPG/API/privilege tests, compileall, Bandit, and diff checks, then commit. + + +## Implementation Notes + + +Implemented citation-only rules lookup and bounded context building. Post-review fixes included: context assembly now stops incrementally at budget instead of slicing large output, REST responses use typed citation/item/diagnostic schemas, REST `max_chars` requires 1000..24000, service callers are clamped into that range, lookup diagnostics mark `result_mode="citation_index"`, and the privilege route registry snapshot was regenerated to include the new rules/context routes. TDD RED confirmed before implementation: focused tests failed with `ModuleNotFoundError: No module named 'tldw_Server_API.app.core.RPG.context'`. Verification: focused rules/context + RPG API tests passed (11 passed); full focused RPG plus privilege catalog/snapshot suite passed (48 passed); compileall passed; Bandit reported 0 results; git diff --check passed. + + +## Final Summary + + +Added citation-only rules lookup, bounded session context generation, owner-scoped service methods, and session-scoped REST endpoints for RPG rules lookup/context. The API now returns typed citation/context diagnostics, and the AuthNZ route snapshot includes the new routes. + + +## Definition of Done + +- [x] #1 Acceptance criteria completed +- [x] #2 Tests or verification recorded +- [x] #3 Documentation updated when relevant +- [x] #4 Bandit run for touched code when applicable or document non-code/environment skip +- [x] #5 Final summary added +- [x] #6 Known skips or blockers documented + diff --git a/backlog/tasks/task-12028 - Document-RPG-runtime-and-run-final-verification.md b/backlog/tasks/task-12028 - Document-RPG-runtime-and-run-final-verification.md new file mode 100644 index 0000000000..319122471f --- /dev/null +++ b/backlog/tasks/task-12028 - Document-RPG-runtime-and-run-final-verification.md @@ -0,0 +1,64 @@ +--- +id: TASK-12028 +title: Document RPG runtime and run final verification +status: Done +created_date: 2026-06-25 04:47 +labels: +- rpg +- ttrpg +- backend +- docs +- verification +priority: high +references: +- TASK-12027 +documentation: +- Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md +modified_files: +- tldw_Server_API/app/core/RPG/README.md +- Docs/superpowers/plans/2026-06-25-rpg-campaign-session-runtime-implementation-plan.md +updated_date: 2026-06-25 04:53 +--- + +## Description + + +Complete Task 10 for the RPG runtime plan: add the RPG runtime README, run focused and adjacent regression checks, run Bandit/compile sanity checks, record outcomes, and commit the documentation closeout. + + +## Acceptance Criteria + +- [x] #1 RPG runtime README documents scope, storage model, adapters, rules-pack/legal boundary, authority/proposal model, REST/MCP surfaces, and explicitly says it is not a VTT canvas/map/token system +- [x] #2 Focused RPG suite passes +- [x] #3 Adjacent VN Play, MCP idempotency/category, and privilege catalog tests pass or any blocker is documented with exact reason +- [x] #4 Bandit and compileall checks pass for touched RPG Python scope +- [x] #5 Backlog task and implementation plan record final verification outcomes + + +## Implementation Plan + + +['Review current RPG implementation and plan Task 10 for documentation risks', 'Write the RPG runtime README using existing module behavior, not speculative features', 'Run focused and adjacent regression/security checks', 'Update Backlog and plan with results, then commit'] + + +## Implementation Notes + + +Added RPG runtime README documenting backend harness scope, non-VTT boundary, per-user ChaCha storage, event/snapshot/idempotency behavior, adapters and citation-first rules lookup, authority/proposal flow, REST/MCP surfaces, concrete input limits, and current non-goals. Subagent documentation review identified and was used to correct over-broad optimistic-sequence wording, overclaiming around user rules-pack/RAG lookup, and missing concrete REST/MCP limits. Verification passed: python -m pytest tldw_Server_API/tests/RPG -q => 59 passed; python -m pytest tldw_Server_API/tests/VN_Play/test_vn_play_db.py tldw_Server_API/app/core/MCP_unified/tests/test_idempotency_and_category.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -q => 47 passed; compileall touched RPG scope passed; Bandit /tmp/bandit_rpg_runtime.json reported 0 results/errors/skips; git diff --check passed. No virtual tabletop canvas/map/token/wall/lighting/live renderer features were added. + + +## Final Summary + + +Documented the RPG runtime as a generic backend TTRPG harness and completed the final regression/security closeout. The README describes implemented storage, event, adapter, rules lookup, authority, REST, and MCP behavior while explicitly calling out non-goals and unimplemented surfaces. + + +## Definition of Done + +- [x] #1 Acceptance criteria completed +- [x] #2 Tests or verification recorded +- [x] #3 Documentation updated when relevant +- [x] #4 Bandit run for touched code when applicable or document non-code/environment skip +- [x] #5 Final summary added +- [x] #6 Known skips or blockers documented + diff --git a/backlog/tasks/task-12029 - Design-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md b/backlog/tasks/task-12029 - Design-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md new file mode 100644 index 0000000000..823f431ac2 --- /dev/null +++ b/backlog/tasks/task-12029 - Design-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md @@ -0,0 +1,78 @@ +--- +id: TASK-12029 +title: Design RPG rules-pack attachment and retrieval-backed lookup +status: Done +created_date: 2026-06-25 15:08 +labels: +- design +- rpg +- ttrpg +- rag +- backend +priority: high +references: +- TASK-12017 +- TASK-12026 +- TASK-12028 +- TASK-12030 +- TASK-12031 +documentation: +- tldw_Server_API/app/core/RPG/README.md +- Docs/superpowers/specs/2026-06-25-rpg-campaign-session-runtime-design.md +- Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md +- Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md +updated_date: 2026-06-26 03:34 +modified_files: +- Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md +- Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md +- backlog/tasks/task-12029 - Design-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md +- backlog/tasks/task-12030 - Implement-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md +--- + +## Description + + +Design the next RPG runtime feature: attaching user-provided rules-pack references to campaigns/sessions and using existing retrieval/RAG infrastructure to augment RPG rules lookup and context building without copying long-form rules prose into RPG tables. Scope is design only; implementation should follow in separate tasks after approval. + + +## Acceptance Criteria + +- [x] #1 Design defines how campaigns and sessions attach, list, update, and remove user rules-pack references without duplicating rules prose into RPG tables +- [x] #2 Design specifies how RPG rules lookup blends built-in citation-only references with user-provided retrieval/RAG results, including citation/attribution fields and ranking/fallback behavior +- [x] #3 Design specifies how the session context builder includes retrieved user rules snippets within existing context bounds and diagnostics +- [x] #4 Design covers REST and MCP surface changes, AuthNZ privileges, idempotency/concurrency behavior for attachment writes, and failure modes +- [x] #5 Design documents licensing/privacy constraints for user-provided rules content and makes clear that bundled adapters remain mechanics-metadata/citation-only +- [x] #6 Design includes a test and verification plan for repository/service/API/MCP behavior, retrieval mocking, privilege catalog sync, Bandit, and focused regression coverage + + +## Implementation Plan + + +['Inspect existing RPG runtime and RAG/retrieval APIs to identify the safest integration boundary', 'Propose design options for rules-pack reference storage and retrieval execution', 'Write an approved design spec under Docs/superpowers/specs/', 'Self-review the spec for scope creep, ambiguous retrieval semantics, and licensing overclaims', 'After approval, create a separate implementation plan/task sequence'] + + +## Implementation Notes + + +2026-06-25: Brainstorming complete. Approved design direction: hybrid direct media/media-collection references now with registry-compatible schema later; sessions copy campaign refs at creation; rules lookup is snippet/citation-first with opt-in generated answer mode; misses fall back only to bundled citation-only references; no broad RAG/web fallback. +2026-06-25: Wrote approved design spec at Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md. Self-review checked for placeholders, contradictory scope, ambiguous retrieval behavior, and licensing overclaims. `git diff --check` passed. Bandit is not applicable because this step changed only Markdown/backlog task metadata, not Python code. +2026-06-25: Post-review amendment tightened the spec around async service boundaries, media.read requirements for attached-source dereference/retrieval, live collection readiness semantics, server-owned ref timestamps, answer-mode quota/governance, and authorization regression tests. +2026-06-25: Wrote implementation plan at Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md after checking current RPG, RAG, Media, MCP, and Chat integration points. Self-review scan found and removed ellipsis placeholders; `git diff --check` passed for the plan. Created follow-on implementation task TASK-12030. Bandit is not applicable for this step because only Markdown/backlog metadata changed. +2026-06-25: Implementation completed in TASK-12030. Follow-up TASK-12031 tracks host-level MCP answer-generation controls so MCP answer mode can pass a trusted preflight marker instead of remaining fail-closed. + + +## Final Summary + + +Completed the approved RPG rules-pack attachment/retrieval design package and implementation handoff. The design spec defines storage, retrieval, answer-mode, REST/MCP, AuthNZ, licensing, and verification behavior; the implementation plan breaks the runtime work into TDD-backed repository, service, REST, retrieval, answer, context, MCP, and verification tasks. Runtime code changes are tracked separately in TASK-12030. + + +## Definition of Done + +- [x] #1 Acceptance criteria completed +- [x] #2 Tests or verification recorded +- [x] #3 Documentation updated when relevant +- [x] #4 Bandit run for touched code when applicable or document non-code/environment skip +- [x] #5 Final summary added +- [x] #6 Known skips or blockers documented + diff --git a/backlog/tasks/task-12030 - Implement-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md b/backlog/tasks/task-12030 - Implement-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md new file mode 100644 index 0000000000..2127a65d63 --- /dev/null +++ b/backlog/tasks/task-12030 - Implement-RPG-rules-pack-attachment-and-retrieval-backed-lookup.md @@ -0,0 +1,117 @@ +--- +id: TASK-12030 +title: Implement RPG rules-pack attachment and retrieval-backed lookup +status: Done +created_date: 2026-06-25 23:29 +dependencies: +- TASK-12029 +labels: +- rpg +- ttrpg +- rag +- backend +- implementation +priority: high +documentation: +- Docs/superpowers/specs/2026-06-25-rpg-rules-pack-attachment-retrieval-design.md +- Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md +updated_date: 2026-06-26 04:23 +modified_files: +- tldw_Server_API/app/core/RPG/rules/refs.py +- tldw_Server_API/app/core/DB_Management/RPG_DB.py +- tldw_Server_API/app/core/RPG/service.py +- tldw_Server_API/tests/RPG/test_rpg_rules_refs.py +- tldw_Server_API/tests/RPG/test_rpg_db.py +- tldw_Server_API/tests/RPG/test_rpg_service.py +- tldw_Server_API/app/api/v1/schemas/rpg_schemas.py +- tldw_Server_API/app/api/v1/endpoints/rpg.py +- tldw_Server_API/tests/RPG/test_rpg_api.py +- tldw_Server_API/Config_Files/privilege_catalog.yaml +- tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json +- Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md +- tldw_Server_API/app/core/RPG/rules/retrieval.py +- tldw_Server_API/app/core/RPG/rules/source_validation.py +- tldw_Server_API/app/core/RPG/rules/content_packs.py +- tldw_Server_API/app/core/RPG/rules/lookup.py +- tldw_Server_API/app/core/RPG/context.py +- tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py +- tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py +- tldw_Server_API/tests/RPG/test_rpg_rules_context.py +- tldw_Server_API/tests/RPG/test_rpg_mcp_module.py +- tldw_Server_API/app/core/RPG/rules/answering.py +- tldw_Server_API/tests/RPG/test_rpg_rules_answering.py +- tldw_Server_API/app/core/RPG/README.md +- tldw_Server_API/app/core/RPG/__init__.py +- tldw_Server_API/app/core/RPG/errors.py +- tldw_Server_API/app/core/RPG/dice.py +- tldw_Server_API/app/core/exceptions.py +- tldw_Server_API/app/core/RAG/rag_service/evidence_models.py +- tldw_Server_API/app/core/MCP_unified/protocol.py +- tldw_Server_API/app/core/MCP_unified/server.py +- tldw_Server_API/app/core/MCP_unified/tool_execution/runtime.py +- tldw_Server_API/app/main.py +- tldw_Server_API/app/api/v1/router_groups/content.py +- tldw_Server_API/app/api/v1/router_groups/minimal.py +- tldw_Server_API/app/api/v1/API_Deps/auth_deps.py +- tldw_Server_API/tests/RPG/test_rpg_events_reducer.py +- tldw_Server_API/tests/RPG/test_rpg_core_models_adapters.py +- tldw_Server_API/tests/RPG/test_rpg_dice_checks.py +references: +- TASK-12031 +--- + +## Description + + +Implement the approved RPG rules-pack attachment feature from TASK-12029. Campaigns and sessions should attach user-owned media items or media collections as rules references, then use scoped retrieval to augment RPG rules lookup and context building with optional grounded answer mode. + + +## Acceptance Criteria + +- [x] #1 Campaigns and sessions can list and replace normalized media_item/media_collection rules-pack refs with whole-list writes, expected_version checks, idempotency replay, and server-owned timestamps. +- [x] #2 New REST endpoints expose campaign/session rules-pack ref list and replace operations with RPG permissions plus media.read requirements. +- [x] #3 Session creation copies campaign refs by default while explicit session refs can diverge from campaign refs. +- [x] #4 Rules lookup blends user-provided scoped retrieval snippets with bundled citation-only references, reports diagnostics, and never falls back to broad RAG or web search. +- [x] #5 Answer mode generates grounded answers only from retrieved snippets using the existing async chat service and returns citation IDs limited to lookup evidence. +- [x] #6 Session context building includes lookup-mode evidence within existing bounds and never invokes answer generation. +- [x] #7 MCP tools expose the same ref-management and lookup semantics as REST. +- [x] #8 Focused RPG, API, MCP, privilege catalog, and Bandit verification are recorded. + + +## Implementation Notes + + +2026-06-25: Began subagent-driven implementation from Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md. Worktree verified clean on branch codex/rpg-runtime before runtime code edits. +2026-06-25: Completed implementation plan Task 1. Commits: 607ec4fe5c (rules-pack ref model and repository replacement) and 1b3ffa4cdb (strict enabled validation plus session idempotency mismatch coverage). RED checks showed missing refs module/repository methods, then strict enabled failures; GREEN verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_refs.py tldw_Server_API/tests/RPG/test_rpg_db.py -v` -> 38 passed, 88 existing warnings. Spec compliance reviewer approved Task 1. Code quality reviewer found one minor enabled-coercion hardening issue; follow-up fixed it and re-review approved. Worker-reported Bandit on touched Task 1 scope had no findings. +2026-06-25: Completed implementation plan Task 2. Commits: d86b0b6156 (service source validation and session copy semantics), 6b5d4e10be (replacement/create-session replay-before-validation fixes), and 62278af3e0 (explicit session rules refs replay-before-validation). GREEN verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_refs.py tldw_Server_API/tests/RPG/test_rpg_db.py tldw_Server_API/tests/RPG/test_rpg_service.py -q` -> 57 passed, 126 existing warnings. Spec compliance approved after final fix. Code quality review initially found idempotency replay issues; follow-up fixes resolved them and final review approved. Worker-reported Bandit on touched Task 2 scope had no findings. +2026-06-25: Completed implementation plan Task 3. Added REST schemas for rules-pack refs and lookup mode/provider/model options; added campaign/session rules-pack list and whole-list replace REST endpoints; wired REST RPGService construction with authenticated user's Media DB and Collections DB plus endpoint-layer rules source validator; added media.read to RPG lookup/ref endpoint permission contracts; regenerated privilege route registry snapshot with Helper_Scripts/update_privilege_registry_snapshot.py. RED verification: focused API/catalog pytest failed before implementation for missing constants/routes and lookup mode schema. GREEN verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` -> 21 passed, 64 existing warnings after snapshot regeneration. Additional manual validator check: enabled missing media ref returned 400 `rules_pack_source_unreadable`. Bandit: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m bandit -r tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/tests/RPG/test_rpg_api.py -f json -o /tmp/bandit_task12030_task3.json` -> zero findings. +2026-06-25: Task 3 post-review hardening completed. Spec re-review initially found collection refs could return ready media IDs without re-checking unreadable/deleted/trash/missing rows; fixed REST validator to re-read ready media IDs through Media DB and owner/client guard. Code quality review found broad service media DB dependency, permissive enabled coercion, and limited runtime media.read denial coverage; split base RPG service from rules-source service, changed RPGRulesPackRefInput.enabled to StrictBool, added non-boolean enabled rejection and rules-pack missing media.read 403 tests. Final focused verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` -> 27 passed, 76 existing warnings. Bandit: `/tmp/bandit_task12030_task3.json` -> zero findings. Final spec re-review passed; quality re-review had no P0/P1 blockers after fixes, with remaining batching concern deferred to retrieval implementation where batched media reads belong. +2026-06-25: Completed implementation plan Task 4. Added scoped RPG rules retrieval adapter over RulesPackSourceValidator plus the existing RAG retrieval executor with media_db-only sources and allowed_media_ids; extended lookup result dataclasses for user_provided and bundled_citation items; wired async lookup through RPGService, REST lookup/context handlers, and existing MCP async call sites without adding Task 6 tools. Lookup keeps bundled citations, puts retrieved user snippets first, reports linked/enabled/ready/retrieved/bundled/skipped/no-fallback diagnostics, and does not implement grounded answer generation. RED verification: focused retrieval/context pytest failed during collection because rules.retrieval and RuleLookupCitation were missing. GREEN verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_service.py -v` -> 35 passed, 82 existing warnings. API async call-site verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` -> 27 passed, 76 existing warnings. Bandit: `/tmp/bandit_task12030_task4.json` -> 0 findings. +2026-06-25: Task 4 post-review hardening completed. Addressed quality review findings by sharing rules source validation between REST and MCP, wiring MCP rules lookup/context to attached media refs when `context.db_paths.media` is present, filtering retrieval executor results back to validated ready_media_ids, and redacting unexpected retrieval exception details from both diagnostics and warning logs. Added regression coverage for out-of-scope retrieval documents, public/log redaction, MCP attached-media lookup, MCP answer-status behavior, and non-rules MCP tools avoiding media DB opens. Spec re-review previously approved after answer-status fix; code quality re-review approved after lazy MCP media binding. Final verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/RPG/test_rpg_mcp_module.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` -> 64 passed, 176 existing warnings. Bandit: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m bandit -r tldw_Server_API/app/core/RPG tldw_Server_API/app/api/v1/endpoints/rpg.py tldw_Server_API/app/api/v1/schemas/rpg_schemas.py tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py -f json -o /tmp/bandit_task12030_task4.json` -> 0 findings, 0 errors. +2026-06-25: Completed implementation plan Task 5. Added grounded answer generation through the existing async chat call path, lookup wiring for `mode="answer"`, context diagnostics/fallback behavior, and REST answer option wiring. Post-review hardening requires `chat.completions` permission plus token-scope, LLM-budget, and chat rate-limit guard checks before REST answer generation; malformed/non-JSON model output now returns `generation_error` without fabricated citations; unexpected answer-generator failures are sanitized to `generation_error` while preserving lookup evidence. RED checks covered missing answer module, missing context diagnostics/fallback, malformed answer grounding, unexpected generator exceptions, and missing answer-mode chat permission. GREEN verification: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_answering.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_api.py -v` -> 54 passed, 133 existing warnings. Broader regression: `source /Users/appledev/Documents/GitHub/tldw_server/.venv/bin/activate && python -m pytest tldw_Server_API/tests/RPG/test_rpg_rules_answering.py tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py tldw_Server_API/tests/RPG/test_rpg_rules_context.py tldw_Server_API/tests/RPG/test_rpg_api.py tldw_Server_API/tests/RPG/test_rpg_mcp_module.py tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -v` -> 81 passed, 213 existing warnings. Bandit: `/tmp/bandit_task12030_task5.json` -> 0 findings, 0 errors. Spec and quality re-reviews reported no blockers after hardening. +2026-06-25: Completed Task 6 post-review hardening. MCP now exposes rules-pack ref get/replace tools, lookup answer options, explicit RPG/media domain permission checks from authenticated context metadata, answer-mode fail-closed governance requiring chat.completions plus trusted host controls metadata, cleanup of partially-opened service resources, and source-validation-only rules-pack replacement without retrieval construction. README documents REST/MCP idempotency differences and the MCP answer-mode host marker. Follow-up TASK-12031 created for host-level MCP answer-generation control wiring so mounted MCP answer mode can pass the trusted marker after protocol-level checks. Verification: focused post-review MCP checks -> 7 passed; full MCP module -> 28 passed; full RPG suite -> 174 passed; RPG endpoint scope catalog -> 1 passed; Ruff changed Python scope -> All checks passed; Bandit /tmp/bandit_task12030_task6_post_review.json -> 0 findings, 0 errors; git diff --check -> clean. +2026-06-25: Rebuilt the work on focused branch codex/rpg-runtime-focused from origin/main to replace the overly broad draft branch. Added only minimal clean-branch integration shims: RPG router registration, optional MCP RPG module loading, RequestContext explicit db_paths override support, auth dependency compatibility aliases, and the small RetrievedEvidence contract needed by RPG retrieval. Regenerated the privilege route snapshot from the focused worktree; it includes RPG routes plus existing live-route drift required for the snapshot test. Fresh focused verification: `python -m pytest tldw_Server_API/tests/RPG tldw_Server_API/tests/Privileges/test_privilege_introspection.py::test_privilege_registry_snapshot_matches_live_app tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -q` -> 176 passed, 425 warnings; `python -m ruff check` on new RPG code/test scope -> All checks passed; full touched-file Ruff still reports pre-existing lint debt in shared bootstrap/protocol files; `python -m bandit -r -f json -o /tmp/bandit_task12030_focused.json` -> 0 findings, 0 errors; `git diff --check` -> clean. +2026-06-26: Rebased PR #2530 branch codex/rpg-runtime-focused onto latest origin/dev and resolved the route/MCP integration conflicts using dev's router-group and protocol-types patterns. Addressed review comments by validating enabled rules-pack refs concurrently, converting malformed stored ref IDs such as `media_item:abc` into `RPGValidationError("invalid_rules_pack_ref_source_id")`, and making the REST rules service a yield dependency that closes its `MultiDatabaseRetriever`. Rebase review also exposed a shared MCP runtime regression where module-level `PermissionError` was sanitized into `tool_execution_error`; fixed the wrapper to re-raise `PermissionError` so protocol authorization mapping preserves useful denial messages. Fresh verification on the rebased branch: `python -m pytest tldw_Server_API/tests/RPG tldw_Server_API/tests/Privileges/test_privilege_introspection.py::test_privilege_registry_snapshot_matches_live_app tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -q` -> 179 passed, 431 warnings; `python -m ruff check ` -> All checks passed; `python -m bandit -r -f json -o /tmp/bandit_task12030_rebased.json` -> 0 findings, 0 errors; `git diff --check` -> clean. +2026-06-26: Addressed follow-up Qodo review comments on PR #2530. Added RPG endpoint/schema docstrings, typed the endpoint permission dependency helpers, centralized RPG exception classes in `app/core/exceptions.py` while preserving `app/core/RPG/errors.py` compatibility exports, added module-level pytest markers across the RPG suite, and tightened RPG context building to require `rpg.rules.read` in addition to session/media read scopes. Regenerated the privilege route registry snapshot after the context permission change. Fresh verification: `python -m pytest tldw_Server_API/tests/RPG tldw_Server_API/tests/Privileges/test_privilege_introspection.py::test_privilege_registry_snapshot_matches_live_app tldw_Server_API/tests/PrivilegeCatalog/test_endpoint_scope_catalog_sync.py -q` -> 179 passed, 431 warnings; `python -m ruff check ` -> All checks passed; `python -m bandit -r -f json -o /tmp/bandit_task12030_rebased_qodo.json` -> 0 findings, 0 errors; `git diff --check` -> clean. + + +## Final Summary + + +Implemented RPG rules-pack attachment and retrieval-backed lookup across persistence, service, REST, MCP, documentation, and tests. The design stores only normalized media_item/media_collection references in RPG campaign/session state while rules prose remains in existing user-owned Media/RAG stores, so ownership and readability checks stay centralized. Lookup and context construction use attached-source retrieval without broad fallback; answer mode is grounded in retrieved snippets and reuses existing chat governance on REST. MCP now has parity for ref management and lookup semantics, with explicit domain-permission enforcement and fail-closed answer-mode controls pending host-level marker wiring in TASK-12031. + + +## Definition of Done + +- [x] #1 Acceptance criteria completed +- [x] #2 Tests or verification recorded +- [x] #3 Documentation updated when relevant +- [x] #4 Bandit run for touched code when applicable or document non-code/environment skip +- [x] #5 Final summary added +- [x] #6 Known skips or blockers documented +- [x] #7 Implementation plan tasks completed or consciously split into follow-up tasks with links. +- [x] #8 Focused pytest commands recorded with results. +- [x] #9 Privilege route catalog check recorded when endpoint metadata changes. +- [x] #10 Bandit JSON report path and result recorded for touched Python scope. +- [x] #11 Final summary explains what changed and why the chosen integration boundaries were used. + diff --git a/backlog/tasks/task-12031 - Wire-host-level-MCP-answer-generation-controls-for-RPG-rules-lookup.md b/backlog/tasks/task-12031 - Wire-host-level-MCP-answer-generation-controls-for-RPG-rules-lookup.md new file mode 100644 index 0000000000..8c1b65f173 --- /dev/null +++ b/backlog/tasks/task-12031 - Wire-host-level-MCP-answer-generation-controls-for-RPG-rules-lookup.md @@ -0,0 +1,52 @@ +--- +id: TASK-12031 +title: Wire host-level MCP answer generation controls for RPG rules lookup +status: To Do +created_date: 2026-06-26 03:33 +dependencies: +- TASK-12030 +labels: +- rpg +- mcp +- security +- backend +priority: high +references: +- TASK-12030 +documentation: +- Docs/superpowers/plans/2026-06-25-rpg-rules-pack-attachment-retrieval-implementation-plan.md +--- + +## Description + + +Connect the MCP host/protocol layer to the RPG rules lookup answer-mode governance marker so `rpg.rules.lookup` can generate grounded answers over MCP only after the same token-scope, budget, rate-limit, and chat-completions controls used by REST have been enforced. TASK-12030 deliberately leaves MCP answer mode fail-closed without this trusted marker. + + +## Acceptance Criteria + +- [ ] #1 MCP request preparation or execution records a trusted `mcp_rpg_answer_generation_controls` marker only after chat-completions permission, token-scope, budget, and rate-limit checks succeed. +- [ ] #2 `rpg.rules.lookup` with `mode=answer` succeeds over the mounted MCP path when the caller is authorized and controls pass, without callers being able to spoof the marker from tool arguments. +- [ ] #3 Unauthorized or over-budget MCP answer-mode requests fail before lookup and before any LLM call. +- [ ] #4 Tests cover success, missing chat permission, missing/failed generation controls, and marker-spoofing attempts. + + +## Implementation Notes + + + + + +## Final Summary + + + + + +## Definition of Done + +- [ ] #1 Focused MCP protocol/runtime tests pass. +- [ ] #2 RPG MCP module tests pass. +- [ ] #3 Security/Bandit check on touched MCP/RPG scope is recorded. +- [ ] #4 RPG README or MCP docs mention the host-level control path. + diff --git a/tldw_Server_API/Config_Files/privilege_catalog.yaml b/tldw_Server_API/Config_Files/privilege_catalog.yaml index 9b089df15c..e406bc39fd 100644 --- a/tldw_Server_API/Config_Files/privilege_catalog.yaml +++ b/tldw_Server_API/Config_Files/privilege_catalog.yaml @@ -71,6 +71,22 @@ scopes: ownership_predicates: - same_org doc_url: https://docs.example.com/privileges/media-catalog-view + - id: media.read + description: Read user-owned media metadata and content for retrieval or display. + resource_tags: + - media + - read + sensitivity_tier: moderate + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + - viewer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/media-read - id: any description: Generic low-risk scope used for permissive endpoints and legacy clients. resource_tags: @@ -85,6 +101,120 @@ scopes: ownership_predicates: - same_org doc_url: https://docs.example.com/privileges/any + - id: rpg + description: Generic token scope bucket for RPG campaign and session APIs. + resource_tags: + - rpg + - ttrpg + sensitivity_tier: moderate + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg + - id: rpg.rules.read + description: View bundled RPG rules adapter metadata and bounded rule lookup results. + resource_tags: + - rpg + - rules + - read + sensitivity_tier: low + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + - viewer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-rules-read + - id: rpg.campaigns.read + description: View RPG campaign metadata owned by the authenticated user or organization. + resource_tags: + - rpg + - campaigns + - read + sensitivity_tier: moderate + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + - viewer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-campaigns-read + - id: rpg.campaigns.manage + description: Create and manage RPG campaigns and their default runtime configuration. + resource_tags: + - rpg + - campaigns + - control + sensitivity_tier: high + rate_limit_class: elevated + default_roles: + - admin + - analyst + - developer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-campaigns-manage + - id: rpg.sessions.read + description: View RPG session metadata, event history, and snapshots. + resource_tags: + - rpg + - sessions + - read + sensitivity_tier: moderate + rate_limit_class: standard + default_roles: + - admin + - analyst + - developer + - viewer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-sessions-read + - id: rpg.sessions.manage + description: Create RPG sessions and record session events or rolls. + resource_tags: + - rpg + - sessions + - control + sensitivity_tier: high + rate_limit_class: elevated + default_roles: + - admin + - analyst + - developer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-sessions-manage + - id: rpg.proposals.review + description: Apply or reject proposed RPG state changes generated by models or external tools. + resource_tags: + - rpg + - proposals + - review + sensitivity_tier: high + rate_limit_class: elevated + default_roles: + - admin + - analyst + - developer + feature_flag_id: null + ownership_predicates: + - same_org + doc_url: https://docs.example.com/privileges/rpg-proposals-review - id: acp.agents.list description: View available ACP agents and their configuration status. resource_tags: diff --git a/tldw_Server_API/app/api/v1/endpoints/rpg.py b/tldw_Server_API/app/api/v1/endpoints/rpg.py new file mode 100644 index 0000000000..3b82592ae0 --- /dev/null +++ b/tldw_Server_API/app/api/v1/endpoints/rpg.py @@ -0,0 +1,504 @@ +"""REST endpoints for the RPG campaign/session runtime.""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import asdict +from typing import Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.security import HTTPAuthorizationCredentials + +from tldw_Server_API.app.api.v1.API_Deps.auth_deps import ( + RequirePermission, + TokenScopeGuard, + enforce_rbac_rate_limit, + get_auth_principal, + get_request_user, + rbac_rate_limit, +) +from tldw_Server_API.app.api.v1.API_Deps.ChaCha_Notes_DB_Deps import get_chacha_db_for_user +from tldw_Server_API.app.api.v1.API_Deps.Collections_DB_Deps import get_collections_db_for_user +from tldw_Server_API.app.api.v1.API_Deps.DB_Deps import get_media_db_for_user, get_media_db_path_for_rag +from tldw_Server_API.app.api.v1.schemas.rpg_schemas import ( + RPGCampaignCreateRequest, + RPGCampaignResponse, + RPGContextBuildRequest, + RPGContextResponse, + RPGProposalApplyRequest, + RPGProposalRejectRequest, + RPGRecordEventsRequest, + RPGRecordEventsResponse, + RPGRulesLookupRequest, + RPGRulesLookupResponse, + RPGRulesPackRefsReplaceRequest, + RPGRulesPackRefsResponse, + RPGSessionCreateRequest, + RPGSessionResponse, +) +from tldw_Server_API.app.core.AuthNZ.database import get_db_pool +from tldw_Server_API.app.core.AuthNZ.llm_budget_guard import enforce_llm_budget +from tldw_Server_API.app.core.AuthNZ.principal_model import AuthPrincipal +from tldw_Server_API.app.core.AuthNZ.User_DB_Handling import User +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.Collections_DB import CollectionsDatabase +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RAG.rag_service.database_retrievers import MultiDatabaseRetriever +from tldw_Server_API.app.core.RPG.errors import RPGConflictError, RPGNotFoundError, RPGValidationError +from tldw_Server_API.app.core.RPG.rules.answering import ChatRulesAnswerGenerator, RulesAnswerOptions +from tldw_Server_API.app.core.RPG.rules.lookup import RulesLookupService +from tldw_Server_API.app.core.RPG.rules.retrieval import RulesRetrievalAdapter +from tldw_Server_API.app.core.RPG.rules.source_validation import RPGRulesSourceValidator +from tldw_Server_API.app.core.RPG.service import RPGService + +router = APIRouter(prefix="/rpg", tags=["rpg"]) + +RPG_RULES_READ = "rpg.rules.read" +RPG_CAMPAIGNS_READ = "rpg.campaigns.read" +RPG_CAMPAIGNS_MANAGE = "rpg.campaigns.manage" +RPG_SESSIONS_READ = "rpg.sessions.read" +RPG_SESSIONS_MANAGE = "rpg.sessions.manage" +RPG_PROPOSALS_REVIEW = "rpg.proposals.review" +MEDIA_READ = "media.read" +CHAT_COMPLETIONS = "chat.completions" +CHAT_CREATE_RATE_LIMIT = "chat.create" + +_answer_mode_permission_guard = RequirePermission(CHAT_COMPLETIONS) +_answer_mode_scope_guard = TokenScopeGuard( + "any", + require_if_present=True, + endpoint_id=CHAT_COMPLETIONS, + count_as="call", +) + + +def _read_dependencies(*scopes: str) -> list[Any]: + """Build standard read dependency guards for RPG route scopes.""" + + primary_scope = scopes[0] + return [ + Depends(rbac_rate_limit(primary_scope)), + Depends(RequirePermission(*scopes)), + Depends(TokenScopeGuard("rpg", require_if_present=True, endpoint_id=primary_scope, count_as="call")), + ] + + +def _write_dependencies(*scopes: str) -> list[Any]: + """Build standard write dependency guards for RPG route scopes.""" + + return _read_dependencies(*scopes) + + +async def _enforce_answer_mode_generation_controls( + request: Request, + principal: AuthPrincipal, +) -> None: + """Apply chat permission, token-scope, budget, and rate controls for answer mode.""" + + await _answer_mode_permission_guard(principal) + await _answer_mode_scope_guard(request, credentials=_bearer_credentials_from_request(request)) + await enforce_llm_budget(request) + await enforce_rbac_rate_limit(request, CHAT_CREATE_RATE_LIMIT, await get_db_pool()) + + +def _bearer_credentials_from_request(request: Request) -> HTTPAuthorizationCredentials | None: + """Extract bearer credentials from a FastAPI request if present.""" + + authorization = request.headers.get("Authorization") + if not authorization: + return None + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + return None + return HTTPAuthorizationCredentials(scheme=scheme, credentials=token.strip()) + + +def _owner_user_id(current_user: User) -> int: + """Return the integer owner id for the authenticated user.""" + + if current_user.id_int is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_user_id") + return int(current_user.id_int) + + +def _service( + db: CharactersRAGDB = Depends(get_chacha_db_for_user), + current_user: User = Depends(get_request_user), +) -> RPGService: + """Construct the base RPG service for non-retrieval operations.""" + + return RPGService(repo=RPGRepository.initialized(db), owner_user_id=_owner_user_id(current_user)) + + +def _rules_service( + db: CharactersRAGDB = Depends(get_chacha_db_for_user), + media_db: Any = Depends(get_media_db_for_user), + collections_db: CollectionsDatabase = Depends(get_collections_db_for_user), + current_user: User = Depends(get_request_user), +) -> Iterator[RPGService]: + """Yield an RPG service with rules retrieval dependencies and cleanup.""" + + owner_user_id = _owner_user_id(current_user) + validator = RPGRulesSourceValidator(media_db=media_db, collections_db=collections_db) + media_db_path = get_media_db_path_for_rag(media_db) + rag_retriever = MultiDatabaseRetriever( + {"media_db": media_db_path} if media_db_path else {}, + user_id=str(owner_user_id), + media_db=media_db, + ) + try: + yield RPGService( + repo=RPGRepository.initialized(db), + owner_user_id=owner_user_id, + rules_source_validator=validator, + rules_lookup_service=RulesLookupService( + retriever=RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=rag_retriever, + ), + answer_generator=ChatRulesAnswerGenerator(), + ), + ) + finally: + close = getattr(rag_retriever, "close", None) + if callable(close): + close() + + +def _map_error(exc: Exception) -> HTTPException: + """Map domain exceptions to stable RPG HTTP errors.""" + + if isinstance(exc, RPGNotFoundError): + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) + if isinstance(exc, RPGConflictError): + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + if isinstance(exc, (RPGValidationError, ValueError)): + return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="rpg_internal_error") + + +def _event_payload(event: Any) -> dict[str, Any]: + """Serialize a domain event dataclass for API responses.""" + + return jsonable_encoder(asdict(event)) + + +def _proposal_payload(proposal: Any | None) -> dict[str, Any] | None: + """Serialize a proposal dataclass for API responses.""" + + if proposal is None: + return None + return jsonable_encoder(asdict(proposal)) + + +def _rules_pack_refs_response(result: Any) -> RPGRulesPackRefsResponse: + """Serialize a rules-pack replacement result.""" + + return RPGRulesPackRefsResponse.model_validate(jsonable_encoder(asdict(result))) + + +@router.get( + "/rules/adapters", + dependencies=_read_dependencies(RPG_RULES_READ), +) +def list_adapters(service: RPGService = Depends(_service)) -> dict[str, Any]: + """List supported RPG rules adapters.""" + + return {"adapters": [asdict(adapter) for adapter in service.adapter_registry.list_infos()]} + + +@router.get( + "/rules/adapters/{adapter_key}", + dependencies=_read_dependencies(RPG_RULES_READ), +) +def get_adapter(adapter_key: str, service: RPGService = Depends(_service)) -> dict[str, Any]: + """Return one supported RPG rules adapter.""" + + try: + return {"adapter": asdict(service.adapter_registry.get(adapter_key).info())} + except Exception as exc: + raise _map_error(exc) from exc + + +@router.post( + "/campaigns", + response_model=RPGCampaignResponse, + status_code=status.HTTP_201_CREATED, + dependencies=_write_dependencies(RPG_CAMPAIGNS_MANAGE), +) +def create_campaign( + request: RPGCampaignCreateRequest, + idempotency_key: str = Header(alias="Idempotency-Key"), + service: RPGService = Depends(_service), +) -> RPGCampaignResponse: + """Create an RPG campaign.""" + + try: + campaign = service.create_campaign( + request.title, + request.description, + request.default_adapter_key, + idempotency_key=idempotency_key, + ) + return RPGCampaignResponse.model_validate(asdict(campaign)) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.post( + "/campaigns/{campaign_id}/sessions", + response_model=RPGSessionResponse, + status_code=status.HTTP_201_CREATED, + dependencies=_write_dependencies(RPG_SESSIONS_MANAGE), +) +def create_session( + campaign_id: int, + request: RPGSessionCreateRequest, + idempotency_key: str = Header(alias="Idempotency-Key"), + service: RPGService = Depends(_service), +) -> RPGSessionResponse: + """Create an RPG session under a campaign.""" + + try: + session = service.create_session( + campaign_id=campaign_id, + title=request.title, + adapter_key=request.adapter_key, + idempotency_key=idempotency_key, + ) + return RPGSessionResponse.model_validate(asdict(session)) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.post( + "/sessions/{session_id}/events", + response_model=RPGRecordEventsResponse, + dependencies=_write_dependencies(RPG_SESSIONS_MANAGE), +) +def record_events( + session_id: int, + request: RPGRecordEventsRequest, + idempotency_key: str = Header(alias="Idempotency-Key"), + service: RPGService = Depends(_service), +) -> RPGRecordEventsResponse: + """Record user-authored RPG session events.""" + + try: + result = service.record_events( + session_id=session_id, + events=[event.model_dump() for event in request.events], + source_type="user", + expected_last_event_sequence=request.expected_last_event_sequence, + idempotency_key=idempotency_key, + ) + return RPGRecordEventsResponse( + committed_events=[_event_payload(event) for event in result.committed_events], + proposal=_proposal_payload(result.proposal), + ) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.post( + "/sessions/{session_id}/rules/lookup", + response_model=RPGRulesLookupResponse, + dependencies=_read_dependencies(RPG_RULES_READ, MEDIA_READ), +) +async def lookup_rules( + session_id: int, + request: RPGRulesLookupRequest, + http_request: Request, + principal: AuthPrincipal = Depends(get_auth_principal), + service: RPGService = Depends(_rules_service), +) -> RPGRulesLookupResponse: + """Run scoped rules lookup or answer mode for an RPG session.""" + + if request.mode == "answer": + await _enforce_answer_mode_generation_controls(http_request, principal) + try: + return RPGRulesLookupResponse.model_validate( + jsonable_encoder( + asdict( + await service.lookup_rules( + session_id=session_id, + query=request.query, + mode=request.mode, + answer_options=RulesAnswerOptions( + provider=request.provider, + model=request.model, + temperature=request.temperature, + max_tokens=request.max_tokens, + ), + ) + ) + ) + ) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.get( + "/campaigns/{campaign_id}/rules-packs", + response_model=RPGRulesPackRefsResponse, + dependencies=_read_dependencies(RPG_CAMPAIGNS_READ, MEDIA_READ), +) +def list_campaign_rules_pack_refs( + campaign_id: int, + service: RPGService = Depends(_service), +) -> RPGRulesPackRefsResponse: + """List campaign-level rules-pack references.""" + + try: + return _rules_pack_refs_response(service.list_campaign_rules_pack_refs(campaign_id)) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.put( + "/campaigns/{campaign_id}/rules-packs", + response_model=RPGRulesPackRefsResponse, + dependencies=_write_dependencies(RPG_CAMPAIGNS_MANAGE, MEDIA_READ), +) +async def replace_campaign_rules_pack_refs( + campaign_id: int, + request: RPGRulesPackRefsReplaceRequest, + service: RPGService = Depends(_rules_service), +) -> RPGRulesPackRefsResponse: + """Replace campaign-level rules-pack references.""" + + try: + result = await service.replace_campaign_rules_pack_refs( + campaign_id=campaign_id, + refs=[ref.model_dump() for ref in request.refs], + expected_version=request.expected_version, + idempotency_key=request.idempotency_key, + ) + return _rules_pack_refs_response(result) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.get( + "/sessions/{session_id}/rules-packs", + response_model=RPGRulesPackRefsResponse, + dependencies=_read_dependencies(RPG_SESSIONS_READ, MEDIA_READ), +) +def list_session_rules_pack_refs( + session_id: int, + service: RPGService = Depends(_service), +) -> RPGRulesPackRefsResponse: + """List session-level rules-pack references.""" + + try: + return _rules_pack_refs_response(service.list_session_rules_pack_refs(session_id)) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.put( + "/sessions/{session_id}/rules-packs", + response_model=RPGRulesPackRefsResponse, + dependencies=_write_dependencies(RPG_SESSIONS_MANAGE, MEDIA_READ), +) +async def replace_session_rules_pack_refs( + session_id: int, + request: RPGRulesPackRefsReplaceRequest, + service: RPGService = Depends(_rules_service), +) -> RPGRulesPackRefsResponse: + """Replace session-level rules-pack references.""" + + try: + result = await service.replace_session_rules_pack_refs( + session_id=session_id, + refs=[ref.model_dump() for ref in request.refs], + expected_version=request.expected_version, + idempotency_key=request.idempotency_key, + ) + return _rules_pack_refs_response(result) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.post( + "/sessions/{session_id}/context", + response_model=RPGContextResponse, + dependencies=_read_dependencies(RPG_SESSIONS_READ, RPG_RULES_READ, MEDIA_READ), +) +async def build_context( + session_id: int, + request: RPGContextBuildRequest, + service: RPGService = Depends(_rules_service), +) -> RPGContextResponse: + """Build a bounded RPG session context bundle.""" + + try: + return RPGContextResponse.model_validate( + jsonable_encoder( + asdict( + await service.build_context( + session_id=session_id, + query=request.query, + max_chars=request.max_chars, + ) + ) + ) + ) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.post( + "/sessions/{session_id}/proposals/{proposal_id}/apply", + response_model=RPGRecordEventsResponse, + dependencies=_write_dependencies(RPG_PROPOSALS_REVIEW), +) +def apply_proposal( + session_id: int, + proposal_id: int, + request: RPGProposalApplyRequest, + idempotency_key: str = Header(alias="Idempotency-Key"), + service: RPGService = Depends(_service), +) -> RPGRecordEventsResponse: + """Apply a pending model proposal after review.""" + + try: + result = service.apply_proposal( + session_id=session_id, + proposal_id=proposal_id, + expected_last_event_sequence=request.expected_last_event_sequence, + idempotency_key=idempotency_key, + review_notes=request.review_notes, + ) + return RPGRecordEventsResponse( + committed_events=[_event_payload(event) for event in result.committed_events], + proposal=None, + ) + except Exception as exc: + raise _map_error(exc) from exc + + +@router.post( + "/sessions/{session_id}/proposals/{proposal_id}/reject", + dependencies=_write_dependencies(RPG_PROPOSALS_REVIEW), +) +def reject_proposal( + session_id: int, + proposal_id: int, + request: RPGProposalRejectRequest, + idempotency_key: str = Header(alias="Idempotency-Key"), + service: RPGService = Depends(_service), +) -> dict[str, Any]: + """Reject a pending model proposal after review.""" + + try: + proposal = service.reject_proposal( + session_id=session_id, + proposal_id=proposal_id, + idempotency_key=idempotency_key, + review_notes=request.review_notes, + ) + return {"proposal": _proposal_payload(proposal)} + except Exception as exc: + raise _map_error(exc) from exc diff --git a/tldw_Server_API/app/api/v1/router_groups/content.py b/tldw_Server_API/app/api/v1/router_groups/content.py index 0e52a20b72..ff777e5165 100644 --- a/tldw_Server_API/app/api/v1/router_groups/content.py +++ b/tldw_Server_API/app/api/v1/router_groups/content.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from typing import Iterable +from collections.abc import Iterable from loguru import logger @@ -459,6 +459,13 @@ def iter_content_router_specs() -> Iterable[RouterSpec]: tags=("character-messages",), route_key="character-messages", ), + ImportedRouterSpec( + import_path="tldw_Server_API.app.api.v1.endpoints.rpg", + log_name="rpg", + prefix=f"{API_V1_PREFIX}", + tags=("rpg",), + route_key="rpg", + ), ): append_imported_router_spec(specs, character_spec) diff --git a/tldw_Server_API/app/api/v1/router_groups/minimal.py b/tldw_Server_API/app/api/v1/router_groups/minimal.py index 24356d785c..e3e7eac7d6 100644 --- a/tldw_Server_API/app/api/v1/router_groups/minimal.py +++ b/tldw_Server_API/app/api/v1/router_groups/minimal.py @@ -5,15 +5,15 @@ """ from __future__ import annotations -from typing import Iterable +from collections.abc import Iterable from loguru import logger +from tldw_Server_API.app.api.v1.router_groups.admin import iter_admin_router_specs from tldw_Server_API.app.api.v1.router_groups.conditional import ( ImportedRouterSpec, append_imported_router_spec, ) -from tldw_Server_API.app.api.v1.router_groups.admin import iter_admin_router_specs from tldw_Server_API.app.api.v1.router_groups.content import iter_content_router_specs from tldw_Server_API.app.api.v1.router_groups.core import iter_core_router_specs from tldw_Server_API.app.api.v1.router_groups.factories import evaluations_router_factory @@ -24,7 +24,11 @@ from tldw_Server_API.app.api.v1.router_groups.spec import RouterSpec from tldw_Server_API.app.core.testing import ( audio_imports_enabled_for_runtime, +) +from tldw_Server_API.app.core.testing import ( env_flag_enabled as _env_flag_enabled, +) +from tldw_Server_API.app.core.testing import ( is_explicit_pytest_runtime as _is_explicit_pytest_runtime, ) @@ -48,6 +52,7 @@ "workspace_migrations", "workspaces", "workspace_memberships", + "rpg", "admin", "workspace_eligibility", ) diff --git a/tldw_Server_API/app/api/v1/schemas/rpg_schemas.py b/tldw_Server_API/app/api/v1/schemas/rpg_schemas.py new file mode 100644 index 0000000000..848a14ca9e --- /dev/null +++ b/tldw_Server_API/app/api/v1/schemas/rpg_schemas.py @@ -0,0 +1,236 @@ +"""Pydantic schemas for the RPG runtime REST API.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictBool + + +class RPGCampaignCreateRequest(BaseModel): + """Request body for creating an RPG campaign.""" + + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=4000) + default_adapter_key: str = Field(min_length=1, max_length=80) + + +class RPGCampaignResponse(BaseModel): + """Serialized RPG campaign metadata.""" + + id: int + title: str + description: str | None + default_adapter_key: str + default_adapter_version: str + status: str + version: int + + +class RPGSessionCreateRequest(BaseModel): + """Request body for creating an RPG session.""" + + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=200) + adapter_key: str = Field(min_length=1, max_length=80) + + +class RPGSessionResponse(BaseModel): + """Serialized RPG session metadata.""" + + id: int + campaign_id: int + title: str + status: str + adapter_key: str + adapter_version: str + current_snapshot_version: int + last_event_sequence: int + version: int + + +class RPGEventInput(BaseModel): + """Client-supplied RPG event envelope.""" + + model_config = ConfigDict(extra="forbid") + + event_type: str = Field(min_length=1, max_length=120) + event_payload: dict[str, Any] + + +class RPGRecordEventsRequest(BaseModel): + """Request body for recording RPG session events.""" + + model_config = ConfigDict(extra="forbid") + + expected_last_event_sequence: int = Field(ge=0) + events: list[RPGEventInput] = Field(min_length=1, max_length=20) + + +class RPGRecordEventsResponse(BaseModel): + """Response body for event recording and proposal workflows.""" + + committed_events: list[dict[str, Any]] + proposal: dict[str, Any] | None + + +class RPGRulesLookupRequest(BaseModel): + """Request body for RPG rules lookup or grounded answer generation.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(min_length=1, max_length=500) + mode: Literal["lookup", "answer"] = "lookup" + provider: str | None = Field(default=None, max_length=100) + model: str | None = Field(default=None, max_length=200) + temperature: float = Field(default=0.2, ge=0, le=2) + max_tokens: int = Field(default=600, ge=64, le=2000) + + +class RPGRulesPackRefInput(BaseModel): + """Client-supplied rules-pack source reference.""" + + model_config = ConfigDict(extra="forbid") + + source_type: Literal["media_item", "media_collection"] + source_id: int = Field(gt=0) + display_name: str | None = None + enabled: StrictBool = True + metadata: dict[str, Any] = Field(default_factory=dict) + + +class RPGRulesPackRefsReplaceRequest(BaseModel): + """Whole-list rules-pack reference replacement request.""" + + model_config = ConfigDict(extra="forbid") + + refs: list[RPGRulesPackRefInput] = Field(default_factory=list, max_length=50) + expected_version: int = Field(ge=1) + idempotency_key: str = Field(min_length=8, max_length=128) + + +class RPGRulesPackRefResponse(BaseModel): + """Serialized normalized rules-pack reference.""" + + ref_id: str + source_type: Literal["media_item", "media_collection"] + source_id: int + display_name: str + enabled: bool + created_at: datetime + updated_at: datetime + metadata: dict[str, Any] + + +class RPGRulesPackRefsResponse(BaseModel): + """Response body for rules-pack reference list operations.""" + + refs: list[RPGRulesPackRefResponse] + version: int + replayed: bool = False + + +class RPGRuleCitationResponse(BaseModel): + """Citation metadata for a returned rules lookup item.""" + + source_type: str + source_id: int | None = None + source_title: str + source_url: str | None + license: str | None + license_url: str | None + attribution: str | None + trust_level: str + content_hash: str + snippet_id: str + adapter_key: str | None = None + source_version: str | None = None + content_pack_version: str | None = None + + +class RPGRuleLookupItemResponse(BaseModel): + """Single rules lookup result item.""" + + origin: Literal["user_provided", "bundled_citation"] + text: str + citation: RPGRuleCitationResponse + score: float + + +class RPGRulesLookupDiagnostics(BaseModel): + """Diagnostics describing rules lookup source selection and retrieval.""" + + model_config = ConfigDict(extra="allow") + + bundled_policy: Literal["citations_only", "no_match"] + result_mode: str + linked_rules_pack_count: int + enabled_rules_pack_count: int = 0 + ready_media_item_count: int = 0 + retrieval_result_count: int = 0 + bundled_citation_count: int = 0 + skipped_refs: list[dict[str, Any]] = Field(default_factory=list) + broad_fallback_used: bool = False + + +class RPGRulesLookupResponse(BaseModel): + """Response body for rules lookup and answer mode.""" + + query: str + mode: Literal["lookup", "answer"] + results: list[RPGRuleLookupItemResponse] + answer: str | None = None + answer_status: str + answer_citation_ids: list[str] = Field(default_factory=list) + diagnostics: RPGRulesLookupDiagnostics + + +class RPGContextBuildRequest(BaseModel): + """Request body for building an RPG session context bundle.""" + + model_config = ConfigDict(extra="forbid") + + query: str | None = Field(default=None, max_length=500) + max_chars: int = Field(default=24_000, ge=1000, le=24_000) + + +class RPGContextDiagnostics(BaseModel): + """Diagnostics for context construction and truncation.""" + + model_config = ConfigDict(extra="allow") + + truncated: bool + max_chars: int + original_chars: int + returned_chars: int + rules_result_count: int + rules_lookup: dict[str, Any] = Field(default_factory=dict) + omitted_sections: list[str] + + +class RPGContextResponse(BaseModel): + """Response body for RPG session context construction.""" + + text: str + diagnostics: RPGContextDiagnostics + + +class RPGProposalApplyRequest(BaseModel): + """Request body for applying a pending RPG proposal.""" + + model_config = ConfigDict(extra="forbid") + + expected_last_event_sequence: int = Field(ge=0) + review_notes: str | None = Field(default=None, max_length=2000) + + +class RPGProposalRejectRequest(BaseModel): + """Request body for rejecting a pending RPG proposal.""" + + model_config = ConfigDict(extra="forbid") + + review_notes: str | None = Field(default=None, max_length=2000) diff --git a/tldw_Server_API/app/core/DB_Management/RPG_DB.py b/tldw_Server_API/app/core/DB_Management/RPG_DB.py new file mode 100644 index 0000000000..111f41b906 --- /dev/null +++ b/tldw_Server_API/app/core/DB_Management/RPG_DB.py @@ -0,0 +1,1152 @@ +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from typing import Any, cast + +from loguru import logger + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.RPG.constants import ( + RPG_EVENT_SCHEMA_VERSION, + RPG_REDUCER_VERSION, + RPG_SNAPSHOT_SCHEMA_VERSION, + RPG_SOURCE_TYPES, +) +from tldw_Server_API.app.core.RPG.errors import RPGConflictError, RPGNotFoundError, RPGValidationError +from tldw_Server_API.app.core.RPG.events import canonical_request_hash +from tldw_Server_API.app.core.RPG.models import ( + RPGCampaign, + RPGSession, + RPGSessionEvent, + RPGSnapshotRecord, + RPGSnapshotState, + RPGSourceType, +) +from tldw_Server_API.app.core.RPG.proposals import RPGProposalRecord +from tldw_Server_API.app.core.RPG.rules.refs import ( + RulesPackRef, + RulesPackRefReplacementResult, + normalize_rules_pack_ref_payloads, + rules_pack_ref_from_dict, + rules_pack_ref_to_dict, +) + + +@dataclass(frozen=True, slots=True) +class CommitEventsResult: + events: list[RPGSessionEvent] + replayed: bool + + +class RPGRepository: + """Persistence adapter for RPG campaign/session state in a user's ChaChaNotes DB.""" + + def __init__(self, db: CharactersRAGDB) -> None: + self.db = db + + @classmethod + def initialized(cls, db: CharactersRAGDB) -> RPGRepository: + repo = cls(db) + repo.ensure_schema() + return repo + + def ensure_schema(self) -> None: + statements = ( + """ + CREATE TABLE IF NOT EXISTS rpg_campaigns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + default_adapter_key TEXT NOT NULL, + default_adapter_version TEXT NOT NULL, + settings_json TEXT NOT NULL DEFAULT '{}', + linked_rules_pack_refs_json TEXT NOT NULL DEFAULT '[]', + version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS rpg_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES rpg_campaigns(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + adapter_key TEXT NOT NULL, + adapter_version TEXT NOT NULL, + authority_settings_json TEXT NOT NULL DEFAULT '{}', + linked_chat_id INTEGER, + active_rules_pack_refs_json TEXT NOT NULL DEFAULT '[]', + current_snapshot_version INTEGER NOT NULL DEFAULT 0, + last_event_sequence INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS rpg_session_proposals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES rpg_sessions(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL, + base_event_sequence INTEGER NOT NULL, + base_snapshot_version INTEGER NOT NULL, + proposed_events_json TEXT NOT NULL, + patch_json TEXT, + rationale TEXT, + confidence REAL, + source_type TEXT NOT NULL, + source_actor_id TEXT, + model_metadata_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'pending', + review_notes TEXT, + created_at TEXT NOT NULL, + applied_at TEXT, + rejected_at TEXT, + CHECK(source_type IN ('user', 'system', 'mcp', 'model', 'import')), + CHECK(status IN ('pending', 'applied', 'rejected', 'expired', 'conflicted')) + ) + """, + """ + CREATE TABLE IF NOT EXISTS rpg_session_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES rpg_sessions(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL, + sequence_number INTEGER NOT NULL, + event_type TEXT NOT NULL, + event_payload_json TEXT NOT NULL, + source_type TEXT NOT NULL, + source_actor_id TEXT, + source_label TEXT, + operation_id INTEGER, + event_schema_version TEXT NOT NULL, + adapter_key TEXT NOT NULL, + adapter_version TEXT NOT NULL, + proposal_id INTEGER REFERENCES rpg_session_proposals(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + UNIQUE(owner_user_id, session_id, sequence_number), + CHECK(source_type IN ('user', 'system', 'mcp', 'model', 'import')) + ) + """, + """ + CREATE TABLE IF NOT EXISTS rpg_idempotency_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_user_id INTEGER NOT NULL, + session_id INTEGER REFERENCES rpg_sessions(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + operation_scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_payload_hash TEXT NOT NULL, + event_ids_json TEXT NOT NULL DEFAULT '[]', + response_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + UNIQUE(owner_user_id, source_type, operation_scope, idempotency_key), + CHECK(source_type IN ('user', 'system', 'mcp', 'model', 'import')) + ) + """, + """ + CREATE TABLE IF NOT EXISTS rpg_session_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL REFERENCES rpg_sessions(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL, + snapshot_version INTEGER NOT NULL, + last_event_sequence INTEGER NOT NULL, + reducer_version TEXT NOT NULL, + snapshot_schema_version TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + diagnostics_json TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(owner_user_id, session_id, snapshot_version) + ) + """, + "CREATE INDEX IF NOT EXISTS idx_rpg_sessions_campaign ON rpg_sessions(owner_user_id, campaign_id)", + "CREATE INDEX IF NOT EXISTS idx_rpg_proposals_session ON rpg_session_proposals(owner_user_id, session_id, status)", + "CREATE INDEX IF NOT EXISTS idx_rpg_events_session ON rpg_session_events(owner_user_id, session_id, sequence_number)", + "CREATE INDEX IF NOT EXISTS idx_rpg_snapshots_latest ON rpg_session_snapshots(owner_user_id, session_id, snapshot_version DESC)", + ) + with self.db.transaction() as conn: + conn.execute("PRAGMA foreign_keys = ON") + for statement in statements: + conn.execute(statement) + logger.debug("RPG repository schema ensured") + + def create_campaign( + self, + owner_user_id: int, + title: str, + description: str | None, + default_adapter_key: str, + default_adapter_version: str, + settings: dict[str, Any], + linked_rules_pack_refs: list[dict[str, Any]], + idempotency_key: str, + request_payload_hash: str, + source_type: str, + ) -> RPGCampaign: + self._validate_source_type(source_type) + operation_scope = "campaigns" + now = self._now() + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=None, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is not None: + self._ensure_replay_hash(replay, request_payload_hash) + campaign_id = int(self._from_json(self._row_value(replay, "response_json"))["campaign_id"]) + return self._get_campaign_with_conn(conn, owner_user_id, campaign_id) + + cursor = conn.execute( + """ + INSERT INTO rpg_campaigns ( + owner_user_id, title, description, default_adapter_key, default_adapter_version, + settings_json, linked_rules_pack_refs_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + owner_user_id, + title, + description, + default_adapter_key, + default_adapter_version, + self._to_json(settings), + self._to_json(linked_rules_pack_refs), + now, + now, + ), + ) + campaign_id = int(cursor.lastrowid) + self._insert_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=None, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + request_payload_hash=request_payload_hash, + event_ids=[], + response={"campaign_id": campaign_id}, + created_at=now, + ) + return self._get_campaign_with_conn(conn, owner_user_id, campaign_id) + + def create_session( + self, + owner_user_id: int, + campaign_id: int, + title: str, + adapter_key: str, + adapter_version: str, + authority_settings: dict[str, Any], + linked_chat_id: int | None, + active_rules_pack_refs: list[dict[str, Any]], + idempotency_key: str, + request_payload_hash: str, + source_type: str, + ) -> RPGSession: + self._validate_source_type(source_type) + operation_scope = f"campaign:{campaign_id}:sessions" + now = self._now() + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=None, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is not None: + self._ensure_replay_hash(replay, request_payload_hash) + session_id = int(self._from_json(self._row_value(replay, "response_json"))["session_id"]) + return self._get_session_with_conn(conn, owner_user_id, session_id) + + self._get_campaign_with_conn(conn, owner_user_id, campaign_id) + cursor = conn.execute( + """ + INSERT INTO rpg_sessions ( + campaign_id, owner_user_id, title, adapter_key, adapter_version, + authority_settings_json, linked_chat_id, active_rules_pack_refs_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + campaign_id, + owner_user_id, + title, + adapter_key, + adapter_version, + self._to_json(authority_settings), + linked_chat_id, + self._to_json(active_rules_pack_refs), + now, + now, + ), + ) + session_id = int(cursor.lastrowid) + conn.execute( + """ + INSERT INTO rpg_session_snapshots ( + session_id, owner_user_id, snapshot_version, last_event_sequence, + reducer_version, snapshot_schema_version, snapshot_json, diagnostics_json, created_at + ) VALUES (?, ?, 0, 0, ?, ?, ?, ?, ?) + """, + ( + session_id, + owner_user_id, + RPG_REDUCER_VERSION, + RPG_SNAPSHOT_SCHEMA_VERSION, + self._to_json(asdict(RPGSnapshotState())), + self._to_json({"initial": True}), + now, + ), + ) + self._insert_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + request_payload_hash=request_payload_hash, + event_ids=[], + response={"session_id": session_id}, + created_at=now, + ) + return self._get_session_with_conn(conn, owner_user_id, session_id) + + def get_session(self, owner_user_id: int, session_id: int) -> RPGSession: + with self.db.transaction() as conn: + return self._get_session_with_conn(conn, owner_user_id, session_id) + + def get_campaign(self, owner_user_id: int, campaign_id: int) -> RPGCampaign: + with self.db.transaction() as conn: + return self._get_campaign_with_conn(conn, owner_user_id, campaign_id) + + def replay_create_session( + self, + owner_user_id: int, + campaign_id: int, + idempotency_key: str, + request_payload_hash: str, + source_type: str, + ) -> RPGSession | None: + self._validate_source_type(source_type) + operation_scope = f"campaign:{campaign_id}:sessions" + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=None, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is None: + return None + self._ensure_replay_hash(replay, request_payload_hash) + session_id = int(self._from_json(self._row_value(replay, "response_json"))["session_id"]) + return self._get_session_with_conn(conn, owner_user_id, session_id) + + def replay_campaign_rules_pack_refs( + self, + owner_user_id: int, + campaign_id: int, + idempotency_key: str, + request_payload_hash: str, + source_type: str, + ) -> RulesPackRefReplacementResult | None: + self._validate_source_type(source_type) + operation_scope = f"campaign:{campaign_id}:rules_pack_refs" + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=None, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is None: + return None + self._ensure_replay_hash(replay, request_payload_hash) + return self._rules_pack_ref_replacement_result_from_response(replay, replayed=True) + + def replace_campaign_rules_pack_refs( + self, + owner_user_id: int, + campaign_id: int, + rules_pack_refs: list[dict[str, Any]], + expected_version: int, + idempotency_key: str, + request_payload_hash: str, + source_type: str, + ) -> RulesPackRefReplacementResult: + self._validate_source_type(source_type) + operation_scope = f"campaign:{campaign_id}:rules_pack_refs" + now_dt = datetime.now(timezone.utc) + now = now_dt.isoformat() + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=None, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is not None: + self._ensure_replay_hash(replay, request_payload_hash) + return self._rules_pack_ref_replacement_result_from_response(replay, replayed=True) + + campaign = self._get_campaign_with_conn(conn, owner_user_id, campaign_id) + if campaign.version != expected_version: + raise RPGConflictError("stale_rules_pack_ref_version") + + refs = normalize_rules_pack_ref_payloads( + rules_pack_refs, + existing_refs=campaign.linked_rules_pack_refs, + now=now_dt, + ) + refs_json = [rules_pack_ref_to_dict(ref) for ref in refs] + next_version = expected_version + 1 + cursor = conn.execute( + """ + UPDATE rpg_campaigns + SET linked_rules_pack_refs_json = ?, version = version + 1, updated_at = ? + WHERE id = ? AND owner_user_id = ? AND version = ? + """, + (self._to_json(refs_json), now, campaign_id, owner_user_id, expected_version), + ) + if cursor.rowcount != 1: + raise RPGConflictError("stale_rules_pack_ref_version") + response = {"refs": refs_json, "version": next_version} + self._insert_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=None, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + request_payload_hash=request_payload_hash, + event_ids=[], + response=response, + created_at=now, + ) + return RulesPackRefReplacementResult(refs=refs, version=next_version) + + def replay_session_rules_pack_refs( + self, + owner_user_id: int, + session_id: int, + idempotency_key: str, + request_payload_hash: str, + source_type: str, + ) -> RulesPackRefReplacementResult | None: + self._validate_source_type(source_type) + operation_scope = f"session:{session_id}:rules_pack_refs" + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is None: + return None + self._ensure_replay_hash(replay, request_payload_hash) + return self._rules_pack_ref_replacement_result_from_response(replay, replayed=True) + + def replace_session_rules_pack_refs( + self, + owner_user_id: int, + session_id: int, + rules_pack_refs: list[dict[str, Any]], + expected_version: int, + idempotency_key: str, + request_payload_hash: str, + source_type: str, + ) -> RulesPackRefReplacementResult: + self._validate_source_type(source_type) + operation_scope = f"session:{session_id}:rules_pack_refs" + now_dt = datetime.now(timezone.utc) + now = now_dt.isoformat() + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is not None: + self._ensure_replay_hash(replay, request_payload_hash) + return self._rules_pack_ref_replacement_result_from_response(replay, replayed=True) + + session = self._get_session_with_conn(conn, owner_user_id, session_id) + if session.version != expected_version: + raise RPGConflictError("stale_rules_pack_ref_version") + + refs = normalize_rules_pack_ref_payloads( + rules_pack_refs, + existing_refs=session.active_rules_pack_refs, + now=now_dt, + ) + refs_json = [rules_pack_ref_to_dict(ref) for ref in refs] + next_version = expected_version + 1 + cursor = conn.execute( + """ + UPDATE rpg_sessions + SET active_rules_pack_refs_json = ?, version = version + 1, updated_at = ? + WHERE id = ? AND owner_user_id = ? AND version = ? + """, + (self._to_json(refs_json), now, session_id, owner_user_id, expected_version), + ) + if cursor.rowcount != 1: + raise RPGConflictError("stale_rules_pack_ref_version") + response = {"refs": refs_json, "version": next_version} + self._insert_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + request_payload_hash=request_payload_hash, + event_ids=[], + response=response, + created_at=now, + ) + return RulesPackRefReplacementResult(refs=refs, version=next_version) + + def get_event(self, owner_user_id: int, event_id: int) -> RPGSessionEvent: + with self.db.transaction() as conn: + return self._get_event_with_conn(conn, owner_user_id, event_id) + + def get_latest_snapshot(self, owner_user_id: int, session_id: int) -> RPGSnapshotRecord: + with self.db.transaction() as conn: + row = conn.execute( + """ + SELECT * + FROM rpg_session_snapshots + WHERE owner_user_id = ? AND session_id = ? + ORDER BY snapshot_version DESC + LIMIT 1 + """, + (owner_user_id, session_id), + ).fetchone() + if row is None: + raise RPGNotFoundError("rpg_snapshot_not_found") + return self._snapshot_from_row(row) + + def create_proposal( + self, + owner_user_id: int, + session_id: int, + base_event_sequence: int, + base_snapshot_version: int, + proposed_events: list[dict[str, Any]], + source_type: str, + source_actor_id: str | None, + model_metadata: dict[str, Any], + idempotency_key: str, + request_payload_hash: str, + ) -> RPGProposalRecord: + self._validate_source_type(source_type) + operation_scope = f"session:{session_id}:proposals" + now = self._now() + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is not None: + self._ensure_replay_hash(replay, request_payload_hash) + proposal_id = int(self._from_json(self._row_value(replay, "response_json"))["proposal_id"]) + return self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + + session = self._get_session_with_conn(conn, owner_user_id, session_id) + if session.last_event_sequence != base_event_sequence: + raise RPGConflictError("stale_event_sequence") + if session.current_snapshot_version != base_snapshot_version: + raise RPGConflictError("stale_snapshot_version") + + cursor = conn.execute( + """ + INSERT INTO rpg_session_proposals ( + session_id, owner_user_id, base_event_sequence, base_snapshot_version, + proposed_events_json, patch_json, rationale, confidence, source_type, + source_actor_id, model_metadata_json, created_at + ) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?) + """, + ( + session_id, + owner_user_id, + base_event_sequence, + base_snapshot_version, + self._to_json(proposed_events), + source_type, + source_actor_id, + self._to_json(model_metadata), + now, + ), + ) + proposal_id = int(cursor.lastrowid) + self._insert_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + request_payload_hash=request_payload_hash, + event_ids=[], + response={"proposal_id": proposal_id}, + created_at=now, + ) + return self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + + def get_proposal(self, owner_user_id: int, proposal_id: int) -> RPGProposalRecord: + with self.db.transaction() as conn: + return self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + + def mark_proposal_applied( + self, + owner_user_id: int, + proposal_id: int, + review_notes: str | None, + ) -> RPGProposalRecord: + now = self._now() + with self.db.transaction() as conn: + proposal = self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + cursor = conn.execute( + """ + UPDATE rpg_session_proposals + SET status = 'applied', applied_at = ?, review_notes = COALESCE(?, review_notes) + WHERE id = ? AND owner_user_id = ? AND status = 'pending' + """, + (now, review_notes, proposal.id, owner_user_id), + ) + if cursor.rowcount != 1: + raise RPGConflictError("proposal_not_pending") + return self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + + def mark_proposal_rejected( + self, + owner_user_id: int, + proposal_id: int, + idempotency_key: str, + review_notes: str | None, + ) -> RPGProposalRecord: + now = self._now() + request_payload_hash = canonical_request_hash({"proposal_id": proposal_id, "review_notes": review_notes}) + operation_scope = f"proposal:{proposal_id}:reject" + with self.db.transaction() as conn: + proposal = self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=proposal.session_id, + source_type="user", + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is not None: + self._ensure_replay_hash(replay, request_payload_hash) + replayed_id = int(self._from_json(self._row_value(replay, "response_json"))["proposal_id"]) + return self._get_proposal_with_conn(conn, owner_user_id, replayed_id) + + cursor = conn.execute( + """ + UPDATE rpg_session_proposals + SET status = 'rejected', rejected_at = ?, review_notes = COALESCE(?, review_notes) + WHERE id = ? AND owner_user_id = ? AND status = 'pending' + """, + (now, review_notes, proposal.id, owner_user_id), + ) + if cursor.rowcount != 1: + raise RPGConflictError("proposal_not_pending") + self._insert_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=proposal.session_id, + source_type="user", + operation_scope=operation_scope, + idempotency_key=idempotency_key, + request_payload_hash=request_payload_hash, + event_ids=[], + response={"proposal_id": proposal_id}, + created_at=now, + ) + return self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + + def mark_proposal_conflicted(self, owner_user_id: int, proposal_id: int) -> RPGProposalRecord: + with self.db.transaction() as conn: + proposal = self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + if proposal.status == "pending": + conn.execute( + """ + UPDATE rpg_session_proposals + SET status = 'conflicted' + WHERE id = ? AND owner_user_id = ? AND status = 'pending' + """, + (proposal_id, owner_user_id), + ) + return self._get_proposal_with_conn(conn, owner_user_id, proposal_id) + + def commit_events_and_snapshot( + self, + owner_user_id: int, + session_id: int, + expected_last_event_sequence: int, + base_snapshot_version: int, + events: list[dict[str, Any]], + snapshot: dict[str, Any], + diagnostics: dict[str, Any], + idempotency_key: str, + request_payload_hash: str, + adapter_key: str, + adapter_version: str, + proposal_id: int | None, + proposal_review_notes: str | None = None, + ) -> CommitEventsResult: + if not events: + raise RPGConflictError("events_required") + source_type = self._event_source_type(events) + operation_scope = f"proposal:{proposal_id}:apply" if proposal_id is not None else f"session:{session_id}:events" + now = self._now() + try: + with self.db.transaction() as conn: + replay = self._find_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + ) + if replay is not None: + self._ensure_replay_hash(replay, request_payload_hash) + event_ids = self._from_json(self._row_value(replay, "event_ids_json")) + return CommitEventsResult( + events=self._events_by_ids(conn, owner_user_id, [int(event_id) for event_id in event_ids]), + replayed=True, + ) + + session = self._get_session_with_conn(conn, owner_user_id, session_id) + if session.last_event_sequence != expected_last_event_sequence: + raise RPGConflictError("stale_event_sequence") + if session.current_snapshot_version != base_snapshot_version: + raise RPGConflictError("stale_snapshot_version") + + event_ids: list[int] = [] + for offset, event in enumerate(events): + event_source_type = event["source_type"] + self._validate_source_type(event_source_type) + cursor = conn.execute( + """ + INSERT INTO rpg_session_events ( + session_id, owner_user_id, sequence_number, event_type, event_payload_json, + source_type, source_actor_id, source_label, operation_id, + event_schema_version, adapter_key, adapter_version, proposal_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?) + """, + ( + session_id, + owner_user_id, + expected_last_event_sequence + offset + 1, + event["event_type"], + self._to_json(event["event_payload"]), + event_source_type, + event.get("source_actor_id"), + event.get("source_label"), + event.get("event_schema_version", RPG_EVENT_SCHEMA_VERSION), + adapter_key, + adapter_version, + proposal_id, + now, + ), + ) + event_ids.append(int(cursor.lastrowid)) + + next_sequence = expected_last_event_sequence + len(events) + next_snapshot_version = base_snapshot_version + 1 + conn.execute( + """ + INSERT INTO rpg_session_snapshots ( + session_id, owner_user_id, snapshot_version, last_event_sequence, + reducer_version, snapshot_schema_version, snapshot_json, diagnostics_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + session_id, + owner_user_id, + next_snapshot_version, + next_sequence, + RPG_REDUCER_VERSION, + RPG_SNAPSHOT_SCHEMA_VERSION, + self._to_json(snapshot), + self._to_json(diagnostics), + now, + ), + ) + update_cursor = conn.execute( + """ + UPDATE rpg_sessions + SET last_event_sequence = ?, current_snapshot_version = ?, + version = version + 1, updated_at = ? + WHERE id = ? AND owner_user_id = ? + AND last_event_sequence = ? AND current_snapshot_version = ? + """, + ( + next_sequence, + next_snapshot_version, + now, + session_id, + owner_user_id, + expected_last_event_sequence, + base_snapshot_version, + ), + ) + if update_cursor.rowcount != 1: + raise RPGConflictError("stale_session_cursor") + if proposal_id is not None: + proposal_cursor = conn.execute( + """ + UPDATE rpg_session_proposals + SET status = 'applied', applied_at = ?, review_notes = COALESCE(?, review_notes) + WHERE id = ? AND owner_user_id = ? AND session_id = ? AND status = 'pending' + """, + (now, proposal_review_notes, proposal_id, owner_user_id, session_id), + ) + if proposal_cursor.rowcount != 1: + raise RPGConflictError("proposal_not_pending") + + operation_id = self._insert_idempotency_record( + conn, + owner_user_id=owner_user_id, + session_id=session_id, + source_type=source_type, + operation_scope=operation_scope, + idempotency_key=idempotency_key, + request_payload_hash=request_payload_hash, + event_ids=event_ids, + response={"event_ids": event_ids}, + created_at=now, + ) + self._set_event_operation_id(conn, operation_id, event_ids) + return CommitEventsResult( + events=self._events_by_ids(conn, owner_user_id, event_ids), + replayed=False, + ) + except sqlite3.IntegrityError as exc: + raise RPGConflictError("rpg_event_append_conflict") from exc + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + @staticmethod + def _to_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + @staticmethod + def _from_json(value: str | None, default: Any = None) -> Any: + if value is None: + return default + return json.loads(value) + + @staticmethod + def _parse_datetime(value: str) -> datetime: + return datetime.fromisoformat(value) + + @classmethod + def _parse_optional_datetime(cls, value: str | None) -> datetime | None: + if value is None: + return None + return cls._parse_datetime(value) + + @staticmethod + def _row_value(row: Any, key: str) -> Any: + if isinstance(row, dict): + return row[key] + return row[key] + + @classmethod + def _campaign_from_row(cls, row: Any) -> RPGCampaign: + return RPGCampaign( + id=int(cls._row_value(row, "id")), + owner_user_id=int(cls._row_value(row, "owner_user_id")), + title=str(cls._row_value(row, "title")), + description=cls._row_value(row, "description"), + default_adapter_key=str(cls._row_value(row, "default_adapter_key")), + default_adapter_version=str(cls._row_value(row, "default_adapter_version")), + settings=cls._from_json(cls._row_value(row, "settings_json"), {}), + linked_rules_pack_refs=cls._from_json(cls._row_value(row, "linked_rules_pack_refs_json"), []), + version=int(cls._row_value(row, "version")), + status=str(cls._row_value(row, "status")), + created_at=cls._parse_datetime(str(cls._row_value(row, "created_at"))), + updated_at=cls._parse_datetime(str(cls._row_value(row, "updated_at"))), + ) + + @classmethod + def _session_from_row(cls, row: Any) -> RPGSession: + return RPGSession( + id=int(cls._row_value(row, "id")), + campaign_id=int(cls._row_value(row, "campaign_id")), + owner_user_id=int(cls._row_value(row, "owner_user_id")), + title=str(cls._row_value(row, "title")), + status=str(cls._row_value(row, "status")), + adapter_key=str(cls._row_value(row, "adapter_key")), + adapter_version=str(cls._row_value(row, "adapter_version")), + authority_settings=cls._from_json(cls._row_value(row, "authority_settings_json"), {}), + linked_chat_id=cls._row_value(row, "linked_chat_id"), + active_rules_pack_refs=cls._from_json(cls._row_value(row, "active_rules_pack_refs_json"), []), + current_snapshot_version=int(cls._row_value(row, "current_snapshot_version")), + last_event_sequence=int(cls._row_value(row, "last_event_sequence")), + version=int(cls._row_value(row, "version")), + created_at=cls._parse_datetime(str(cls._row_value(row, "created_at"))), + updated_at=cls._parse_datetime(str(cls._row_value(row, "updated_at"))), + ) + + @classmethod + def _event_from_row(cls, row: Any) -> RPGSessionEvent: + return RPGSessionEvent( + id=int(cls._row_value(row, "id")), + session_id=int(cls._row_value(row, "session_id")), + owner_user_id=int(cls._row_value(row, "owner_user_id")), + sequence_number=int(cls._row_value(row, "sequence_number")), + event_type=str(cls._row_value(row, "event_type")), + event_payload=cls._from_json(cls._row_value(row, "event_payload_json"), {}), + source_type=cast(RPGSourceType, cls._row_value(row, "source_type")), + source_actor_id=cls._row_value(row, "source_actor_id"), + source_label=cls._row_value(row, "source_label"), + operation_id=cls._row_value(row, "operation_id"), + event_schema_version=str(cls._row_value(row, "event_schema_version")), + adapter_key=str(cls._row_value(row, "adapter_key")), + adapter_version=str(cls._row_value(row, "adapter_version")), + proposal_id=cls._row_value(row, "proposal_id"), + created_at=cls._parse_datetime(str(cls._row_value(row, "created_at"))), + ) + + @classmethod + def _snapshot_from_row(cls, row: Any) -> RPGSnapshotRecord: + return RPGSnapshotRecord( + id=int(cls._row_value(row, "id")), + session_id=int(cls._row_value(row, "session_id")), + owner_user_id=int(cls._row_value(row, "owner_user_id")), + snapshot_version=int(cls._row_value(row, "snapshot_version")), + last_event_sequence=int(cls._row_value(row, "last_event_sequence")), + reducer_version=str(cls._row_value(row, "reducer_version")), + snapshot_schema_version=str(cls._row_value(row, "snapshot_schema_version")), + snapshot_json=cls._from_json(cls._row_value(row, "snapshot_json"), {}), + diagnostics_json=cls._from_json(cls._row_value(row, "diagnostics_json"), {}), + created_at=cls._parse_datetime(str(cls._row_value(row, "created_at"))), + ) + + @classmethod + def _proposal_from_row(cls, row: Any) -> RPGProposalRecord: + return RPGProposalRecord( + id=int(cls._row_value(row, "id")), + session_id=int(cls._row_value(row, "session_id")), + owner_user_id=int(cls._row_value(row, "owner_user_id")), + base_event_sequence=int(cls._row_value(row, "base_event_sequence")), + base_snapshot_version=int(cls._row_value(row, "base_snapshot_version")), + proposed_events=cls._from_json(cls._row_value(row, "proposed_events_json"), []), + patch=cls._from_json(cls._row_value(row, "patch_json"), None), + rationale=cls._row_value(row, "rationale"), + confidence=cls._row_value(row, "confidence"), + source_type=str(cls._row_value(row, "source_type")), + source_actor_id=cls._row_value(row, "source_actor_id"), + model_metadata=cls._from_json(cls._row_value(row, "model_metadata_json"), {}), + status=str(cls._row_value(row, "status")), + review_notes=cls._row_value(row, "review_notes"), + created_at=cls._parse_datetime(str(cls._row_value(row, "created_at"))), + applied_at=cls._parse_optional_datetime(cls._row_value(row, "applied_at")), + rejected_at=cls._parse_optional_datetime(cls._row_value(row, "rejected_at")), + ) + + def _get_campaign_with_conn(self, conn: Any, owner_user_id: int, campaign_id: int) -> RPGCampaign: + row = conn.execute( + """ + SELECT * + FROM rpg_campaigns + WHERE id = ? AND owner_user_id = ? + """, + (campaign_id, owner_user_id), + ).fetchone() + if row is None: + raise RPGNotFoundError("rpg_campaign_not_found") + return self._campaign_from_row(row) + + def _get_session_with_conn(self, conn: Any, owner_user_id: int, session_id: int) -> RPGSession: + row = conn.execute( + """ + SELECT * + FROM rpg_sessions + WHERE id = ? AND owner_user_id = ? + """, + (session_id, owner_user_id), + ).fetchone() + if row is None: + raise RPGNotFoundError("rpg_session_not_found") + return self._session_from_row(row) + + def _get_event_with_conn(self, conn: Any, owner_user_id: int, event_id: int) -> RPGSessionEvent: + row = conn.execute( + """ + SELECT * + FROM rpg_session_events + WHERE id = ? AND owner_user_id = ? + """, + (event_id, owner_user_id), + ).fetchone() + if row is None: + raise RPGNotFoundError("rpg_event_not_found") + return self._event_from_row(row) + + def _get_proposal_with_conn(self, conn: Any, owner_user_id: int, proposal_id: int) -> RPGProposalRecord: + row = conn.execute( + """ + SELECT * + FROM rpg_session_proposals + WHERE id = ? AND owner_user_id = ? + """, + (proposal_id, owner_user_id), + ).fetchone() + if row is None: + raise RPGNotFoundError("rpg_proposal_not_found") + return self._proposal_from_row(row) + + def _events_by_ids(self, conn: Any, owner_user_id: int, event_ids: list[int]) -> list[RPGSessionEvent]: + return [self._get_event_with_conn(conn, owner_user_id, event_id) for event_id in event_ids] + + def _rules_pack_ref_replacement_result_from_response( + self, + row: Any, + *, + replayed: bool, + ) -> RulesPackRefReplacementResult: + response = self._from_json(self._row_value(row, "response_json"), {}) + stored_refs = response.get("refs", []) + refs: list[RulesPackRef] = [] + if isinstance(stored_refs, list): + refs = [rules_pack_ref_from_dict(ref) for ref in stored_refs if isinstance(ref, dict)] + return RulesPackRefReplacementResult( + refs=refs, + version=int(response.get("version", 0)), + replayed=replayed, + ) + + def _find_idempotency_record( + self, + conn: Any, + *, + owner_user_id: int, + session_id: int | None, + source_type: str, + operation_scope: str, + idempotency_key: str, + ) -> Any | None: + if session_id is None: + return conn.execute( + """ + SELECT * + FROM rpg_idempotency_records + WHERE owner_user_id = ? + AND source_type = ? + AND operation_scope = ? + AND idempotency_key = ? + """, + (owner_user_id, source_type, operation_scope, idempotency_key), + ).fetchone() + return conn.execute( + """ + SELECT * + FROM rpg_idempotency_records + WHERE owner_user_id = ? + AND source_type = ? + AND operation_scope = ? + AND idempotency_key = ? + AND session_id = ? + """, + (owner_user_id, source_type, operation_scope, idempotency_key, session_id), + ).fetchone() + + def _insert_idempotency_record( + self, + conn: Any, + *, + owner_user_id: int, + session_id: int | None, + source_type: str, + operation_scope: str, + idempotency_key: str, + request_payload_hash: str, + event_ids: list[int], + response: dict[str, Any], + created_at: str, + ) -> int: + cursor = conn.execute( + """ + INSERT INTO rpg_idempotency_records ( + owner_user_id, session_id, source_type, operation_scope, idempotency_key, + request_payload_hash, event_ids_json, response_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + owner_user_id, + session_id, + source_type, + operation_scope, + idempotency_key, + request_payload_hash, + self._to_json(event_ids), + self._to_json(response), + created_at, + ), + ) + return int(cursor.lastrowid) + + @staticmethod + def _ensure_replay_hash(row: Any, request_payload_hash: str) -> None: + row_hash = row["request_payload_hash"] if isinstance(row, dict) else row["request_payload_hash"] + if row_hash != request_payload_hash: + raise RPGConflictError("idempotency_key_conflict") + + def _set_event_operation_id(self, conn: Any, operation_id: int, event_ids: list[int]) -> None: + for event_id in event_ids: + conn.execute( + "UPDATE rpg_session_events SET operation_id = ? WHERE id = ?", + (operation_id, event_id), + ) + + @staticmethod + def _validate_source_type(source_type: str) -> None: + if source_type not in RPG_SOURCE_TYPES: + raise RPGValidationError("invalid_source_type") + + def _event_source_type(self, events: list[dict[str, Any]]) -> str: + source_type = str(events[0]["source_type"]) + self._validate_source_type(source_type) + for event in events[1:]: + if event["source_type"] != source_type: + raise RPGConflictError("mixed_event_sources") + return source_type diff --git a/tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py b/tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py new file mode 100644 index 0000000000..41603570dc --- /dev/null +++ b/tldw_Server_API/app/core/MCP_unified/modules/implementations/rpg_module.py @@ -0,0 +1,943 @@ +"""MCP tools for the generic RPG campaign/session runtime.""" + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from datetime import datetime +from typing import Any + +from loguru import logger + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.Collections_DB import CollectionsDatabase +from tldw_Server_API.app.core.DB_Management.media_db.native_class import MediaDatabase +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RAG.rag_service.database_retrievers import MultiDatabaseRetriever +from tldw_Server_API.app.core.RPG.errors import RPGError +from tldw_Server_API.app.core.RPG.rules.adapters import build_default_adapter_registry +from tldw_Server_API.app.core.RPG.rules.answering import ChatRulesAnswerGenerator, RulesAnswerOptions +from tldw_Server_API.app.core.RPG.rules.lookup import RulesLookupService +from tldw_Server_API.app.core.RPG.rules.refs import RulesPackRef +from tldw_Server_API.app.core.RPG.rules.retrieval import RulesRetrievalAdapter +from tldw_Server_API.app.core.RPG.rules.source_validation import RPGRulesSourceValidator +from tldw_Server_API.app.core.RPG.service import RPGService + +from ..base import BaseModule, ModuleConfig, create_tool_definition + +_TOOL_ADAPTERS_LIST = "rpg.adapters.list" +_TOOL_SESSIONS_GET = "rpg.sessions.get" +_TOOL_CAMPAIGN_RULES_PACKS_GET = "rpg.campaigns.rules_packs.get" +_TOOL_CAMPAIGN_RULES_PACKS_REPLACE = "rpg.campaigns.rules_packs.replace" +_TOOL_SESSION_RULES_PACKS_GET = "rpg.sessions.rules_packs.get" +_TOOL_SESSION_RULES_PACKS_REPLACE = "rpg.sessions.rules_packs.replace" +_TOOL_RULES_LOOKUP = "rpg.rules.lookup" +_TOOL_CONTEXT_BUILD = "rpg.context.build" +_TOOL_EVENTS_RECORD = "rpg.events.record" +_TOOL_PROPOSALS_APPLY = "rpg.proposals.apply" +_TOOL_PROPOSALS_REJECT = "rpg.proposals.reject" + +_PERM_RPG_CAMPAIGNS_READ = "rpg.campaigns.read" +_PERM_RPG_CAMPAIGNS_MANAGE = "rpg.campaigns.manage" +_PERM_RPG_SESSIONS_READ = "rpg.sessions.read" +_PERM_RPG_SESSIONS_MANAGE = "rpg.sessions.manage" +_PERM_RPG_PROPOSALS_REVIEW = "rpg.proposals.review" +_PERM_RPG_RULES_READ = "rpg.rules.read" +_PERM_MEDIA_READ = "media.read" +_PERM_CHAT_COMPLETIONS = "chat.completions" + +_READ_TOOL_NAMES = { + _TOOL_ADAPTERS_LIST, + _TOOL_SESSIONS_GET, + _TOOL_CAMPAIGN_RULES_PACKS_GET, + _TOOL_SESSION_RULES_PACKS_GET, + _TOOL_RULES_LOOKUP, + _TOOL_CONTEXT_BUILD, +} +_WRITE_TOOL_NAMES = { + _TOOL_CAMPAIGN_RULES_PACKS_REPLACE, + _TOOL_SESSION_RULES_PACKS_REPLACE, + _TOOL_EVENTS_RECORD, + _TOOL_PROPOSALS_APPLY, + _TOOL_PROPOSALS_REJECT, +} +_ALL_TOOL_NAMES = _READ_TOOL_NAMES | _WRITE_TOOL_NAMES +_MAX_CONTEXT_CHARS = 24000 +_MIN_CONTEXT_CHARS = 1000 +_MAX_IDEMPOTENCY_KEY_CHARS = 256 +_MAX_QUERY_CHARS = 500 +_MAX_REVIEW_NOTES_CHARS = 2000 +_MAX_RULES_PACK_REFS = 50 +_MAX_PROVIDER_CHARS = 100 +_MAX_MODEL_CHARS = 200 +_MIN_ANSWER_TOKENS = 64 +_MAX_ANSWER_TOKENS = 2000 +_DEFAULT_ANSWER_TOKENS = 600 +_DEFAULT_ANSWER_TEMPERATURE = 0.2 +_MAX_ANSWER_TEMPERATURE = 2.0 +_ANSWER_GENERATION_CONTROLS_METADATA = "mcp_rpg_answer_generation_controls" + +_TOOL_REQUIRED_PERMISSIONS = { + _TOOL_SESSIONS_GET: [_PERM_RPG_SESSIONS_READ], + _TOOL_CAMPAIGN_RULES_PACKS_GET: [_PERM_RPG_CAMPAIGNS_READ, _PERM_MEDIA_READ], + _TOOL_CAMPAIGN_RULES_PACKS_REPLACE: [_PERM_RPG_CAMPAIGNS_MANAGE, _PERM_MEDIA_READ], + _TOOL_SESSION_RULES_PACKS_GET: [_PERM_RPG_SESSIONS_READ, _PERM_MEDIA_READ], + _TOOL_SESSION_RULES_PACKS_REPLACE: [_PERM_RPG_SESSIONS_MANAGE, _PERM_MEDIA_READ], + _TOOL_RULES_LOOKUP: [_PERM_RPG_RULES_READ, _PERM_MEDIA_READ], + _TOOL_CONTEXT_BUILD: [_PERM_RPG_SESSIONS_READ, _PERM_RPG_RULES_READ, _PERM_MEDIA_READ], + _TOOL_EVENTS_RECORD: [_PERM_RPG_SESSIONS_MANAGE], + _TOOL_PROPOSALS_APPLY: [_PERM_RPG_PROPOSALS_REVIEW], + _TOOL_PROPOSALS_REJECT: [_PERM_RPG_PROPOSALS_REVIEW], +} + + +class RPGModule(BaseModule): + """Expose RPG runtime orchestration tools through MCP Unified.""" + + def __init__(self, config: ModuleConfig) -> None: + super().__init__(config) + + async def on_initialize(self) -> None: + logger.info("Initializing RPG MCP module: {}", self.name) + + async def on_shutdown(self) -> None: + logger.info("Shutting down RPG MCP module: {}", self.name) + + async def check_health(self) -> dict[str, bool]: + return {"initialized": True, "adapter_registry": True} + + async def get_tools(self) -> list[dict[str, Any]]: + return [ + self._strict_tool( + name=_TOOL_ADAPTERS_LIST, + description="List bundled RPG rules adapters.", + parameters={"properties": {}, "required": []}, + metadata={"category": "retrieval", "readOnlyHint": True}, + ), + self._strict_tool( + name=_TOOL_SESSIONS_GET, + description="Get RPG session metadata and the current deterministic snapshot.", + parameters={ + "properties": { + "session_id": {"type": "integer", "minimum": 1}, + }, + "required": ["session_id"], + }, + metadata=self._read_metadata(required_permissions=[_PERM_RPG_SESSIONS_READ]), + ), + self._strict_tool( + name=_TOOL_CAMPAIGN_RULES_PACKS_GET, + description="Get rules-pack references attached to an RPG campaign.", + parameters={ + "properties": { + "campaign_id": {"type": "integer", "minimum": 1}, + }, + "required": ["campaign_id"], + }, + metadata=self._read_metadata(required_permissions=[_PERM_RPG_CAMPAIGNS_READ, _PERM_MEDIA_READ]), + ), + self._strict_tool( + name=_TOOL_CAMPAIGN_RULES_PACKS_REPLACE, + description="Replace the full rules-pack reference list attached to an RPG campaign.", + parameters={ + "properties": { + "campaign_id": {"type": "integer", "minimum": 1}, + "expected_version": {"type": "integer", "minimum": 1}, + "refs": { + "type": "array", + "items": self._rules_pack_ref_schema(), + "maxItems": _MAX_RULES_PACK_REFS, + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + }, + "required": ["campaign_id", "expected_version", "refs"], + }, + metadata=self._write_metadata( + required_permissions=[_PERM_RPG_CAMPAIGNS_MANAGE, _PERM_MEDIA_READ], + ), + ), + self._strict_tool( + name=_TOOL_SESSION_RULES_PACKS_GET, + description="Get rules-pack references attached to an RPG session.", + parameters={ + "properties": { + "session_id": {"type": "integer", "minimum": 1}, + }, + "required": ["session_id"], + }, + metadata=self._read_metadata(required_permissions=[_PERM_RPG_SESSIONS_READ, _PERM_MEDIA_READ]), + ), + self._strict_tool( + name=_TOOL_SESSION_RULES_PACKS_REPLACE, + description="Replace the full rules-pack reference list attached to an RPG session.", + parameters={ + "properties": { + "session_id": {"type": "integer", "minimum": 1}, + "expected_version": {"type": "integer", "minimum": 1}, + "refs": { + "type": "array", + "items": self._rules_pack_ref_schema(), + "maxItems": _MAX_RULES_PACK_REFS, + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + }, + "required": ["session_id", "expected_version", "refs"], + }, + metadata=self._write_metadata( + required_permissions=[_PERM_RPG_SESSIONS_MANAGE, _PERM_MEDIA_READ], + ), + ), + self._strict_tool( + name=_TOOL_RULES_LOOKUP, + description="Look up cited RPG rules references or generate a grounded answer for a session.", + parameters={ + "properties": { + "session_id": {"type": "integer", "minimum": 1}, + "query": {"type": "string", "minLength": 1, "maxLength": _MAX_QUERY_CHARS}, + "mode": {"type": "string", "enum": ["lookup", "answer"], "default": "lookup"}, + "provider": {"type": "string", "maxLength": _MAX_PROVIDER_CHARS}, + "model": {"type": "string", "maxLength": _MAX_MODEL_CHARS}, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": _MAX_ANSWER_TEMPERATURE, + "default": _DEFAULT_ANSWER_TEMPERATURE, + }, + "max_tokens": { + "type": "integer", + "minimum": _MIN_ANSWER_TOKENS, + "maximum": _MAX_ANSWER_TOKENS, + "default": _DEFAULT_ANSWER_TOKENS, + }, + }, + "required": ["session_id", "query"], + }, + metadata=self._read_metadata(required_permissions=[_PERM_RPG_RULES_READ, _PERM_MEDIA_READ]), + ), + self._strict_tool( + name=_TOOL_CONTEXT_BUILD, + description="Build bounded RPG session context with citation diagnostics.", + parameters={ + "properties": { + "session_id": {"type": "integer", "minimum": 1}, + "query": {"type": "string", "maxLength": _MAX_QUERY_CHARS}, + "max_chars": { + "type": "integer", + "minimum": _MIN_CONTEXT_CHARS, + "maximum": _MAX_CONTEXT_CHARS, + }, + }, + "required": ["session_id"], + }, + metadata=self._read_metadata( + required_permissions=[_PERM_RPG_SESSIONS_READ, _PERM_RPG_RULES_READ, _PERM_MEDIA_READ], + ), + ), + self._strict_tool( + name=_TOOL_EVENTS_RECORD, + description="Record trusted MCP-origin RPG session events or create a proposal by authority policy.", + parameters={ + "properties": { + "session_id": {"type": "integer", "minimum": 1}, + "expected_last_event_sequence": {"type": "integer", "minimum": 0}, + "events": {"type": "array", "items": {"type": "object"}, "minItems": 1}, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + }, + "required": ["session_id", "expected_last_event_sequence", "events"], + }, + metadata=self._write_metadata(required_permissions=[_PERM_RPG_SESSIONS_MANAGE]), + ), + self._strict_tool( + name=_TOOL_PROPOSALS_APPLY, + description="Apply a pending RPG proposal atomically.", + parameters={ + "properties": { + "session_id": {"type": "integer", "minimum": 1}, + "proposal_id": {"type": "integer", "minimum": 1}, + "expected_last_event_sequence": {"type": "integer", "minimum": 0}, + "review_notes": {"type": "string", "maxLength": _MAX_REVIEW_NOTES_CHARS}, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + }, + "required": ["session_id", "proposal_id", "expected_last_event_sequence"], + }, + metadata=self._write_metadata(required_permissions=[_PERM_RPG_PROPOSALS_REVIEW]), + ), + self._strict_tool( + name=_TOOL_PROPOSALS_REJECT, + description="Reject a pending RPG proposal.", + parameters={ + "properties": { + "session_id": {"type": "integer", "minimum": 1}, + "proposal_id": {"type": "integer", "minimum": 1}, + "review_notes": {"type": "string", "maxLength": _MAX_REVIEW_NOTES_CHARS}, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_IDEMPOTENCY_KEY_CHARS, + }, + }, + "required": ["session_id", "proposal_id"], + }, + metadata=self._write_metadata(required_permissions=[_PERM_RPG_PROPOSALS_REVIEW]), + ), + ] + + async def execute_tool( + self, + tool_name: str, + arguments: dict[str, Any], + context: Any | None = None, + ) -> Any: + if tool_name == _TOOL_ADAPTERS_LIST: + return self._list_adapters() + if tool_name not in _ALL_TOOL_NAMES: + raise ValueError(f"Unknown RPG tool: {tool_name}") + + args = arguments or {} + if tool_name in _WRITE_TOOL_NAMES: + self.validate_tool_arguments(tool_name, args) + self._required_idempotency_key(args) + else: + self._validate_read_arguments(tool_name, args) + rules_pack_refs = ( + self._rules_pack_refs_arg(args) + if tool_name in {_TOOL_CAMPAIGN_RULES_PACKS_REPLACE, _TOOL_SESSION_RULES_PACKS_REPLACE} + else None + ) + self._require_context_permissions(tool_name, args, context) + service, closeables = self._service_for_context( + context, + include_rules_retrieval=tool_name in {_TOOL_RULES_LOOKUP, _TOOL_CONTEXT_BUILD}, + include_rules_source_validation=tool_name in { + _TOOL_CAMPAIGN_RULES_PACKS_REPLACE, + _TOOL_SESSION_RULES_PACKS_REPLACE, + }, + require_media_db=self._has_enabled_rules_pack_refs(rules_pack_refs), + ) + try: + if tool_name == _TOOL_SESSIONS_GET: + return self._get_session(service, self._positive_int_arg(args, "session_id")) + if tool_name == _TOOL_CAMPAIGN_RULES_PACKS_GET: + return _to_jsonable( + service.list_campaign_rules_pack_refs( + self._positive_int_arg(args, "campaign_id"), + ) + ) + if tool_name == _TOOL_CAMPAIGN_RULES_PACKS_REPLACE: + return _to_jsonable( + await service.replace_campaign_rules_pack_refs( + campaign_id=self._positive_int_arg(args, "campaign_id"), + refs=rules_pack_refs or [], + expected_version=self._positive_int_arg(args, "expected_version"), + idempotency_key=self._required_idempotency_key(args), + source_type="mcp", + ) + ) + if tool_name == _TOOL_SESSION_RULES_PACKS_GET: + return _to_jsonable( + service.list_session_rules_pack_refs( + self._positive_int_arg(args, "session_id"), + ) + ) + if tool_name == _TOOL_SESSION_RULES_PACKS_REPLACE: + return _to_jsonable( + await service.replace_session_rules_pack_refs( + session_id=self._positive_int_arg(args, "session_id"), + refs=rules_pack_refs or [], + expected_version=self._positive_int_arg(args, "expected_version"), + idempotency_key=self._required_idempotency_key(args), + source_type="mcp", + ) + ) + if tool_name == _TOOL_RULES_LOOKUP: + return _to_jsonable( + await service.lookup_rules( + session_id=self._positive_int_arg(args, "session_id"), + query=self._str_arg(args, "query", max_chars=_MAX_QUERY_CHARS), + mode=self._lookup_mode_arg(args), + answer_options=self._answer_options_arg(args), + ) + ) + if tool_name == _TOOL_CONTEXT_BUILD: + return _to_jsonable( + await service.build_context( + session_id=self._positive_int_arg(args, "session_id"), + query=self._optional_str_arg(args, "query", max_chars=_MAX_QUERY_CHARS), + max_chars=self._optional_bounded_int_arg( + args, + "max_chars", + _MAX_CONTEXT_CHARS, + min_value=_MIN_CONTEXT_CHARS, + max_value=_MAX_CONTEXT_CHARS, + ), + ) + ) + if tool_name == _TOOL_EVENTS_RECORD: + result = service.record_events( + session_id=self._positive_int_arg(args, "session_id"), + events=self._events_arg(args), + source_type="mcp", + expected_last_event_sequence=self._non_negative_int_arg(args, "expected_last_event_sequence"), + idempotency_key=self._required_idempotency_key(args), + ) + return { + "committed_events": [_to_jsonable(event) for event in result.committed_events], + "proposal": _to_jsonable(result.proposal) if result.proposal is not None else None, + } + if tool_name == _TOOL_PROPOSALS_APPLY: + result = service.apply_proposal( + session_id=self._positive_int_arg(args, "session_id"), + proposal_id=self._positive_int_arg(args, "proposal_id"), + expected_last_event_sequence=self._non_negative_int_arg(args, "expected_last_event_sequence"), + idempotency_key=self._required_idempotency_key(args), + review_notes=self._optional_str_arg(args, "review_notes", max_chars=_MAX_REVIEW_NOTES_CHARS), + ) + return { + "committed_events": [_to_jsonable(event) for event in result.committed_events], + "proposal": _to_jsonable(result.proposal) if result.proposal is not None else None, + } + if tool_name == _TOOL_PROPOSALS_REJECT: + return _to_jsonable( + service.reject_proposal( + session_id=self._positive_int_arg(args, "session_id"), + proposal_id=self._positive_int_arg(args, "proposal_id"), + idempotency_key=self._required_idempotency_key(args), + review_notes=self._optional_str_arg(args, "review_notes", max_chars=_MAX_REVIEW_NOTES_CHARS), + ) + ) + finally: + for closeable in reversed(closeables): + self._close_db(closeable) + raise ValueError(f"Unknown RPG tool: {tool_name}") + + def validate_tool_arguments(self, tool_name: str, arguments: dict[str, Any]) -> None: + if tool_name not in _WRITE_TOOL_NAMES: + return + args = arguments or {} + if tool_name == _TOOL_CAMPAIGN_RULES_PACKS_REPLACE: + self._positive_int_arg(args, "campaign_id") + self._positive_int_arg(args, "expected_version") + self._rules_pack_refs_arg(args) + if tool_name == _TOOL_SESSION_RULES_PACKS_REPLACE: + self._positive_int_arg(args, "session_id") + self._positive_int_arg(args, "expected_version") + self._rules_pack_refs_arg(args) + if tool_name == _TOOL_EVENTS_RECORD and not self._events_arg(args): + raise ValueError("events must contain at least one event") + if tool_name in {_TOOL_EVENTS_RECORD, _TOOL_PROPOSALS_APPLY}: + self._non_negative_int_arg(args, "expected_last_event_sequence") + if tool_name in {_TOOL_EVENTS_RECORD, _TOOL_PROPOSALS_APPLY, _TOOL_PROPOSALS_REJECT}: + self._positive_int_arg(args, "session_id") + if tool_name in {_TOOL_PROPOSALS_APPLY, _TOOL_PROPOSALS_REJECT}: + self._positive_int_arg(args, "proposal_id") + if tool_name in {_TOOL_PROPOSALS_APPLY, _TOOL_PROPOSALS_REJECT}: + self._optional_str_arg(args, "review_notes", max_chars=_MAX_REVIEW_NOTES_CHARS) + + def _validate_read_arguments(self, tool_name: str, arguments: dict[str, Any]) -> None: + if tool_name == _TOOL_SESSIONS_GET: + self._positive_int_arg(arguments, "session_id") + elif tool_name == _TOOL_CAMPAIGN_RULES_PACKS_GET: + self._positive_int_arg(arguments, "campaign_id") + elif tool_name == _TOOL_SESSION_RULES_PACKS_GET: + self._positive_int_arg(arguments, "session_id") + elif tool_name == _TOOL_RULES_LOOKUP: + self._positive_int_arg(arguments, "session_id") + self._str_arg(arguments, "query", max_chars=_MAX_QUERY_CHARS) + self._lookup_mode_arg(arguments) + self._answer_options_arg(arguments) + elif tool_name == _TOOL_CONTEXT_BUILD: + self._positive_int_arg(arguments, "session_id") + self._optional_str_arg(arguments, "query", max_chars=_MAX_QUERY_CHARS) + if arguments.get("max_chars") is not None: + self._optional_bounded_int_arg( + arguments, + "max_chars", + _MAX_CONTEXT_CHARS, + min_value=_MIN_CONTEXT_CHARS, + max_value=_MAX_CONTEXT_CHARS, + ) + + def _require_context_permissions( + self, + tool_name: str, + args: dict[str, Any], + context: Any | None, + ) -> None: + required = list(_TOOL_REQUIRED_PERMISSIONS.get(tool_name, ())) + if tool_name == _TOOL_RULES_LOOKUP and self._lookup_mode_arg(args) == "answer": + required.append(_PERM_CHAT_COMPLETIONS) + if not required: + return + if context is None: + raise ValueError("RPG MCP tools require an authenticated user context") + + metadata = getattr(context, "metadata", None) + roles, permissions = self._context_role_and_permission_sets(metadata) + if "admin" in roles or "*" in permissions: + if tool_name == _TOOL_RULES_LOOKUP and self._lookup_mode_arg(args) == "answer": + self._require_answer_generation_controls(metadata) + return + + missing = [permission for permission in required if permission not in permissions] + if missing: + raise PermissionError(f"RPG MCP tool requires permissions: {', '.join(missing)}") + if tool_name == _TOOL_RULES_LOOKUP and self._lookup_mode_arg(args) == "answer": + self._require_answer_generation_controls(metadata) + + @staticmethod + def _context_role_and_permission_sets(metadata: Any) -> tuple[set[str], set[str]]: + if not isinstance(metadata, dict): + return set(), set() + return ( + _normalized_metadata_values(metadata.get("roles")), + _normalized_metadata_values(metadata.get("permissions")), + ) + + @staticmethod + def _require_answer_generation_controls(metadata: Any) -> None: + if not isinstance(metadata, dict): + raise PermissionError("RPG MCP answer mode requires host-enforced answer generation controls") + value = metadata.get(_ANSWER_GENERATION_CONTROLS_METADATA) + if value is True: + return + if isinstance(value, str) and value.strip().lower() in {"enforced", "true", "1", "yes"}: + return + raise PermissionError("RPG MCP answer mode requires host-enforced answer generation controls") + + def _service_for_context( + self, + context: Any | None, + *, + include_rules_retrieval: bool = False, + include_rules_source_validation: bool = False, + require_media_db: bool = False, + ) -> tuple[RPGService, list[Any]]: + if context is None or not str(getattr(context, "user_id", "") or "").strip(): + raise ValueError("RPG MCP tools require an authenticated user context") + db_paths = getattr(context, "db_paths", None) + if not isinstance(db_paths, dict) or not db_paths.get("chacha"): + raise ValueError("ChaChaNotes DB path not available in context") + try: + owner_user_id = int(str(context.user_id)) + except (TypeError, ValueError) as exc: + raise ValueError("RPG MCP tools require an integer user context") from exc + include_media = include_rules_retrieval or include_rules_source_validation + media_path = str(db_paths.get("media") or "").strip() if include_media else "" + if require_media_db and not media_path: + raise ValueError("Media DB path not available in context") + closeables: list[Any] = [] + rules_source_validator = None + rules_lookup_service = None + try: + db = CharactersRAGDB( + db_path=str(db_paths["chacha"]), + client_id=f"mcp_rpg_{self.config.name}", + ) + closeables.append(db) + if media_path: + media_db = MediaDatabase( + db_path=media_path, + client_id=str(owner_user_id), + ) + closeables.append(media_db) + collections_db = CollectionsDatabase.from_backend(owner_user_id, media_db.backend) + rules_source_validator = RPGRulesSourceValidator( + media_db=media_db, + collections_db=collections_db, + ) + if include_rules_retrieval: + rag_retriever = MultiDatabaseRetriever( + {"media_db": media_path}, + user_id=str(owner_user_id), + media_db=media_db, + ) + closeables.append(rag_retriever) + rules_lookup_service = RulesLookupService( + retriever=RulesRetrievalAdapter( + source_validator=rules_source_validator, + rag_retriever=rag_retriever, + ), + answer_generator=ChatRulesAnswerGenerator(), + ) + elif include_rules_retrieval: + rules_lookup_service = RulesLookupService( + retriever=_MissingMediaRulesRetriever(), + answer_generator=ChatRulesAnswerGenerator(), + ) + return ( + RPGService( + repo=RPGRepository.initialized(db), + owner_user_id=owner_user_id, + rules_source_validator=rules_source_validator, + rules_lookup_service=rules_lookup_service, + ), + closeables, + ) + except Exception: + for closeable in reversed(closeables): + self._close_db(closeable) + raise + + def _get_session(self, service: RPGService, session_id: int) -> dict[str, Any]: + session = service.repo.get_session(owner_user_id=service.owner_user_id, session_id=session_id) + snapshot = service.get_snapshot(session_id) + return { + "session": _to_jsonable(session), + "snapshot": { + "snapshot_version": snapshot.snapshot_version, + "last_event_sequence": snapshot.last_event_sequence, + "state": _to_jsonable(snapshot.snapshot), + "diagnostics": _to_jsonable(snapshot.diagnostics), + }, + } + + def _list_adapters(self) -> dict[str, Any]: + registry = build_default_adapter_registry() + return {"adapters": [_to_jsonable(info) for info in registry.list_infos()]} + + @staticmethod + def _strict_tool( + *, + name: str, + description: str, + parameters: dict[str, Any], + metadata: dict[str, Any], + ) -> dict[str, Any]: + tool = create_tool_definition( + name=name, + description=description, + parameters=parameters, + metadata=metadata, + ) + tool["inputSchema"]["additionalProperties"] = False + return tool + + @staticmethod + def _read_metadata(*, required_permissions: list[str] | None = None) -> dict[str, Any]: + metadata = { + "category": "retrieval", + "readOnlyHint": True, + "auth_required": True, + } + if required_permissions is not None: + metadata["required_permissions"] = list(required_permissions) + return metadata + + @staticmethod + def _write_metadata(*, required_permissions: list[str] | None = None) -> dict[str, Any]: + metadata = { + "category": "management", + "readOnlyHint": False, + "auth_required": True, + "is_write": True, + "mutates_state": True, + "requires_confirmation": True, + "agent_write_policy": "approval_required", + "governance_preflight_required": True, + "sensitive": True, + } + if required_permissions is not None: + metadata["required_permissions"] = list(required_permissions) + return metadata + + @staticmethod + def _rules_pack_ref_schema() -> dict[str, Any]: + return { + "type": "object", + "additionalProperties": False, + "properties": { + "source_type": {"type": "string", "enum": ["media_item", "media_collection"]}, + "source_id": {"type": "integer", "minimum": 1}, + "display_name": {"type": "string"}, + "enabled": {"type": "boolean", "default": True}, + "metadata": {"type": "object", "default": {}}, + }, + "required": ["source_type", "source_id"], + } + + @staticmethod + def _integer_arg(args: dict[str, Any], name: str) -> int: + value = args.get(name) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{name} must be an integer") + return value + + @classmethod + def _positive_int_arg(cls, args: dict[str, Any], name: str) -> int: + value = cls._integer_arg(args, name) + if value <= 0: + raise ValueError(f"{name} must be a positive integer") + return value + + @classmethod + def _non_negative_int_arg(cls, args: dict[str, Any], name: str) -> int: + value = cls._integer_arg(args, name) + if value < 0: + raise ValueError(f"{name} must be non-negative") + return value + + @staticmethod + def _optional_int_arg(args: dict[str, Any], name: str, default: int) -> int: + value = args.get(name) + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{name} must be an integer") + return value + + @classmethod + def _optional_bounded_int_arg( + cls, + args: dict[str, Any], + name: str, + default: int, + *, + min_value: int, + max_value: int, + ) -> int: + value = cls._optional_int_arg(args, name, default) + if value < min_value or value > max_value: + raise ValueError(f"{name} must be between {min_value} and {max_value}") + return value + + @staticmethod + def _optional_float_arg(args: dict[str, Any], name: str, default: float) -> float: + value = args.get(name) + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a number") + return float(value) + + @classmethod + def _optional_bounded_float_arg( + cls, + args: dict[str, Any], + name: str, + default: float, + *, + min_value: float, + max_value: float, + ) -> float: + value = cls._optional_float_arg(args, name, default) + if value < min_value or value > max_value: + raise ValueError(f"{name} must be between {min_value:g} and {max_value:g}") + return value + + @staticmethod + def _str_arg(args: dict[str, Any], name: str, *, max_chars: int | None = None) -> str: + value = args.get(name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} must be a non-empty string") + trimmed = value.strip() + if max_chars is not None and len(trimmed) > max_chars: + raise ValueError(f"{name} must be <= {max_chars} characters") + return trimmed + + @staticmethod + def _optional_str_arg(args: dict[str, Any], name: str, *, max_chars: int | None = None) -> str | None: + value = args.get(name) + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{name} must be a string") + trimmed = value.strip() + if not trimmed: + return None + if max_chars is not None and len(trimmed) > max_chars: + raise ValueError(f"{name} must be <= {max_chars} characters") + return trimmed + + @classmethod + def _lookup_mode_arg(cls, args: dict[str, Any]) -> str: + value = args.get("mode") + if value is None: + return "lookup" + if value not in {"lookup", "answer"}: + raise ValueError("mode must be lookup or answer") + return str(value) + + @classmethod + def _answer_options_arg(cls, args: dict[str, Any]) -> RulesAnswerOptions: + return RulesAnswerOptions( + provider=cls._optional_str_arg(args, "provider", max_chars=_MAX_PROVIDER_CHARS), + model=cls._optional_str_arg(args, "model", max_chars=_MAX_MODEL_CHARS), + temperature=cls._optional_bounded_float_arg( + args, + "temperature", + _DEFAULT_ANSWER_TEMPERATURE, + min_value=0.0, + max_value=_MAX_ANSWER_TEMPERATURE, + ), + max_tokens=cls._optional_bounded_int_arg( + args, + "max_tokens", + _DEFAULT_ANSWER_TOKENS, + min_value=_MIN_ANSWER_TOKENS, + max_value=_MAX_ANSWER_TOKENS, + ), + ) + + @classmethod + def _rules_pack_refs_arg(cls, args: dict[str, Any]) -> list[dict[str, Any]]: + refs = args.get("refs") + if not isinstance(refs, list): + raise ValueError("refs must be a list") + if len(refs) > _MAX_RULES_PACK_REFS: + raise ValueError(f"refs must contain at most {_MAX_RULES_PACK_REFS} items") + + normalized: list[dict[str, Any]] = [] + for index, ref in enumerate(refs): + if not isinstance(ref, dict): + raise ValueError(f"refs[{index}] must be an object") + source_type = ref.get("source_type") + if source_type not in {"media_item", "media_collection"}: + raise ValueError(f"refs[{index}].source_type must be media_item or media_collection") + source_id = cls._positive_int_arg(ref, "source_id") + enabled = ref.get("enabled", True) + if not isinstance(enabled, bool): + raise ValueError(f"refs[{index}].enabled must be a boolean") + metadata = ref.get("metadata", {}) + if metadata is None: + metadata = {} + if not isinstance(metadata, dict): + raise ValueError(f"refs[{index}].metadata must be an object") + item: dict[str, Any] = { + "source_type": source_type, + "source_id": source_id, + "enabled": enabled, + "metadata": dict(metadata), + } + display_name = ref.get("display_name") + if display_name is not None: + if not isinstance(display_name, str): + raise ValueError(f"refs[{index}].display_name must be a string") + item["display_name"] = display_name.strip() + normalized.append(item) + return normalized + + @staticmethod + def _has_enabled_rules_pack_refs(refs: list[dict[str, Any]] | None) -> bool: + if not refs: + return False + return any(ref.get("enabled", True) is True for ref in refs) + + @staticmethod + def _events_arg(args: dict[str, Any]) -> list[dict[str, Any]]: + events = args.get("events") + if not isinstance(events, list) or not events: + raise ValueError("events must contain at least one event") + if not all(isinstance(event, dict) for event in events): + raise ValueError("events must be objects") + return [dict(event) for event in events] + + @staticmethod + def _idempotency_key(args: dict[str, Any]) -> str: + value = args.get("idempotencyKey") + if value is None: + value = args.get("idempotency_key") + if not isinstance(value, str) or not value.strip(): + return "" + return value.strip() + + @classmethod + def _required_idempotency_key(cls, args: dict[str, Any]) -> str: + key = cls._idempotency_key(args) + if not key: + raise ValueError("idempotencyKey is required") + if len(key) > _MAX_IDEMPOTENCY_KEY_CHARS: + raise ValueError(f"idempotencyKey must be <= {_MAX_IDEMPOTENCY_KEY_CHARS} characters") + return key + + @staticmethod + def _close_db(db: Any) -> None: + close_all = getattr(db, "close_all_connections", None) + if callable(close_all): + close_all() + return + close_connection = getattr(db, "close_connection", None) + if callable(close_connection): + close_connection() + return + close = getattr(db, "close", None) + if callable(close): + close() + + +class _MissingMediaRulesRetriever: + async def retrieve( + self, + *, + owner_user_id: int, + query: str, + refs: list[RulesPackRef], + max_results: int, + ) -> Any: + del owner_user_id, query, refs, max_results + raise _MissingMediaRulesAccessError("Media DB path not available in context") + + +class _MissingMediaRulesAccessError(ValueError, RPGError): + pass + + +def _normalized_metadata_values(value: Any) -> set[str]: + if value is None: + return set() + if isinstance(value, str): + return {value.strip().lower()} if value.strip() else set() + if isinstance(value, (list, tuple, set)): + return {str(item).strip().lower() for item in value if str(item).strip()} + return set() + + +def _to_jsonable(value: Any) -> Any: + if is_dataclass(value) and not isinstance(value, type): + return _to_jsonable(asdict(value)) + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, dict): + return {str(key): _to_jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_to_jsonable(item) for item in value] + return value diff --git a/tldw_Server_API/app/core/MCP_unified/server.py b/tldw_Server_API/app/core/MCP_unified/server.py index bf68c0d9cb..dcb5bbb8bb 100644 --- a/tldw_Server_API/app/core/MCP_unified/server.py +++ b/tldw_Server_API/app/core/MCP_unified/server.py @@ -930,6 +930,20 @@ async def _register_default_modules(self): }) logger.info("MCP browser CDP module enabled/configured; queuing BrowserCDPModule for registration") + # 9) Optional: RPG module for campaign/session orchestration tools. + if self._env_flag_enabled("MCP_ENABLE_RPG_MODULE"): + if not any(m.get("id") == "rpg" for m in modules_to_load if isinstance(m, dict)): + modules_to_load.append({ + "id": "rpg", + "class": "tldw_Server_API.app.core.MCP_unified.modules.implementations.rpg_module:RPGModule", + "enabled": True, + "name": "RPG", + "version": "1.0.0", + "department": "management", + "settings": {}, + }) + logger.info("MCP_ENABLE_RPG_MODULE=true; queuing RPGModule for registration") + self._remember_configured_modules_for_status(modules_to_load) # Register all specified modules diff --git a/tldw_Server_API/app/core/MCP_unified/tool_execution/runtime.py b/tldw_Server_API/app/core/MCP_unified/tool_execution/runtime.py index ce924e1918..e78abc5457 100644 --- a/tldw_Server_API/app/core/MCP_unified/tool_execution/runtime.py +++ b/tldw_Server_API/app/core/MCP_unified/tool_execution/runtime.py @@ -259,6 +259,11 @@ async def _execute_tool_call() -> dict[str, Any]: with contextlib.suppress(self._noncritical_exceptions): self.metrics.record_tool_invalid_params(getattr(module, "name", "unknown"), str(tool_name)) raise InvalidParamsException(str(_tool_e)) from _tool_e + except PermissionError as _tool_e: + span.set_attribute("mcp.status", "failure") + span.set_attribute("mcp.error_type", _tool_e.__class__.__name__) + span.set_attribute("mcp.error_message", str(_tool_e)[:200]) + raise except self._noncritical_exceptions as _tool_e: sanitized_tool_error = self._generic_exception_like( _tool_e, diff --git a/tldw_Server_API/app/core/RPG/README.md b/tldw_Server_API/app/core/RPG/README.md new file mode 100644 index 0000000000..135a2af10a --- /dev/null +++ b/tldw_Server_API/app/core/RPG/README.md @@ -0,0 +1,136 @@ +# RPG Runtime + +The RPG runtime is a backend harness for tabletop roleplaying sessions. It stores campaigns, sessions, append-only events, cached snapshots, and reviewable state-change proposals in each user's ChaChaNotes database through `RPGRepository`. + +It is not a virtual tabletop. It does not manage battle maps, tokens, token movement, lighting, walls, shared canvas rendering, or FoundryVTT-style scene automation. The runtime is intended to help reference rules, maintain structured session state, prepare bounded context for inference, and route state changes through explicit authority rules. + +## Storage Model + +RPG data lives in the authenticated user's ChaChaNotes database, alongside the rest of the user's local/self-hosted data. `RPGRepository.initialized(db)` ensures the RPG tables exist before use. + +The main tables are: + +- `rpg_campaigns`: campaign metadata, default rules adapter, settings, and linked rules pack references. +- `rpg_sessions`: session metadata, adapter version, authority settings, optional linked chat id, snapshot cursor, and event cursor. +- `rpg_session_events`: append-only event log with monotonically increasing `sequence_number` per session. +- `rpg_session_snapshots`: deterministic cached snapshots produced from committed events. +- `rpg_session_proposals`: reviewable event batches that are not yet committed. +- `rpg_idempotency_records`: replay records for mutating campaign, session, event, and proposal operations. + +Event appends, proposal creation, and proposal application use optimistic sequence checks. Those requests provide the expected current event sequence, and stale requests fail rather than rewriting history. Campaign creation, session creation, proposal rejection, event appends, and proposal application are idempotent. Replaying the same payload returns the stored result, while reusing a key with a different payload raises `idempotency_key_conflict`. + +## Events And Snapshots + +Committed session state is derived from append-only events. Supported event types include scene, actor, NPC, quest, inventory, location, faction, clock, roll, note, rule reference, and ruling updates. Each event has: + +- `event_type` +- `event_payload` +- `source_type`: one of `user`, `system`, `mcp`, `model`, or `import` +- adapter and schema version metadata +- a stable id field inside `event_payload` based on the event type + +The reducer applies events into `RPGSnapshotState`, which contains structured session fields such as scene, actors, resources, clocks, rolls, notes, quests, NPCs, inventory, locations, factions, rules references, and unresolved rulings. + +## Rules Adapters + +The bundled adapter registry currently includes: + +- `dnd5e_srd`: D&D 5e SRD 5.1 mechanics metadata and citations. +- `pf2e`: Pathfinder 2e Remaster mechanics metadata and citations. +- `fate`: Fate Core mechanics metadata and citations. + +Adapters expose actor/check schemas and check resolution helpers. They do not bundle long-form rules prose. Built-in rules lookup is citation-first: it returns reference metadata and citation diagnostics, not copied rulebook text. + +## Rules-Pack References + +Campaigns and sessions can attach user-owned rules sources by reference. Supported source types are `media_item` and `media_collection`. The RPG tables store only normalized reference metadata such as `source_type`, `source_id`, `display_name`, `enabled`, timestamps, and caller metadata. Extracted rules prose stays in the existing Media/RAG stores and is checked through the user's normal media ownership/readability rules before retrieval. + +Campaign rules-pack refs act as defaults for new sessions. When a session is created without explicit active refs, the campaign refs are copied into the session. After session creation, campaign and session lists are independent and are managed through whole-list replacement. + +Replacement writes require the target campaign/session `expected_version` and an idempotency key. Successful replacement increments the campaign/session version; stale versions fail instead of merging. Replaying the same idempotency key and payload returns the stored replacement result, while reusing a key with a different payload raises a conflict. + +Rules lookup first returns scoped user-provided snippets from enabled attached refs, then appends bundled citation-only adapter references. Disabled refs are retained but skipped. Collections with no ready media items produce diagnostics instead of broad fallback search. `mode="answer"` runs lookup first, then asks the configured chat backend to answer only from retrieved user-provided snippets; generated answers can cite only returned snippet IDs. If no snippets are available, answer generation is skipped. + +The bundled adapters remain mechanics metadata and citations. They do not ship long-form copyrighted rules text. User-provided rules content remains under the user's local/self-hosted media databases and is not copied into RPG tables. + +## Authority And Proposals + +Authority is source-aware: + +- `user` and `system` events commit directly. +- `import` events commit only when `import_auto_commit` is enabled. +- `mcp` events commit only when `mcp_auto_commit` is enabled. +- `model` events commit only when `model_auto_commit` is enabled and the event type is allowed. + +Otherwise, the service creates a pending proposal. Applying a proposal validates the proposal's session, status, and base event sequence, then commits the proposed events atomically. Rejecting a proposal marks it rejected without appending session events. + +New sessions default to conservative authority settings with `model_auto_commit` and `mcp_auto_commit` disabled. + +## Context Builder + +`RPGService.build_context()` creates bounded text context for inference. It combines the session title, adapter key, current snapshot summary, recent notes, open rulings, NPC names, and optional rules citations. The context size is bounded between 1,000 and 24,000 characters, and diagnostics report truncation, returned character count, rules result count, and omitted sections. + +This context is intended for reference/orchestration and DM-assistant workflows. It is not a live game renderer. + +## REST Surface + +The REST router is registered under `/api/v1/rpg`. Current endpoints include: + +- `GET /rules/adapters` +- `GET /rules/adapters/{adapter_key}` +- `POST /campaigns` +- `POST /campaigns/{campaign_id}/sessions` +- `POST /sessions/{session_id}/events` +- `POST /sessions/{session_id}/rules/lookup` +- `GET /campaigns/{campaign_id}/rules-packs` +- `PUT /campaigns/{campaign_id}/rules-packs` +- `GET /sessions/{session_id}/rules-packs` +- `PUT /sessions/{session_id}/rules-packs` +- `POST /sessions/{session_id}/context` +- `POST /sessions/{session_id}/proposals/{proposal_id}/apply` +- `POST /sessions/{session_id}/proposals/{proposal_id}/reject` + +Campaign/session creation, event recording, and proposal review REST calls require the `Idempotency-Key` header. Rules-pack replacement calls carry `idempotency_key` in the JSON request body because they use an optimistic whole-list replacement schema. Endpoints use RPG-specific permissions, rate limits, and token-scope guards such as `rpg.rules.read`, `rpg.campaigns.manage`, `rpg.sessions.read`, `rpg.sessions.manage`, and `rpg.proposals.review`. + +REST request limits include a maximum of 20 events per event-recording request, 64 KiB per event payload after canonical JSON encoding, 500 characters for rules/context queries, 1,000 to 24,000 characters for context output, and 2,000 characters for proposal review notes. + +## MCP Surface + +The MCP module is optional and disabled by default. Enable it with `MCP_ENABLE_RPG_MODULE`. + +Read tools: + +- `rpg.adapters.list` +- `rpg.sessions.get` +- `rpg.campaigns.rules_packs.get` +- `rpg.sessions.rules_packs.get` +- `rpg.rules.lookup` +- `rpg.context.build` + +Write-classified tools: + +- `rpg.campaigns.rules_packs.replace` +- `rpg.sessions.rules_packs.replace` +- `rpg.events.record` +- `rpg.proposals.apply` +- `rpg.proposals.reject` + +Database-backed MCP tools require an authenticated request context with a per-user ChaCha DB path. Write tools are categorized as management operations, require idempotency through `idempotencyKey` or `idempotency_key`, and are marked for approval/governance preflight. Direct module execution validates arguments before opening the database so invalid ids, missing idempotency, bad sequence values, and out-of-bounds context/query inputs fail closed. + +MCP rules-pack ref tools expose the same whole-list replacement model as REST. GET tools declare `rpg.campaigns.read` or `rpg.sessions.read` plus `media.read`; replace tools declare `rpg.campaigns.manage` or `rpg.sessions.manage` plus `media.read`. The RPG MCP module enforces those declared domain permissions from the authenticated context before opening databases. Replace calls validate `expected_version`, `refs`, and idempotency before opening databases. If enabled attached refs require dereferencing but the MCP context has no media DB path, lookup and replacement fail closed instead of treating sources as empty. + +MCP write calls enforce a 256-character idempotency key limit. MCP rules/context queries share the 500-character query limit, lookup answer options support `mode`, `provider`, `model`, `temperature`, and `max_tokens`, context requests share the 1,000 to 24,000 character output bound, and proposal review notes share the 2,000-character limit. `mode="answer"` additionally requires `chat.completions` and a trusted MCP host marker that chat-generation token-scope, budget, and rate-limit controls have already run; otherwise it fails before lookup and does not call an LLM. + +## Current Non-Goals + +This runtime does not currently provide: + +- map, grid, token, wall, lighting, or shared tabletop rendering features +- campaign/session list endpoints +- event list endpoints +- a REST or MCP dice-roll endpoint +- bundled long-form rules prose +- broad retrieval fallback outside attached rules sources +- autonomous model state commits by default + +Those boundaries are intentional for this backend-first harness. The runtime should remain generic enough to support multiple TTRPG systems while letting tldw_server handle inference, retrieval, session memory, and reviewable orchestration. diff --git a/tldw_Server_API/app/core/RPG/__init__.py b/tldw_Server_API/app/core/RPG/__init__.py new file mode 100644 index 0000000000..27fb69e8c8 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/__init__.py @@ -0,0 +1,35 @@ +"""Core RPG runtime primitives.""" + +from tldw_Server_API.app.core.RPG.authority import AuthorityDecision, decide_authority + +__all__ = [ + "AuthorityDecision", + "RPGProposalRecord", + "RPGService", + "RPGServiceProposal", + "RecordEventsResult", + "SnapshotResult", + "decide_authority", +] + + +def __getattr__(name: str) -> object: + if name == "RPGProposalRecord": + from tldw_Server_API.app.core.RPG.proposals import RPGProposalRecord + + return RPGProposalRecord + if name in {"RPGService", "RPGServiceProposal", "RecordEventsResult", "SnapshotResult"}: + from tldw_Server_API.app.core.RPG.service import ( + RecordEventsResult, + RPGService, + RPGServiceProposal, + SnapshotResult, + ) + + return { + "RPGService": RPGService, + "RPGServiceProposal": RPGServiceProposal, + "RecordEventsResult": RecordEventsResult, + "SnapshotResult": SnapshotResult, + }[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tldw_Server_API/app/core/RPG/authority.py b/tldw_Server_API/app/core/RPG/authority.py new file mode 100644 index 0000000000..3e9f166a6f --- /dev/null +++ b/tldw_Server_API/app/core/RPG/authority.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class AuthorityDecision: + action: str + reason: str + + +def decide_authority( + source_type: str, + event_type: str, + authority_settings: dict[str, object], +) -> AuthorityDecision: + if source_type == "user": + return AuthorityDecision(action="commit", reason="user_direct_commit") + if source_type == "system": + return AuthorityDecision(action="commit", reason="internal_system_commit") + if source_type == "import" and authority_settings.get("import_auto_commit") is True: + return AuthorityDecision(action="commit", reason="import_auto_commit_enabled") + if source_type == "mcp" and authority_settings.get("mcp_auto_commit") is True: + return AuthorityDecision(action="commit", reason="mcp_auto_commit_enabled") + if source_type == "model" and authority_settings.get("model_auto_commit") is True: + allowed_event_types = set(authority_settings.get("model_auto_commit_event_types") or []) + if event_type in allowed_event_types: + return AuthorityDecision( + action="commit", + reason="model_event_type_auto_commit_enabled", + ) + return AuthorityDecision( + action="proposal", + reason=f"{source_type}_{event_type}_requires_review", + ) diff --git a/tldw_Server_API/app/core/RPG/checks.py b/tldw_Server_API/app/core/RPG/checks.py new file mode 100644 index 0000000000..4e0cd2c562 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/checks.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + +from tldw_Server_API.app.core.RPG.errors import RPGValidationError +from tldw_Server_API.app.core.RPG.models import CheckResult + + +def resolve_check(adapter: Any, roller: Any, payload: dict[str, Any]) -> CheckResult: + result = adapter.resolve_check(roller, payload) + if not isinstance(result, CheckResult): + raise RPGValidationError("rules adapter returned an invalid check result") + return result diff --git a/tldw_Server_API/app/core/RPG/constants.py b/tldw_Server_API/app/core/RPG/constants.py new file mode 100644 index 0000000000..71c5215670 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/constants.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +RPG_ADAPTER_DND5E_SRD = "dnd5e_srd" +RPG_ADAPTER_FATE = "fate" +RPG_ADAPTER_PF2E = "pf2e" +RPG_ADAPTER_VERSION_V1 = "1.0.0" + +RPG_EVENT_SCHEMA_VERSION = "1.0.0" +RPG_SNAPSHOT_SCHEMA_VERSION = "1.0.0" +RPG_REDUCER_VERSION = "1.0.0" + +RPG_SOURCE_TYPES = frozenset({"user", "system", "mcp", "model", "import"}) + +MAX_RPG_EVENT_PAYLOAD_BYTES = 64 * 1024 +MAX_RPG_CONTEXT_CHARS = 24_000 diff --git a/tldw_Server_API/app/core/RPG/context.py b/tldw_Server_API/app/core/RPG/context.py new file mode 100644 index 0000000000..ad392b5667 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/context.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from tldw_Server_API.app.core.RPG.models import RPGSnapshotState +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupItem + + +@dataclass(frozen=True, slots=True) +class SessionContext: + text: str + diagnostics: dict[str, object] + + +class SessionContextBuilder: + def __init__(self, max_chars: int) -> None: + self.max_chars = max(1, int(max_chars)) + + def build( + self, + adapter_key: str, + session_title: str, + snapshot: RPGSnapshotState, + rules_results: list[RuleLookupItem], + rules_diagnostics: dict[str, Any] | None = None, + ) -> SessionContext: + lines: list[str] = [] + omitted_sections: list[str] = [] + + def append_line(line: str, section: str) -> bool: + stripped = line.strip() + if not stripped: + return True + candidate_length = len(stripped) if not lines else sum(len(existing) + 1 for existing in lines) + len(stripped) + if candidate_length > self.max_chars: + if section not in omitted_sections: + omitted_sections.append(section) + return False + lines.append(stripped) + return True + + append_line(f"RPG session: {session_title}", "header") + append_line(f"Rules adapter: {adapter_key}", "header") + + scene_summary = str(snapshot.scene.get("summary") or "").strip() + if scene_summary: + append_line(f"Scene: {scene_summary}", "scene") + + npc_names = sorted(str(npc.get("name") or npc_id) for npc_id, npc in list(snapshot.npcs.items())[:20]) + if npc_names: + append_line(f"NPCs: {', '.join(npc_names)}", "npcs") + + if snapshot.notes: + notes_open = append_line("Recent notes:", "notes") + for note in snapshot.notes[-5:]: + text = str(note.get("text") or note.get("summary") or "").strip() + if text and (not notes_open or not append_line(f"- {text}", "notes")): + break + + if snapshot.unresolved_rulings: + append_line(f"Open rulings: {len(snapshot.unresolved_rulings)}", "rulings") + + user_rules = [item for item in rules_results if item.origin == "user_provided" and item.text.strip()] + bundled_rules = [item for item in rules_results if item.origin == "bundled_citation"] + if user_rules: + evidence_open = append_line("Rules evidence:", "rules") + for item in user_rules: + citation = item.citation + snippet = item.text.strip() + line = f"- {citation.source_title} [{citation.snippet_id}]: {snippet}" + if not evidence_open or not append_line(line, "rules"): + break + + if bundled_rules: + citations_open = append_line("Rules citations:", "rules") + for item in bundled_rules: + citation = item.citation + if not citations_open or not append_line(f"- {citation.source_title}: {citation.source_url}", "rules"): + break + + text = "\n".join(line for line in lines if line.strip()) + original_length = len(text) + truncated = bool(omitted_sections) + + return SessionContext( + text=text, + diagnostics={ + "truncated": truncated, + "max_chars": self.max_chars, + "original_chars": original_length, + "returned_chars": len(text), + "rules_result_count": len(rules_results), + "rules_lookup": dict(rules_diagnostics or {}), + "omitted_sections": omitted_sections, + }, + ) diff --git a/tldw_Server_API/app/core/RPG/dice.py b/tldw_Server_API/app/core/RPG/dice.py new file mode 100644 index 0000000000..02fabe9d69 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/dice.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import random +import re +from collections.abc import Iterable + +from tldw_Server_API.app.core.RPG.errors import RPGValidationError +from tldw_Server_API.app.core.RPG.models import DiceRollResult + +_DICE_EXPR_RE = re.compile(r"^(?P\d{1,3})d(?P\d{1,4})(?P[+-]\d{1,4})?$") + +MAX_DICE_COUNT = 100 +MAX_DIE_SIDES = 1000 +MAX_ROLL_MODIFIER = 1000 +MAX_FATE_DICE = 20 + + +class DiceRoller: + def __init__( + self, + *, + rng: random.Random | None = None, + injected_values: Iterable[int] | None = None, + injected_fate_values: Iterable[int] | None = None, + ) -> None: + self._rng = rng or random.Random() # nosec B311 + self._injected_values = list(injected_values or []) + self._injected_fate_values = list(injected_fate_values or []) + + def roll(self, expression: str) -> DiceRollResult: + dice_count, sides, modifier = self._parse_expression(expression) + values = [self._next_die_value(sides) for _ in range(dice_count)] + total = sum(values) + modifier + + return DiceRollResult( + expression=expression, + values=values, + modifier=modifier, + total=total, + dice_count=dice_count, + sides=sides, + details={}, + ) + + def roll_fate(self, *, dice_count: int = 4, modifier: int = 0) -> DiceRollResult: + if not 1 <= dice_count <= MAX_FATE_DICE: + raise RPGValidationError(f"fate dice count must be between 1 and {MAX_FATE_DICE}") + if abs(modifier) > MAX_ROLL_MODIFIER: + raise RPGValidationError(f"fate modifier must be between -{MAX_ROLL_MODIFIER} and {MAX_ROLL_MODIFIER}") + + values = [self._next_fate_value() for _ in range(dice_count)] + total = sum(values) + modifier + expression = f"{dice_count}dF{modifier:+d}" if modifier else f"{dice_count}dF" + + return DiceRollResult( + expression=expression, + values=values, + modifier=modifier, + total=total, + dice_count=dice_count, + sides=None, + details={"dice": "fate"}, + ) + + def _parse_expression(self, expression: str) -> tuple[int, int, int]: + match = _DICE_EXPR_RE.fullmatch(expression.strip()) + if match is None: + raise RPGValidationError("dice expression must match NdM, NdM+K, or NdM-K") + + dice_count = int(match.group("count")) + sides = int(match.group("sides")) + modifier_text = match.group("modifier") + modifier = int(modifier_text) if modifier_text else 0 + + if not 1 <= dice_count <= MAX_DICE_COUNT: + raise RPGValidationError(f"dice count must be between 1 and {MAX_DICE_COUNT}") + if not 1 <= sides <= MAX_DIE_SIDES: + raise RPGValidationError(f"die sides must be between 1 and {MAX_DIE_SIDES}") + if abs(modifier) > MAX_ROLL_MODIFIER: + raise RPGValidationError(f"modifier must be between -{MAX_ROLL_MODIFIER} and {MAX_ROLL_MODIFIER}") + + return dice_count, sides, modifier + + def _next_die_value(self, sides: int) -> int: + if self._injected_values: + value = self._injected_values.pop(0) + else: + value = self._rng.randint(1, sides) # nosec B311 + + if not 1 <= value <= sides: + raise RPGValidationError(f"injected die value {value} is outside 1..{sides}") + return value + + def _next_fate_value(self) -> int: + if self._injected_fate_values: + value = self._injected_fate_values.pop(0) + else: + value = self._rng.choice((-1, 0, 1)) # nosec B311 + + if value not in {-1, 0, 1}: + raise RPGValidationError("injected fate die values must be -1, 0, or 1") + return value diff --git a/tldw_Server_API/app/core/RPG/errors.py b/tldw_Server_API/app/core/RPG/errors.py new file mode 100644 index 0000000000..1acb14e142 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/errors.py @@ -0,0 +1,17 @@ +"""Compatibility exports for RPG domain exceptions.""" + +from __future__ import annotations + +from tldw_Server_API.app.core.exceptions import ( + RPGConflictError, + RPGError, + RPGNotFoundError, + RPGValidationError, +) + +__all__ = [ + "RPGConflictError", + "RPGError", + "RPGNotFoundError", + "RPGValidationError", +] diff --git a/tldw_Server_API/app/core/RPG/events.py b/tldw_Server_API/app/core/RPG/events.py new file mode 100644 index 0000000000..c31ae8e1f5 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/events.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from copy import deepcopy +from typing import Any + +from tldw_Server_API.app.core.RPG.constants import ( + MAX_RPG_EVENT_PAYLOAD_BYTES, + RPG_EVENT_SCHEMA_VERSION, + RPG_SOURCE_TYPES, +) + +_REQUIRED_EVENT_IDS = { + "actor.upserted": "actor_id", + "clock.updated": "clock_id", + "faction.upserted": "faction_id", + "inventory.item.upserted": "item_id", + "location.upserted": "location_id", + "note.added": "note_id", + "npc.upserted": "npc_id", + "quest.upserted": "quest_id", + "roll.recorded": "roll_id", + "rule.reference.added": "reference_id", + "ruling.added": "ruling_id", + "scene.updated": "scene_id", +} + +SUPPORTED_EVENT_TYPES = frozenset(_REQUIRED_EVENT_IDS) + + +def _canonical_json_bytes(payload: Any) -> bytes: + try: + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + except (TypeError, ValueError) as exc: + raise ValueError("payload must be JSON-serializable") from exc + return encoded.encode("utf-8") + + +def canonical_request_hash(payload: dict[str, Any]) -> str: + return hashlib.sha256(_canonical_json_bytes(payload)).hexdigest() + + +def validate_event_envelope(event: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(event, Mapping): + raise ValueError("event must be an object") + + event_type = str(event.get("event_type") or "").strip() + if not event_type: + raise ValueError("event_type is required") + if event_type not in SUPPORTED_EVENT_TYPES: + raise ValueError(f"Unsupported RPG event type: {event_type}") + + source_type = str(event.get("source_type") or "").strip() + if source_type not in RPG_SOURCE_TYPES: + raise ValueError("source_type is invalid") + + payload = event.get("event_payload") + if not isinstance(payload, Mapping): + raise ValueError("event_payload must be an object") + + payload_copy = deepcopy(dict(payload)) + payload_size = len(_canonical_json_bytes(payload_copy)) + if payload_size > MAX_RPG_EVENT_PAYLOAD_BYTES: + raise ValueError("event_payload is too large") + + required_id = _REQUIRED_EVENT_IDS[event_type] + stable_id = payload_copy.get(required_id) + if not isinstance(stable_id, str) or not stable_id.strip(): + raise ValueError(f"{required_id} is required for {event_type}") + + normalized = dict(event) + normalized["event_type"] = event_type + normalized["source_type"] = source_type + normalized["event_payload"] = payload_copy + normalized.setdefault("event_schema_version", RPG_EVENT_SCHEMA_VERSION) + return normalized diff --git a/tldw_Server_API/app/core/RPG/models.py b/tldw_Server_API/app/core/RPG/models.py new file mode 100644 index 0000000000..14a0be7d66 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/models.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Literal + +RPGSourceType = Literal["user", "system", "mcp", "model", "import"] + + +@dataclass(frozen=True, slots=True) +class RuleLicenseSummary: + license_name: str + source_title: str + source_url: str + attribution_required: bool + commercial_use_allowed: bool + notes: str = "" + + +@dataclass(frozen=True, slots=True) +class RuleAdapterInfo: + adapter_key: str + adapter_version: str + display_name: str + status: str + source_version: str + bundled_snippet_policy: str + license_summary: RuleLicenseSummary + mechanics_tags: dict[str, str] + + +@dataclass(frozen=True, slots=True) +class RuleCitation: + adapter_key: str + source_title: str + source_url: str + license: str + license_url: str | None + attribution: str + trust_level: str + content_hash: str + snippet_id: str + source_version: str + content_pack_version: str + + +@dataclass(frozen=True, slots=True) +class DiceRollResult: + expression: str + values: list[int] + modifier: int + total: int + dice_count: int + sides: int | None + details: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class CheckResult: + check_label: str + mechanics: str + roll: DiceRollResult + target: int | None + success: bool | None + margin: int | None + details: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RPGCampaign: + id: int + owner_user_id: int + title: str + description: str | None + default_adapter_key: str + default_adapter_version: str + settings: dict[str, Any] + linked_rules_pack_refs: list[dict[str, Any]] + version: int + status: str + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class RPGSession: + id: int + campaign_id: int + owner_user_id: int + title: str + status: str + adapter_key: str + adapter_version: str + authority_settings: dict[str, Any] + linked_chat_id: int | None + active_rules_pack_refs: list[dict[str, Any]] + current_snapshot_version: int + last_event_sequence: int + version: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class RPGSessionEvent: + id: int + session_id: int + owner_user_id: int + sequence_number: int + event_type: str + event_payload: dict[str, Any] + source_type: RPGSourceType + source_actor_id: str | None + source_label: str | None + operation_id: int | None + event_schema_version: str + adapter_key: str + adapter_version: str + proposal_id: int | None + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class RPGSnapshotState: + scene: dict[str, Any] = field(default_factory=dict) + actors: dict[str, dict[str, Any]] = field(default_factory=dict) + resources: dict[str, dict[str, Any]] = field(default_factory=dict) + clocks: dict[str, dict[str, Any]] = field(default_factory=dict) + rolls: list[dict[str, Any]] = field(default_factory=list) + notes: list[dict[str, Any]] = field(default_factory=list) + recap: str = "" + quests: dict[str, dict[str, Any]] = field(default_factory=dict) + npcs: dict[str, dict[str, Any]] = field(default_factory=dict) + inventory: dict[str, dict[str, Any]] = field(default_factory=dict) + locations: dict[str, dict[str, Any]] = field(default_factory=dict) + factions: dict[str, dict[str, Any]] = field(default_factory=dict) + rules_references: list[dict[str, Any]] = field(default_factory=list) + unresolved_rulings: dict[str, dict[str, Any]] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RPGSnapshotRecord: + id: int + session_id: int + owner_user_id: int + snapshot_version: int + last_event_sequence: int + reducer_version: str + snapshot_schema_version: str + snapshot_json: dict[str, Any] + diagnostics_json: dict[str, Any] + created_at: datetime diff --git a/tldw_Server_API/app/core/RPG/proposals.py b/tldw_Server_API/app/core/RPG/proposals.py new file mode 100644 index 0000000000..4f59c5370f --- /dev/null +++ b/tldw_Server_API/app/core/RPG/proposals.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass(frozen=True, slots=True) +class RPGProposalRecord: + id: int + session_id: int + owner_user_id: int + base_event_sequence: int + base_snapshot_version: int + proposed_events: list[dict[str, Any]] + patch: dict[str, Any] | None + rationale: str | None + confidence: float | None + source_type: str + source_actor_id: str | None + model_metadata: dict[str, Any] + status: str + review_notes: str | None + created_at: datetime + applied_at: datetime | None + rejected_at: datetime | None diff --git a/tldw_Server_API/app/core/RPG/reducer.py b/tldw_Server_API/app/core/RPG/reducer.py new file mode 100644 index 0000000000..237f8321f2 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/reducer.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import replace +from typing import Any + +from tldw_Server_API.app.core.RPG.events import validate_event_envelope +from tldw_Server_API.app.core.RPG.models import RPGSnapshotState + + +def initial_snapshot() -> RPGSnapshotState: + return RPGSnapshotState() + + +def _merged_mapping( + current: dict[str, dict[str, Any]], + stable_id: str, + payload: dict[str, Any], +) -> dict[str, dict[str, Any]]: + updated = {key: deepcopy(value) for key, value in current.items()} + updated[stable_id] = {**updated.get(stable_id, {}), **deepcopy(payload)} + return updated + + +def _appended( + current: list[dict[str, Any]], + payload: dict[str, Any], +) -> list[dict[str, Any]]: + return [*[deepcopy(item) for item in current], deepcopy(payload)] + + +def reduce_event(snapshot: RPGSnapshotState, event: dict[str, Any]) -> RPGSnapshotState: + normalized = validate_event_envelope(event) + event_type = normalized["event_type"] + payload = normalized["event_payload"] + + if event_type == "scene.updated": + return replace(snapshot, scene={**deepcopy(snapshot.scene), **deepcopy(payload)}) + if event_type == "actor.upserted": + return replace( + snapshot, + actors=_merged_mapping(snapshot.actors, payload["actor_id"], payload), + ) + if event_type == "npc.upserted": + return replace(snapshot, npcs=_merged_mapping(snapshot.npcs, payload["npc_id"], payload)) + if event_type == "quest.upserted": + return replace( + snapshot, + quests=_merged_mapping(snapshot.quests, payload["quest_id"], payload), + ) + if event_type == "inventory.item.upserted": + return replace( + snapshot, + inventory=_merged_mapping(snapshot.inventory, payload["item_id"], payload), + ) + if event_type == "location.upserted": + return replace( + snapshot, + locations=_merged_mapping(snapshot.locations, payload["location_id"], payload), + ) + if event_type == "faction.upserted": + return replace( + snapshot, + factions=_merged_mapping(snapshot.factions, payload["faction_id"], payload), + ) + if event_type == "clock.updated": + return replace(snapshot, clocks=_merged_mapping(snapshot.clocks, payload["clock_id"], payload)) + if event_type == "roll.recorded": + return replace(snapshot, rolls=_appended(snapshot.rolls, payload)) + if event_type == "note.added": + return replace(snapshot, notes=_appended(snapshot.notes, payload)) + if event_type == "rule.reference.added": + return replace(snapshot, rules_references=_appended(snapshot.rules_references, payload)) + if event_type == "ruling.added": + return replace( + snapshot, + unresolved_rulings=_merged_mapping( + snapshot.unresolved_rulings, + payload["ruling_id"], + payload, + ), + ) + raise ValueError(f"Unsupported RPG event type: {event_type}") + + +def reduce_events(snapshot: RPGSnapshotState, events: list[dict[str, Any]]) -> RPGSnapshotState: + current = snapshot + for event in events: + current = reduce_event(current, event) + return current diff --git a/tldw_Server_API/app/core/RPG/rules/__init__.py b/tldw_Server_API/app/core/RPG/rules/__init__.py new file mode 100644 index 0000000000..d35e5ce718 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/rules/__init__.py @@ -0,0 +1 @@ +"""RPG rules adapter registry and helpers.""" diff --git a/tldw_Server_API/app/core/RPG/rules/adapters.py b/tldw_Server_API/app/core/RPG/rules/adapters.py new file mode 100644 index 0000000000..789f6218bd --- /dev/null +++ b/tldw_Server_API/app/core/RPG/rules/adapters.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from collections.abc import Callable +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Protocol + +from tldw_Server_API.app.core.RPG.constants import ( + RPG_ADAPTER_DND5E_SRD, + RPG_ADAPTER_FATE, + RPG_ADAPTER_PF2E, + RPG_ADAPTER_VERSION_V1, +) +from tldw_Server_API.app.core.RPG.errors import RPGNotFoundError, RPGValidationError +from tldw_Server_API.app.core.RPG.models import CheckResult, DiceRollResult, RuleAdapterInfo, RuleLicenseSummary + + +class RuleAdapter(Protocol): + adapter_key: str + adapter_version: str + display_name: str + status: str + source_version: str + bundled_snippet_policy: str + license_summary: RuleLicenseSummary + mechanics_tags: dict[str, str] + + def actor_schema(self) -> dict[str, Any]: + raise NotImplementedError + + def check_schema(self) -> dict[str, Any]: + raise NotImplementedError + + def supported_event_types(self) -> set[str]: + raise NotImplementedError + + def validate_actor(self, actor_payload: dict[str, Any]) -> dict[str, Any]: + raise NotImplementedError + + def resolve_check(self, roller: Any, payload: dict[str, Any]) -> CheckResult: + raise NotImplementedError + + def content_pack_refs(self) -> list[dict[str, Any]]: + raise NotImplementedError + + def info(self) -> RuleAdapterInfo: + raise NotImplementedError + + +_CORE_EVENT_TYPES = frozenset( + { + "actor.upserted", + "clock.updated", + "faction.upserted", + "inventory.item.upserted", + "location.upserted", + "note.added", + "npc.upserted", + "quest.upserted", + "roll.recorded", + "rule.reference.added", + "ruling.added", + "scene.updated", + } +) + + +@dataclass(frozen=True, slots=True) +class StaticRuleAdapter: + adapter_key: str + adapter_version: str + display_name: str + status: str + source_version: str + bundled_snippet_policy: str + license_summary: RuleLicenseSummary + mechanics_tags: dict[str, str] + _actor_schema: dict[str, Any] + _check_schema: dict[str, Any] + _check_resolver: Callable[[Any, dict[str, Any]], CheckResult] + + def actor_schema(self) -> dict[str, Any]: + return deepcopy(self._actor_schema) + + def check_schema(self) -> dict[str, Any]: + return deepcopy(self._check_schema) + + def supported_event_types(self) -> set[str]: + return set(_CORE_EVENT_TYPES) + + def validate_actor(self, actor_payload: dict[str, Any]) -> dict[str, Any]: + return dict(actor_payload) + + def resolve_check(self, roller: Any, payload: dict[str, Any]) -> CheckResult: + return self._check_resolver(roller, payload) + + def content_pack_refs(self) -> list[dict[str, Any]]: + return [] + + def info(self) -> RuleAdapterInfo: + return RuleAdapterInfo( + adapter_key=self.adapter_key, + adapter_version=self.adapter_version, + display_name=self.display_name, + status=self.status, + source_version=self.source_version, + bundled_snippet_policy=self.bundled_snippet_policy, + license_summary=self.license_summary, + mechanics_tags=dict(self.mechanics_tags), + ) + + +class RuleAdapterRegistry: + def __init__(self, adapters: list[RuleAdapter]) -> None: + self._adapters = {adapter.adapter_key: adapter for adapter in adapters} + + def get(self, adapter_key: str) -> RuleAdapter: + try: + return self._adapters[adapter_key] + except KeyError as exc: + raise RPGNotFoundError(f"unknown RPG rules adapter: {adapter_key}") from exc + + def list_infos(self) -> list[RuleAdapterInfo]: + return [self._adapters[key].info() for key in sorted(self._adapters)] + + +def _base_actor_schema() -> dict[str, Any]: + return { + "type": "object", + "required": ["actor_id", "name"], + "properties": { + "actor_id": {"type": "string", "minLength": 1, "maxLength": 120}, + "name": {"type": "string", "minLength": 1, "maxLength": 200}, + "kind": {"type": "string", "maxLength": 80}, + "traits": {"type": "array", "items": {"type": "string"}}, + }, + "additionalProperties": True, + } + + +def _d20_check_schema() -> dict[str, Any]: + return { + "type": "object", + "required": ["check_label"], + "properties": { + "check_label": {"type": "string", "minLength": 1, "maxLength": 160}, + "roll_expression": {"type": "string", "default": "1d20", "maxLength": 40}, + "dc": {"type": "integer", "minimum": 0, "maximum": 100}, + "modifier": {"type": "integer", "minimum": -100, "maximum": 100}, + }, + "additionalProperties": True, + } + + +def _fate_check_schema() -> dict[str, Any]: + return { + "type": "object", + "required": ["check_label", "ladder_target"], + "properties": { + "check_label": {"type": "string", "minLength": 1, "maxLength": 160}, + "skill_bonus": {"type": "integer", "minimum": -8, "maximum": 12}, + "ladder_target": {"type": "integer", "minimum": -4, "maximum": 12}, + }, + "additionalProperties": True, + } + + +def _required_label(payload: dict[str, Any]) -> str: + value = payload.get("check_label") + if not isinstance(value, str) or not value.strip(): + raise RPGValidationError("check_label is required") + return value.strip() + + +def _bounded_int(payload: dict[str, Any], key: str, *, default: int, minimum: int, maximum: int) -> int: + value = payload.get(key, default) + if type(value) is not int: + raise RPGValidationError(f"{key} must be an integer") + if not minimum <= value <= maximum: + raise RPGValidationError(f"{key} must be between {minimum} and {maximum}") + return value + + +def _optional_bounded_int(payload: dict[str, Any], key: str, *, minimum: int, maximum: int) -> int | None: + if key not in payload or payload[key] is None: + return None + return _bounded_int(payload, key, default=0, minimum=minimum, maximum=maximum) + + +def _roll_with_extra_modifier(roller: Any, expression: str, modifier: int) -> DiceRollResult: + roll = roller.roll(expression) + if modifier == 0: + return roll + + combined_modifier = roll.modifier + modifier + expression_prefix = f"{roll.dice_count}d{roll.sides}" + combined_expression = f"{expression_prefix}{combined_modifier:+d}" if combined_modifier else expression_prefix + + return DiceRollResult( + expression=combined_expression, + values=list(roll.values), + modifier=combined_modifier, + total=sum(roll.values) + combined_modifier, + dice_count=roll.dice_count, + sides=roll.sides, + details={"base_expression": roll.expression, "payload_modifier": modifier}, + ) + + +def _resolve_d20_check(roller: Any, payload: dict[str, Any]) -> CheckResult: + check_label = _required_label(payload) + roll_expression = payload.get("roll_expression", "1d20") + if not isinstance(roll_expression, str): + raise RPGValidationError("roll_expression must be a string") + + modifier = _bounded_int(payload, "modifier", default=0, minimum=-100, maximum=100) + dc = _optional_bounded_int(payload, "dc", minimum=0, maximum=100) + roll = _roll_with_extra_modifier(roller, roll_expression, modifier) + margin = roll.total - dc if dc is not None else None + + return CheckResult( + check_label=check_label, + mechanics="d20", + roll=roll, + target=dc, + success=roll.total >= dc if dc is not None else None, + margin=margin, + details={"roll_expression": roll_expression, "modifier": modifier}, + ) + + +def _resolve_fate_check(roller: Any, payload: dict[str, Any]) -> CheckResult: + check_label = _required_label(payload) + skill_bonus = _bounded_int(payload, "skill_bonus", default=0, minimum=-8, maximum=12) + target = _optional_bounded_int(payload, "ladder_target", minimum=-4, maximum=12) + if target is None: + raise RPGValidationError("ladder_target is required") + + roll = roller.roll_fate(modifier=skill_bonus) + margin = roll.total - target + + return CheckResult( + check_label=check_label, + mechanics="fate", + roll=roll, + target=target, + success=roll.total >= target, + margin=margin, + details={"skill_bonus": skill_bonus}, + ) + + +def build_default_adapter_registry() -> RuleAdapterRegistry: + return RuleAdapterRegistry( + [ + StaticRuleAdapter( + adapter_key=RPG_ADAPTER_DND5E_SRD, + adapter_version=RPG_ADAPTER_VERSION_V1, + display_name="D&D 5e SRD", + status="bundled", + source_version="SRD 5.1", + bundled_snippet_policy="mechanics_metadata_only", + license_summary=RuleLicenseSummary( + license_name="CC-BY-4.0", + source_title="Systems Reference Document 5.1", + source_url="https://dnd.wizards.com/resources/systems-reference-document", + attribution_required=True, + commercial_use_allowed=True, + notes="Adapter stores mechanics metadata and citations, not long-form SRD prose.", + ), + mechanics_tags={"resolution_family": "d20", "dice": "d20", "genre": "fantasy"}, + _actor_schema=_base_actor_schema(), + _check_schema=_d20_check_schema(), + _check_resolver=_resolve_d20_check, + ), + StaticRuleAdapter( + adapter_key=RPG_ADAPTER_FATE, + adapter_version=RPG_ADAPTER_VERSION_V1, + display_name="Fate", + status="bundled", + source_version="Fate Core", + bundled_snippet_policy="mechanics_metadata_only", + license_summary=RuleLicenseSummary( + license_name="CC-BY-3.0", + source_title="Fate Core System", + source_url="https://fate-srd.com/", + attribution_required=True, + commercial_use_allowed=True, + notes="Adapter stores mechanics metadata and citations.", + ), + mechanics_tags={"resolution_family": "fate", "dice": "fate", "genre": "generic"}, + _actor_schema=_base_actor_schema(), + _check_schema=_fate_check_schema(), + _check_resolver=_resolve_fate_check, + ), + StaticRuleAdapter( + adapter_key=RPG_ADAPTER_PF2E, + adapter_version=RPG_ADAPTER_VERSION_V1, + display_name="Pathfinder 2e", + status="bundled", + source_version="Remaster", + bundled_snippet_policy="mechanics_metadata_only", + license_summary=RuleLicenseSummary( + license_name="ORC", + source_title="Pathfinder Second Edition Remaster", + source_url="https://paizo.com/pathfinder", + attribution_required=True, + commercial_use_allowed=True, + notes="Adapter stores mechanics metadata and citations; user-provided rules packs provide prose.", + ), + mechanics_tags={"resolution_family": "d20", "dice": "d20", "genre": "fantasy"}, + _actor_schema=_base_actor_schema(), + _check_schema=_d20_check_schema(), + _check_resolver=_resolve_d20_check, + ), + ] + ) diff --git a/tldw_Server_API/app/core/RPG/rules/answering.py b/tldw_Server_API/app/core/RPG/rules/answering.py new file mode 100644 index 0000000000..0e87ed4eed --- /dev/null +++ b/tldw_Server_API/app/core/RPG/rules/answering.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Protocol + +from loguru import logger + +from tldw_Server_API.app.core.Chat.Chat_Deps import ChatAPIError, ChatProviderError +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupItem +from tldw_Server_API.app.core.Workflows.adapters._common import extract_openai_content + +ChatCall = Callable[..., Awaitable[Any]] + +_SYSTEM_MESSAGE = ( + "You are answering a tabletop RPG rules question. Answer only from the provided rules evidence. " + "If the evidence is insufficient, say so. Return JSON with keys `answer` and `citation_ids`; " + "`citation_ids` must only contain snippet IDs shown in the evidence." +) + + +@dataclass(frozen=True, slots=True) +class RulesAnswerOptions: + provider: str | None = None + model: str | None = None + temperature: float = 0.2 + max_tokens: int = 600 + + +@dataclass(frozen=True, slots=True) +class RulesAnswerResult: + answer: str | None + answer_status: str + citation_ids: list[str] + + +class RulesAnswerGenerator(Protocol): + async def generate( + self, + *, + query: str, + evidence: list[RuleLookupItem], + options: RulesAnswerOptions, + ) -> RulesAnswerResult: + ... + + +class ChatRulesAnswerGenerator: + def __init__(self, *, chat_call: ChatCall | None = None) -> None: + self._chat_call = chat_call or _default_chat_call + + async def generate( + self, + *, + query: str, + evidence: list[RuleLookupItem], + options: RulesAnswerOptions, + ) -> RulesAnswerResult: + evidence_items = [item for item in evidence if item.origin == "user_provided" and item.text.strip()] + if not evidence_items: + return RulesAnswerResult(answer=None, answer_status="no_evidence", citation_ids=[]) + + try: + response = await self._chat_call( + messages=[{"role": "user", "content": _user_prompt(query=query, evidence=evidence_items)}], + system_message=_SYSTEM_MESSAGE, + api_provider=options.provider, + model=options.model, + temperature=options.temperature, + max_tokens=options.max_tokens, + stream=False, + ) + except (ChatAPIError, ChatProviderError) as exc: + logger.warning("RPG rules answer generation failed: {}", type(exc).__name__) + return RulesAnswerResult(answer=None, answer_status="generation_error", citation_ids=[]) + + return _answer_result_from_response(response, evidence_items) + + +async def _default_chat_call(**kwargs: Any) -> Any: + from tldw_Server_API.app.core.Chat.chat_service import perform_chat_api_call_async + + return await perform_chat_api_call_async(**kwargs) + + +def _user_prompt(*, query: str, evidence: list[RuleLookupItem]) -> str: + evidence_blocks = [] + for item in evidence: + citation = item.citation + evidence_blocks.append( + "\n".join( + [ + f"Snippet ID: {citation.snippet_id}", + f"Source: {citation.source_title}", + f"Text: {item.text.strip()}", + ] + ) + ) + evidence_text = "\n\n".join(evidence_blocks) + return ( + f"Question: {query.strip()}\n\n" + "Rules evidence:\n" + f"{evidence_text}\n\n" + "Return JSON only, for example: " + '{"answer": "brief grounded answer", "citation_ids": ["media:1:chunk:2"]}' + ) + + +def _answer_result_from_response(response: Any, evidence: list[RuleLookupItem]) -> RulesAnswerResult: + text = str(extract_openai_content(response) or "").strip() + if not text: + return RulesAnswerResult(answer=None, answer_status="generation_error", citation_ids=[]) + + allowed_ids = [item.citation.snippet_id for item in evidence if item.citation.snippet_id] + parsed = _loads_json_object(text) + if parsed is not None: + answer = str(parsed.get("answer") or "").strip() + citation_ids = _filtered_citation_ids(parsed.get("citation_ids"), allowed_ids) + if not answer: + return RulesAnswerResult(answer=None, answer_status="generation_error", citation_ids=[]) + return RulesAnswerResult(answer=answer, answer_status="answered", citation_ids=citation_ids) + + logger.warning("RPG rules answer generation returned non-JSON content") + return RulesAnswerResult(answer=None, answer_status="generation_error", citation_ids=[]) + + +def _loads_json_object(text: str) -> dict[str, Any] | None: + candidate = _strip_json_fence(text) + try: + loaded = json.loads(candidate) + except json.JSONDecodeError: + return None + if isinstance(loaded, dict): + return loaded + return None + + +def _strip_json_fence(text: str) -> str: + stripped = text.strip() + if not stripped.startswith("```"): + return stripped + lines = stripped.splitlines() + if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```": + return "\n".join(lines[1:-1]).strip() + return stripped + + +def _filtered_citation_ids(value: Any, allowed_ids: list[str]) -> list[str]: + allowed = set(allowed_ids) + filtered: list[str] = [] + if not isinstance(value, list): + return filtered + for item in value: + text = str(item or "").strip() + if text in allowed and text not in filtered: + filtered.append(text) + return filtered diff --git a/tldw_Server_API/app/core/RPG/rules/content_packs.py b/tldw_Server_API/app/core/RPG/rules/content_packs.py new file mode 100644 index 0000000000..ba7e14a0d8 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/rules/content_packs.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +from tldw_Server_API.app.core.RPG.constants import ( + RPG_ADAPTER_DND5E_SRD, + RPG_ADAPTER_FATE, + RPG_ADAPTER_PF2E, +) +from tldw_Server_API.app.core.RPG.models import RuleCitation + + +@dataclass(frozen=True, slots=True) +class RuleLookupCitation: + source_type: str + source_id: int | None + source_title: str + source_url: str | None + license: str | None + license_url: str | None + attribution: str | None + trust_level: str + content_hash: str + snippet_id: str + adapter_key: str | None = None + source_version: str | None = None + content_pack_version: str | None = None + + +@dataclass(frozen=True, slots=True) +class RuleLookupItem: + origin: Literal["user_provided", "bundled_citation"] + text: str + citation: RuleLookupCitation + score: float + + +@dataclass(frozen=True, slots=True) +class RuleLookupResult: + query: str + mode: Literal["lookup", "answer"] + results: list[RuleLookupItem] + answer: str | None + answer_status: str + answer_citation_ids: list[str] + diagnostics: dict[str, Any] + + +def bundled_citation_lookup_item(citation: RuleCitation) -> RuleLookupItem: + return RuleLookupItem( + origin="bundled_citation", + text="", + citation=RuleLookupCitation( + source_type="bundled_rules_citation", + source_id=None, + source_title=citation.source_title, + source_url=citation.source_url, + license=citation.license, + license_url=citation.license_url, + attribution=citation.attribution, + trust_level=citation.trust_level, + content_hash=citation.content_hash, + snippet_id=citation.snippet_id, + adapter_key=citation.adapter_key, + source_version=citation.source_version, + content_pack_version=citation.content_pack_version, + ), + score=0.0, + ) + + +DND5E_SRD_CITATIONS = ( + RuleCitation( + adapter_key=RPG_ADAPTER_DND5E_SRD, + source_title="Systems Reference Document 5.1", + source_url="https://dnd.wizards.com/resources/systems-reference-document", + license="CC-BY-4.0", + license_url="https://creativecommons.org/licenses/by/4.0/", + attribution="D&D 5e SRD rules reference", + trust_level="reference", + content_hash="citation-only", + snippet_id="dnd5e-srd-citation-index", + source_version="SRD 5.1", + content_pack_version="1.0.0", + ), +) + +FATE_CITATIONS = ( + RuleCitation( + adapter_key=RPG_ADAPTER_FATE, + source_title="Fate SRD", + source_url="https://fate-srd.com/", + license="CC-BY-3.0", + license_url="https://creativecommons.org/licenses/by/3.0/", + attribution="Fate Core System rules reference", + trust_level="reference", + content_hash="citation-only", + snippet_id="fate-srd-citation-index", + source_version="Fate Core", + content_pack_version="1.0.0", + ), +) + +PF2E_CITATIONS = ( + RuleCitation( + adapter_key=RPG_ADAPTER_PF2E, + source_title="Archives of Nethys Pathfinder 2e", + source_url="https://2e.aonprd.com/", + license="ORC and Paizo Community Use references", + license_url="https://downloads.paizo.com/ORC_License_FINAL.pdf", + attribution="Pathfinder Second Edition rules reference", + trust_level="reference", + content_hash="citation-only", + snippet_id="pf2e-citation-index", + source_version="Pathfinder 2e Remaster", + content_pack_version="1.0.0", + ), +) + +BUILT_IN_CITATIONS_BY_ADAPTER: dict[str, tuple[RuleCitation, ...]] = { + RPG_ADAPTER_DND5E_SRD: DND5E_SRD_CITATIONS, + RPG_ADAPTER_FATE: FATE_CITATIONS, + RPG_ADAPTER_PF2E: PF2E_CITATIONS, +} diff --git a/tldw_Server_API/app/core/RPG/rules/lookup.py b/tldw_Server_API/app/core/RPG/rules/lookup.py new file mode 100644 index 0000000000..f86fc2a40b --- /dev/null +++ b/tldw_Server_API/app/core/RPG/rules/lookup.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import Any, Literal + +from loguru import logger + +from tldw_Server_API.app.core.RPG.errors import RPGError, RPGValidationError +from tldw_Server_API.app.core.RPG.rules.answering import ( + RulesAnswerGenerator, + RulesAnswerOptions, +) +from tldw_Server_API.app.core.RPG.rules.content_packs import ( + BUILT_IN_CITATIONS_BY_ADAPTER, + RuleLookupResult, + bundled_citation_lookup_item, +) +from tldw_Server_API.app.core.RPG.rules.refs import rules_pack_ref_from_dict +from tldw_Server_API.app.core.RPG.rules.retrieval import RulesRetriever + + +class RulesLookupService: + def __init__( + self, + *, + retriever: RulesRetriever | None = None, + answer_generator: RulesAnswerGenerator | None = None, + ) -> None: + self._retriever = retriever + self._answer_generator = answer_generator + + async def lookup( + self, + *, + owner_user_id: int, + adapter_key: str, + query: str, + linked_rules_pack_refs: list[dict[str, Any]], + mode: Literal["lookup", "answer"] = "lookup", + answer_options: RulesAnswerOptions | None = None, + ) -> RuleLookupResult: + normalized_query = query.strip() + if not normalized_query: + raise RPGValidationError("rules_query_required") + if mode not in {"lookup", "answer"}: + raise RPGValidationError("invalid_rules_lookup_mode") + + refs = [rules_pack_ref_from_dict(ref) for ref in linked_rules_pack_refs] + enabled_count = sum(1 for ref in refs if ref.enabled) + citations = BUILT_IN_CITATIONS_BY_ADAPTER.get(adapter_key, ()) + bundled_items = [bundled_citation_lookup_item(citation) for citation in citations] + diagnostics: dict[str, Any] = { + "bundled_policy": "citations_only" if citations else "no_match", + "result_mode": "retrieval_with_citation_index" + if self._retriever is not None and refs + else "citation_index", + "linked_rules_pack_count": len(refs), + "enabled_rules_pack_count": enabled_count, + "ready_media_item_count": 0, + "retrieval_result_count": 0, + "bundled_citation_count": len(bundled_items), + "skipped_refs": [], + "broad_fallback_used": False, + } + retrieval_items = [] + answer = None + answer_citation_ids: list[str] = [] + answer_status = "not_requested" if mode == "lookup" else "no_evidence" + + if self._retriever is not None and refs: + try: + retrieval = await self._retriever.retrieve( + owner_user_id=owner_user_id, + query=normalized_query, + refs=refs, + max_results=8, + ) + except RPGError: + raise + except Exception as exc: # noqa: BLE001 + logger.warning("RPG rules retrieval failed: {}", type(exc).__name__) + diagnostics["retrieval_error"] = "retrieval_failed" + if mode == "answer": + answer_status = "retrieval_error" + else: + retrieval_items = list(retrieval.items) + diagnostics.update(retrieval.diagnostics) + diagnostics["ready_media_item_count"] = len(retrieval.ready_media_ids) + diagnostics["retrieval_result_count"] = len(retrieval_items) + diagnostics["skipped_refs"] = list(retrieval.skipped_refs) + + if mode == "answer" and retrieval_items: + if self._answer_generator is None: + answer_status = "not_generated" + else: + try: + answer_result = await self._answer_generator.generate( + query=normalized_query, + evidence=retrieval_items, + options=answer_options or RulesAnswerOptions(), + ) + except Exception as exc: # noqa: BLE001 + logger.warning("RPG rules answer generation failed: {}", type(exc).__name__) + answer_status = "generation_error" + else: + allowed_citation_ids = {item.citation.snippet_id for item in retrieval_items} + answer = answer_result.answer + answer_status = answer_result.answer_status + answer_citation_ids = [ + citation_id + for citation_id in answer_result.citation_ids + if citation_id in allowed_citation_ids + ] + + return RuleLookupResult( + query=normalized_query, + mode=mode, + results=[*retrieval_items, *bundled_items], + answer=answer, + answer_status=answer_status, + answer_citation_ids=answer_citation_ids, + diagnostics=diagnostics, + ) diff --git a/tldw_Server_API/app/core/RPG/rules/refs.py b/tldw_Server_API/app/core/RPG/rules/refs.py new file mode 100644 index 0000000000..661c1243d8 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/rules/refs.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal, Protocol, cast + +from tldw_Server_API.app.core.RPG.errors import RPGValidationError + +RulesPackSourceType = Literal["media_item", "media_collection"] +RULES_PACK_SOURCE_TYPES = {"media_item", "media_collection"} + + +@dataclass(frozen=True, slots=True) +class RulesPackRef: + ref_id: str + source_type: RulesPackSourceType + source_id: int + display_name: str + enabled: bool + created_at: datetime + updated_at: datetime + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RulesPackRefReplacementResult: + refs: list[RulesPackRef] + version: int + replayed: bool = False + + +@dataclass(frozen=True, slots=True) +class RulesPackSourceValidation: + ref_id: str + readable: bool + display_name: str | None + ready_media_ids: list[int] = field(default_factory=list) + + +class RulesPackSourceValidator(Protocol): + async def validate_media_item(self, owner_user_id: int, media_id: int) -> RulesPackSourceValidation: + ... + + async def validate_media_collection( + self, + owner_user_id: int, + collection_id: int, + ) -> RulesPackSourceValidation: + ... + + +def normalize_rules_pack_ref_payloads( + payloads: list[dict[str, Any]], + existing_refs: list[dict[str, Any]], + now: datetime, +) -> list[RulesPackRef]: + existing_by_ref_id = _existing_refs_by_ref_id(existing_refs) + normalized: list[RulesPackRef] = [] + seen_ref_ids: set[str] = set() + + for payload in payloads: + source_type = _normalize_source_type(payload.get("source_type")) + source_id = _normalize_source_id(payload.get("source_id")) + ref_id = f"{source_type}:{source_id}" + if ref_id in seen_ref_ids: + raise RPGValidationError("duplicate_rules_pack_ref") + seen_ref_ids.add(ref_id) + + display_name = str(payload.get("display_name") or "").strip() or ref_id + metadata = payload.get("metadata", {}) + if metadata is None: + metadata = {} + if not isinstance(metadata, dict): + raise RPGValidationError("invalid_rules_pack_ref_metadata") + + existing = existing_by_ref_id.get(ref_id) + normalized.append( + RulesPackRef( + ref_id=ref_id, + source_type=source_type, + source_id=source_id, + display_name=display_name, + enabled=_normalize_enabled(payload), + created_at=existing.created_at if existing is not None else now, + updated_at=now, + metadata=dict(metadata), + ) + ) + + return normalized + + +def rules_pack_ref_to_dict(ref: RulesPackRef) -> dict[str, Any]: + return { + "ref_id": ref.ref_id, + "source_type": ref.source_type, + "source_id": ref.source_id, + "display_name": ref.display_name, + "enabled": ref.enabled, + "created_at": _format_utc(ref.created_at), + "updated_at": _format_utc(ref.updated_at), + "metadata": dict(ref.metadata), + } + + +def rules_pack_ref_from_dict(data: dict[str, Any]) -> RulesPackRef: + source_type = data.get("source_type") + source_id = data.get("source_id") + if source_type is None or source_id is None: + ref_id_value = data.get("ref_id") + if isinstance(ref_id_value, str) and ":" in ref_id_value: + source_type, raw_source_id = ref_id_value.split(":", 1) + try: + source_id = int(raw_source_id) + except ValueError as exc: + raise RPGValidationError("invalid_rules_pack_ref_source_id") from exc + + source_type = _normalize_source_type(source_type) + source_id = _normalize_source_id(source_id) + ref_id = f"{source_type}:{source_id}" + metadata = data.get("metadata", {}) + if metadata is None: + metadata = {} + if not isinstance(metadata, dict): + raise RPGValidationError("invalid_rules_pack_ref_metadata") + + return RulesPackRef( + ref_id=ref_id, + source_type=source_type, + source_id=source_id, + display_name=str(data.get("display_name") or "").strip() or ref_id, + enabled=_normalize_enabled(data), + created_at=_parse_datetime(data.get("created_at")), + updated_at=_parse_datetime(data.get("updated_at")), + metadata=dict(metadata), + ) + + +def _existing_refs_by_ref_id(existing_refs: list[dict[str, Any]]) -> dict[str, RulesPackRef]: + refs: dict[str, RulesPackRef] = {} + for existing_ref in existing_refs: + try: + ref = rules_pack_ref_from_dict(existing_ref) + except (RPGValidationError, TypeError, ValueError): + continue + refs[ref.ref_id] = ref + return refs + + +def _normalize_source_type(source_type: Any) -> RulesPackSourceType: + if source_type not in RULES_PACK_SOURCE_TYPES: + raise RPGValidationError("invalid_rules_pack_ref_source_type") + return cast(RulesPackSourceType, source_type) + + +def _normalize_source_id(source_id: Any) -> int: + if isinstance(source_id, bool) or not isinstance(source_id, int) or source_id <= 0: + raise RPGValidationError("invalid_rules_pack_ref_source_id") + return source_id + + +def _normalize_enabled(data: dict[str, Any]) -> bool: + if "enabled" not in data: + return True + enabled = data["enabled"] + if not isinstance(enabled, bool): + raise RPGValidationError("invalid_rules_pack_ref_enabled") + return enabled + + +def _parse_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + else: + parsed = datetime.now(timezone.utc) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _format_utc(value: datetime) -> str: + utc_value = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) + return utc_value.isoformat().replace("+00:00", "Z") diff --git a/tldw_Server_API/app/core/RPG/rules/retrieval.py b/tldw_Server_API/app/core/RPG/rules/retrieval.py new file mode 100644 index 0000000000..06aa9126f6 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/rules/retrieval.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, Protocol + +from tldw_Server_API.app.core.RAG.rag_service.database_retrievers import RetrievalConfig +from tldw_Server_API.app.core.RAG.rag_service.evidence_models import RetrievedEvidence +from tldw_Server_API.app.core.RAG.rag_service.types import DataSource +from tldw_Server_API.app.core.RPG.errors import RPGValidationError +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupCitation, RuleLookupItem +from tldw_Server_API.app.core.RPG.rules.refs import ( + RulesPackRef, + RulesPackSourceValidation, + RulesPackSourceValidator, +) + +MAX_RULE_SNIPPET_CHARS = 1500 + +RetrievalExecutor = Callable[..., Awaitable[RetrievedEvidence]] + + +@dataclass(frozen=True, slots=True) +class ResolvedRAGRequest: + query: str + strategy: str + payload: dict[str, Any] + index_namespace: str | None + rag_profile: str | None + user_id: str | None + feedback_user_id: str | None + + +@dataclass(frozen=True, slots=True) +class RetrievalPlan: + query: str + sources: tuple[str, ...] + search_mode: str + top_k: int + min_score: float + index_namespace: str | None + collection_names: dict[str, str] = field(default_factory=dict) + + +async def execute_retrieval_phase( + *, + resolved_request: ResolvedRAGRequest, + retrieval_plan: RetrievalPlan, + retriever: Any, + retrieval_config: RetrievalConfig | None = None, + allowed_media_ids: list[int] | None = None, + allowed_note_ids: list[str] | None = None, +) -> RetrievedEvidence: + """Run RPG rules retrieval through the existing mainline RAG retriever API.""" + + if retrieval_config is None: + search_mode = retrieval_plan.search_mode.lower() + retrieval_config = RetrievalConfig( + max_results=max(1, int(retrieval_plan.top_k)), + min_score=float(retrieval_plan.min_score), + use_fts=search_mode in {"fts", "hybrid", "standard"}, + use_vector=search_mode in {"vector", "hybrid", "standard"}, + ) + + sources = [ + DataSource.MEDIA_DB if str(source) == DataSource.MEDIA_DB.value else DataSource(str(source)) + for source in retrieval_plan.sources + ] + documents = await retriever.retrieve( + resolved_request.query, + sources=sources, + config=retrieval_config, + index_namespace=retrieval_plan.index_namespace, + allowed_media_ids=allowed_media_ids, + allowed_note_ids=allowed_note_ids, + ) + return RetrievedEvidence( + documents=list(documents), + metadata={"retrieval_plan": retrieval_plan, "source": "rpg_rules"}, + ) + + +@dataclass(frozen=True, slots=True) +class RulesRetrievalResult: + items: list[RuleLookupItem] + ready_media_ids: list[int] + skipped_refs: list[dict[str, Any]] = field(default_factory=list) + diagnostics: dict[str, Any] = field(default_factory=dict) + + +class RulesRetriever(Protocol): + async def retrieve( + self, + *, + owner_user_id: int, + query: str, + refs: list[RulesPackRef], + max_results: int, + ) -> RulesRetrievalResult: + ... + + +class RulesRetrievalAdapter: + def __init__( + self, + *, + source_validator: RulesPackSourceValidator, + rag_retriever: Any, + retrieval_executor: RetrievalExecutor = execute_retrieval_phase, + ) -> None: + self._source_validator = source_validator + self._rag_retriever = rag_retriever + self._retrieval_executor = retrieval_executor + + async def retrieve( + self, + *, + owner_user_id: int, + query: str, + refs: list[RulesPackRef], + max_results: int, + ) -> RulesRetrievalResult: + enabled_refs = [ref for ref in refs if ref.enabled] + skipped_refs = [{"ref_id": ref.ref_id, "reason": "disabled"} for ref in refs if not ref.enabled] + ready_media_ids: list[int] = [] + seen_media_ids: set[int] = set() + + validations = await asyncio.gather( + *(self._validate_ref(owner_user_id=owner_user_id, ref=ref) for ref in enabled_refs) + ) + + for ref, validation in zip(enabled_refs, validations): + if not validation.readable: + raise RPGValidationError("rules_pack_source_unreadable") + ref_ready_ids = [media_id for media_id in validation.ready_media_ids if isinstance(media_id, int)] + if not ref_ready_ids: + skipped_refs.append({"ref_id": ref.ref_id, "reason": "no_ready_media"}) + continue + for media_id in ref_ready_ids: + if media_id <= 0 or media_id in seen_media_ids: + continue + seen_media_ids.add(media_id) + ready_media_ids.append(media_id) + + base_diagnostics = { + "enabled_rules_pack_count": len(enabled_refs), + "ready_media_item_count": len(ready_media_ids), + "skipped_refs": skipped_refs, + "broad_fallback_used": False, + } + if not ready_media_ids: + return RulesRetrievalResult( + items=[], + ready_media_ids=[], + skipped_refs=skipped_refs, + diagnostics={**base_diagnostics, "retrieval_result_count": 0, "no_ready_sources": True}, + ) + + max_results = max(1, int(max_results)) + resolved_request = ResolvedRAGRequest( + query=query, + strategy="standard", + payload={"sources": ["media_db"], "search_mode": "hybrid", "top_k": max_results}, + index_namespace=None, + rag_profile=None, + user_id=str(owner_user_id), + feedback_user_id=str(owner_user_id), + ) + retrieval_plan = RetrievalPlan( + query=query, + sources=("media_db",), + search_mode="hybrid", + top_k=max_results, + min_score=0.0, + index_namespace=None, + collection_names={"media_db": f"user_{owner_user_id}_media_embeddings"}, + ) + evidence = await self._retrieval_executor( + resolved_request=resolved_request, + retrieval_plan=retrieval_plan, + retriever=self._rag_retriever, + retrieval_config=None, + allowed_media_ids=ready_media_ids, + allowed_note_ids=None, + ) + ready_media_id_set = set(ready_media_ids) + scoped_documents = [ + document + for document in list(evidence.documents) + if _document_media_id(document) in ready_media_id_set + ] + items = [_document_to_lookup_item(document) for document in scoped_documents[:max_results]] + return RulesRetrievalResult( + items=items, + ready_media_ids=ready_media_ids, + skipped_refs=skipped_refs, + diagnostics={**base_diagnostics, "retrieval_result_count": len(items), "no_ready_sources": False}, + ) + + async def _validate_ref(self, *, owner_user_id: int, ref: RulesPackRef) -> RulesPackSourceValidation: + if ref.source_type == "media_item": + return await self._source_validator.validate_media_item(owner_user_id, ref.source_id) + if ref.source_type == "media_collection": + return await self._source_validator.validate_media_collection(owner_user_id, ref.source_id) + raise RPGValidationError("invalid_rules_pack_ref_source_type") + + +def _document_to_lookup_item(document: Any) -> RuleLookupItem: + metadata = _metadata(document) + media_id = _document_media_id(document) + chunk_index = _int_value(metadata.get("chunk_index") or getattr(document, "chunk_index", None)) + snippet_id = str(metadata.get("snippet_id") or metadata.get("chunk_id") or "").strip() + if not snippet_id: + snippet_id = _stable_snippet_id(media_id=media_id, chunk_index=chunk_index, document_id=getattr(document, "id", None)) + + text = str(getattr(document, "content", "") or "")[:MAX_RULE_SNIPPET_CHARS] + return RuleLookupItem( + origin="user_provided", + text=text, + citation=RuleLookupCitation( + source_type="media_item", + source_id=media_id, + source_title=_source_title(document=document, metadata=metadata), + source_url=_optional_text(metadata.get("source_url") or metadata.get("url")), + license=_optional_text(metadata.get("license")), + license_url=_optional_text(metadata.get("license_url")), + attribution=_optional_text(metadata.get("attribution") or metadata.get("author")), + trust_level="user_provided", + content_hash=str(metadata.get("content_hash") or metadata.get("hash") or ""), + snippet_id=snippet_id, + ), + score=float(getattr(document, "score", 0.0) or 0.0), + ) + + +def _document_media_id(document: Any) -> int | None: + metadata = _metadata(document) + return _int_value( + metadata.get("media_id") + or metadata.get("media_item_id") + or metadata.get("source_id") + or getattr(document, "source_document_id", None) + or getattr(document, "id", None) + ) + + +def _metadata(document: Any) -> dict[str, Any]: + raw_metadata = getattr(document, "metadata", None) + if isinstance(raw_metadata, dict): + return raw_metadata + return {} + + +def _source_title(*, document: Any, metadata: dict[str, Any]) -> str: + for value in ( + metadata.get("title"), + metadata.get("source_title"), + metadata.get("document_title"), + getattr(document, "source_document_id", None), + getattr(document, "id", None), + ): + text = str(value or "").strip() + if text: + return text + return "User-provided rules" + + +def _stable_snippet_id(*, media_id: int | None, chunk_index: int | None, document_id: Any) -> str: + if media_id is not None and chunk_index is not None: + return f"media:{media_id}:chunk:{chunk_index}" + if media_id is not None: + return f"media:{media_id}:chunk:unknown" + return f"document:{str(document_id or 'unknown')}" + + +def _int_value(value: Any) -> int | None: + if isinstance(value, bool) or value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _optional_text(value: Any) -> str | None: + text = str(value or "").strip() + return text or None diff --git a/tldw_Server_API/app/core/RPG/rules/source_validation.py b/tldw_Server_API/app/core/RPG/rules/source_validation.py new file mode 100644 index 0000000000..2a45792015 --- /dev/null +++ b/tldw_Server_API/app/core/RPG/rules/source_validation.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import Any + +from tldw_Server_API.app.core.DB_Management.Collections_DB import CollectionsDatabase +from tldw_Server_API.app.core.RPG.rules.refs import RulesPackSourceValidation + +READY_COLLECTION_ITEM_STATUSES = {"completed", "skipped_existing"} + + +class RPGRulesSourceValidator: + def __init__(self, media_db: Any, collections_db: CollectionsDatabase) -> None: + self.media_db = media_db + self.collections_db = collections_db + + async def validate_media_item(self, owner_user_id: int, media_id: int) -> RulesPackSourceValidation: + media = self._readable_media_by_id(owner_user_id=owner_user_id, media_id=media_id) + if not media: + return RulesPackSourceValidation( + ref_id=f"media_item:{media_id}", + readable=False, + display_name=None, + ) + return RulesPackSourceValidation( + ref_id=f"media_item:{media_id}", + readable=True, + display_name=media_display_name(media), + ready_media_ids=[int(media_id)], + ) + + async def validate_media_collection( + self, + owner_user_id: int, + collection_id: int, + ) -> RulesPackSourceValidation: + try: + collection = self.collections_db.get_media_collection(collection_id) + except KeyError: + return RulesPackSourceValidation( + ref_id=f"media_collection:{collection_id}", + readable=False, + display_name=None, + ) + + ready_media_ids = [ + int(item.media_id) + for item in collection.items + if item.media_id is not None + and item.status in READY_COLLECTION_ITEM_STATUSES + and self._readable_media_by_id(owner_user_id=owner_user_id, media_id=int(item.media_id)) is not None + ] + return RulesPackSourceValidation( + ref_id=f"media_collection:{collection_id}", + readable=True, + display_name=collection.name, + ready_media_ids=ready_media_ids, + ) + + def _readable_media_by_id(self, *, owner_user_id: int, media_id: int) -> dict[str, Any] | None: + media = self.media_db.get_media_by_id(media_id, include_deleted=False, include_trash=False) + if not media: + return None + if not media_belongs_to_owner(media, owner_user_id=owner_user_id, media_db=self.media_db): + return None + return media + + +def media_display_name(media: dict[str, Any]) -> str | None: + for key in ("title", "name", "filename", "url"): + value = str(media.get(key) or "").strip() + if value: + return value + return None + + +def media_belongs_to_owner(media: dict[str, Any], *, owner_user_id: int, media_db: Any) -> bool: + allowed_owner_ids = {str(owner_user_id)} + client_id = getattr(media_db, "client_id", None) + if client_id is not None: + allowed_owner_ids.add(str(client_id)) + + owner_value = media.get("owner_user_id") + if owner_value is not None: + owner_text = str(owner_value).strip() + return not owner_text or owner_text in allowed_owner_ids + + client_value = media.get("client_id") + if client_value is not None: + client_text = str(client_value).strip() + return not client_text or client_text in allowed_owner_ids + + return True diff --git a/tldw_Server_API/app/core/RPG/service.py b/tldw_Server_API/app/core/RPG/service.py new file mode 100644 index 0000000000..5c2f3cbbac --- /dev/null +++ b/tldw_Server_API/app/core/RPG/service.py @@ -0,0 +1,578 @@ +from __future__ import annotations + +import asyncio +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from typing import Any + +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RPG.authority import decide_authority +from tldw_Server_API.app.core.RPG.constants import MAX_RPG_CONTEXT_CHARS +from tldw_Server_API.app.core.RPG.context import SessionContext, SessionContextBuilder +from tldw_Server_API.app.core.RPG.errors import RPGConflictError, RPGValidationError +from tldw_Server_API.app.core.RPG.events import canonical_request_hash, validate_event_envelope +from tldw_Server_API.app.core.RPG.models import ( + RPGCampaign, + RPGSession, + RPGSessionEvent, + RPGSnapshotState, +) +from tldw_Server_API.app.core.RPG.reducer import reduce_events +from tldw_Server_API.app.core.RPG.rules.adapters import RuleAdapterRegistry, build_default_adapter_registry +from tldw_Server_API.app.core.RPG.rules.answering import RulesAnswerOptions +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupResult +from tldw_Server_API.app.core.RPG.rules.lookup import RulesLookupService +from tldw_Server_API.app.core.RPG.rules.refs import ( + RulesPackRef, + RulesPackRefReplacementResult, + RulesPackSourceValidator, + normalize_rules_pack_ref_payloads, + rules_pack_ref_from_dict, + rules_pack_ref_to_dict, +) + + +@dataclass(frozen=True, slots=True) +class RPGServiceProposal: + id: int + session_id: int + status: str + proposed_events: list[dict[str, Any]] + + +@dataclass(frozen=True, slots=True) +class RecordEventsResult: + committed_events: list[RPGSessionEvent] + proposal: RPGServiceProposal | None + + +@dataclass(frozen=True, slots=True) +class SnapshotResult: + snapshot_version: int + last_event_sequence: int + snapshot: RPGSnapshotState + diagnostics: dict[str, Any] + + +class RPGService: + def __init__( + self, + repo: RPGRepository, + owner_user_id: int, + adapter_registry: RuleAdapterRegistry | None = None, + rules_source_validator: RulesPackSourceValidator | None = None, + rules_lookup_service: RulesLookupService | None = None, + ) -> None: + self.repo = repo + self.owner_user_id = owner_user_id + self.adapter_registry = adapter_registry or build_default_adapter_registry() + self.rules_source_validator = rules_source_validator + self.rules_lookup_service = rules_lookup_service or RulesLookupService() + + def create_campaign( + self, + title: str, + description: str | None, + default_adapter_key: str, + idempotency_key: str, + ) -> RPGCampaign: + self._require_idempotency_key(idempotency_key) + adapter = self.adapter_registry.get(default_adapter_key) + request_hash = canonical_request_hash( + { + "title": title, + "description": description, + "default_adapter_key": adapter.adapter_key, + "default_adapter_version": adapter.adapter_version, + } + ) + return self.repo.create_campaign( + owner_user_id=self.owner_user_id, + title=title, + description=description, + default_adapter_key=adapter.adapter_key, + default_adapter_version=adapter.adapter_version, + settings={}, + linked_rules_pack_refs=[], + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type="user", + ) + + def create_session( + self, + campaign_id: int, + title: str, + adapter_key: str, + idempotency_key: str, + active_rules_pack_refs: list[dict[str, Any]] | None = None, + ) -> RPGSession: + self._require_idempotency_key(idempotency_key) + adapter = self.adapter_registry.get(adapter_key) + authority_settings = {"model_auto_commit": False, "mcp_auto_commit": False} + campaign = self.repo.get_campaign(owner_user_id=self.owner_user_id, campaign_id=campaign_id) + active_refs = self._prepare_session_rules_pack_refs(active_rules_pack_refs, campaign) + active_refs_request = self._session_rules_pack_refs_request(active_rules_pack_refs, active_refs) + request_hash = canonical_request_hash( + { + "campaign_id": campaign_id, + "title": title, + "adapter_key": adapter.adapter_key, + "adapter_version": adapter.adapter_version, + "authority_settings": authority_settings, + "active_rules_pack_refs": active_refs_request, + } + ) + replay = self.repo.replay_create_session( + owner_user_id=self.owner_user_id, + campaign_id=campaign_id, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type="user", + ) + if replay is not None: + return replay + self._validate_explicit_session_rules_pack_refs(active_rules_pack_refs, active_refs) + return self.repo.create_session( + owner_user_id=self.owner_user_id, + campaign_id=campaign_id, + title=title, + adapter_key=adapter.adapter_key, + adapter_version=adapter.adapter_version, + authority_settings=authority_settings, + linked_chat_id=None, + active_rules_pack_refs=active_refs, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type="user", + ) + + def list_campaign_rules_pack_refs(self, campaign_id: int) -> RulesPackRefReplacementResult: + campaign = self.repo.get_campaign(owner_user_id=self.owner_user_id, campaign_id=campaign_id) + return RulesPackRefReplacementResult( + refs=[rules_pack_ref_from_dict(ref) for ref in campaign.linked_rules_pack_refs], + version=campaign.version, + ) + + def list_session_rules_pack_refs(self, session_id: int) -> RulesPackRefReplacementResult: + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + return RulesPackRefReplacementResult( + refs=[rules_pack_ref_from_dict(ref) for ref in session.active_rules_pack_refs], + version=session.version, + ) + + async def replace_campaign_rules_pack_refs( + self, + campaign_id: int, + refs: list[dict[str, Any]], + expected_version: int, + idempotency_key: str, + source_type: str = "user", + ) -> RulesPackRefReplacementResult: + self._require_idempotency_key(idempotency_key) + campaign = self.repo.get_campaign(owner_user_id=self.owner_user_id, campaign_id=campaign_id) + normalized = self._normalize_rules_pack_refs(refs, campaign.linked_rules_pack_refs) + normalized_dicts = [rules_pack_ref_to_dict(ref) for ref in normalized] + request_hash = canonical_request_hash( + { + "target_type": "campaign", + "campaign_id": campaign_id, + "rules_pack_refs": self._rules_pack_ref_request_dicts(normalized), + "expected_version": expected_version, + "source_type": source_type, + } + ) + replay = self.repo.replay_campaign_rules_pack_refs( + owner_user_id=self.owner_user_id, + campaign_id=campaign_id, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type=source_type, + ) + if replay is not None: + return replay + await self._validate_rules_pack_refs(normalized) + return self.repo.replace_campaign_rules_pack_refs( + owner_user_id=self.owner_user_id, + campaign_id=campaign_id, + rules_pack_refs=normalized_dicts, + expected_version=expected_version, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type=source_type, + ) + + async def replace_session_rules_pack_refs( + self, + session_id: int, + refs: list[dict[str, Any]], + expected_version: int, + idempotency_key: str, + source_type: str = "user", + ) -> RulesPackRefReplacementResult: + self._require_idempotency_key(idempotency_key) + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + normalized = self._normalize_rules_pack_refs(refs, session.active_rules_pack_refs) + normalized_dicts = [rules_pack_ref_to_dict(ref) for ref in normalized] + request_hash = canonical_request_hash( + { + "target_type": "session", + "session_id": session_id, + "rules_pack_refs": self._rules_pack_ref_request_dicts(normalized), + "expected_version": expected_version, + "source_type": source_type, + } + ) + replay = self.repo.replay_session_rules_pack_refs( + owner_user_id=self.owner_user_id, + session_id=session_id, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type=source_type, + ) + if replay is not None: + return replay + await self._validate_rules_pack_refs(normalized) + return self.repo.replace_session_rules_pack_refs( + owner_user_id=self.owner_user_id, + session_id=session_id, + rules_pack_refs=normalized_dicts, + expected_version=expected_version, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + source_type=source_type, + ) + + def record_events( + self, + session_id: int, + events: list[dict[str, Any]], + source_type: str, + expected_last_event_sequence: int, + idempotency_key: str, + ) -> RecordEventsResult: + self._require_idempotency_key(idempotency_key) + if not events: + raise RPGConflictError("events_required") + + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + source_actor_id = self._source_actor_id(source_type) + normalized = [ + validate_event_envelope( + { + **event, + "source_type": source_type, + "source_actor_id": source_actor_id, + } + ) + for event in events + ] + decisions = [ + decide_authority(source_type, event["event_type"], session.authority_settings) for event in normalized + ] + request_hash = canonical_request_hash( + { + "events": normalized, + "expected_last_event_sequence": expected_last_event_sequence, + "source_type": source_type, + } + ) + if any(decision.action == "proposal" for decision in decisions): + current = self.repo.get_latest_snapshot( + owner_user_id=self.owner_user_id, + session_id=session_id, + ) + proposal = self.repo.create_proposal( + owner_user_id=self.owner_user_id, + session_id=session_id, + base_event_sequence=expected_last_event_sequence, + base_snapshot_version=current.snapshot_version, + proposed_events=normalized, + source_type=source_type, + source_actor_id=source_actor_id, + model_metadata={}, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + ) + return RecordEventsResult( + committed_events=[], + proposal=RPGServiceProposal( + id=proposal.id, + session_id=session_id, + status=proposal.status, + proposed_events=proposal.proposed_events, + ), + ) + + return self._commit_validated_events( + session=session, + normalized=normalized, + expected_last_event_sequence=expected_last_event_sequence, + idempotency_key=idempotency_key, + request_hash=request_hash, + proposal_id=None, + ) + + def apply_proposal( + self, + session_id: int, + proposal_id: int, + expected_last_event_sequence: int, + idempotency_key: str, + review_notes: str | None = None, + ) -> RecordEventsResult: + self._require_idempotency_key(idempotency_key) + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + proposal = self.repo.get_proposal(owner_user_id=self.owner_user_id, proposal_id=proposal_id) + if proposal.session_id != session_id: + raise RPGConflictError("proposal_session_mismatch") + if expected_last_event_sequence != proposal.base_event_sequence: + raise RPGConflictError("stale_event_sequence") + + normalized = [validate_event_envelope(event) for event in proposal.proposed_events] + request_hash = canonical_request_hash( + { + "proposal_id": proposal_id, + "expected_last_event_sequence": expected_last_event_sequence, + "events": normalized, + "review_notes": review_notes, + } + ) + if proposal.status == "applied": + return self._commit_validated_events( + session=session, + normalized=normalized, + expected_last_event_sequence=proposal.base_event_sequence, + idempotency_key=idempotency_key, + request_hash=request_hash, + proposal_id=proposal_id, + proposal_review_notes=review_notes, + ) + if proposal.status != "pending": + raise RPGConflictError("proposal_not_pending") + if proposal.base_event_sequence != session.last_event_sequence: + self.repo.mark_proposal_conflicted(self.owner_user_id, proposal_id) + raise RPGConflictError("proposal_base_sequence_conflict") + + return self._commit_validated_events( + session=session, + normalized=normalized, + expected_last_event_sequence=proposal.base_event_sequence, + idempotency_key=idempotency_key, + request_hash=request_hash, + proposal_id=proposal_id, + proposal_review_notes=review_notes, + ) + + def reject_proposal( + self, + session_id: int, + proposal_id: int, + idempotency_key: str, + review_notes: str | None = None, + ) -> RPGServiceProposal: + self._require_idempotency_key(idempotency_key) + proposal = self.repo.get_proposal(owner_user_id=self.owner_user_id, proposal_id=proposal_id) + if proposal.session_id != session_id: + raise RPGConflictError("proposal_session_mismatch") + rejected = self.repo.mark_proposal_rejected( + owner_user_id=self.owner_user_id, + proposal_id=proposal_id, + idempotency_key=idempotency_key, + review_notes=review_notes, + ) + return RPGServiceProposal( + id=rejected.id, + session_id=session_id, + status=rejected.status, + proposed_events=rejected.proposed_events, + ) + + def get_snapshot(self, session_id: int) -> SnapshotResult: + record = self.repo.get_latest_snapshot( + owner_user_id=self.owner_user_id, + session_id=session_id, + ) + return SnapshotResult( + snapshot_version=record.snapshot_version, + last_event_sequence=record.last_event_sequence, + snapshot=RPGSnapshotState(**record.snapshot_json), + diagnostics=record.diagnostics_json, + ) + + async def lookup_rules( + self, + session_id: int, + query: str, + mode: str = "lookup", + answer_options: RulesAnswerOptions | None = None, + ) -> RuleLookupResult: + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + return await self.rules_lookup_service.lookup( + owner_user_id=self.owner_user_id, + adapter_key=session.adapter_key, + query=query, + linked_rules_pack_refs=session.active_rules_pack_refs, + mode=mode, # type: ignore[arg-type] + answer_options=answer_options, + ) + + async def build_context( + self, + session_id: int, + query: str | None = None, + max_chars: int = MAX_RPG_CONTEXT_CHARS, + ) -> SessionContext: + bounded_max_chars = min(max(int(max_chars), 1000), MAX_RPG_CONTEXT_CHARS) + session = self.repo.get_session(owner_user_id=self.owner_user_id, session_id=session_id) + snapshot = self.get_snapshot(session_id).snapshot + rules_results = [] + rules_diagnostics: dict[str, Any] = {} + if query and query.strip(): + try: + rules_lookup = await self.lookup_rules(session_id, query, mode="lookup") + except RPGValidationError as exc: + rules_diagnostics = { + "lookup_error": str(exc), + "retrieval_result_count": 0, + "skipped_refs": [], + } + else: + rules_results = rules_lookup.results + rules_diagnostics = dict(rules_lookup.diagnostics) + return SessionContextBuilder(max_chars=bounded_max_chars).build( + adapter_key=session.adapter_key, + session_title=session.title, + snapshot=snapshot, + rules_results=rules_results, + rules_diagnostics=rules_diagnostics, + ) + + def _commit_validated_events( + self, + session: RPGSession, + normalized: list[dict[str, Any]], + expected_last_event_sequence: int, + idempotency_key: str, + request_hash: str, + proposal_id: int | None, + proposal_review_notes: str | None = None, + ) -> RecordEventsResult: + current = self.repo.get_latest_snapshot( + owner_user_id=self.owner_user_id, + session_id=session.id, + ) + next_snapshot = reduce_events(RPGSnapshotState(**current.snapshot_json), normalized) + committed = self.repo.commit_events_and_snapshot( + owner_user_id=self.owner_user_id, + session_id=session.id, + expected_last_event_sequence=expected_last_event_sequence, + base_snapshot_version=current.snapshot_version, + events=normalized, + snapshot=asdict(next_snapshot), + diagnostics={"applied_event_count": len(normalized)}, + idempotency_key=idempotency_key, + request_payload_hash=request_hash, + adapter_key=session.adapter_key, + adapter_version=session.adapter_version, + proposal_id=proposal_id, + proposal_review_notes=proposal_review_notes, + ) + return RecordEventsResult(committed_events=committed.events, proposal=None) + + @staticmethod + def _require_idempotency_key(idempotency_key: str) -> None: + if not idempotency_key: + raise RPGConflictError("idempotency_key_required") + + def _prepare_session_rules_pack_refs( + self, + active_rules_pack_refs: list[dict[str, Any]] | None, + campaign: RPGCampaign, + ) -> list[dict[str, Any]]: + if active_rules_pack_refs is None: + return [dict(ref) for ref in campaign.linked_rules_pack_refs] + if not active_rules_pack_refs: + return [] + + normalized = self._normalize_rules_pack_refs(active_rules_pack_refs, existing_refs=[]) + return [rules_pack_ref_to_dict(ref) for ref in normalized] + + def _validate_explicit_session_rules_pack_refs( + self, + active_rules_pack_refs: list[dict[str, Any]] | None, + active_refs: list[dict[str, Any]], + ) -> None: + if not active_rules_pack_refs: + return + refs = [rules_pack_ref_from_dict(ref) for ref in active_refs] + if any(ref.enabled for ref in refs): + try: + asyncio.get_running_loop() + except RuntimeError: + asyncio.run(self._validate_rules_pack_refs(refs)) + else: + raise RPGValidationError("rules_source_validation_requires_async_context") + + def _session_rules_pack_refs_request( + self, + active_rules_pack_refs: list[dict[str, Any]] | None, + active_refs: list[dict[str, Any]], + ) -> dict[str, Any]: + if active_rules_pack_refs is None: + return {"mode": "campaign_default"} + if not active_rules_pack_refs: + return {"mode": "explicit", "refs": []} + refs = [rules_pack_ref_from_dict(ref) for ref in active_refs] + return {"mode": "explicit", "refs": self._rules_pack_ref_request_dicts(refs)} + + @staticmethod + def _normalize_rules_pack_refs( + refs: list[dict[str, Any]], + existing_refs: list[dict[str, Any]], + ) -> list[RulesPackRef]: + return normalize_rules_pack_ref_payloads( + refs, + existing_refs=existing_refs, + now=datetime.now(timezone.utc), + ) + + @staticmethod + def _rules_pack_ref_request_dicts(refs: list[RulesPackRef]) -> list[dict[str, Any]]: + return [ + { + "ref_id": ref.ref_id, + "source_type": ref.source_type, + "source_id": ref.source_id, + "display_name": ref.display_name, + "enabled": ref.enabled, + "metadata": dict(ref.metadata), + } + for ref in refs + ] + + async def _validate_rules_pack_refs(self, refs: list[RulesPackRef]) -> None: + enabled_refs = [ref for ref in refs if ref.enabled] + if not enabled_refs: + return + if self.rules_source_validator is None: + raise RPGValidationError("rules_source_validator_required") + + for ref in enabled_refs: + if ref.source_type == "media_item": + validation = await self.rules_source_validator.validate_media_item( + self.owner_user_id, + ref.source_id, + ) + else: + validation = await self.rules_source_validator.validate_media_collection( + self.owner_user_id, + ref.source_id, + ) + if not validation.readable: + raise RPGValidationError("rules_pack_source_unreadable") + + def _source_actor_id(self, source_type: str) -> str | None: + if source_type in {"user", "mcp"}: + return f"{source_type}:{self.owner_user_id}" + return None diff --git a/tldw_Server_API/app/core/exceptions.py b/tldw_Server_API/app/core/exceptions.py index 8be2e89741..70bb8faba9 100644 --- a/tldw_Server_API/app/core/exceptions.py +++ b/tldw_Server_API/app/core/exceptions.py @@ -362,6 +362,22 @@ class ValidationError(BadRequestError): """Raised when validation of input parameters fails.""" +class RPGError(Exception): + """Base exception for RPG runtime errors.""" + + +class RPGNotFoundError(RPGError): + """Raised when an RPG resource cannot be found.""" + + +class RPGValidationError(RPGError): + """Raised when RPG input fails domain validation.""" + + +class RPGConflictError(RPGError): + """Raised when an RPG write conflicts with current state.""" + + class SetupError(RuntimeError): """Base class for first-run setup and installer failures.""" diff --git a/tldw_Server_API/tests/RPG/test_rpg_api.py b/tldw_Server_API/tests/RPG/test_rpg_api.py new file mode 100644 index 0000000000..2b9ecf14cb --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_api.py @@ -0,0 +1,779 @@ +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient +from starlette.requests import Request + +from tldw_Server_API.app.api.v1.API_Deps.auth_deps import AuthPrincipal, get_auth_principal +from tldw_Server_API.app.api.v1.API_Deps.Collections_DB_Deps import get_collections_db_for_user +from tldw_Server_API.app.api.v1.API_Deps.DB_Deps import get_media_db_for_user +from tldw_Server_API.app.api.v1.endpoints import rpg as rpg_endpoint +from tldw_Server_API.app.core.AuthNZ.settings import get_settings +from tldw_Server_API.app.core.RPG.context import SessionContext +from tldw_Server_API.app.core.RPG.rules.answering import RulesAnswerOptions +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupResult +from tldw_Server_API.app.main import app +from tldw_Server_API.tests.PrivilegeCatalog.test_endpoint_scope_catalog_sync import load_catalog_scope_ids + +pytestmark = pytest.mark.integration + +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _headers(**extra): + return {"X-API-KEY": get_settings().SINGLE_USER_API_KEY, **extra} + + +def _create_campaign_and_session(client: TestClient, prefix: str) -> tuple[int, int]: + campaign = client.post( + "/api/v1/rpg/campaigns", + headers=_headers(**{"Idempotency-Key": f"{prefix}-campaign"}), + json={"title": f"{prefix} Campaign", "default_adapter_key": "fate"}, + ) + assert campaign.status_code == 201 # nosec B101 + campaign_id = campaign.json()["id"] + + session = client.post( + f"/api/v1/rpg/campaigns/{campaign_id}/sessions", + headers=_headers(**{"Idempotency-Key": f"{prefix}-session"}), + json={"title": f"{prefix} Opening", "adapter_key": "fate"}, + ) + assert session.status_code == 201 # nosec B101 + return campaign_id, session.json()["id"] + + +def _route_permissions(method: str, path: str) -> set[str]: + route = next( + route + for route in app.routes + if isinstance(route, APIRoute) and route.path == path and method.upper() in route.methods + ) + permissions: set[str] = set() + for dependency in route.dependant.dependencies: + call = dependency.call + closure = getattr(call, "__closure__", None) or () + for cell in closure: + value = cell.cell_contents + if isinstance(value, list): + permissions.update(str(item) for item in value) + elif isinstance(value, str) and "." in value: + permissions.add(value) + return permissions + + +def _route_dependency_calls(method: str, path: str) -> set[object]: + route = next( + route + for route in app.routes + if isinstance(route, APIRoute) and route.path == path and method.upper() in route.methods + ) + calls: set[object] = set() + + def collect(dependant) -> None: + for dependency in getattr(dependant, "dependencies", []): + calls.add(dependency.call) + collect(dependency) + + collect(route.dependant) + return calls + + +def test_rules_service_closes_rag_retriever(monkeypatch): + class FakeRetriever: + def __init__(self, *args, **kwargs) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + fake_retriever = FakeRetriever() + monkeypatch.setattr(rpg_endpoint, "MultiDatabaseRetriever", lambda *args, **kwargs: fake_retriever) + monkeypatch.setattr(rpg_endpoint, "get_media_db_path_for_rag", lambda media_db: "/tmp/media.db") + monkeypatch.setattr(rpg_endpoint.RPGRepository, "initialized", staticmethod(lambda db: object())) + + dependency = rpg_endpoint._rules_service( + db=object(), + media_db=object(), + collections_db=object(), + current_user=SimpleNamespace(id_int=42), + ) + service = next(dependency) + + dependency.close() + + assert service.owner_user_id == 42 # nosec B101 + assert fake_retriever.closed is True # nosec B101 + + +def test_rpg_endpoint_scopes_are_cataloged(): + catalog_path = _REPO_ROOT / "tldw_Server_API" / "Config_Files" / "privilege_catalog.yaml" + catalog_ids = load_catalog_scope_ids(catalog_path) + expected_ids = { + rpg_endpoint.CHAT_COMPLETIONS, + rpg_endpoint.RPG_RULES_READ, + rpg_endpoint.RPG_CAMPAIGNS_READ, + rpg_endpoint.RPG_CAMPAIGNS_MANAGE, + rpg_endpoint.RPG_SESSIONS_READ, + rpg_endpoint.RPG_SESSIONS_MANAGE, + rpg_endpoint.RPG_PROPOSALS_REVIEW, + rpg_endpoint.MEDIA_READ, + } + + assert expected_ids <= catalog_ids # nosec B101 + + +def test_campaign_rules_pack_refs_get_requires_campaign_read_and_media_read(): + permissions = _route_permissions("GET", "/api/v1/rpg/campaigns/{campaign_id}/rules-packs") + + assert {rpg_endpoint.RPG_CAMPAIGNS_READ, rpg_endpoint.MEDIA_READ} <= permissions # nosec B101 + + +def test_campaign_rules_pack_refs_put_requires_campaign_manage_and_media_read(): + permissions = _route_permissions("PUT", "/api/v1/rpg/campaigns/{campaign_id}/rules-packs") + + assert {rpg_endpoint.RPG_CAMPAIGNS_MANAGE, rpg_endpoint.MEDIA_READ} <= permissions # nosec B101 + + +def test_session_rules_pack_refs_get_requires_session_read_and_media_read(): + permissions = _route_permissions("GET", "/api/v1/rpg/sessions/{session_id}/rules-packs") + + assert {rpg_endpoint.RPG_SESSIONS_READ, rpg_endpoint.MEDIA_READ} <= permissions # nosec B101 + + +def test_session_rules_pack_refs_put_requires_session_manage_and_media_read(): + permissions = _route_permissions("PUT", "/api/v1/rpg/sessions/{session_id}/rules-packs") + + assert {rpg_endpoint.RPG_SESSIONS_MANAGE, rpg_endpoint.MEDIA_READ} <= permissions # nosec B101 + + +def test_lookup_requires_rules_read_and_media_read(): + permissions = _route_permissions("POST", "/api/v1/rpg/sessions/{session_id}/rules/lookup") + + assert {rpg_endpoint.RPG_RULES_READ, rpg_endpoint.MEDIA_READ} <= permissions # nosec B101 + + +def test_context_requires_session_read_rules_read_and_media_read(): + permissions = _route_permissions("POST", "/api/v1/rpg/sessions/{session_id}/context") + + assert {rpg_endpoint.RPG_SESSIONS_READ, rpg_endpoint.RPG_RULES_READ, rpg_endpoint.MEDIA_READ} <= permissions # nosec B101 + + +def test_non_rules_pack_routes_do_not_open_media_databases(): + calls = _route_dependency_calls("GET", "/api/v1/rpg/rules/adapters") + + assert get_media_db_for_user not in calls # nosec B101 + assert get_collections_db_for_user not in calls # nosec B101 + + +def test_endpoint_scope_catalog_includes_rules_pack_routes(): + catalog_path = _REPO_ROOT / "tldw_Server_API" / "Config_Files" / "privilege_catalog.yaml" + catalog_ids = load_catalog_scope_ids(catalog_path) + for method, path in ( + ("GET", "/api/v1/rpg/campaigns/{campaign_id}/rules-packs"), + ("PUT", "/api/v1/rpg/campaigns/{campaign_id}/rules-packs"), + ("GET", "/api/v1/rpg/sessions/{session_id}/rules-packs"), + ("PUT", "/api/v1/rpg/sessions/{session_id}/rules-packs"), + ): + assert _route_permissions(method, path) <= catalog_ids # nosec B101 + + +def test_rpg_adapters_endpoint_lists_default_adapters(): + client = TestClient(app) + + response = client.get("/api/v1/rpg/rules/adapters", headers=_headers()) + + assert response.status_code == 200 # nosec B101 + keys = [item["adapter_key"] for item in response.json()["adapters"]] + assert keys == ["dnd5e_srd", "fate", "pf2e"] # nosec B101 + + +def test_media_item_validator_rejects_owner_mismatch(): + class FakeMediaDb: + client_id = "1" + + def get_media_by_id(self, media_id, *, include_deleted, include_trash): + assert include_deleted is False # nosec B101 + assert include_trash is False # nosec B101 + return {"id": media_id, "title": "Other User Rules", "owner_user_id": 2} + + validator = rpg_endpoint.RPGRulesSourceValidator( + media_db=FakeMediaDb(), + collections_db=SimpleNamespace(), + ) + + result = asyncio.run(validator.validate_media_item(owner_user_id=1, media_id=42)) + + assert result.readable is False # nosec B101 + assert result.ready_media_ids == [] # nosec B101 + + +def test_collection_validator_filters_unreadable_ready_media_ids(): + class FakeMediaDb: + client_id = "1" + + def __init__(self): + self.calls = [] + + def get_media_by_id(self, media_id, *, include_deleted, include_trash): + self.calls.append((media_id, include_deleted, include_trash)) + rows = { + 10: {"id": 10, "title": "Readable Rules", "owner_user_id": 1}, + 12: {"id": 12, "title": "Other User Rules", "owner_user_id": 2}, + } + return rows.get(media_id) + + class FakeCollectionsDb: + def get_media_collection(self, collection_id): + assert collection_id == 3 # nosec B101 + return SimpleNamespace( + name="Mixed Collection", + items=[ + SimpleNamespace(media_id=10, status="completed"), + SimpleNamespace(media_id=11, status="completed"), + SimpleNamespace(media_id=12, status="skipped_existing"), + SimpleNamespace(media_id=13, status="failed"), + SimpleNamespace(media_id=None, status="completed"), + ], + ) + + media_db = FakeMediaDb() + validator = rpg_endpoint.RPGRulesSourceValidator( + media_db=media_db, + collections_db=FakeCollectionsDb(), + ) + + result = asyncio.run(validator.validate_media_collection(owner_user_id=1, collection_id=3)) + + assert result.readable is True # nosec B101 + assert result.display_name == "Mixed Collection" # nosec B101 + assert result.ready_media_ids == [10] # nosec B101 + assert media_db.calls == [(10, False, False), (11, False, False), (12, False, False)] # nosec B101 + + +def test_create_campaign_session_and_record_user_event(): + client = TestClient(app) + + _, session_id = _create_campaign_and_session(client, "api-main") + + event_response = client.post( + f"/api/v1/rpg/sessions/{session_id}/events", + headers=_headers(**{"Idempotency-Key": "api-main-event"}), + json={ + "expected_last_event_sequence": 0, + "events": [ + { + "event_type": "note.added", + "event_payload": {"note_id": "n1", "text": "At the docks"}, + } + ], + }, + ) + + assert event_response.status_code == 200 # nosec B101 + payload = event_response.json() + assert payload["committed_events"][0]["sequence_number"] == 1 # nosec B101 + assert payload["proposal"] is None # nosec B101 + + +def test_create_campaign_requires_idempotency_key(): + client = TestClient(app) + + response = client.post( + "/api/v1/rpg/campaigns", + headers=_headers(), + json={"title": "Missing Header", "default_adapter_key": "fate"}, + ) + + assert response.status_code == 422 # nosec B101 + + +def test_record_events_rejects_stale_expected_sequence(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-stale") + + first = client.post( + f"/api/v1/rpg/sessions/{session_id}/events", + headers=_headers(**{"Idempotency-Key": "api-stale-event-1"}), + json={ + "expected_last_event_sequence": 0, + "events": [ + { + "event_type": "note.added", + "event_payload": {"note_id": "n1", "text": "First note"}, + } + ], + }, + ) + assert first.status_code == 200 # nosec B101 + + stale = client.post( + f"/api/v1/rpg/sessions/{session_id}/events", + headers=_headers(**{"Idempotency-Key": "api-stale-event-2"}), + json={ + "expected_last_event_sequence": 0, + "events": [ + { + "event_type": "note.added", + "event_payload": {"note_id": "n2", "text": "Second note"}, + } + ], + }, + ) + + assert stale.status_code == 409 # nosec B101 + assert stale.json()["detail"] == "stale_event_sequence" # nosec B101 + + +def test_rules_lookup_and_context_endpoints(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-rules") + scene = client.post( + f"/api/v1/rpg/sessions/{session_id}/events", + headers=_headers(**{"Idempotency-Key": "api-rules-scene"}), + json={ + "expected_last_event_sequence": 0, + "events": [ + { + "event_type": "scene.updated", + "event_payload": {"scene_id": "scene-1", "summary": "Lanterns in the rain"}, + } + ], + }, + ) + assert scene.status_code == 200 # nosec B101 + + lookup = client.post( + f"/api/v1/rpg/sessions/{session_id}/rules/lookup", + headers=_headers(), + json={"query": "stress"}, + ) + + assert lookup.status_code == 200 # nosec B101 + lookup_payload = lookup.json() + assert lookup_payload["query"] == "stress" # nosec B101 + assert lookup_payload["diagnostics"]["bundled_policy"] == "citations_only" # nosec B101 + assert lookup_payload["diagnostics"]["result_mode"] == "citation_index" # nosec B101 + assert all(item["text"] == "" for item in lookup_payload["results"]) # nosec B101 + + context = client.post( + f"/api/v1/rpg/sessions/{session_id}/context", + headers=_headers(), + json={"query": "stress", "max_chars": 1000}, + ) + + assert context.status_code == 200 # nosec B101 + context_payload = context.json() + assert "Lanterns in the rain" in context_payload["text"] # nosec B101 + assert context_payload["diagnostics"]["rules_result_count"] >= 1 # nosec B101 + + +def test_context_endpoint_reports_rules_lookup_diagnostics(): + client = TestClient(app) + + class FakeService: + async def build_context(self, *, session_id, query, max_chars): + assert session_id == 99 # nosec B101 + assert query == "stress" # nosec B101 + assert max_chars == 1000 # nosec B101 + return SessionContext( + text="Rules evidence:\n- Retrieved stress rule", + diagnostics={ + "truncated": False, + "max_chars": 1000, + "original_chars": 40, + "returned_chars": 40, + "rules_result_count": 1, + "omitted_sections": [], + "rules_lookup": {"retrieval_result_count": 1, "skipped_refs": []}, + }, + ) + + app.dependency_overrides[rpg_endpoint._rules_service] = lambda: FakeService() + try: + response = client.post( + "/api/v1/rpg/sessions/99/context", + headers=_headers(), + json={"query": "stress", "max_chars": 1000}, + ) + finally: + app.dependency_overrides.pop(rpg_endpoint._rules_service, None) + + assert response.status_code == 200 # nosec B101 + payload = response.json() + assert "Retrieved stress rule" in payload["text"] # nosec B101 + assert payload["diagnostics"]["rules_lookup"]["retrieval_result_count"] == 1 # nosec B101 + + +def test_replace_campaign_rules_pack_refs_returns_version_and_refs(): + client = TestClient(app) + campaign_id, _ = _create_campaign_and_session(client, "api-campaign-rules-packs") + + response = client.put( + f"/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + headers=_headers(), + json={ + "expected_version": 1, + "idempotency_key": "api-campaign-rules-packs-replace", + "refs": [ + { + "source_type": "media_item", + "source_id": 7, + "display_name": "Disabled Rules", + "enabled": False, + } + ], + }, + ) + + assert response.status_code == 200 # nosec B101 + payload = response.json() + assert payload["version"] == 2 # nosec B101 + assert payload["replayed"] is False # nosec B101 + assert payload["refs"][0]["ref_id"] == "media_item:7" # nosec B101 + assert payload["refs"][0]["display_name"] == "Disabled Rules" # nosec B101 + + listed = client.get(f"/api/v1/rpg/campaigns/{campaign_id}/rules-packs", headers=_headers()) + assert listed.status_code == 200 # nosec B101 + assert listed.json()["version"] == 2 # nosec B101 + assert listed.json()["refs"] == payload["refs"] # nosec B101 + + +def test_replace_session_rules_pack_refs_returns_version_and_refs(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-session-rules-packs") + + response = client.put( + f"/api/v1/rpg/sessions/{session_id}/rules-packs", + headers=_headers(), + json={ + "expected_version": 1, + "idempotency_key": "api-session-rules-packs-replace", + "refs": [ + { + "source_type": "media_collection", + "source_id": 3, + "display_name": "Disabled Collection", + "enabled": False, + } + ], + }, + ) + + assert response.status_code == 200 # nosec B101 + payload = response.json() + assert payload["version"] == 2 # nosec B101 + assert payload["replayed"] is False # nosec B101 + assert payload["refs"][0]["ref_id"] == "media_collection:3" # nosec B101 + + listed = client.get(f"/api/v1/rpg/sessions/{session_id}/rules-packs", headers=_headers()) + assert listed.status_code == 200 # nosec B101 + assert listed.json()["version"] == 2 # nosec B101 + assert listed.json()["refs"] == payload["refs"] # nosec B101 + + +def test_replace_rules_pack_refs_rejects_stale_version_with_409(): + client = TestClient(app) + campaign_id, _ = _create_campaign_and_session(client, "api-rules-packs-stale") + + response = client.put( + f"/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + headers=_headers(), + json={ + "expected_version": 999, + "idempotency_key": "api-rules-packs-stale-replace", + "refs": [{"source_type": "media_item", "source_id": 7, "enabled": False}], + }, + ) + + assert response.status_code == 409 # nosec B101 + assert response.json()["detail"] == "stale_rules_pack_ref_version" # nosec B101 + + +def test_replace_rules_pack_refs_replays_idempotency_key(): + client = TestClient(app) + campaign_id, _ = _create_campaign_and_session(client, "api-rules-packs-replay") + request = { + "expected_version": 1, + "idempotency_key": "api-rules-packs-replay-replace", + "refs": [{"source_type": "media_item", "source_id": 7, "enabled": False}], + } + + first = client.put( + f"/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + headers=_headers(), + json=request, + ) + second = client.put( + f"/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + headers=_headers(), + json=request, + ) + + assert first.status_code == 200 # nosec B101 + assert second.status_code == 200 # nosec B101 + assert second.json()["replayed"] is True # nosec B101 + assert second.json()["version"] == first.json()["version"] # nosec B101 + assert second.json()["refs"] == first.json()["refs"] # nosec B101 + + +def test_replace_rules_pack_refs_rejects_non_boolean_enabled(): + client = TestClient(app) + campaign_id, _ = _create_campaign_and_session(client, "api-rules-packs-enabled") + + response = client.put( + f"/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + headers=_headers(), + json={ + "expected_version": 1, + "idempotency_key": "api-rules-packs-enabled-replace", + "refs": [{"source_type": "media_item", "source_id": 7, "enabled": "false"}], + }, + ) + + assert response.status_code == 422 # nosec B101 + + +def test_rules_lookup_accepts_lookup_mode(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-lookup-mode") + + response = client.post( + f"/api/v1/rpg/sessions/{session_id}/rules/lookup", + headers=_headers(), + json={"query": "stress", "mode": "lookup"}, + ) + + assert response.status_code == 200 # nosec B101 + assert response.json()["query"] == "stress" # nosec B101 + + +def test_rules_lookup_accepts_answer_mode(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-answer-mode") + + response = client.post( + f"/api/v1/rpg/sessions/{session_id}/rules/lookup", + headers=_headers(), + json={"query": "stress", "mode": "answer"}, + ) + + assert response.status_code == 200 # nosec B101 + assert response.json()["query"] == "stress" # nosec B101 + + +def test_rules_lookup_answer_mode_passes_generation_options(): + client = TestClient(app) + + class FakeService: + async def lookup_rules(self, *, session_id, query, mode, answer_options): + assert session_id == 99 # nosec B101 + assert query == "stress" # nosec B101 + assert mode == "answer" # nosec B101 + assert isinstance(answer_options, RulesAnswerOptions) # nosec B101 + assert answer_options.provider == "openai" # nosec B101 + assert answer_options.model == "gpt-test" # nosec B101 + assert answer_options.temperature == 0.4 # nosec B101 + assert answer_options.max_tokens == 321 # nosec B101 + return RuleLookupResult( + query=query, + mode=mode, + results=[], + answer=None, + answer_status="no_evidence", + answer_citation_ids=[], + diagnostics={ + "bundled_policy": "no_match", + "result_mode": "citation_index", + "linked_rules_pack_count": 0, + "enabled_rules_pack_count": 0, + "ready_media_item_count": 0, + "retrieval_result_count": 0, + "bundled_citation_count": 0, + "skipped_refs": [], + "broad_fallback_used": False, + }, + ) + + app.dependency_overrides[rpg_endpoint._rules_service] = lambda: FakeService() + try: + response = client.post( + "/api/v1/rpg/sessions/99/rules/lookup", + headers=_headers(), + json={ + "query": "stress", + "mode": "answer", + "provider": "openai", + "model": "gpt-test", + "temperature": 0.4, + "max_tokens": 321, + }, + ) + finally: + app.dependency_overrides.pop(rpg_endpoint._rules_service, None) + + assert response.status_code == 200 # nosec B101 + assert response.json()["answer_status"] == "no_evidence" # nosec B101 + + +def test_rules_lookup_answer_mode_requires_chat_completion_permission(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-answer-mode-no-chat") + + async def no_chat_principal(): + return AuthPrincipal( + kind="user", + user_id=1, + roles=[], + permissions=[rpg_endpoint.RPG_RULES_READ, rpg_endpoint.MEDIA_READ], + is_admin=False, + ) + + app.dependency_overrides[get_auth_principal] = no_chat_principal + try: + lookup_response = client.post( + f"/api/v1/rpg/sessions/{session_id}/rules/lookup", + headers=_headers(), + json={"query": "stress", "mode": "lookup"}, + ) + answer_response = client.post( + f"/api/v1/rpg/sessions/{session_id}/rules/lookup", + headers=_headers(), + json={"query": "stress", "mode": "answer"}, + ) + finally: + app.dependency_overrides.pop(get_auth_principal, None) + + assert lookup_response.status_code == 200 # nosec B101 + assert answer_response.status_code == 403 # nosec B101 + assert "chat.completions" in answer_response.json()["detail"] # nosec B101 + + +def test_answer_mode_generation_controls_pass_bearer_credentials(monkeypatch): + captured: dict[str, object] = {} + + async def fake_permission_guard(principal): + captured["principal"] = principal + + async def fake_scope_guard(request, credentials=None): + captured["credentials"] = credentials + + async def fake_llm_budget(request): + captured["budget_checked"] = True + + async def fake_rbac_limit(request, resource, db_pool): + captured["rate_limit_resource"] = resource + captured["db_pool"] = db_pool + + async def fake_get_db_pool(): + return object() + + monkeypatch.setattr(rpg_endpoint, "_answer_mode_permission_guard", fake_permission_guard) + monkeypatch.setattr(rpg_endpoint, "_answer_mode_scope_guard", fake_scope_guard) + monkeypatch.setattr(rpg_endpoint, "enforce_llm_budget", fake_llm_budget) + monkeypatch.setattr(rpg_endpoint, "enforce_rbac_rate_limit", fake_rbac_limit) + monkeypatch.setattr(rpg_endpoint, "get_db_pool", fake_get_db_pool) + + request = Request( + { + "type": "http", + "method": "POST", + "path": "/api/v1/rpg/sessions/99/rules/lookup", + "headers": [(b"authorization", b"Bearer scoped-token")], + } + ) + principal = AuthPrincipal( + kind="user", + user_id=1, + roles=[], + permissions=[rpg_endpoint.CHAT_COMPLETIONS], + is_admin=False, + ) + + asyncio.run(rpg_endpoint._enforce_answer_mode_generation_controls(request, principal)) + + credentials = captured["credentials"] + assert credentials.scheme == "Bearer" # nosec B101 + assert credentials.credentials == "scoped-token" # nosec B101 + assert captured["principal"] is principal # nosec B101 + assert captured["budget_checked"] is True # nosec B101 + assert captured["rate_limit_resource"] == rpg_endpoint.CHAT_CREATE_RATE_LIMIT # nosec B101 + + +def test_rules_lookup_rejects_unknown_mode(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-bad-mode") + + response = client.post( + f"/api/v1/rpg/sessions/{session_id}/rules/lookup", + headers=_headers(), + json={"query": "stress", "mode": "summarize"}, + ) + + assert response.status_code == 422 # nosec B101 + + +def test_rules_lookup_denies_principal_missing_media_read(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-lookup-no-media") + + async def no_media_principal(): + return AuthPrincipal( + kind="user", + user_id=1, + roles=[], + permissions=[rpg_endpoint.RPG_RULES_READ], + is_admin=False, + ) + + app.dependency_overrides[get_auth_principal] = no_media_principal + try: + response = client.post( + f"/api/v1/rpg/sessions/{session_id}/rules/lookup", + headers=_headers(), + json={"query": "stress"}, + ) + finally: + app.dependency_overrides.pop(get_auth_principal, None) + + assert response.status_code == 403 # nosec B101 + + +def test_rules_pack_refs_denies_principal_missing_media_read(): + client = TestClient(app) + campaign_id, _ = _create_campaign_and_session(client, "api-rules-pack-no-media") + + async def no_media_principal(): + return AuthPrincipal( + kind="user", + user_id=1, + roles=[], + permissions=[rpg_endpoint.RPG_CAMPAIGNS_READ], + is_admin=False, + ) + + app.dependency_overrides[get_auth_principal] = no_media_principal + try: + response = client.get( + f"/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + headers=_headers(), + ) + finally: + app.dependency_overrides.pop(get_auth_principal, None) + + assert response.status_code == 403 # nosec B101 + + +def test_context_endpoint_rejects_tiny_budget(): + client = TestClient(app) + _, session_id = _create_campaign_and_session(client, "api-context-budget") + + response = client.post( + f"/api/v1/rpg/sessions/{session_id}/context", + headers=_headers(), + json={"max_chars": 999}, + ) + + assert response.status_code == 422 # nosec B101 diff --git a/tldw_Server_API/tests/RPG/test_rpg_core_models_adapters.py b/tldw_Server_API/tests/RPG/test_rpg_core_models_adapters.py new file mode 100644 index 0000000000..073fdc14e1 --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_core_models_adapters.py @@ -0,0 +1,60 @@ +import pytest + +from tldw_Server_API.app.core.RPG.constants import ( + RPG_ADAPTER_DND5E_SRD, + RPG_ADAPTER_FATE, + RPG_ADAPTER_PF2E, + RPG_ADAPTER_VERSION_V1, +) +from tldw_Server_API.app.core.RPG.errors import RPGNotFoundError +from tldw_Server_API.app.core.RPG.models import RPGSnapshotState +from tldw_Server_API.app.core.RPG.rules.adapters import build_default_adapter_registry + +pytestmark = pytest.mark.unit + + +def test_default_adapter_registry_lists_supported_rulesets_with_license_summaries(): + registry = build_default_adapter_registry() + + infos = registry.list_infos() + keys = [info.adapter_key for info in infos] + + assert keys == [RPG_ADAPTER_DND5E_SRD, RPG_ADAPTER_FATE, RPG_ADAPTER_PF2E] # nosec B101 + assert {info.adapter_version for info in infos} == {RPG_ADAPTER_VERSION_V1} # nosec B101 + assert infos[0].license_summary.license_name == "CC-BY-4.0" # nosec B101 + assert infos[1].license_summary.license_name == "CC-BY-3.0" # nosec B101 + assert infos[2].license_summary.license_name == "ORC" # nosec B101 + + +def test_default_adapter_registry_exposes_mechanics_and_defensive_schema_copies(): + registry = build_default_adapter_registry() + fate = registry.get(RPG_ADAPTER_FATE) + pf2e = registry.get(RPG_ADAPTER_PF2E) + + assert fate.mechanics_tags["resolution_family"] == "fate" # nosec B101 + assert pf2e.mechanics_tags["resolution_family"] == "d20" # nosec B101 + assert "note.added" in fate.supported_event_types() # nosec B101 + assert "actor_id" in fate.actor_schema()["properties"] # nosec B101 + + actor_schema = fate.actor_schema() + actor_schema["properties"]["actor_id"]["minLength"] = 99 + + assert fate.actor_schema()["properties"]["actor_id"]["minLength"] == 1 # nosec B101 + + +def test_default_adapter_registry_rejects_unknown_adapter_key(): + registry = build_default_adapter_registry() + + with pytest.raises(RPGNotFoundError, match="unknown RPG rules adapter"): + registry.get("not-a-system") + + +def test_snapshot_state_defaults_do_not_share_mutable_collections(): + first = RPGSnapshotState() + second = RPGSnapshotState() + + first.notes.append({"note_id": "n1", "text": "private"}) + first.actors["pc-1"] = {"name": "Ada"} + + assert second.notes == [] # nosec B101 + assert second.actors == {} # nosec B101 diff --git a/tldw_Server_API/tests/RPG/test_rpg_db.py b/tldw_Server_API/tests/RPG/test_rpg_db.py new file mode 100644 index 0000000000..35022200b8 --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_db.py @@ -0,0 +1,428 @@ +import pytest + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RPG.errors import RPGConflictError, RPGNotFoundError + +pytestmark = pytest.mark.integration + + +def _repo() -> RPGRepository: + return RPGRepository.initialized(CharactersRAGDB(":memory:", "rpg-test-client")) + + +def _campaign(repo: RPGRepository, owner_user_id: int = 42, adapter_key: str = "fate"): + return repo.create_campaign( + owner_user_id=owner_user_id, + title="Campaign", + description=None, + default_adapter_key=adapter_key, + default_adapter_version="1.0.0", + settings={}, + linked_rules_pack_refs=[], + idempotency_key=f"campaign-{adapter_key}", + request_payload_hash=f"hash-campaign-{adapter_key}", + source_type="user", + ) + + +def _session(repo: RPGRepository, campaign_id: int, owner_user_id: int = 42, adapter_key: str = "fate"): + return repo.create_session( + owner_user_id=owner_user_id, + campaign_id=campaign_id, + title="Opening", + adapter_key=adapter_key, + adapter_version="1.0.0", + authority_settings={"model_auto_commit": False}, + linked_chat_id=None, + active_rules_pack_refs=[], + idempotency_key=f"session-{campaign_id}-{adapter_key}", + request_payload_hash=f"hash-session-{campaign_id}-{adapter_key}", + source_type="user", + ) + + +def test_repository_creates_campaign_session_and_initial_snapshot(): + repo = _repo() + + campaign = repo.create_campaign( + owner_user_id=42, + title="Saltmarsh", + description="Coastal trouble", + default_adapter_key="dnd5e_srd", + default_adapter_version="1.0.0", + settings={"tone": "nautical"}, + linked_rules_pack_refs=[{"media_id": 7}], + idempotency_key="campaign-saltmarsh", + request_payload_hash="hash-campaign-saltmarsh", + source_type="user", + ) + session = repo.create_session( + owner_user_id=42, + campaign_id=campaign.id, + title="Session 1", + adapter_key="dnd5e_srd", + adapter_version="1.0.0", + authority_settings={"model_auto_commit": False}, + linked_chat_id=None, + active_rules_pack_refs=[{"media_id": 7}], + idempotency_key="session-1", + request_payload_hash="hash-session-1", + source_type="user", + ) + + snapshot = repo.get_latest_snapshot(owner_user_id=42, session_id=session.id) + + assert campaign.settings["tone"] == "nautical" # nosec B101 + assert session.campaign_id == campaign.id # nosec B101 + assert session.last_event_sequence == 0 # nosec B101 + assert session.current_snapshot_version == 0 # nosec B101 + assert snapshot.snapshot_version == 0 # nosec B101 + assert snapshot.last_event_sequence == 0 # nosec B101 + assert snapshot.snapshot_json["notes"] == [] # nosec B101 + + +def test_create_session_replays_same_idempotency_key_without_duplicate_session(): + repo = _repo() + campaign = _campaign(repo) + + first = _session(repo, campaign.id) + second = _session(repo, campaign.id) + + assert second.id == first.id # nosec B101 + assert repo.get_latest_snapshot(owner_user_id=42, session_id=second.id).snapshot_version == 0 # nosec B101 + + +def test_get_campaign_returns_owner_scoped_campaign(): + repo = _repo() + campaign = _campaign(repo, owner_user_id=42) + + fetched = repo.get_campaign(owner_user_id=42, campaign_id=campaign.id) + + assert fetched.id == campaign.id # nosec B101 + assert fetched.owner_user_id == 42 # nosec B101 + with pytest.raises(RPGNotFoundError, match="rpg_campaign_not_found"): + repo.get_campaign(owner_user_id=43, campaign_id=campaign.id) + + +def test_replace_campaign_rules_pack_refs_requires_expected_version(): + repo = _repo() + campaign = _campaign(repo) + + with pytest.raises(RPGConflictError, match="stale_rules_pack_ref_version"): + repo.replace_campaign_rules_pack_refs( + owner_user_id=42, + campaign_id=campaign.id, + rules_pack_refs=[{"source_type": "media_item", "source_id": 7}], + expected_version=campaign.version + 1, + idempotency_key="campaign-refs", + request_payload_hash="hash-campaign-refs", + source_type="user", + ) + + +def test_replace_campaign_rules_pack_refs_increments_version(): + repo = _repo() + campaign = _campaign(repo) + + result = repo.replace_campaign_rules_pack_refs( + owner_user_id=42, + campaign_id=campaign.id, + rules_pack_refs=[{"source_type": "media_item", "source_id": 7, "display_name": "Rules"}], + expected_version=campaign.version, + idempotency_key="campaign-refs", + request_payload_hash="hash-campaign-refs", + source_type="user", + ) + updated = repo.get_campaign(owner_user_id=42, campaign_id=campaign.id) + + assert result.version == campaign.version + 1 # nosec B101 + assert updated.version == campaign.version + 1 # nosec B101 + assert updated.linked_rules_pack_refs[0]["ref_id"] == "media_item:7" # nosec B101 + assert result.refs[0].display_name == "Rules" # nosec B101 + + +def test_replace_campaign_rules_pack_refs_replays_idempotency_key(): + repo = _repo() + campaign = _campaign(repo) + payload = [{"source_type": "media_item", "source_id": 7}] + + first = repo.replace_campaign_rules_pack_refs( + 42, + campaign.id, + payload, + campaign.version, + "campaign-refs", + "hash-campaign-refs", + "user", + ) + second = repo.replace_campaign_rules_pack_refs( + 42, + campaign.id, + payload, + campaign.version, + "campaign-refs", + "hash-campaign-refs", + "user", + ) + + assert second.replayed is True # nosec B101 + assert second.version == first.version # nosec B101 + assert [ref.ref_id for ref in second.refs] == [ref.ref_id for ref in first.refs] # nosec B101 + + +def test_replace_campaign_rules_pack_refs_rejects_idempotency_payload_mismatch(): + repo = _repo() + campaign = _campaign(repo) + payload = [{"source_type": "media_item", "source_id": 7}] + + repo.replace_campaign_rules_pack_refs( + 42, + campaign.id, + payload, + campaign.version, + "campaign-refs", + "hash-campaign-refs", + "user", + ) + + with pytest.raises(RPGConflictError, match="idempotency"): + repo.replace_campaign_rules_pack_refs( + 42, + campaign.id, + payload, + campaign.version, + "campaign-refs", + "hash-campaign-refs-changed", + "user", + ) + + +def test_replace_session_rules_pack_refs_requires_expected_version(): + repo = _repo() + campaign = _campaign(repo) + session = _session(repo, campaign.id) + + with pytest.raises(RPGConflictError, match="stale_rules_pack_ref_version"): + repo.replace_session_rules_pack_refs( + owner_user_id=42, + session_id=session.id, + rules_pack_refs=[{"source_type": "media_collection", "source_id": 3}], + expected_version=session.version + 1, + idempotency_key="session-refs", + request_payload_hash="hash-session-refs", + source_type="user", + ) + + +def test_replace_session_rules_pack_refs_increments_version(): + repo = _repo() + campaign = _campaign(repo) + session = _session(repo, campaign.id) + + result = repo.replace_session_rules_pack_refs( + owner_user_id=42, + session_id=session.id, + rules_pack_refs=[{"source_type": "media_collection", "source_id": 3}], + expected_version=session.version, + idempotency_key="session-refs", + request_payload_hash="hash-session-refs", + source_type="user", + ) + updated = repo.get_session(owner_user_id=42, session_id=session.id) + + assert result.version == session.version + 1 # nosec B101 + assert updated.version == session.version + 1 # nosec B101 + assert updated.active_rules_pack_refs[0]["ref_id"] == "media_collection:3" # nosec B101 + assert result.refs[0].ref_id == "media_collection:3" # nosec B101 + + +def test_replace_session_rules_pack_refs_replays_idempotency_key(): + repo = _repo() + campaign = _campaign(repo) + session = _session(repo, campaign.id) + payload = [{"source_type": "media_collection", "source_id": 3}] + + first = repo.replace_session_rules_pack_refs( + 42, + session.id, + payload, + session.version, + "session-refs", + "hash-session-refs", + "user", + ) + second = repo.replace_session_rules_pack_refs( + 42, + session.id, + payload, + session.version, + "session-refs", + "hash-session-refs", + "user", + ) + + assert second.replayed is True # nosec B101 + assert second.version == first.version # nosec B101 + assert [ref.ref_id for ref in second.refs] == [ref.ref_id for ref in first.refs] # nosec B101 + + +def test_replace_session_rules_pack_refs_rejects_idempotency_payload_mismatch(): + repo = _repo() + campaign = _campaign(repo) + session = _session(repo, campaign.id) + payload = [{"source_type": "media_collection", "source_id": 3}] + + repo.replace_session_rules_pack_refs( + 42, + session.id, + payload, + session.version, + "session-refs", + "hash-session-refs", + "user", + ) + + with pytest.raises(RPGConflictError, match="idempotency"): + repo.replace_session_rules_pack_refs( + 42, + session.id, + payload, + session.version, + "session-refs", + "hash-session-refs-changed", + "user", + ) + + +def test_commit_events_assigns_sequences_and_updates_snapshot_cursor(): + repo = _repo() + campaign = _campaign(repo) + session = _session(repo, campaign.id) + + result = repo.commit_events_and_snapshot( + owner_user_id=42, + session_id=session.id, + expected_last_event_sequence=0, + base_snapshot_version=0, + events=[ + { + "event_type": "scene.updated", + "event_payload": {"scene_id": "scene-start", "summary": "At the docks"}, + "source_type": "user", + }, + { + "event_type": "note.added", + "event_payload": {"note_id": "note-1", "text": "Storm clouds gather"}, + "source_type": "user", + }, + ], + snapshot={ + "scene": {"scene_id": "scene-start", "summary": "At the docks"}, + "notes": [{"note_id": "note-1", "text": "Storm clouds gather"}], + }, + diagnostics={"applied_event_count": 2}, + idempotency_key="req-1", + request_payload_hash="hash-a", + adapter_key="fate", + adapter_version="1.0.0", + proposal_id=None, + ) + + updated = repo.get_session(owner_user_id=42, session_id=session.id) + snapshot = repo.get_latest_snapshot(owner_user_id=42, session_id=session.id) + + assert [event.sequence_number for event in result.events] == [1, 2] # nosec B101 + assert all(event.operation_id is not None for event in result.events) # nosec B101 + assert updated.last_event_sequence == 2 # nosec B101 + assert updated.current_snapshot_version == 1 # nosec B101 + assert snapshot.last_event_sequence == 2 # nosec B101 + assert snapshot.snapshot_json["scene"]["summary"] == "At the docks" # nosec B101 + + +def test_commit_events_replays_same_idempotency_key_without_new_snapshot(): + repo = _repo() + campaign = _campaign(repo) + session = _session(repo, campaign.id) + payload = [{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "A"}, "source_type": "user"}] + + first = repo.commit_events_and_snapshot( + 42, + session.id, + 0, + 0, + payload, + {"notes": [{"note_id": "n1", "text": "A"}]}, + {}, + "same-key", + "hash-a", + "fate", + "1.0.0", + None, + ) + second = repo.commit_events_and_snapshot( + 42, + session.id, + 0, + 0, + payload, + {"notes": [{"note_id": "n1", "text": "A"}]}, + {}, + "same-key", + "hash-a", + "fate", + "1.0.0", + None, + ) + + assert [event.id for event in second.events] == [event.id for event in first.events] # nosec B101 + assert second.replayed is True # nosec B101 + assert repo.get_session(owner_user_id=42, session_id=session.id).current_snapshot_version == 1 # nosec B101 + + +def test_commit_events_rejects_same_idempotency_key_with_different_hash(): + repo = _repo() + campaign = _campaign(repo) + session = _session(repo, campaign.id) + payload = [{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "A"}, "source_type": "user"}] + + repo.commit_events_and_snapshot( + 42, + session.id, + 0, + 0, + payload, + {"notes": [{"note_id": "n1", "text": "A"}]}, + {}, + "same-key", + "hash-a", + "fate", + "1.0.0", + None, + ) + + with pytest.raises(RPGConflictError, match="idempotency"): + repo.commit_events_and_snapshot( + 42, + session.id, + 1, + 1, + payload, + {"notes": [{"note_id": "n1", "text": "A"}]}, + {}, + "same-key", + "hash-b", + "fate", + "1.0.0", + None, + ) + + +def test_commit_events_rejects_stale_expected_sequence(): + repo = _repo() + campaign = _campaign(repo) + session = _session(repo, campaign.id) + payload = [{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "A"}, "source_type": "user"}] + + with pytest.raises(RPGConflictError, match="stale_event_sequence"): + repo.commit_events_and_snapshot(42, session.id, 7, 0, payload, {"notes": []}, {}, "stale-key", "hash-a", "fate", "1.0.0", None) diff --git a/tldw_Server_API/tests/RPG/test_rpg_dice_checks.py b/tldw_Server_API/tests/RPG/test_rpg_dice_checks.py new file mode 100644 index 0000000000..f9f4d3562a --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_dice_checks.py @@ -0,0 +1,131 @@ +import pytest + +from tldw_Server_API.app.core.RPG.checks import resolve_check +from tldw_Server_API.app.core.RPG.constants import RPG_ADAPTER_DND5E_SRD, RPG_ADAPTER_FATE +from tldw_Server_API.app.core.RPG.dice import DiceRoller +from tldw_Server_API.app.core.RPG.errors import RPGValidationError +from tldw_Server_API.app.core.RPG.models import CheckResult, DiceRollResult +from tldw_Server_API.app.core.RPG.rules.adapters import build_default_adapter_registry + +pytestmark = pytest.mark.unit + + +def test_dice_roller_parses_expression_with_injected_values(): + roller = DiceRoller(injected_values=[2, 6]) + + result = roller.roll("2d6+3") + + assert isinstance(result, DiceRollResult) # nosec B101 + assert result.expression == "2d6+3" # nosec B101 + assert result.values == [2, 6] # nosec B101 + assert result.modifier == 3 # nosec B101 + assert result.total == 11 # nosec B101 + + +@pytest.mark.parametrize( + "expression", + [ + "", + "2d6+", + "101d6", + "1d1001", + "2d6+1001", + "2d6-1001", + "2d6+3+4", + ], +) +def test_dice_roller_rejects_invalid_or_unbounded_expressions(expression): + roller = DiceRoller() + + with pytest.raises(RPGValidationError): + roller.roll(expression) + + +def test_dice_roller_uses_injected_fate_values(): + roller = DiceRoller(injected_fate_values=[-1, 0, 1, 1]) + + result = roller.roll_fate(modifier=2) + + assert isinstance(result, DiceRollResult) # nosec B101 + assert result.expression == "4dF+2" # nosec B101 + assert result.values == [-1, 0, 1, 1] # nosec B101 + assert result.modifier == 2 # nosec B101 + assert result.total == 3 # nosec B101 + + +def test_dnd5e_adapter_resolves_d20_check_with_injected_roll(): + registry = build_default_adapter_registry() + adapter = registry.get(RPG_ADAPTER_DND5E_SRD) + roller = DiceRoller(injected_values=[14]) + + result = resolve_check( + adapter, + roller, + { + "check_label": "Stealth", + "roll_expression": "1d20", + "modifier": 5, + "dc": 18, + }, + ) + + assert isinstance(result, CheckResult) # nosec B101 + assert result.check_label == "Stealth" # nosec B101 + assert result.mechanics == "d20" # nosec B101 + assert result.roll.total == 19 # nosec B101 + assert result.target == 18 # nosec B101 + assert result.success is True # nosec B101 + assert result.margin == 1 # nosec B101 + + +def test_fate_adapter_resolves_ladder_check_with_injected_fate_rolls(): + registry = build_default_adapter_registry() + adapter = registry.get(RPG_ADAPTER_FATE) + roller = DiceRoller(injected_fate_values=[-1, 0, 1, 1]) + + result = resolve_check( + adapter, + roller, + { + "check_label": "Careful defense", + "skill_bonus": 2, + "ladder_target": 3, + }, + ) + + assert isinstance(result, CheckResult) # nosec B101 + assert result.check_label == "Careful defense" # nosec B101 + assert result.mechanics == "fate" # nosec B101 + assert result.roll.total == 3 # nosec B101 + assert result.target == 3 # nosec B101 + assert result.success is True # nosec B101 + assert result.margin == 0 # nosec B101 + + +def test_resolve_check_delegates_to_adapter_owned_resolution(): + class SpyAdapter: + mechanics_tags = {"resolution_family": "unsupported"} + + def __init__(self) -> None: + self.payload = None + + def resolve_check(self, roller, payload): + self.payload = payload + return CheckResult( + check_label=payload["check_label"], + mechanics="spy", + roll=roller.roll("1d4"), + target=None, + success=None, + margin=None, + details={}, + ) + + adapter = SpyAdapter() + roller = DiceRoller(injected_values=[3]) + + result = resolve_check(adapter, roller, {"check_label": "Adapter-owned"}) + + assert adapter.payload == {"check_label": "Adapter-owned"} # nosec B101 + assert result.mechanics == "spy" # nosec B101 + assert result.roll.total == 3 # nosec B101 diff --git a/tldw_Server_API/tests/RPG/test_rpg_events_reducer.py b/tldw_Server_API/tests/RPG/test_rpg_events_reducer.py new file mode 100644 index 0000000000..f9fe05f660 --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_events_reducer.py @@ -0,0 +1,137 @@ +import pytest + +from tldw_Server_API.app.core.RPG.constants import MAX_RPG_EVENT_PAYLOAD_BYTES +from tldw_Server_API.app.core.RPG.events import ( + SUPPORTED_EVENT_TYPES, + canonical_request_hash, + validate_event_envelope, +) +from tldw_Server_API.app.core.RPG.reducer import initial_snapshot, reduce_events + +pytestmark = pytest.mark.unit + + +def _event(event_type, payload, source_type="user"): + return { + "event_type": event_type, + "event_payload": payload, + "source_type": source_type, + } + + +def test_canonical_request_hash_is_stable_for_key_order(): + left = {"events": [_event("note.added", {"text": "A", "note_id": "n1"})]} + right = { + "events": [ + { + "event_payload": {"note_id": "n1", "text": "A"}, + "source_type": "user", + "event_type": "note.added", + } + ] + } + + assert canonical_request_hash(left) == canonical_request_hash(right) # nosec B101 + + +def test_validate_event_envelope_rejects_missing_stable_ids(): + event = _event("npc.upserted", {"name": "Ada"}) + + with pytest.raises(ValueError, match="npc_id"): + validate_event_envelope(event) + + +def test_validate_event_envelope_rejects_invalid_source_type(): + event = _event("note.added", {"note_id": "n1", "text": "A"}, source_type="browser") + + with pytest.raises(ValueError, match="source_type"): + validate_event_envelope(event) + + +def test_validate_event_envelope_rejects_oversized_payload(): + event = _event( + "note.added", + {"note_id": "n1", "text": "x" * (MAX_RPG_EVENT_PAYLOAD_BYTES + 1)}, + ) + + with pytest.raises(ValueError, match="too large"): + validate_event_envelope(event) + + +def test_validate_event_envelope_rejects_unknown_event_type(): + event = _event("homebrew.mutates_state", {"id": "x"}) + + with pytest.raises(ValueError, match="Unsupported RPG event type"): + validate_event_envelope(event) + + +def test_supported_event_registry_exposes_v1_core_event_types(): + assert frozenset( # nosec B101 + { + "actor.upserted", + "clock.updated", + "faction.upserted", + "inventory.item.upserted", + "location.upserted", + "note.added", + "npc.upserted", + "quest.upserted", + "roll.recorded", + "rule.reference.added", + "ruling.added", + "scene.updated", + } + ) == SUPPORTED_EVENT_TYPES + + +def test_reducer_rebuilds_same_snapshot_from_all_v1_core_events(): + events = [ + _event("scene.updated", {"scene_id": "s1", "summary": "Rainy docks"}), + _event("actor.upserted", {"actor_id": "pc-1", "name": "Marin"}), + _event("npc.upserted", {"npc_id": "npc-ada", "name": "Ada"}), + _event("quest.upserted", {"quest_id": "q1", "title": "Find the map"}), + _event("inventory.item.upserted", {"item_id": "map", "name": "Wet map"}), + _event("location.upserted", {"location_id": "docks", "name": "The docks"}), + _event("faction.upserted", {"faction_id": "guild", "name": "Harbor Guild"}), + _event("clock.updated", {"clock_id": "storm", "progress": 2, "segments": 6}), + _event("roll.recorded", {"roll_id": "roll-1", "total": 15}), + _event("note.added", {"note_id": "note-1", "text": "Storm clouds gather"}), + _event( + "rule.reference.added", + {"reference_id": "ref-1", "label": "Cover", "source": "SRD"}, + ), + _event( + "ruling.added", + { + "ruling_id": "ruling-1", + "question": "Can Marin jump?", + "status": "open", + }, + ), + ] + + first = reduce_events(initial_snapshot(), events) + second = reduce_events(initial_snapshot(), events) + + assert first == second # nosec B101 + assert first.scene["summary"] == "Rainy docks" # nosec B101 + assert first.actors["pc-1"]["name"] == "Marin" # nosec B101 + assert first.npcs["npc-ada"]["name"] == "Ada" # nosec B101 + assert first.quests["q1"]["title"] == "Find the map" # nosec B101 + assert first.inventory["map"]["name"] == "Wet map" # nosec B101 + assert first.locations["docks"]["name"] == "The docks" # nosec B101 + assert first.factions["guild"]["name"] == "Harbor Guild" # nosec B101 + assert first.clocks["storm"]["progress"] == 2 # nosec B101 + assert first.rolls[0]["roll_id"] == "roll-1" # nosec B101 + assert first.notes[0]["note_id"] == "note-1" # nosec B101 + assert first.rules_references[0]["reference_id"] == "ref-1" # nosec B101 + assert ( # nosec B101 + first.unresolved_rulings["ruling-1"]["question"] == "Can Marin jump?" + ) + + +def test_reducer_rejects_unsupported_event_type(): + events = [_event("homebrew.mutates_state", {"id": "x"})] + + with pytest.raises(ValueError, match="Unsupported RPG event type"): + reduce_events(initial_snapshot(), events) diff --git a/tldw_Server_API/tests/RPG/test_rpg_mcp_module.py b/tldw_Server_API/tests/RPG/test_rpg_mcp_module.py new file mode 100644 index 0000000000..6578c0a047 --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_mcp_module.py @@ -0,0 +1,962 @@ +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Any + +import pytest + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.media_db.native_class import MediaDatabase +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.MCP_unified.modules.base import ModuleConfig +from tldw_Server_API.app.core.MCP_unified.modules.implementations import rpg_module +from tldw_Server_API.app.core.MCP_unified.modules.implementations.rpg_module import RPGModule +from tldw_Server_API.app.core.MCP_unified.protocol import MCPProtocol, MCPRequest, RequestContext +from tldw_Server_API.app.core.RAG.rag_service.types import DataSource, Document +from tldw_Server_API.app.core.RPG.context import SessionContext +from tldw_Server_API.app.core.RPG.rules.answering import RulesAnswerOptions +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupResult +from tldw_Server_API.app.core.RPG.service import RPGService + +pytestmark = pytest.mark.integration + +_MCP_RPG_TEST_PERMISSIONS = [ + "rpg.campaigns.read", + "rpg.campaigns.manage", + "rpg.sessions.read", + "rpg.sessions.manage", + "rpg.proposals.review", + "rpg.rules.read", + "media.read", + "chat.completions", +] + + +class _RPGRegistryStub: + def __init__(self, module: RPGModule) -> None: + self.module = module + + async def find_module_for_tool(self, tool_name: str) -> RPGModule | None: + return self.module if tool_name in self._tool_names else None + + def get_module_id_for_tool(self, tool_name: str) -> str | None: + return "rpg" if tool_name in self._tool_names else None + + async def get_all_modules(self) -> dict[str, RPGModule]: + return {"rpg": self.module} + + @property + def _tool_names(self) -> set[str]: + return { + "rpg.adapters.list", + "rpg.sessions.get", + "rpg.rules.lookup", + "rpg.context.build", + "rpg.campaigns.rules_packs.get", + "rpg.campaigns.rules_packs.replace", + "rpg.sessions.rules_packs.get", + "rpg.sessions.rules_packs.replace", + "rpg.events.record", + "rpg.proposals.apply", + "rpg.proposals.reject", + } + + +class _RPGPolicy: + def __init__(self, tool_permissions: set[str], *, module_read: bool = True) -> None: + self.tool_permissions = tool_permissions + self.module_read = module_read + + async def check_permission(self, user_id, resource, action, resource_id=None): # noqa: ANN001 + del user_id, action + if resource.value == "module": + return self.module_read and resource_id == "rpg" + if resource.value == "tool": + tool_id = str(resource_id or "") + return ( + "*" in self.tool_permissions + or tool_id in self.tool_permissions + or any( + pattern.endswith(".*") and tool_id.startswith(pattern[:-1]) + for pattern in self.tool_permissions + ) + ) + return True + + +def _chacha_path(tmp_path: Path) -> str: + return str(tmp_path / "rpg-mcp-chacha.sqlite") + + +def _seed_session(chacha_path: str) -> int: + repo = RPGRepository.initialized(CharactersRAGDB(chacha_path, "rpg-mcp-seed")) + service = RPGService(repo=repo, owner_user_id=42) + campaign = service.create_campaign( + "MCP Campaign", + None, + "fate", + idempotency_key="mcp-campaign", + ) + session = service.create_session( + campaign.id, + "MCP Session", + adapter_key="fate", + idempotency_key="mcp-session", + ) + return session.id + + +def _seed_campaign_and_session(chacha_path: str) -> tuple[int, int]: + repo = RPGRepository.initialized(CharactersRAGDB(chacha_path, "rpg-mcp-campaign-seed")) + service = RPGService(repo=repo, owner_user_id=42) + campaign = service.create_campaign( + "MCP Campaign", + None, + "fate", + idempotency_key="mcp-campaign-pair", + ) + session = service.create_session( + campaign.id, + "MCP Session", + adapter_key="fate", + idempotency_key="mcp-session-pair", + ) + return campaign.id, session.id + + +def _seed_session_with_rules_pack(chacha_path: str, media_id: int) -> int: + repo = RPGRepository.initialized(CharactersRAGDB(chacha_path, "rpg-mcp-rules-seed")) + service = RPGService(repo=repo, owner_user_id=42) + campaign = service.create_campaign( + "MCP Rules Campaign", + None, + "fate", + idempotency_key="mcp-rules-campaign", + ) + session = service.create_session( + campaign.id, + "MCP Rules Session", + adapter_key="fate", + idempotency_key="mcp-rules-session", + ) + repo.replace_session_rules_pack_refs( + owner_user_id=42, + session_id=session.id, + expected_version=session.version, + rules_pack_refs=[ + { + "ref_id": f"media_item:{media_id}", + "source_type": "media_item", + "source_id": media_id, + "display_name": "MCP Rules", + "enabled": True, + "metadata": {}, + } + ], + idempotency_key="mcp-rules-pack-refs", + request_payload_hash="mcp-rules-pack-refs-hash", + source_type="mcp", + ) + return session.id + + +def _seed_media(media_path: str, *, title: str, content: str) -> int: + db = MediaDatabase(db_path=media_path, client_id="42") + try: + media_uuid = str(uuid.uuid4()) + last_modified = db._get_current_utc_timestamp_str() + cursor = db.execute_query( + "INSERT INTO Media (title, type, content, author, content_hash, uuid, last_modified, client_id, owner_user_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (title, "document", content, "Test Author", f"hash:{media_uuid}", media_uuid, last_modified, "42", 42), + commit=True, + ) + media_id = getattr(cursor, "lastrowid", None) + if media_id: + return int(media_id) + row = db.execute_query("SELECT id FROM Media WHERE uuid = ?", (media_uuid,)).fetchone() + return int(row["id"] if isinstance(row, dict) else row[0]) + finally: + db.close_connection() + + +def _context( + tmp_path: Path, + *, + user_id: str | None = "42", + allowed_tools: list[str] | None = None, + media_path: str | None = None, + permissions: list[str] | None = None, + answer_generation_controls: bool = True, +) -> RequestContext: + metadata: dict[str, Any] = { + "permissions": list(_MCP_RPG_TEST_PERMISSIONS if permissions is None else permissions), + } + if answer_generation_controls: + metadata["mcp_rpg_answer_generation_controls"] = "enforced" + if allowed_tools is not None: + metadata["allowed_tools"] = allowed_tools + return RequestContext( + request_id="rpg-mcp", + user_id=user_id, + client_id="unit", + metadata=metadata, + db_paths={"chacha": _chacha_path(tmp_path), **({"media": media_path} if media_path else {})}, + ) + + +class _FakeRagRetriever: + calls: list[dict[str, Any]] = [] + instances: list[_FakeRagRetriever] = [] + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.close_called = False + self.__class__.instances.append(self) + + def close(self): + self.close_called = True + + async def retrieve(self, query, **kwargs): + self.__class__.calls.append({"query": query, **kwargs}) + media_id = int(kwargs["allowed_media_ids"][0]) + return [ + Document( + id=str(media_id), + content="MCP retrieved rules evidence", + metadata={"media_id": media_id, "title": "MCP Rules"}, + source=DataSource.MEDIA_DB, + score=0.9, + ) + ] + + async def retrieve_from_plan(self, plan, **kwargs): + return await self.retrieve(getattr(plan, "query", ""), **kwargs) + + +def _protocol(tool_permissions: set[str]) -> MCPProtocol: + module = RPGModule(ModuleConfig(name="rpg")) + proto = MCPProtocol() + proto.module_registry = _RPGRegistryStub(module) + proto.rbac_policy = _RPGPolicy(tool_permissions) + return proto + + +@pytest.mark.asyncio +async def test_rpg_module_lists_read_and_write_tools() -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + tools = await module.get_tools() + tool_by_name = {tool["name"]: tool for tool in tools} + + assert tool_by_name["rpg.adapters.list"]["metadata"]["readOnlyHint"] is True # nosec B101 + assert tool_by_name["rpg.events.record"]["metadata"]["category"] == "management" # nosec B101 + assert tool_by_name["rpg.events.record"]["metadata"]["is_write"] is True # nosec B101 + assert module.is_write_tool_def(tool_by_name["rpg.events.record"]) is True # nosec B101 + assert module.is_write_tool_def(tool_by_name["rpg.context.build"]) is False # nosec B101 + assert "rpg.proposals.apply" in tool_by_name # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_mcp_tool_list_includes_rules_pack_ref_tools() -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + tools = await module.get_tools() + tool_names = {tool["name"] for tool in tools} + + assert { # nosec B101 + "rpg.campaigns.rules_packs.get", + "rpg.campaigns.rules_packs.replace", + "rpg.sessions.rules_packs.get", + "rpg.sessions.rules_packs.replace", + } <= tool_names + + +@pytest.mark.asyncio +async def test_rpg_mcp_rules_pack_ref_tools_have_read_write_metadata() -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + tools = await module.get_tools() + tool_by_name = {tool["name"]: tool for tool in tools} + + campaign_get = tool_by_name["rpg.campaigns.rules_packs.get"] + session_replace = tool_by_name["rpg.sessions.rules_packs.replace"] + + assert campaign_get["metadata"]["readOnlyHint"] is True # nosec B101 + assert campaign_get["metadata"]["required_permissions"] == ["rpg.campaigns.read", "media.read"] # nosec B101 + assert session_replace["metadata"]["readOnlyHint"] is False # nosec B101 + assert session_replace["metadata"]["required_permissions"] == ["rpg.sessions.manage", "media.read"] # nosec B101 + assert module.is_write_tool_def(session_replace) is True # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_module_lists_adapters_without_database_context() -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + result = await module.execute_tool("rpg.adapters.list", {}, context=None) + + assert [item["adapter_key"] for item in result["adapters"]] == ["dnd5e_srd", "fate", "pf2e"] # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_database_tools_fail_closed_without_user_context(tmp_path: Path) -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(ValueError, match="authenticated user context"): + await module.execute_tool("rpg.sessions.get", {"session_id": 1}, context=None) + + context = _context(tmp_path, user_id=None) + with pytest.raises(ValueError, match="authenticated user context"): + await module.execute_tool("rpg.sessions.get", {"session_id": 1}, context=context) + + missing_db_context = RequestContext( + request_id="missing-db", + user_id="42", + client_id="unit", + metadata={"permissions": list(_MCP_RPG_TEST_PERMISSIONS)}, + db_paths={}, + ) + with pytest.raises(ValueError, match="ChaChaNotes DB path"): + await module.execute_tool("rpg.sessions.get", {"session_id": 1}, context=missing_db_context) + + +@pytest.mark.asyncio +async def test_rpg_write_validation_runs_before_context_binding() -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(ValueError, match="idempotencyKey is required"): + await module.execute_tool( + "rpg.events.record", + { + "session_id": 1, + "expected_last_event_sequence": 0, + "events": [{"event_type": "note.added", "event_payload": {"text": "Missing key"}}], + }, + context=None, + ) + + with pytest.raises(ValueError, match="expected_last_event_sequence must be non-negative"): + await module.execute_tool( + "rpg.events.record", + { + "session_id": 1, + "expected_last_event_sequence": -1, + "events": [{"event_type": "note.added", "event_payload": {"text": "Bad sequence"}}], + "idempotencyKey": "bad-sequence", + }, + context=None, + ) + + with pytest.raises(ValueError, match="idempotencyKey must be <= 256 characters"): + await module.execute_tool( + "rpg.events.record", + { + "session_id": 1, + "expected_last_event_sequence": 0, + "events": [{"event_type": "note.added", "event_payload": {"text": "Long key"}}], + "idempotencyKey": "x" * 257, + }, + context=None, + ) + + +@pytest.mark.asyncio +async def test_rpg_module_rejects_invalid_arguments_before_db_lookup(tmp_path: Path) -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(ValueError, match="session_id must be a positive integer"): + await module.execute_tool("rpg.sessions.get", {"session_id": 0}, context=_context(tmp_path)) + + with pytest.raises(ValueError, match="max_chars must be between 1000 and 24000"): + await module.execute_tool( + "rpg.context.build", + {"session_id": 1, "max_chars": 999}, + context=None, + ) + + with pytest.raises(ValueError, match="query must be <= 500 characters"): + await module.execute_tool( + "rpg.rules.lookup", + {"session_id": 1, "query": "x" * 501}, + context=None, + ) + + with pytest.raises(ValueError, match="proposal_id must be an integer"): + module.validate_tool_arguments( + "rpg.proposals.apply", + { + "session_id": 1, + "proposal_id": True, + "expected_last_event_sequence": 0, + "idempotencyKey": "bad-bool", + }, + ) + + +@pytest.mark.asyncio +async def test_rpg_mcp_rules_pack_ref_replace_validates_expected_version() -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(ValueError, match="expected_version must be a positive integer"): + await module.execute_tool( + "rpg.sessions.rules_packs.replace", + { + "session_id": 1, + "expected_version": 0, + "refs": [], + "idempotencyKey": "mcp-rules-pack-version", + }, + context=None, + ) + + +@pytest.mark.asyncio +async def test_rpg_module_gets_session_snapshot_from_chacha_context(tmp_path: Path) -> None: + chacha_path = _chacha_path(tmp_path) + session_id = _seed_session(chacha_path) + module = RPGModule(ModuleConfig(name="rpg")) + + result = await module.execute_tool( + "rpg.sessions.get", + {"session_id": session_id}, + context=_context(tmp_path), + ) + + assert result["session"]["id"] == session_id # nosec B101 + assert result["snapshot"]["last_event_sequence"] == 0 # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_module_session_lookup_does_not_open_media_db( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + chacha_path = _chacha_path(tmp_path) + session_id = _seed_session(chacha_path) + + def fail_media_database(*args, **kwargs): + raise AssertionError("media db should not be opened for session metadata") + + monkeypatch.setattr(rpg_module, "MediaDatabase", fail_media_database) + module = RPGModule(ModuleConfig(name="rpg")) + + result = await module.execute_tool( + "rpg.sessions.get", + {"session_id": session_id}, + context=_context(tmp_path, media_path=str(tmp_path / "unused-media.sqlite")), + ) + + assert result["session"]["id"] == session_id # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_module_rules_lookup_uses_attached_media_refs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + media_path = str(tmp_path / "rpg-mcp-media.sqlite") + media_id = _seed_media(media_path, title="MCP Rules", content="MCP rules source") + session_id = _seed_session_with_rules_pack(_chacha_path(tmp_path), media_id) + _FakeRagRetriever.calls = [] + _FakeRagRetriever.instances = [] + monkeypatch.setattr(rpg_module, "MultiDatabaseRetriever", _FakeRagRetriever, raising=False) + module = RPGModule(ModuleConfig(name="rpg")) + + result = await module.execute_tool( + "rpg.rules.lookup", + {"session_id": session_id, "query": "stress"}, + context=_context(tmp_path, media_path=media_path), + ) + + assert result["results"][0]["origin"] == "user_provided" # nosec B101 + assert result["results"][0]["text"] == "MCP retrieved rules evidence" # nosec B101 + assert _FakeRagRetriever.calls[0]["allowed_media_ids"] == [media_id] # nosec B101 + assert _FakeRagRetriever.instances[0].close_called is True # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_mcp_tool_requires_declared_domain_permissions(tmp_path: Path) -> None: + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(PermissionError, match="media.read"): + await module.execute_tool( + "rpg.rules.lookup", + {"session_id": 1, "query": "stress"}, + context=_context(tmp_path, permissions=["rpg.rules.read"]), + ) + + with pytest.raises(PermissionError, match="rpg.sessions.manage"): + await module.execute_tool( + "rpg.sessions.rules_packs.replace", + { + "session_id": 1, + "expected_version": 1, + "refs": [], + "idempotencyKey": "mcp-rules-pack-missing-manage", + }, + context=_context(tmp_path, permissions=["media.read"]), + ) + + +@pytest.mark.asyncio +async def test_protocol_denies_rpg_tool_when_domain_permission_missing(tmp_path: Path) -> None: + _seed_session(_chacha_path(tmp_path)) + proto = _protocol({"rpg.rules.lookup"}) + + response = await proto.process_request( + MCPRequest( + method="tools/call", + params={"name": "rpg.rules.lookup", "arguments": {"session_id": 1, "query": "stress"}}, + id="deny-domain-rpg", + ), + _context(tmp_path, permissions=["rpg.rules.read"]), + ) + + assert response.error is not None # nosec B101 + assert response.error.code == -32001 # nosec B101 + assert response.error.message == "RPG MCP tool requires permissions: media.read" # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_mcp_answer_mode_requires_chat_permissions_and_generation_controls( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + called = False + + async def fake_lookup_rules(self, *, session_id, query, mode="lookup", answer_options=None): + nonlocal called + called = True + return RuleLookupResult( + query=query, + mode=mode, + results=[], + answer=None, + answer_status="no_evidence", + answer_citation_ids=[], + diagnostics={}, + ) + + monkeypatch.setattr(RPGService, "lookup_rules", fake_lookup_rules) + module = RPGModule(ModuleConfig(name="rpg")) + args = {"session_id": 1, "query": "stress", "mode": "answer"} + + with pytest.raises(PermissionError, match="chat.completions"): + await module.execute_tool( + "rpg.rules.lookup", + args, + context=_context(tmp_path, permissions=["rpg.rules.read", "media.read"]), + ) + + with pytest.raises(PermissionError, match="answer generation controls"): + await module.execute_tool( + "rpg.rules.lookup", + args, + context=_context( + tmp_path, + permissions=["rpg.rules.read", "media.read", "chat.completions"], + answer_generation_controls=False, + ), + ) + + assert called is False # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_mcp_session_rules_pack_replace_succeeds_and_replays(tmp_path: Path) -> None: + media_path = str(tmp_path / "rpg-mcp-replace-media.sqlite") + media_id = _seed_media(media_path, title="Replacement Rules", content="replacement rules") + _, session_id = _seed_campaign_and_session(_chacha_path(tmp_path)) + module = RPGModule(ModuleConfig(name="rpg")) + args = { + "session_id": session_id, + "expected_version": 1, + "refs": [{"source_type": "media_item", "source_id": media_id}], + "idempotency_key": "mcp-rules-pack-replace-success", + } + context = _context(tmp_path, media_path=media_path) + + result = await module.execute_tool("rpg.sessions.rules_packs.replace", args, context=context) + replay = await module.execute_tool("rpg.sessions.rules_packs.replace", args, context=context) + + assert result["version"] == 2 # nosec B101 + assert result["refs"][0]["source_id"] == media_id # nosec B101 + assert replay["replayed"] is True # nosec B101 + + with pytest.raises(Exception, match="idempotency_key_conflict"): + await module.execute_tool( + "rpg.sessions.rules_packs.replace", + {**args, "refs": []}, + context=context, + ) + + +@pytest.mark.asyncio +async def test_rpg_mcp_rules_pack_replace_does_not_construct_retriever( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + media_path = str(tmp_path / "rpg-mcp-replace-media-no-retriever.sqlite") + media_id = _seed_media(media_path, title="Replacement Rules", content="replacement rules") + _, session_id = _seed_campaign_and_session(_chacha_path(tmp_path)) + + def fail_retriever(*args, **kwargs): + raise AssertionError("rules-pack replacement should not construct a retriever") + + monkeypatch.setattr(rpg_module, "MultiDatabaseRetriever", fail_retriever) + module = RPGModule(ModuleConfig(name="rpg")) + + result = await module.execute_tool( + "rpg.sessions.rules_packs.replace", + { + "session_id": session_id, + "expected_version": 1, + "refs": [{"source_type": "media_item", "source_id": media_id}], + "idempotency_key": "mcp-rules-pack-replace-no-retriever", + }, + context=_context(tmp_path, media_path=media_path), + ) + + assert result["refs"][0]["source_id"] == media_id # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_mcp_service_construction_closes_opened_resources_on_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + closed: list[str] = [] + + class FakeChaChaDB: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + def close_connection(self): + closed.append("chacha") + + class FakeMediaDB: + backend = object() + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + def close_connection(self): + closed.append("media") + + def fail_retriever(*args, **kwargs): + raise RuntimeError("retriever unavailable") + + monkeypatch.setattr(rpg_module, "CharactersRAGDB", FakeChaChaDB) + monkeypatch.setattr(rpg_module, "MediaDatabase", FakeMediaDB) + monkeypatch.setattr(rpg_module.CollectionsDatabase, "from_backend", lambda *args, **kwargs: object()) + monkeypatch.setattr(rpg_module, "MultiDatabaseRetriever", fail_retriever) + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(RuntimeError, match="retriever unavailable"): + await module.execute_tool( + "rpg.rules.lookup", + {"session_id": 1, "query": "stress"}, + context=_context(tmp_path, media_path=str(tmp_path / "media.sqlite")), + ) + + assert closed == ["media", "chacha"] # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_mcp_rules_lookup_accepts_answer_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + async def fake_lookup_rules(self, *, session_id, query, mode="lookup", answer_options=None): + captured.update( + { + "session_id": session_id, + "query": query, + "mode": mode, + "answer_options": answer_options, + } + ) + return RuleLookupResult( + query=query, + mode=mode, + results=[], + answer=None, + answer_status="no_evidence", + answer_citation_ids=[], + diagnostics={ + "bundled_policy": "no_match", + "result_mode": "citation_index", + "linked_rules_pack_count": 0, + "enabled_rules_pack_count": 0, + "ready_media_item_count": 0, + "retrieval_result_count": 0, + "bundled_citation_count": 0, + "skipped_refs": [], + "broad_fallback_used": False, + }, + ) + + monkeypatch.setattr(RPGService, "lookup_rules", fake_lookup_rules) + module = RPGModule(ModuleConfig(name="rpg")) + + result = await module.execute_tool( + "rpg.rules.lookup", + { + "session_id": 1, + "query": "stress", + "mode": "answer", + "provider": "openai", + "model": "gpt-test", + "temperature": 0.4, + "max_tokens": 321, + }, + context=_context(tmp_path), + ) + + assert result["mode"] == "answer" # nosec B101 + assert captured["mode"] == "answer" # nosec B101 + options = captured["answer_options"] + assert isinstance(options, RulesAnswerOptions) # nosec B101 + assert options.provider == "openai" # nosec B101 + assert options.model == "gpt-test" # nosec B101 + assert options.temperature == 0.4 # nosec B101 + assert options.max_tokens == 321 # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_mcp_context_build_awaits_async_service(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + async def fake_build_context(self, *, session_id, query=None, max_chars=24000): + captured.update({"session_id": session_id, "query": query, "max_chars": max_chars}) + return SessionContext(text="async context", diagnostics={"rules_lookup": {}}) + + monkeypatch.setattr(RPGService, "build_context", fake_build_context) + module = RPGModule(ModuleConfig(name="rpg")) + + result = await module.execute_tool( + "rpg.context.build", + {"session_id": 7, "query": "stress", "max_chars": 1000}, + context=_context(tmp_path), + ) + + assert result["text"] == "async context" # nosec B101 + assert captured == {"session_id": 7, "query": "stress", "max_chars": 1000} # nosec B101 + + +@pytest.mark.asyncio +async def test_rpg_mcp_read_tools_require_media_read_for_attached_refs(tmp_path: Path) -> None: + session_id = _seed_session_with_rules_pack(_chacha_path(tmp_path), media_id=123) + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(ValueError, match="Media DB path not available"): + await module.execute_tool( + "rpg.rules.lookup", + {"session_id": session_id, "query": "stress"}, + context=_context(tmp_path), + ) + + +@pytest.mark.asyncio +async def test_rpg_mcp_write_tools_require_media_read_for_source_validation(tmp_path: Path) -> None: + _, session_id = _seed_campaign_and_session(_chacha_path(tmp_path)) + module = RPGModule(ModuleConfig(name="rpg")) + + with pytest.raises(ValueError, match="Media DB path not available"): + await module.execute_tool( + "rpg.sessions.rules_packs.replace", + { + "session_id": session_id, + "expected_version": 1, + "refs": [{"source_type": "media_item", "source_id": 123}], + "idempotencyKey": "mcp-rules-pack-no-media", + }, + context=_context(tmp_path), + ) + + +@pytest.mark.asyncio +async def test_protocol_denies_rpg_tool_without_execute_permission(tmp_path: Path) -> None: + session_id = _seed_session(_chacha_path(tmp_path)) + proto = _protocol(set()) + + response = await proto.process_request( + MCPRequest( + method="tools/call", + params={"name": "rpg.sessions.get", "arguments": {"session_id": session_id}}, + id="deny-rpg", + ), + _context(tmp_path), + ) + listed = await proto.process_request(MCPRequest(method="tools/list", params={}, id="list-rpg"), _context(tmp_path)) + tools = {tool["name"]: tool for tool in listed.result["tools"]} + + assert response.error is not None # nosec B101 + assert response.error.code == -32001 # nosec B101 + assert tools["rpg.sessions.get"]["canExecute"] is False # nosec B101 + + +@pytest.mark.asyncio +async def test_protocol_allows_read_permission_and_denies_write_permission(tmp_path: Path) -> None: + session_id = _seed_session(_chacha_path(tmp_path)) + proto = _protocol({"rpg.sessions.get", "rpg.adapters.list"}) + + read_response = await proto.process_request( + MCPRequest( + method="tools/call", + params={"name": "rpg.sessions.get", "arguments": {"session_id": session_id}}, + id="read-rpg", + ), + _context(tmp_path), + ) + write_response = await proto.process_request( + MCPRequest( + method="tools/call", + params={ + "name": "rpg.events.record", + "arguments": { + "session_id": session_id, + "expected_last_event_sequence": 0, + "events": [{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "Denied"}}], + "idempotencyKey": "mcp-denied-write", + }, + }, + id="write-denied-rpg", + ), + _context(tmp_path), + ) + + assert read_response.error is None # nosec B101 + assert read_response.result["tool"] == "rpg.sessions.get" # nosec B101 + assert write_response.error is not None # nosec B101 + assert write_response.error.code == -32001 # nosec B101 + + +@pytest.mark.asyncio +async def test_protocol_allows_exact_write_permission_for_record_events(tmp_path: Path) -> None: + session_id = _seed_session(_chacha_path(tmp_path)) + proto = _protocol({"rpg.events.record"}) + + response = await proto.process_request( + MCPRequest( + method="tools/call", + params={ + "name": "rpg.events.record", + "arguments": { + "session_id": session_id, + "expected_last_event_sequence": 0, + "events": [ + { + "event_type": "note.added", + "event_payload": {"note_id": "n1", "text": "Recorded through MCP"}, + } + ], + "idempotencyKey": "mcp-record-exact", + }, + }, + id="write-exact-rpg", + ), + _context(tmp_path), + ) + + assert response.error is None # nosec B101 + payload = response.result["content"][0]["json"] + assert payload["committed_events"] == [] # nosec B101 + assert payload["proposal"]["status"] == "pending" # nosec B101 + assert payload["proposal"]["proposed_events"][0]["source_type"] == "mcp" # nosec B101 + + +@pytest.mark.asyncio +async def test_protocol_wildcard_tool_permission_marks_rpg_tools_executable(tmp_path: Path) -> None: + _seed_session(_chacha_path(tmp_path)) + proto = _protocol({"rpg.*"}) + + response = await proto.process_request( + MCPRequest(method="tools/list", params={}, id="list-wildcard-rpg"), + _context(tmp_path), + ) + tools = {tool["name"]: tool for tool in response.result["tools"]} + + assert tools["rpg.adapters.list"]["canExecute"] is True # nosec B101 + assert tools["rpg.events.record"]["canExecute"] is True # nosec B101 + assert tools["rpg.proposals.apply"]["canExecute"] is True # nosec B101 + + +@pytest.mark.asyncio +async def test_protocol_allowed_tools_metadata_denies_unlisted_rpg_tool(tmp_path: Path) -> None: + session_id = _seed_session(_chacha_path(tmp_path)) + proto = _protocol({"rpg.*"}) + + denied = await proto.process_request( + MCPRequest( + method="tools/call", + params={"name": "rpg.sessions.get", "arguments": {"session_id": session_id}}, + id="allowed-tools-deny-rpg", + ), + _context(tmp_path, allowed_tools=["notes.search"]), + ) + allowed = await proto.process_request( + MCPRequest( + method="tools/call", + params={"name": "rpg.sessions.get", "arguments": {"session_id": session_id}}, + id="allowed-tools-allow-rpg", + ), + _context(tmp_path, allowed_tools=["rpg.sessions.get"]), + ) + + assert denied.error is not None # nosec B101 + assert "not allowed by execution context" in denied.error.message # nosec B101 + assert allowed.error is None # nosec B101 + + +async def _capture_default_registrations( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> list[dict[str, Any]]: + from tldw_Server_API.app.core.MCP_unified.server import MCPServer + + server = MCPServer() + registrations: list[dict[str, Any]] = [] + + async def _capture_registration(module_id: str, module_type: type[Any], config: Any) -> None: + registrations.append( + {"module_id": module_id, "module_type": module_type, "config": config} + ) + + monkeypatch.setattr(server.module_registry, "register_module", _capture_registration) + monkeypatch.setenv("MCP_MODULES_CONFIG", str(tmp_path / "missing-modules.yaml")) + monkeypatch.delenv("MCP_MODULES", raising=False) + monkeypatch.setenv("MCP_ENABLE_MEDIA_MODULE", "0") + monkeypatch.setenv("MCP_ENABLE_FILESYSTEM_MODULE", "0") + monkeypatch.setenv("MCP_ENABLE_BROWSER_CDP_MODULE", "0") + monkeypatch.delenv("MCP_BROWSER_CDP_URL", raising=False) + + await server._register_default_modules() + return registrations + + +@pytest.mark.asyncio +async def test_server_registers_rpg_module_when_enabled( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("MCP_ENABLE_RPG_MODULE", "1") + + registrations = await _capture_default_registrations(monkeypatch, tmp_path) + + registration = next(item for item in registrations if item["module_id"] == "rpg") + assert registration["module_type"].__name__ == "RPGModule" # nosec B101 + assert registration["config"].name == "RPG" # nosec B101 + assert registration["config"].department == "management" # nosec B101 + + +@pytest.mark.asyncio +async def test_server_does_not_register_rpg_module_when_flag_unset( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("MCP_ENABLE_RPG_MODULE", raising=False) + + registrations = await _capture_default_registrations(monkeypatch, tmp_path) + + assert "rpg" not in {item["module_id"] for item in registrations} # nosec B101 diff --git a/tldw_Server_API/tests/RPG/test_rpg_rules_answering.py b/tldw_Server_API/tests/RPG/test_rpg_rules_answering.py new file mode 100644 index 0000000000..55b55e1c7b --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_rules_answering.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from tldw_Server_API.app.core.Chat.Chat_Deps import ChatProviderError +from tldw_Server_API.app.core.RPG.rules.answering import ( + ChatRulesAnswerGenerator, + RulesAnswerOptions, + RulesAnswerResult, +) +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupCitation, RuleLookupItem +from tldw_Server_API.app.core.RPG.rules.lookup import RulesLookupService +from tldw_Server_API.app.core.RPG.rules.retrieval import RulesRetrievalResult + +pytestmark = pytest.mark.unit + + +class FakeLookupRetriever: + def __init__(self, items: list[RuleLookupItem]) -> None: + self.items = items + + async def retrieve(self, **kwargs: Any) -> RulesRetrievalResult: + return RulesRetrievalResult( + items=self.items, + ready_media_ids=[42] if self.items else [], + skipped_refs=[], + diagnostics={"retrieval_result_count": len(self.items)}, + ) + + +class FakeAnswerGenerator: + def __init__(self, result: RulesAnswerResult) -> None: + self.result = result + self.calls: list[dict[str, Any]] = [] + + async def generate(self, **kwargs: Any) -> RulesAnswerResult: + self.calls.append(kwargs) + return self.result + + +def _item(snippet_id: str = "media:42:chunk:7", text: str = "Spend a stress box to reduce harm.") -> RuleLookupItem: + return RuleLookupItem( + origin="user_provided", + text=text, + citation=RuleLookupCitation( + source_type="media_item", + source_id=42, + source_title="Fate Rules", + source_url=None, + license=None, + license_url=None, + attribution=None, + trust_level="user_provided", + content_hash="sha256:abc", + snippet_id=snippet_id, + ), + score=0.9, + ) + + +@pytest.mark.asyncio +async def test_answer_mode_returns_not_requested_for_lookup_mode() -> None: + generator = FakeAnswerGenerator(RulesAnswerResult(answer="unused", answer_status="answered", citation_ids=[])) + lookup = RulesLookupService(retriever=FakeLookupRetriever([_item()]), answer_generator=generator) + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + mode="lookup", + ) + + assert result.answer is None # nosec B101 + assert result.answer_status == "not_requested" # nosec B101 + assert result.answer_citation_ids == [] # nosec B101 + assert generator.calls == [] # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_returns_no_evidence_without_user_snippets() -> None: + generator = FakeAnswerGenerator(RulesAnswerResult(answer="unused", answer_status="answered", citation_ids=[])) + lookup = RulesLookupService(retriever=FakeLookupRetriever([]), answer_generator=generator) + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + mode="answer", + ) + + assert result.answer is None # nosec B101 + assert result.answer_status == "no_evidence" # nosec B101 + assert result.answer_citation_ids == [] # nosec B101 + assert generator.calls == [] # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_calls_chat_service_with_grounded_prompt() -> None: + calls: list[dict[str, Any]] = [] + + async def fake_chat(**kwargs: Any) -> dict[str, Any]: + calls.append(kwargs) + return {"choices": [{"message": {"content": '{"answer": "Use the stress track.", "citation_ids": ["media:42:chunk:7"]}'}}]} + + generator = ChatRulesAnswerGenerator(chat_call=fake_chat) + + await generator.generate(query="How does stress work?", evidence=[_item()], options=RulesAnswerOptions()) + + assert len(calls) == 1 # nosec B101 + call = calls[0] + assert call["messages"][0]["role"] == "user" # nosec B101 + assert "How does stress work?" in call["messages"][0]["content"] # nosec B101 + assert "media:42:chunk:7" in call["messages"][0]["content"] # nosec B101 + assert "Spend a stress box" in call["messages"][0]["content"] # nosec B101 + assert "only from the provided rules evidence" in call["system_message"] # nosec B101 + assert call["stream"] is False # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_extracts_openai_content() -> None: + async def fake_chat(**kwargs: Any) -> dict[str, Any]: + return {"choices": [{"message": {"content": '{"answer": "Stress absorbs harm.", "citation_ids": ["media:42:chunk:7"]}'}}]} + + result = await ChatRulesAnswerGenerator(chat_call=fake_chat).generate( + query="stress", + evidence=[_item()], + options=RulesAnswerOptions(), + ) + + assert result.answer == "Stress absorbs harm." # nosec B101 + assert result.answer_status == "answered" # nosec B101 + assert result.citation_ids == ["media:42:chunk:7"] # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_filters_unknown_citation_ids() -> None: + async def fake_chat(**kwargs: Any) -> str: + return '{"answer": "Stress absorbs harm.", "citation_ids": ["media:42:chunk:7", "unknown"]}' + + result = await ChatRulesAnswerGenerator(chat_call=fake_chat).generate( + query="stress", + evidence=[_item()], + options=RulesAnswerOptions(), + ) + + assert result.citation_ids == ["media:42:chunk:7"] # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_rejects_malformed_json_without_citations() -> None: + async def fake_chat(**kwargs: Any) -> str: + return "Stress absorbs harm." + + result = await ChatRulesAnswerGenerator(chat_call=fake_chat).generate( + query="stress", + evidence=[_item()], + options=RulesAnswerOptions(), + ) + + assert result.answer is None # nosec B101 + assert result.answer_status == "generation_error" # nosec B101 + assert result.citation_ids == [] # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_returns_generation_error_on_provider_failure() -> None: + async def fake_chat(**kwargs: Any) -> str: + raise ChatProviderError("provider failed", provider="openai") + + result = await ChatRulesAnswerGenerator(chat_call=fake_chat).generate( + query="stress", + evidence=[_item()], + options=RulesAnswerOptions(provider="openai"), + ) + + assert result.answer is None # nosec B101 + assert result.answer_status == "generation_error" # nosec B101 + assert result.citation_ids == [] # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_uses_request_provider_and_model() -> None: + calls: list[dict[str, Any]] = [] + + async def fake_chat(**kwargs: Any) -> str: + calls.append(kwargs) + return "Plain answer" + + await ChatRulesAnswerGenerator(chat_call=fake_chat).generate( + query="stress", + evidence=[_item()], + options=RulesAnswerOptions(provider="openai", model="gpt-test", temperature=0.4, max_tokens=321), + ) + + assert calls[0]["api_provider"] == "openai" # nosec B101 + assert calls[0]["model"] == "gpt-test" # nosec B101 + assert calls[0]["temperature"] == 0.4 # nosec B101 + assert calls[0]["max_tokens"] == 321 # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_uses_default_temperature_and_token_bounds() -> None: + calls: list[dict[str, Any]] = [] + + async def fake_chat(**kwargs: Any) -> str: + calls.append(kwargs) + return "Plain answer" + + await ChatRulesAnswerGenerator(chat_call=fake_chat).generate( + query="stress", + evidence=[_item()], + options=RulesAnswerOptions(), + ) + + assert calls[0]["temperature"] == 0.2 # nosec B101 + assert calls[0]["max_tokens"] == 600 # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_wires_generator_result_into_lookup() -> None: + generator = FakeAnswerGenerator( + RulesAnswerResult( + answer="Use the stress track.", + answer_status="answered", + citation_ids=["media:42:chunk:7", "unknown"], + ) + ) + lookup = RulesLookupService(retriever=FakeLookupRetriever([_item()]), answer_generator=generator) + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + mode="answer", + answer_options=RulesAnswerOptions(provider="openai", model="gpt-test"), + ) + + assert result.answer == "Use the stress track." # nosec B101 + assert result.answer_status == "answered" # nosec B101 + assert result.answer_citation_ids == ["media:42:chunk:7"] # nosec B101 + assert generator.calls[0]["options"].provider == "openai" # nosec B101 + assert generator.calls[0]["options"].model == "gpt-test" # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_returns_generation_error_on_unexpected_generator_failure() -> None: + class FailingAnswerGenerator: + async def generate(self, **kwargs: Any) -> RulesAnswerResult: + raise RuntimeError("provider adapter crashed") + + lookup = RulesLookupService(retriever=FakeLookupRetriever([_item()]), answer_generator=FailingAnswerGenerator()) + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + mode="answer", + ) + + assert result.answer is None # nosec B101 + assert result.answer_status == "generation_error" # nosec B101 + assert result.answer_citation_ids == [] # nosec B101 diff --git a/tldw_Server_API/tests/RPG/test_rpg_rules_context.py b/tldw_Server_API/tests/RPG/test_rpg_rules_context.py new file mode 100644 index 0000000000..4a51fb295a --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_rules_context.py @@ -0,0 +1,361 @@ +import pytest + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RPG.context import SessionContextBuilder +from tldw_Server_API.app.core.RPG.errors import RPGValidationError +from tldw_Server_API.app.core.RPG.models import RPGSnapshotState +from tldw_Server_API.app.core.RPG.rules.content_packs import RuleLookupCitation, RuleLookupItem, RuleLookupResult +from tldw_Server_API.app.core.RPG.rules.lookup import RulesLookupService +from tldw_Server_API.app.core.RPG.rules.retrieval import RulesRetrievalResult +from tldw_Server_API.app.core.RPG.service import RPGService + +pytestmark = pytest.mark.unit + + +def _service() -> RPGService: + repo = RPGRepository.initialized(CharactersRAGDB(":memory:", "rpg-rules-context-test")) + return RPGService(repo=repo, owner_user_id=42) + + +class FakeLookupRetriever: + def __init__(self, result: RulesRetrievalResult) -> None: + self.result = result + self.calls: list[dict[str, object]] = [] + + async def retrieve(self, **kwargs): + self.calls.append(kwargs) + return self.result + + +class FailingLookupRetriever: + def __init__(self, message: str) -> None: + self.message = message + + async def retrieve(self, **kwargs): + raise RuntimeError(self.message) + + +class FakeRulesLookupService: + def __init__(self, result: RuleLookupResult | None = None, exc: Exception | None = None) -> None: + self.result = result + self.exc = exc + self.calls: list[dict[str, object]] = [] + + async def lookup(self, **kwargs): + self.calls.append(kwargs) + if self.exc is not None: + raise self.exc + return self.result + + +def _user_lookup_item(snippet_id: str = "media:42:chunk:7") -> RuleLookupItem: + return RuleLookupItem( + origin="user_provided", + text="User rules say this applies.", + citation=RuleLookupCitation( + source_type="media_item", + source_id=42, + source_title="Player Rules", + source_url=None, + license=None, + license_url=None, + attribution=None, + trust_level="user_provided", + content_hash="sha256:abc", + snippet_id=snippet_id, + ), + score=0.91, + ) + + +@pytest.mark.asyncio +async def test_rules_lookup_returns_citations_without_pf2e_prose(): + lookup = RulesLookupService() + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="pf2e", + query="dying condition", + linked_rules_pack_refs=[], + ) + + assert result.query == "dying condition" # nosec B101 + assert result.results # nosec B101 + assert all(item.text == "" for item in result.results) # nosec B101 + assert all(item.origin == "bundled_citation" for item in result.results) # nosec B101 + assert all(item.citation.source_type == "bundled_rules_citation" for item in result.results) # nosec B101 + assert result.diagnostics["bundled_policy"] == "citations_only" # nosec B101 + assert result.answer_status == "not_requested" # nosec B101 + + +@pytest.mark.asyncio +async def test_context_builder_includes_snapshot_rule_citations_and_budget(): + lookup = RulesLookupService() + rule_result = await lookup.lookup(owner_user_id=42, adapter_key="fate", query="stress", linked_rules_pack_refs=[]) + snapshot = RPGSnapshotState( + scene={"summary": "Rain at the old docks"}, + npcs={"npc-1": {"npc_id": "npc-1", "name": "Ada"}}, + notes=[{"note_id": "n1", "text": "The ferry bell rings twice."}], + unresolved_rulings={"r1": {"ruling_id": "r1", "question": "How does stress clear?"}}, + ) + + context = SessionContextBuilder(max_chars=1000).build( + adapter_key="fate", + session_title="Opening", + snapshot=snapshot, + rules_results=rule_result.results, + ) + + assert "Opening" in context.text # nosec B101 + assert "Rain at the old docks" in context.text # nosec B101 + assert "Ada" in context.text # nosec B101 + assert "Fate SRD" in context.text # nosec B101 + assert context.diagnostics["truncated"] is False # nosec B101 + assert context.diagnostics["rules_result_count"] == len(rule_result.results) # nosec B101 + + truncated = SessionContextBuilder(max_chars=80).build( + adapter_key="fate", + session_title="Opening", + snapshot=snapshot, + rules_results=rule_result.results, + ) + + assert len(truncated.text) <= 80 # nosec B101 + assert truncated.diagnostics["truncated"] is True # nosec B101 + assert not truncated.text.endswith("https:/") # nosec B101 + + +@pytest.mark.asyncio +async def test_service_lookup_rules_and_context_are_owner_scoped(): + service = _service() + campaign = service.create_campaign("Campaign", None, "pf2e", idempotency_key="campaign-rules") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="pf2e", + idempotency_key="session-rules", + ) + service.record_events( + session_id=session.id, + events=[ + { + "event_type": "scene.updated", + "event_payload": {"scene_id": "scene-1", "summary": "Ruins under moonlight"}, + } + ], + source_type="user", + expected_last_event_sequence=0, + idempotency_key="rules-scene-1", + ) + + lookup = await service.lookup_rules(session.id, query="dying condition") + context = await service.build_context(session.id, query="dying condition", max_chars=1000) + + assert lookup.results # nosec B101 + assert lookup.results[0].citation.source_title == "Archives of Nethys Pathfinder 2e" # nosec B101 + assert "Ruins under moonlight" in context.text # nosec B101 + assert "Archives of Nethys" in context.text # nosec B101 + + +@pytest.mark.asyncio +async def test_service_context_clamps_tiny_budget_for_non_rest_callers(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-clamp") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-clamp", + ) + + context = await service.build_context(session.id, query="stress", max_chars=1) + + assert context.diagnostics["max_chars"] == 1000 # nosec B101 + + +@pytest.mark.asyncio +async def test_service_context_includes_lookup_diagnostics_and_uses_lookup_mode(): + rule_result = RuleLookupResult( + query="stress", + mode="lookup", + results=[_user_lookup_item()], + answer=None, + answer_status="not_requested", + answer_citation_ids=[], + diagnostics={"retrieval_result_count": 1, "skipped_refs": [{"ref_id": "media:9", "reason": "disabled"}]}, + ) + lookup = FakeRulesLookupService(rule_result) + repo = RPGRepository.initialized(CharactersRAGDB(":memory:", "rpg-context-diagnostics-test")) + service = RPGService(repo=repo, owner_user_id=42, rules_lookup_service=lookup) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-context-diag") + session = service.create_session(campaign.id, "Opening", "fate", idempotency_key="session-context-diag") + + context = await service.build_context(session.id, query="stress", max_chars=1000) + + assert "User rules say this applies." in context.text # nosec B101 + assert lookup.calls[0]["mode"] == "lookup" # nosec B101 + assert context.diagnostics["rules_lookup"]["retrieval_result_count"] == 1 # nosec B101 + assert context.diagnostics["rules_lookup"]["skipped_refs"] == [{"ref_id": "media:9", "reason": "disabled"}] # nosec B101 + + +@pytest.mark.asyncio +async def test_service_context_continues_when_rules_lookup_validation_fails(): + lookup = FakeRulesLookupService(exc=RPGValidationError("rules_pack_source_unreadable")) + repo = RPGRepository.initialized(CharactersRAGDB(":memory:", "rpg-context-lookup-error-test")) + service = RPGService(repo=repo, owner_user_id=42, rules_lookup_service=lookup) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-context-error") + session = service.create_session(campaign.id, "Opening", "fate", idempotency_key="session-context-error") + service.record_events( + session_id=session.id, + events=[{"event_type": "scene.updated", "event_payload": {"scene_id": "s1", "summary": "Still playable"}}], + source_type="user", + expected_last_event_sequence=0, + idempotency_key="context-error-scene", + ) + + context = await service.build_context(session.id, query="stress", max_chars=1000) + + assert "Still playable" in context.text # nosec B101 + assert context.diagnostics["rules_lookup"]["lookup_error"] == "rules_pack_source_unreadable" # nosec B101 + assert context.diagnostics["rules_result_count"] == 0 # nosec B101 + + +@pytest.mark.asyncio +async def test_lookup_returns_user_results_before_bundled_citations(): + retriever = FakeLookupRetriever( + RulesRetrievalResult( + items=[_user_lookup_item()], + ready_media_ids=[42], + skipped_refs=[], + diagnostics={"retrieval_result_count": 1}, + ) + ) + lookup = RulesLookupService(retriever=retriever) + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + ) + + assert result.results[0].origin == "user_provided" # nosec B101 + assert result.results[1].origin == "bundled_citation" # nosec B101 + assert result.diagnostics["retrieval_result_count"] == 1 # nosec B101 + + +@pytest.mark.asyncio +async def test_answer_mode_marks_retrieved_evidence_as_not_generated(): + retriever = FakeLookupRetriever( + RulesRetrievalResult( + items=[_user_lookup_item()], + ready_media_ids=[42], + skipped_refs=[], + diagnostics={"retrieval_result_count": 1}, + ) + ) + lookup = RulesLookupService(retriever=retriever) + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + mode="answer", + ) + + assert result.results[0].origin == "user_provided" # nosec B101 + assert result.answer is None # nosec B101 + assert result.answer_status == "not_generated" # nosec B101 + + +@pytest.mark.asyncio +async def test_lookup_keeps_bundled_citations_score_zero(): + lookup = RulesLookupService() + + result = await lookup.lookup(owner_user_id=42, adapter_key="pf2e", query="dying", linked_rules_pack_refs=[]) + + bundled = [item for item in result.results if item.origin == "bundled_citation"] + assert bundled # nosec B101 + assert all(item.score == 0.0 for item in bundled) # nosec B101 + + +@pytest.mark.asyncio +async def test_lookup_returns_diagnostics_for_skipped_refs(): + retriever = FakeLookupRetriever( + RulesRetrievalResult( + items=[], + ready_media_ids=[], + skipped_refs=[{"ref_id": "media_collection:5", "reason": "no_ready_media"}], + diagnostics={"broad_fallback_used": False}, + ) + ) + lookup = RulesLookupService(retriever=retriever) + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_collection", "source_id": 5}], + ) + + assert result.diagnostics["skipped_refs"] == [ # nosec B101 + {"ref_id": "media_collection:5", "reason": "no_ready_media"} + ] + assert result.diagnostics["broad_fallback_used"] is False # nosec B101 + + +@pytest.mark.asyncio +async def test_lookup_redacts_unexpected_retrieval_errors_from_diagnostics(): + lookup = RulesLookupService(retriever=FailingLookupRetriever("/private/db/path failed")) + + result = await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + ) + + assert result.results # nosec B101 + assert result.diagnostics["retrieval_error"] == "retrieval_failed" # nosec B101 + assert "/private/db/path" not in str(result.diagnostics) # nosec B101 + + +@pytest.mark.asyncio +async def test_lookup_redacts_unexpected_retrieval_errors_from_warning_log(monkeypatch): + from tldw_Server_API.app.core.RPG.rules import lookup as lookup_module + + logged: list[tuple[str, tuple[object, ...]]] = [] + + def fake_warning(message, *args): + logged.append((str(message), args)) + + monkeypatch.setattr(lookup_module.logger, "warning", fake_warning) + lookup = RulesLookupService(retriever=FailingLookupRetriever("/private/db/path failed")) + + await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query="stress", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + ) + + assert logged # nosec B101 + assert "/private/db/path" not in str(logged) # nosec B101 + + +@pytest.mark.asyncio +async def test_lookup_does_not_call_retriever_when_query_is_blank(): + retriever = FakeLookupRetriever(RulesRetrievalResult(items=[], ready_media_ids=[], skipped_refs=[], diagnostics={})) + lookup = RulesLookupService(retriever=retriever) + + with pytest.raises(RPGValidationError, match="rules_query_required"): + await lookup.lookup( + owner_user_id=42, + adapter_key="fate", + query=" ", + linked_rules_pack_refs=[{"source_type": "media_item", "source_id": 42}], + ) + + assert retriever.calls == [] # nosec B101 diff --git a/tldw_Server_API/tests/RPG/test_rpg_rules_refs.py b/tldw_Server_API/tests/RPG/test_rpg_rules_refs.py new file mode 100644 index 0000000000..49ea1dc091 --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_rules_refs.py @@ -0,0 +1,187 @@ +from datetime import datetime, timezone + +import pytest + +from tldw_Server_API.app.core.RPG.errors import RPGValidationError +from tldw_Server_API.app.core.RPG.rules.refs import ( + RulesPackRef, + normalize_rules_pack_ref_payloads, + rules_pack_ref_from_dict, + rules_pack_ref_to_dict, +) + +pytestmark = pytest.mark.unit + + +def _now() -> datetime: + return datetime(2026, 6, 25, 12, 0, tzinfo=timezone.utc) + + +def test_normalize_rules_pack_refs_accepts_media_item_and_collection(): + refs = normalize_rules_pack_ref_payloads( + [ + {"source_type": "media_item", "source_id": 7, "display_name": "Player Rules"}, + {"source_type": "media_collection", "source_id": 3}, + ], + existing_refs=[], + now=_now(), + ) + + assert [ref.ref_id for ref in refs] == ["media_item:7", "media_collection:3"] # nosec B101 + assert refs[0].display_name == "Player Rules" # nosec B101 + assert refs[1].display_name == "media_collection:3" # nosec B101 + assert all(ref.enabled is True for ref in refs) # nosec B101 + + +def test_normalize_rules_pack_refs_rejects_unknown_source_type(): + with pytest.raises(RPGValidationError, match="invalid_rules_pack_ref_source_type"): + normalize_rules_pack_ref_payloads( + [{"source_type": "web", "source_id": 7}], + existing_refs=[], + now=_now(), + ) + + +@pytest.mark.parametrize("source_id", [0, -1, "7", True]) +def test_normalize_rules_pack_refs_rejects_non_positive_source_id(source_id): + with pytest.raises(RPGValidationError, match="invalid_rules_pack_ref_source_id"): + normalize_rules_pack_ref_payloads( + [{"source_type": "media_item", "source_id": source_id}], + existing_refs=[], + now=_now(), + ) + + +def test_normalize_rules_pack_refs_rejects_duplicate_ref_identity(): + with pytest.raises(RPGValidationError, match="duplicate_rules_pack_ref"): + normalize_rules_pack_ref_payloads( + [ + {"source_type": "media_item", "source_id": 7}, + {"source_type": "media_item", "source_id": 7, "display_name": "Duplicate"}, + ], + existing_refs=[], + now=_now(), + ) + + +def test_normalize_rules_pack_refs_ignores_client_timestamps(): + now = _now() + + refs = normalize_rules_pack_ref_payloads( + [ + { + "source_type": "media_item", + "source_id": 7, + "created_at": "2001-01-01T00:00:00Z", + "updated_at": "2001-01-01T00:00:00Z", + } + ], + existing_refs=[], + now=now, + ) + + assert refs[0].created_at == now # nosec B101 + assert refs[0].updated_at == now # nosec B101 + + +def test_normalize_rules_pack_refs_preserves_created_at_for_existing_ref(): + original_created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + existing = RulesPackRef( + ref_id="media_item:7", + source_type="media_item", + source_id=7, + display_name="Old", + enabled=True, + created_at=original_created_at, + updated_at=original_created_at, + metadata={}, + ) + + refs = normalize_rules_pack_ref_payloads( + [{"source_type": "media_item", "source_id": 7, "display_name": "New"}], + existing_refs=[rules_pack_ref_to_dict(existing)], + now=_now(), + ) + + assert refs[0].created_at == original_created_at # nosec B101 + + +def test_normalize_rules_pack_refs_updates_updated_at_for_existing_ref(): + original_time = datetime(2026, 1, 1, tzinfo=timezone.utc) + now = _now() + existing = RulesPackRef( + ref_id="media_item:7", + source_type="media_item", + source_id=7, + display_name="Old", + enabled=True, + created_at=original_time, + updated_at=original_time, + metadata={}, + ) + + refs = normalize_rules_pack_ref_payloads( + [{"source_type": "media_item", "source_id": 7}], + existing_refs=[rules_pack_ref_to_dict(existing)], + now=now, + ) + + assert refs[0].updated_at == now # nosec B101 + + +def test_normalize_rules_pack_refs_limits_metadata_to_json_object(): + refs = normalize_rules_pack_ref_payloads( + [{"source_type": "media_item", "source_id": 7, "metadata": {"source_label": "user"}}], + existing_refs=[], + now=_now(), + ) + + assert refs[0].metadata == {"source_label": "user"} # nosec B101 + + with pytest.raises(RPGValidationError, match="invalid_rules_pack_ref_metadata"): + normalize_rules_pack_ref_payloads( + [{"source_type": "media_item", "source_id": 8, "metadata": ["not", "object"]}], + existing_refs=[], + now=_now(), + ) + + +@pytest.mark.parametrize("enabled", [None, 0, 1, "false", [], {}]) +def test_normalize_rules_pack_refs_rejects_non_bool_enabled(enabled): + with pytest.raises(RPGValidationError, match="invalid_rules_pack_ref_enabled"): + normalize_rules_pack_ref_payloads( + [{"source_type": "media_item", "source_id": 7, "enabled": enabled}], + existing_refs=[], + now=_now(), + ) + + +@pytest.mark.parametrize("enabled", [None, 0, 1, "false", [], {}]) +def test_rules_pack_ref_from_dict_rejects_non_bool_enabled(enabled): + with pytest.raises(RPGValidationError, match="invalid_rules_pack_ref_enabled"): + rules_pack_ref_from_dict( + { + "ref_id": "media_item:7", + "source_type": "media_item", + "source_id": 7, + "display_name": "Rules", + "enabled": enabled, + "created_at": "2026-06-25T12:00:00Z", + "updated_at": "2026-06-25T12:00:00Z", + "metadata": {}, + } + ) + + +def test_rules_pack_ref_from_dict_rejects_non_integer_ref_id_suffix(): + with pytest.raises(RPGValidationError, match="invalid_rules_pack_ref_source_id"): + rules_pack_ref_from_dict( + { + "ref_id": "media_item:abc", + "display_name": "Rules", + "enabled": True, + "created_at": "2026-06-25T12:00:00Z", + "updated_at": "2026-06-25T12:00:00Z", + "metadata": {}, + } + ) diff --git a/tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py b/tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py new file mode 100644 index 0000000000..0bec858d67 --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_rules_retrieval.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any + +import pytest + +from tldw_Server_API.app.core.RAG.rag_service.evidence_models import RetrievedEvidence +from tldw_Server_API.app.core.RAG.rag_service.types import Document +from tldw_Server_API.app.core.RPG.rules.refs import RulesPackRef, RulesPackSourceValidation +from tldw_Server_API.app.core.RPG.rules.retrieval import RulesRetrievalAdapter + +pytestmark = pytest.mark.unit + + +def _ref(source_type: str, source_id: int, *, enabled: bool = True) -> RulesPackRef: + now = datetime(2026, 6, 25, tzinfo=timezone.utc) + return RulesPackRef( + ref_id=f"{source_type}:{source_id}", + source_type=source_type, # type: ignore[arg-type] + source_id=source_id, + display_name=f"{source_type} {source_id}", + enabled=enabled, + created_at=now, + updated_at=now, + metadata={}, + ) + + +class FakeSourceValidator: + def __init__(self, validations: dict[str, RulesPackSourceValidation]) -> None: + self.validations = validations + self.calls: list[tuple[str, int, int]] = [] + + async def validate_media_item(self, owner_user_id: int, media_id: int) -> RulesPackSourceValidation: + self.calls.append(("media_item", owner_user_id, media_id)) + return self.validations[f"media_item:{media_id}"] + + async def validate_media_collection( + self, + owner_user_id: int, + collection_id: int, + ) -> RulesPackSourceValidation: + self.calls.append(("media_collection", owner_user_id, collection_id)) + return self.validations[f"media_collection:{collection_id}"] + + +class FakeRetrievalExecutor: + def __init__(self, documents: list[Document] | None = None) -> None: + self.documents = documents or [] + self.calls: list[dict[str, Any]] = [] + + async def __call__(self, **kwargs: Any) -> RetrievedEvidence: + self.calls.append(kwargs) + return RetrievedEvidence(documents=self.documents) + + +@pytest.mark.asyncio +async def test_retrieval_validates_enabled_refs_concurrently(): + class BlockingValidator: + def __init__(self) -> None: + self.started: list[int] = [] + self.all_started = asyncio.Event() + + async def validate_media_item(self, owner_user_id: int, media_id: int) -> RulesPackSourceValidation: + self.started.append(media_id) + if len(self.started) == 2: + self.all_started.set() + await self.all_started.wait() + return RulesPackSourceValidation( + ref_id=f"media_item:{media_id}", + readable=True, + display_name=f"Rules {media_id}", + ready_media_ids=[], + ) + + async def validate_media_collection( + self, + owner_user_id: int, + collection_id: int, + ) -> RulesPackSourceValidation: + raise AssertionError("unexpected collection validation") + + validator = BlockingValidator() + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=FakeRetrievalExecutor(), + ) + + result = await asyncio.wait_for( + retriever.retrieve( + owner_user_id=42, + query="rules", + refs=[_ref("media_item", 1), _ref("media_item", 2)], + max_results=3, + ), + timeout=1, + ) + + assert sorted(validator.started) == [1, 2] # nosec B101 + assert result.ready_media_ids == [] # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_skips_disabled_refs(): + validator = FakeSourceValidator({}) + executor = FakeRetrievalExecutor() + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve( + owner_user_id=42, + query="advantage", + refs=[_ref("media_item", 7, enabled=False)], + max_results=5, + ) + + assert validator.calls == [] # nosec B101 + assert executor.calls == [] # nosec B101 + assert result.ready_media_ids == [] # nosec B101 + assert result.diagnostics["broad_fallback_used"] is False # nosec B101 + assert result.skipped_refs == [{"ref_id": "media_item:7", "reason": "disabled"}] # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_resolves_media_item_to_allowed_media_ids(): + validator = FakeSourceValidator( + { + "media_item:11": RulesPackSourceValidation( + ref_id="media_item:11", + readable=True, + display_name="Rules Notes", + ready_media_ids=[11], + ) + } + ) + executor = FakeRetrievalExecutor() + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve(owner_user_id=42, query="rules", refs=[_ref("media_item", 11)], max_results=3) + + assert result.ready_media_ids == [11] # nosec B101 + assert validator.calls == [("media_item", 42, 11)] # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_resolves_collection_ready_items_only(): + validator = FakeSourceValidator( + { + "media_collection:5": RulesPackSourceValidation( + ref_id="media_collection:5", + readable=True, + display_name="Collection", + ready_media_ids=[9, 9, 12], + ) + } + ) + executor = FakeRetrievalExecutor() + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve( + owner_user_id=42, + query="rules", + refs=[_ref("media_collection", 5)], + max_results=3, + ) + + assert result.ready_media_ids == [9, 12] # nosec B101 + assert validator.calls == [("media_collection", 42, 5)] # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_reports_empty_collection_without_error(): + validator = FakeSourceValidator( + { + "media_collection:5": RulesPackSourceValidation( + ref_id="media_collection:5", + readable=True, + display_name="Empty", + ready_media_ids=[], + ) + } + ) + executor = FakeRetrievalExecutor() + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve( + owner_user_id=42, + query="rules", + refs=[_ref("media_collection", 5)], + max_results=3, + ) + + assert result.items == [] # nosec B101 + assert result.ready_media_ids == [] # nosec B101 + assert executor.calls == [] # nosec B101 + assert result.skipped_refs == [{"ref_id": "media_collection:5", "reason": "no_ready_media"}] # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_reports_no_ready_sources_without_broad_fallback(): + validator = FakeSourceValidator({}) + executor = FakeRetrievalExecutor() + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve(owner_user_id=42, query="rules", refs=[], max_results=3) + + assert result.items == [] # nosec B101 + assert executor.calls == [] # nosec B101 + assert result.diagnostics["broad_fallback_used"] is False # nosec B101 + assert result.diagnostics["no_ready_sources"] is True # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_passes_allowed_media_ids_to_executor(): + validator = FakeSourceValidator( + { + "media_item:9": RulesPackSourceValidation( + ref_id="media_item:9", + readable=True, + display_name="Rules", + ready_media_ids=[9], + ) + } + ) + executor = FakeRetrievalExecutor() + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + await retriever.retrieve(owner_user_id=42, query="rules", refs=[_ref("media_item", 9)], max_results=2) + + assert len(executor.calls) == 1 # nosec B101 + assert executor.calls[0]["allowed_media_ids"] == [9] # nosec B101 + assert executor.calls[0]["allowed_note_ids"] is None # nosec B101 + assert executor.calls[0]["retrieval_plan"].sources == ("media_db",) # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_maps_documents_to_user_provided_lookup_items(): + document = Document( + id="doc-1", + content="Rules snippet", + metadata={ + "media_id": 12, + "title": "Player Rules", + "source_url": "https://example.test/rules", + "content_hash": "sha256:abc", + "snippet_id": "custom-snippet", + }, + score=0.82, + chunk_index=3, + ) + validator = FakeSourceValidator( + { + "media_item:12": RulesPackSourceValidation( + ref_id="media_item:12", + readable=True, + display_name="Player Rules", + ready_media_ids=[12], + ) + } + ) + executor = FakeRetrievalExecutor([document]) + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve(owner_user_id=42, query="rules", refs=[_ref("media_item", 12)], max_results=2) + + item = result.items[0] + assert item.origin == "user_provided" # nosec B101 + assert item.text == "Rules snippet" # nosec B101 + assert item.score == 0.82 # nosec B101 + assert item.citation.source_type == "media_item" # nosec B101 + assert item.citation.source_id == 12 # nosec B101 + assert item.citation.source_title == "Player Rules" # nosec B101 + assert item.citation.source_url == "https://example.test/rules" # nosec B101 + assert item.citation.trust_level == "user_provided" # nosec B101 + assert item.citation.content_hash == "sha256:abc" # nosec B101 + assert item.citation.snippet_id == "custom-snippet" # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_drops_documents_outside_ready_media_scope(): + documents = [ + Document(id="12", content="Allowed rules snippet", metadata={"title": "Allowed"}, score=0.9), + Document(id="99", content="Unscoped rules snippet", metadata={"title": "Other"}, score=0.8), + ] + validator = FakeSourceValidator( + { + "media_item:12": RulesPackSourceValidation( + ref_id="media_item:12", + readable=True, + display_name="Allowed", + ready_media_ids=[12], + ) + } + ) + executor = FakeRetrievalExecutor(documents) + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve(owner_user_id=42, query="rules", refs=[_ref("media_item", 12)], max_results=5) + + assert [item.citation.source_id for item in result.items] == [12] # nosec B101 + assert result.items[0].text == "Allowed rules snippet" # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_uses_document_id_for_media_level_results_without_media_metadata(): + document = Document( + id="12", + content="Media-level rules snippet", + metadata={"title": "Player Rules"}, + score=0.5, + ) + validator = FakeSourceValidator( + { + "media_item:12": RulesPackSourceValidation( + ref_id="media_item:12", + readable=True, + display_name="Player Rules", + ready_media_ids=[12], + ) + } + ) + executor = FakeRetrievalExecutor([document]) + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve(owner_user_id=42, query="rules", refs=[_ref("media_item", 12)], max_results=2) + + item = result.items[0] + assert item.citation.source_id == 12 # nosec B101 + assert item.citation.snippet_id == "media:12:chunk:unknown" # nosec B101 + + +@pytest.mark.asyncio +async def test_retrieval_uses_stable_snippet_ids(): + document = Document( + id="doc-1", + content="Rules snippet", + metadata={"media_id": 12, "title": "Player Rules"}, + score=0.5, + chunk_index=4, + ) + validator = FakeSourceValidator( + { + "media_item:12": RulesPackSourceValidation( + ref_id="media_item:12", + readable=True, + display_name="Player Rules", + ready_media_ids=[12], + ) + } + ) + executor = FakeRetrievalExecutor([document]) + retriever = RulesRetrievalAdapter( + source_validator=validator, + rag_retriever=object(), + retrieval_executor=executor, + ) + + result = await retriever.retrieve(owner_user_id=42, query="rules", refs=[_ref("media_item", 12)], max_results=2) + + assert result.items[0].citation.snippet_id == "media:12:chunk:4" # nosec B101 diff --git a/tldw_Server_API/tests/RPG/test_rpg_service.py b/tldw_Server_API/tests/RPG/test_rpg_service.py new file mode 100644 index 0000000000..4e07ba7cc8 --- /dev/null +++ b/tldw_Server_API/tests/RPG/test_rpg_service.py @@ -0,0 +1,547 @@ +import pytest + +from tldw_Server_API.app.core.DB_Management.ChaChaNotes_DB import CharactersRAGDB +from tldw_Server_API.app.core.DB_Management.RPG_DB import RPGRepository +from tldw_Server_API.app.core.RPG.errors import RPGValidationError +from tldw_Server_API.app.core.RPG.rules.refs import RulesPackSourceValidation +from tldw_Server_API.app.core.RPG.service import RPGService + +pytestmark = pytest.mark.unit + + +class FakeRulesSourceValidator: + def __init__(self, readable: bool = True) -> None: + self.readable = readable + self.media_item_calls: list[tuple[int, int]] = [] + self.media_collection_calls: list[tuple[int, int]] = [] + + async def validate_media_item(self, owner_user_id: int, media_id: int) -> RulesPackSourceValidation: + self.media_item_calls.append((owner_user_id, media_id)) + return RulesPackSourceValidation( + ref_id=f"media_item:{media_id}", + readable=self.readable, + display_name=f"Media {media_id}", + ) + + async def validate_media_collection( + self, + owner_user_id: int, + collection_id: int, + ) -> RulesPackSourceValidation: + self.media_collection_calls.append((owner_user_id, collection_id)) + return RulesPackSourceValidation( + ref_id=f"media_collection:{collection_id}", + readable=self.readable, + display_name=f"Collection {collection_id}", + ready_media_ids=[], + ) + + +def _service(rules_source_validator: FakeRulesSourceValidator | None = None) -> RPGService: + repo = RPGRepository.initialized(CharactersRAGDB(":memory:", "rpg-service-test")) + return RPGService(repo=repo, owner_user_id=42, rules_source_validator=rules_source_validator) + + +def test_model_events_create_pending_proposal_by_default(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-model") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-model", + ) + + result = service.record_events( + session_id=session.id, + events=[{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "Suggested"}}], + source_type="model", + expected_last_event_sequence=0, + idempotency_key="model-1", + ) + + assert result.committed_events == [] # nosec B101 + assert result.proposal is not None # nosec B101 + assert result.proposal.status == "pending" # nosec B101 + assert service.get_snapshot(session.id).snapshot_version == 0 # nosec B101 + + +def test_user_events_commit_and_update_snapshot(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-user") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-user", + ) + + result = service.record_events( + session_id=session.id, + events=[{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "Observed"}}], + source_type="user", + expected_last_event_sequence=0, + idempotency_key="user-1", + ) + + snapshot = service.get_snapshot(session.id) + + assert [event.sequence_number for event in result.committed_events] == [1] # nosec B101 + assert snapshot.snapshot_version == 1 # nosec B101 + assert snapshot.last_event_sequence == 1 # nosec B101 + assert snapshot.snapshot.notes[0]["text"] == "Observed" # nosec B101 + + +def test_applying_proposal_commits_events_and_advances_snapshot_once(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-proposal") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-proposal", + ) + proposal = service.record_events( + session_id=session.id, + events=[ + {"event_type": "npc.upserted", "event_payload": {"npc_id": "npc-1", "name": "Ada"}}, + {"event_type": "quest.upserted", "event_payload": {"quest_id": "q1", "title": "Find Ada"}}, + ], + source_type="model", + expected_last_event_sequence=0, + idempotency_key="model-2", + ).proposal + + assert proposal is not None # nosec B101 + + applied = service.apply_proposal( + session_id=session.id, + proposal_id=proposal.id, + expected_last_event_sequence=0, + idempotency_key="proposal-apply-1", + review_notes="accepted", + ) + + snapshot = service.get_snapshot(session.id) + + assert [event.sequence_number for event in applied.committed_events] == [1, 2] # nosec B101 + assert snapshot.snapshot_version == 1 # nosec B101 + assert snapshot.last_event_sequence == 2 # nosec B101 + assert snapshot.snapshot.npcs["npc-1"]["name"] == "Ada" # nosec B101 + assert snapshot.snapshot.quests["q1"]["title"] == "Find Ada" # nosec B101 + + +def test_replaying_proposal_apply_returns_events_without_new_snapshot(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-replay") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-replay", + ) + proposal = service.record_events( + session_id=session.id, + events=[{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "Accepted"}}], + source_type="model", + expected_last_event_sequence=0, + idempotency_key="model-replay", + ).proposal + + assert proposal is not None # nosec B101 + + first = service.apply_proposal( + session_id=session.id, + proposal_id=proposal.id, + expected_last_event_sequence=0, + idempotency_key="proposal-apply-replay", + ) + second = service.apply_proposal( + session_id=session.id, + proposal_id=proposal.id, + expected_last_event_sequence=0, + idempotency_key="proposal-apply-replay", + ) + snapshot = service.get_snapshot(session.id) + + assert [event.id for event in second.committed_events] == [ + event.id for event in first.committed_events + ] # nosec B101 + assert snapshot.snapshot_version == 1 # nosec B101 + assert snapshot.snapshot.notes == [{"note_id": "n1", "text": "Accepted"}] # nosec B101 + + +def test_replaying_proposal_reject_returns_same_rejected_proposal(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-reject") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-reject", + ) + proposal = service.record_events( + session_id=session.id, + events=[{"event_type": "note.added", "event_payload": {"note_id": "n1", "text": "Nope"}}], + source_type="model", + expected_last_event_sequence=0, + idempotency_key="model-reject", + ).proposal + + assert proposal is not None # nosec B101 + + first = service.reject_proposal( + session_id=session.id, + proposal_id=proposal.id, + idempotency_key="proposal-reject-replay", + review_notes="not now", + ) + second = service.reject_proposal( + session_id=session.id, + proposal_id=proposal.id, + idempotency_key="proposal-reject-replay", + review_notes="not now", + ) + + snapshot = service.get_snapshot(session.id) + + assert first.id == proposal.id # nosec B101 + assert second.id == first.id # nosec B101 + assert second.status == "rejected" # nosec B101 + assert snapshot.snapshot_version == 0 # nosec B101 + + +def test_create_session_copies_campaign_rules_refs_when_request_omits_refs(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-copy-rules") + service.repo.replace_campaign_rules_pack_refs( + owner_user_id=42, + campaign_id=campaign.id, + rules_pack_refs=[{"source_type": "media_item", "source_id": 7, "display_name": "Rules"}], + expected_version=campaign.version, + idempotency_key="campaign-copy-rules-ref", + request_payload_hash="arranged", + source_type="user", + ) + + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-copy-rules", + ) + + assert session.active_rules_pack_refs[0]["ref_id"] == "media_item:7" # nosec B101 + + +def test_create_session_replays_omitted_rules_refs_after_campaign_refs_change(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-replay-omitted-rules") + service.repo.replace_campaign_rules_pack_refs( + owner_user_id=42, + campaign_id=campaign.id, + rules_pack_refs=[{"source_type": "media_item", "source_id": 7}], + expected_version=campaign.version, + idempotency_key="campaign-replay-omitted-rules-ref-1", + request_payload_hash="arranged-1", + source_type="user", + ) + + first = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-replay-omitted-rules", + ) + service.repo.replace_campaign_rules_pack_refs( + owner_user_id=42, + campaign_id=campaign.id, + rules_pack_refs=[{"source_type": "media_item", "source_id": 8}], + expected_version=2, + idempotency_key="campaign-replay-omitted-rules-ref-2", + request_payload_hash="arranged-2", + source_type="user", + ) + + second = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-replay-omitted-rules", + ) + + assert second.id == first.id # nosec B101 + assert [ref["ref_id"] for ref in second.active_rules_pack_refs] == ["media_item:7"] # nosec B101 + + +def test_create_session_uses_explicit_empty_rules_refs(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-empty-rules") + service.repo.replace_campaign_rules_pack_refs( + owner_user_id=42, + campaign_id=campaign.id, + rules_pack_refs=[{"source_type": "media_item", "source_id": 7, "display_name": "Rules"}], + expected_version=campaign.version, + idempotency_key="campaign-empty-rules-ref", + request_payload_hash="arranged", + source_type="user", + ) + + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-empty-rules", + active_rules_pack_refs=[], + ) + + assert session.active_rules_pack_refs == [] # nosec B101 + + +def test_create_session_validates_explicit_rules_refs(): + validator = FakeRulesSourceValidator() + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-explicit-rules") + + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-explicit-rules", + active_rules_pack_refs=[{"source_type": "media_item", "source_id": 7}], + ) + + assert validator.media_item_calls == [(42, 7)] # nosec B101 + assert session.active_rules_pack_refs[0]["ref_id"] == "media_item:7" # nosec B101 + + +def test_create_session_replays_explicit_rules_refs_before_source_validation(): + validator = FakeRulesSourceValidator() + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-explicit-replay") + refs = [{"source_type": "media_item", "source_id": 7}] + + first = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-explicit-replay", + active_rules_pack_refs=refs, + ) + validator.readable = False + second = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-explicit-replay", + active_rules_pack_refs=refs, + ) + + assert second.id == first.id # nosec B101 + assert [ref["ref_id"] for ref in second.active_rules_pack_refs] == ["media_item:7"] # nosec B101 + assert validator.media_item_calls == [(42, 7)] # nosec B101 + + +@pytest.mark.asyncio +async def test_replace_campaign_rules_pack_refs_validates_each_enabled_source(): + validator = FakeRulesSourceValidator() + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-validate-rules") + + result = await service.replace_campaign_rules_pack_refs( + campaign.id, + [ + {"source_type": "media_item", "source_id": 7}, + {"source_type": "media_collection", "source_id": 3}, + ], + expected_version=campaign.version, + idempotency_key="campaign-validate-rules-ref", + ) + + assert validator.media_item_calls == [(42, 7)] # nosec B101 + assert validator.media_collection_calls == [(42, 3)] # nosec B101 + assert [ref.ref_id for ref in result.refs] == ["media_item:7", "media_collection:3"] # nosec B101 + + +@pytest.mark.asyncio +async def test_replace_campaign_rules_pack_refs_replays_before_source_validation(): + validator = FakeRulesSourceValidator() + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-replay-rules") + refs = [{"source_type": "media_item", "source_id": 7}] + + first = await service.replace_campaign_rules_pack_refs( + campaign.id, + refs, + expected_version=campaign.version, + idempotency_key="campaign-replay-rules-ref", + ) + validator.readable = False + second = await service.replace_campaign_rules_pack_refs( + campaign.id, + refs, + expected_version=campaign.version, + idempotency_key="campaign-replay-rules-ref", + ) + + assert first.replayed is False # nosec B101 + assert second.replayed is True # nosec B101 + assert [ref.ref_id for ref in second.refs] == ["media_item:7"] # nosec B101 + assert validator.media_item_calls == [(42, 7)] # nosec B101 + + +@pytest.mark.asyncio +async def test_replace_session_rules_pack_refs_validates_each_enabled_source(): + validator = FakeRulesSourceValidator() + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-session-validate") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-validate-rules", + ) + + result = await service.replace_session_rules_pack_refs( + session.id, + [ + {"source_type": "media_item", "source_id": 7}, + {"source_type": "media_collection", "source_id": 3}, + ], + expected_version=session.version, + idempotency_key="session-validate-rules-ref", + ) + + assert validator.media_item_calls == [(42, 7)] # nosec B101 + assert validator.media_collection_calls == [(42, 3)] # nosec B101 + assert [ref.ref_id for ref in result.refs] == ["media_item:7", "media_collection:3"] # nosec B101 + + +@pytest.mark.asyncio +async def test_replace_session_rules_pack_refs_replays_before_source_validation(): + validator = FakeRulesSourceValidator() + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-session-replay") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-replay-rules", + ) + refs = [{"source_type": "media_collection", "source_id": 3}] + + first = await service.replace_session_rules_pack_refs( + session.id, + refs, + expected_version=session.version, + idempotency_key="session-replay-rules-ref", + ) + validator.readable = False + second = await service.replace_session_rules_pack_refs( + session.id, + refs, + expected_version=session.version, + idempotency_key="session-replay-rules-ref", + ) + + assert first.replayed is False # nosec B101 + assert second.replayed is True # nosec B101 + assert [ref.ref_id for ref in second.refs] == ["media_collection:3"] # nosec B101 + assert validator.media_collection_calls == [(42, 3)] # nosec B101 + + +@pytest.mark.asyncio +async def test_replace_rules_pack_refs_allows_disabled_unreadable_source_without_dereference(): + validator = FakeRulesSourceValidator(readable=False) + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-disabled-rules") + + result = await service.replace_campaign_rules_pack_refs( + campaign.id, + [{"source_type": "media_item", "source_id": 7, "enabled": False}], + expected_version=campaign.version, + idempotency_key="campaign-disabled-rules-ref", + ) + + assert validator.media_item_calls == [] # nosec B101 + assert result.refs[0].enabled is False # nosec B101 + + +@pytest.mark.asyncio +async def test_replace_rules_pack_refs_rejects_unreadable_media_item(): + validator = FakeRulesSourceValidator(readable=False) + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-unreadable-rules") + + with pytest.raises(RPGValidationError, match="rules_pack_source_unreadable"): + await service.replace_campaign_rules_pack_refs( + campaign.id, + [{"source_type": "media_item", "source_id": 7}], + expected_version=campaign.version, + idempotency_key="campaign-unreadable-rules-ref", + ) + + assert validator.media_item_calls == [(42, 7)] # nosec B101 + + +@pytest.mark.asyncio +async def test_replace_rules_pack_refs_allows_empty_readable_collection(): + validator = FakeRulesSourceValidator() + service = _service(validator) + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-empty-collection") + + result = await service.replace_campaign_rules_pack_refs( + campaign.id, + [{"source_type": "media_collection", "source_id": 3}], + expected_version=campaign.version, + idempotency_key="campaign-empty-collection-ref", + ) + + assert validator.media_collection_calls == [(42, 3)] # nosec B101 + assert result.refs[0].ref_id == "media_collection:3" # nosec B101 + + +def test_list_campaign_rules_pack_refs_returns_current_version(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-list-rules") + service.repo.replace_campaign_rules_pack_refs( + owner_user_id=42, + campaign_id=campaign.id, + rules_pack_refs=[{"source_type": "media_item", "source_id": 7}], + expected_version=campaign.version, + idempotency_key="campaign-list-rules-ref", + request_payload_hash="arranged", + source_type="user", + ) + + result = service.list_campaign_rules_pack_refs(campaign.id) + + assert result.version == 2 # nosec B101 + assert result.refs[0].ref_id == "media_item:7" # nosec B101 + assert result.replayed is False # nosec B101 + + +def test_list_session_rules_pack_refs_returns_current_version(): + service = _service() + campaign = service.create_campaign("Campaign", None, "fate", idempotency_key="campaign-list-session-rules") + session = service.create_session( + campaign.id, + "Opening", + adapter_key="fate", + idempotency_key="session-list-rules", + ) + service.repo.replace_session_rules_pack_refs( + owner_user_id=42, + session_id=session.id, + rules_pack_refs=[{"source_type": "media_collection", "source_id": 3}], + expected_version=session.version, + idempotency_key="session-list-rules-ref", + request_payload_hash="arranged", + source_type="user", + ) + + result = service.list_session_rules_pack_refs(session.id) + + assert result.version == 2 # nosec B101 + assert result.refs[0].ref_id == "media_collection:3" # nosec B101 + assert result.replayed is False # nosec B101 diff --git a/tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json b/tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json index d10eba0563..84a1b3145e 100644 --- a/tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json +++ b/tldw_Server_API/tests/fixtures/privilege_route_registry_snapshot.json @@ -7683,6 +7683,914 @@ ] } ], + "rpg": [ + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Create an RPG campaign.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.create_campaign", + "methods": [ + "POST" + ], + "name": "create_campaign", + "path": "/api/v1/rpg/campaigns", + "rate_limit_resources": [ + "rpg.campaigns.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "List campaign-level rules-pack references.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.list_campaign_rules_pack_refs", + "methods": [ + "GET" + ], + "name": "list_campaign_rules_pack_refs", + "path": "/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + "rate_limit_resources": [ + "rpg.campaigns.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._rules_service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Replace campaign-level rules-pack references.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.replace_campaign_rules_pack_refs", + "methods": [ + "PUT" + ], + "name": "replace_campaign_rules_pack_refs", + "path": "/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + "rate_limit_resources": [ + "rpg.campaigns.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Create an RPG session under a campaign.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.create_session", + "methods": [ + "POST" + ], + "name": "create_session", + "path": "/api/v1/rpg/campaigns/{campaign_id}/sessions", + "rate_limit_resources": [ + "rpg.sessions.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "List supported RPG rules adapters.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.list_adapters", + "methods": [ + "GET" + ], + "name": "list_adapters", + "path": "/api/v1/rpg/rules/adapters", + "rate_limit_resources": [ + "rpg.rules.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Return one supported RPG rules adapter.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.get_adapter", + "methods": [ + "GET" + ], + "name": "get_adapter", + "path": "/api/v1/rpg/rules/adapters/{adapter_key}", + "rate_limit_resources": [ + "rpg.rules.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._rules_service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Build a bounded RPG session context bundle.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.build_context", + "methods": [ + "POST" + ], + "name": "build_context", + "path": "/api/v1/rpg/sessions/{session_id}/context", + "rate_limit_resources": [ + "rpg.sessions.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Record user-authored RPG session events.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.record_events", + "methods": [ + "POST" + ], + "name": "record_events", + "path": "/api/v1/rpg/sessions/{session_id}/events", + "rate_limit_resources": [ + "rpg.sessions.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Apply a pending model proposal after review.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.apply_proposal", + "methods": [ + "POST" + ], + "name": "apply_proposal", + "path": "/api/v1/rpg/sessions/{session_id}/proposals/{proposal_id}/apply", + "rate_limit_resources": [ + "rpg.proposals.review" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Reject a pending model proposal after review.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.reject_proposal", + "methods": [ + "POST" + ], + "name": "reject_proposal", + "path": "/api/v1/rpg/sessions/{session_id}/proposals/{proposal_id}/reject", + "rate_limit_resources": [ + "rpg.proposals.review" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "List session-level rules-pack references.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.list_session_rules_pack_refs", + "methods": [ + "GET" + ], + "name": "list_session_rules_pack_refs", + "path": "/api/v1/rpg/sessions/{session_id}/rules-packs", + "rate_limit_resources": [ + "rpg.sessions.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._rules_service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Replace session-level rules-pack references.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.replace_session_rules_pack_refs", + "methods": [ + "PUT" + ], + "name": "replace_session_rules_pack_refs", + "path": "/api/v1/rpg/sessions/{session_id}/rules-packs", + "rate_limit_resources": [ + "rpg.sessions.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps.get_auth_principal", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._rules_service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Run scoped rules lookup or answer mode for an RPG session.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.lookup_rules", + "methods": [ + "POST" + ], + "name": "lookup_rules", + "path": "/api/v1/rpg/sessions/{session_id}/rules/lookup", + "rate_limit_resources": [ + "rpg.rules.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + } + ], + "rpg.campaigns.manage": [ + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Create an RPG campaign.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.create_campaign", + "methods": [ + "POST" + ], + "name": "create_campaign", + "path": "/api/v1/rpg/campaigns", + "rate_limit_resources": [ + "rpg.campaigns.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._rules_service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Replace campaign-level rules-pack references.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.replace_campaign_rules_pack_refs", + "methods": [ + "PUT" + ], + "name": "replace_campaign_rules_pack_refs", + "path": "/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + "rate_limit_resources": [ + "rpg.campaigns.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + } + ], + "rpg.campaigns.read": [ + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "List campaign-level rules-pack references.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.list_campaign_rules_pack_refs", + "methods": [ + "GET" + ], + "name": "list_campaign_rules_pack_refs", + "path": "/api/v1/rpg/campaigns/{campaign_id}/rules-packs", + "rate_limit_resources": [ + "rpg.campaigns.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + } + ], + "rpg.proposals.review": [ + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Apply a pending model proposal after review.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.apply_proposal", + "methods": [ + "POST" + ], + "name": "apply_proposal", + "path": "/api/v1/rpg/sessions/{session_id}/proposals/{proposal_id}/apply", + "rate_limit_resources": [ + "rpg.proposals.review" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Reject a pending model proposal after review.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.reject_proposal", + "methods": [ + "POST" + ], + "name": "reject_proposal", + "path": "/api/v1/rpg/sessions/{session_id}/proposals/{proposal_id}/reject", + "rate_limit_resources": [ + "rpg.proposals.review" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + } + ], + "rpg.rules.read": [ + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "List supported RPG rules adapters.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.list_adapters", + "methods": [ + "GET" + ], + "name": "list_adapters", + "path": "/api/v1/rpg/rules/adapters", + "rate_limit_resources": [ + "rpg.rules.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Return one supported RPG rules adapter.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.get_adapter", + "methods": [ + "GET" + ], + "name": "get_adapter", + "path": "/api/v1/rpg/rules/adapters/{adapter_key}", + "rate_limit_resources": [ + "rpg.rules.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps.get_auth_principal", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._rules_service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Run scoped rules lookup or answer mode for an RPG session.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.lookup_rules", + "methods": [ + "POST" + ], + "name": "lookup_rules", + "path": "/api/v1/rpg/sessions/{session_id}/rules/lookup", + "rate_limit_resources": [ + "rpg.rules.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + } + ], + "rpg.sessions.manage": [ + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Create an RPG session under a campaign.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.create_session", + "methods": [ + "POST" + ], + "name": "create_session", + "path": "/api/v1/rpg/campaigns/{campaign_id}/sessions", + "rate_limit_resources": [ + "rpg.sessions.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Record user-authored RPG session events.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.record_events", + "methods": [ + "POST" + ], + "name": "record_events", + "path": "/api/v1/rpg/sessions/{session_id}/events", + "rate_limit_resources": [ + "rpg.sessions.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._rules_service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Replace session-level rules-pack references.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.replace_session_rules_pack_refs", + "methods": [ + "PUT" + ], + "name": "replace_session_rules_pack_refs", + "path": "/api/v1/rpg/sessions/{session_id}/rules-packs", + "rate_limit_resources": [ + "rpg.sessions.manage" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + } + ], + "rpg.sessions.read": [ + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._rules_service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "Build a bounded RPG session context bundle.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.build_context", + "methods": [ + "POST" + ], + "name": "build_context", + "path": "/api/v1/rpg/sessions/{session_id}/context", + "rate_limit_resources": [ + "rpg.sessions.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + }, + { + "dependencies": [ + { + "id": "auth_deps._checker", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "auth_deps._dep", + "module": "tldw_Server_API.app.api.v1.API_Deps.auth_deps", + "type": "dependency" + }, + { + "id": "rpg._service", + "module": "tldw_Server_API.app.api.v1.endpoints.rpg", + "type": "dependency" + } + ], + "description": "List session-level rules-pack references.", + "endpoint": "tldw_Server_API.app.api.v1.endpoints.rpg.list_session_rules_pack_refs", + "methods": [ + "GET" + ], + "name": "list_session_rules_pack_refs", + "path": "/api/v1/rpg/sessions/{session_id}/rules-packs", + "rate_limit_resources": [ + "rpg.sessions.read" + ], + "summary": null, + "tags": [ + "rpg", + "rpg" + ] + } + ], "scheduler.workflows.admin_rescan": [ { "dependencies": [