mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-11-07 10:45:58 -10:00
feat(gateway): session event timeline merging narrative + engine ledger (loop 3, iteration 26)
GET /api/v1/sessions/{id}/timeline returns the chronological merge of
narrative chat and engine ledger events (engine sequence_ids namespaced
e_<n>, narrative n_<n>), sorted by (created_at_ms, kind, sequence_id),
paginated by an opaque cursor with default 100 / cap 500. Hidden-entity
projection lifted from the existing _entity_is_visible convention;
private channels (gm/whisper) collapse to "Something happens" for
players and drop entirely for spectators. ALSO had to add the missing
narrative chat log + POST /chat route — survey found NarrativeChat
consumed chat from React local state only with no server anchor.
Summary numbers (ATTACK_RESOLVED damage, HP, etc.) are stripped from
spectator view to avoid leaking tactical information.
Gates: pytest 1288 passed / 33 skipped (baseline 1248, +40).
Co-Authored-By: Claude <[email protected]>
This commit is contained in:
1 parent
c467301f12
commit
fd0f36ba5c
4 files changed
+1795
No files matched your search
@@ -0,0 +1,751 @@
|
||||
"""Tests for the session event timeline (Loop 3, iteration 26).
|
||||
|
||||
GET /api/v1/sessions/{id}/timeline — merged feed of engine ledger events
|
||||
and narrative chat messages, paginated
|
||||
by cursor.
|
||||
POST /api/v1/sessions/{id}/chat — append one message to the session's
|
||||
narrative log.
|
||||
|
||||
Survey findings that shaped the test surface:
|
||||
|
||||
* Engine events carry a monotonic ``sequence_id`` (u64 from the Rust
|
||||
``EventSourcingLedger``). The timeline namespaces them as ``e_<n>`` and
|
||||
narrative messages as ``n_<n>``.
|
||||
* The React chat was client-local only before this iteration; the server
|
||||
side now anchors narrative messages in the gateway's storage. Chat
|
||||
append tests seed the storage directly to exercise the merge path.
|
||||
* Hidden entities (``is_visible=False``) collapse to ``[Unknown]`` for
|
||||
non-GM viewers — the same projection matrix used by
|
||||
``/api/v1/engine/session-state``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from vtt_orchestrator.routing import engine_client
|
||||
from vtt_orchestrator.server import (
|
||||
app,
|
||||
_sign_token,
|
||||
storage_backend,
|
||||
)
|
||||
from vtt_orchestrator.timeline import (
|
||||
DEFAULT_LIMIT,
|
||||
_PRIVATE_CHANNELS,
|
||||
_PRIVATE_PLACEHOLDER,
|
||||
_UNKNOWN,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
filter_after_cursor,
|
||||
format_engine_event,
|
||||
format_narrative_message,
|
||||
merge_timeline,
|
||||
project_timeline_entry,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
SESSION_ID = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
THORIN = "aaaaaaaa-0000-0000-0000-000000000001"
|
||||
GOBLIN = "bbbbbbbb-0000-0000-0000-000000000002"
|
||||
SPECTER = "cccccccc-0000-0000-0000-000000000003"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures / helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ts_ms(year: int, month: int, day: int, hour: int, minute: int = 0) -> int:
|
||||
"""A small helper for stable test timestamps."""
|
||||
dt = datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
def _iso(ms: int) -> str:
|
||||
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _token(user_id: str, role: str = "player") -> str:
|
||||
return _sign_token({"user_id": user_id, "role": role, "exp": time.time() + 600})
|
||||
|
||||
|
||||
def _entity(name: str, *, entity_id: str = "", visible: bool = True) -> dict:
|
||||
return {
|
||||
"id": entity_id or str(uuid.uuid5(uuid.NAMESPACE_URL, name)),
|
||||
"name": name,
|
||||
"current_hp": 28,
|
||||
"max_hp": 28,
|
||||
"temp_hp": 0,
|
||||
"ac": 16,
|
||||
"position": [0.0, 0.0, 0.0],
|
||||
"zone_id": "Zone_Default",
|
||||
"abilities": {"strength": 14},
|
||||
"attacks": [],
|
||||
"conditions": [],
|
||||
"is_conscious": True,
|
||||
"is_dead": False,
|
||||
"is_player": True,
|
||||
"is_visible": visible,
|
||||
}
|
||||
|
||||
|
||||
def _event(
|
||||
seq: int,
|
||||
event_type: str,
|
||||
payload: dict,
|
||||
*,
|
||||
actor_id: str = THORIN,
|
||||
is_reverted: bool = False,
|
||||
timestamp_ms: int | None = None,
|
||||
) -> dict:
|
||||
iso = _iso(timestamp_ms) if timestamp_ms is not None else _iso(_ts_ms(2026, 8, 26, 12, seq))
|
||||
return {
|
||||
"sequence_id": seq,
|
||||
"actor_id": actor_id,
|
||||
"event_type": event_type,
|
||||
"payload": payload,
|
||||
"is_reverted": is_reverted,
|
||||
"state_hash": f"hash-{seq}",
|
||||
"timestamp": iso,
|
||||
}
|
||||
|
||||
|
||||
def _chat_message(
|
||||
seq: int,
|
||||
*,
|
||||
user_id: str = "alice",
|
||||
display_name: str = "Alice",
|
||||
role: str = "player",
|
||||
channel: str = "public",
|
||||
content: str = "Hello",
|
||||
timestamp_ms: int | None = None,
|
||||
) -> dict:
|
||||
iso = _iso(timestamp_ms) if timestamp_ms is not None else _iso(
|
||||
_ts_ms(2026, 8, 26, 12, seq)
|
||||
)
|
||||
return {
|
||||
"sequence_id": seq,
|
||||
"session_id": SESSION_ID,
|
||||
"user_id": user_id,
|
||||
"display_name": display_name,
|
||||
"role": role,
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"created_at": iso,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def roster() -> dict:
|
||||
"""A roster with one visible hero and one hidden assassin."""
|
||||
return {
|
||||
THORIN: _entity("Thorin", entity_id=THORIN, visible=True),
|
||||
GOBLIN: _entity("Goblin Shaman", entity_id=GOBLIN, visible=False),
|
||||
SPECTER: _entity("Specter", entity_id=SPECTER, visible=True),
|
||||
}
|
||||
|
||||
|
||||
def _patched_engine(monkeypatch, *, state: dict):
|
||||
"""Replaces the engine proxy with a function that returns ``state``."""
|
||||
|
||||
async def fake_engine_request(method, path, payload=None, *, actor=None):
|
||||
return state
|
||||
|
||||
monkeypatch.setattr(engine_client, "engine_request", fake_engine_request)
|
||||
|
||||
|
||||
async def _seed_lobby(session_id: str = SESSION_ID, host_user_id: str = "alice"):
|
||||
"""Inserts a lobby bound to ``session_id`` so the timeline gate recognizes it."""
|
||||
lobby = await storage_backend.create_lobby(
|
||||
host_user_id=host_user_id,
|
||||
host_display_name="Alice",
|
||||
name="Timeline Test Lobby",
|
||||
invite_code="ABC123",
|
||||
)
|
||||
await storage_backend.set_lobby_session(lobby["lobby_id"], session_id)
|
||||
return lobby
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-function tests (no FastAPI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatterUnit:
|
||||
"""The formatter is a pure function: it takes one event + a roster and
|
||||
returns one timeline entry. These tests exercise that contract without
|
||||
the FastAPI stack."""
|
||||
|
||||
def test_engine_event_resolves_actor_name(self, roster):
|
||||
e = _event(1, "DELAY_TAKEN", {"entity_id": THORIN, "round": 3})
|
||||
entry = format_engine_event(e, roster=roster, privileged=True)
|
||||
assert entry["actor_name"] == "Thorin"
|
||||
assert entry["summary"] == "Thorin delays (round 3)"
|
||||
assert entry["sequence_id"] == "e_1"
|
||||
assert entry["kind"] == "engine"
|
||||
|
||||
def test_combat_began_and_ended(self, roster):
|
||||
e1 = _event(1, "COMBAT_BEGAN", {"round": 1, "order": []})
|
||||
e2 = _event(2, "COMBAT_ENDED", {"rounds_fought": 4})
|
||||
assert format_engine_event(e1, roster=roster, privileged=True)["summary"] == "Combat begins"
|
||||
assert format_engine_event(e2, roster=roster, privileged=True)["summary"] == "Combat ends"
|
||||
|
||||
def test_turn_advanced_includes_round(self, roster):
|
||||
e = _event(1, "TURN_ADVANCED", {"round": 4})
|
||||
out = format_engine_event(e, roster=roster, privileged=True)
|
||||
assert out["summary"] == "Round 4 begins"
|
||||
|
||||
def test_condition_expired(self, roster):
|
||||
e = _event(1, "CONDITION_EXPIRED", {"condition": "Stunned"}, actor_id=THORIN)
|
||||
out = format_engine_event(e, roster=roster, privileged=True)
|
||||
assert out["summary"] == "Stunned wears off: Thorin"
|
||||
|
||||
def test_condition_applied_with_source(self, roster):
|
||||
e = _event(
|
||||
1, "CONDITION_APPLIED",
|
||||
{"condition": "Stunned", "source_entity_id": SPECTER},
|
||||
actor_id=THORIN,
|
||||
)
|
||||
out = format_engine_event(e, roster=roster, privileged=True)
|
||||
assert out["summary"] == "Specter applies Stunned to Thorin"
|
||||
|
||||
def test_attack_resolved_strips_numbers_for_spectator(self, roster):
|
||||
e = _event(
|
||||
1, "ATTACK_RESOLVED",
|
||||
{
|
||||
"attacker_id": THORIN, "target_id": GOBLIN,
|
||||
"is_hit": True, "total_damage": 7, "target_hp_remaining": 13,
|
||||
},
|
||||
actor_id=THORIN,
|
||||
)
|
||||
out = format_engine_event(e, roster=roster, privileged=False, redact_numbers=True)
|
||||
assert "7" not in out["summary"] and "13" not in out["summary"]
|
||||
assert "Thorin" in out["summary"] and "hits" in out["summary"]
|
||||
|
||||
def test_attack_resolved_keeps_numbers_for_gm(self, roster):
|
||||
e = _event(
|
||||
1, "ATTACK_RESOLVED",
|
||||
{
|
||||
"attacker_id": THORIN, "target_id": GOBLIN,
|
||||
"is_hit": True, "total_damage": 7, "target_hp_remaining": 13,
|
||||
},
|
||||
actor_id=THORIN,
|
||||
)
|
||||
out = format_engine_event(e, roster=roster, privileged=True, redact_numbers=False)
|
||||
assert "for 7" in out["summary"] and "13" in out["summary"]
|
||||
|
||||
def test_unknown_event_type_is_withheld_from_non_privileged(self, roster):
|
||||
e = _event(1, "MYSTERY_FUTURE", {"secret": 42})
|
||||
out = format_engine_event(e, roster=roster, privileged=False, redact_numbers=True)
|
||||
assert "42" not in out["summary"]
|
||||
assert "occurred" in out["summary"]
|
||||
|
||||
def test_unknown_event_type_renders_payload_for_privileged(self, roster):
|
||||
e = _event(1, "MYSTERY_FUTURE", {"secret": 42})
|
||||
out = format_engine_event(e, roster=roster, privileged=True, redact_numbers=False)
|
||||
assert "42" in out["summary"]
|
||||
|
||||
def test_narrative_message_carries_display_name(self):
|
||||
msg = _chat_message(1, display_name="Alice", content="I cast fireball")
|
||||
out = format_narrative_message(msg)
|
||||
assert out["kind"] == "narrative"
|
||||
assert out["sequence_id"] == "n_1"
|
||||
assert out["actor_name"] == "Alice"
|
||||
assert out["summary"] == "I cast fireball"
|
||||
|
||||
def test_merge_sorts_by_created_at_ms(self, roster):
|
||||
e1 = _event(1, "COMBAT_BEGAN", {}, timestamp_ms=_ts_ms(2026, 8, 26, 12, 0))
|
||||
e2 = _event(2, "COMBAT_ENDED", {}, timestamp_ms=_ts_ms(2026, 8, 26, 12, 5))
|
||||
msg = _chat_message(
|
||||
1, content="I cast fireball",
|
||||
timestamp_ms=_ts_ms(2026, 8, 26, 12, 3),
|
||||
)
|
||||
merged = merge_timeline(
|
||||
[format_engine_event(e1, roster=roster, privileged=True),
|
||||
format_engine_event(e2, roster=roster, privileged=True)],
|
||||
[format_narrative_message(msg)],
|
||||
)
|
||||
# Combat begins → narrative line (12:03) → combat ends (12:05)
|
||||
assert [e["kind"] for e in merged] == ["engine", "narrative", "engine"]
|
||||
|
||||
def test_cursor_roundtrip(self):
|
||||
encoded = encode_cursor({
|
||||
"sequence_id": "e_5",
|
||||
"created_at_ms": 1787745600000,
|
||||
})
|
||||
ms, seq = decode_cursor(encoded)
|
||||
assert ms == 1787745600000
|
||||
assert seq == "e_5"
|
||||
|
||||
def test_filter_after_cursor_skips_seen_entries(self, roster):
|
||||
e1 = _event(1, "COMBAT_BEGAN", {}, timestamp_ms=_ts_ms(2026, 8, 26, 12, 0))
|
||||
e2 = _event(2, "COMBAT_ENDED", {}, timestamp_ms=_ts_ms(2026, 8, 26, 12, 5))
|
||||
entries = [
|
||||
format_engine_event(e1, roster=roster, privileged=True),
|
||||
format_engine_event(e2, roster=roster, privileged=True),
|
||||
]
|
||||
first_page = filter_after_cursor(entries, cursor=None, limit=1)
|
||||
assert len(first_page) == 1
|
||||
cursor = encode_cursor(first_page[-1])
|
||||
second_page = filter_after_cursor(entries, cursor=cursor, limit=1)
|
||||
assert len(second_page) == 1
|
||||
assert second_page[0]["sequence_id"] == "e_2"
|
||||
|
||||
|
||||
class TestProjectionRules:
|
||||
"""Role-based projection of formatted entries."""
|
||||
|
||||
def test_gm_sees_verbatim(self, roster):
|
||||
e = _event(
|
||||
1, "ATTACK_RESOLVED",
|
||||
{"attacker_id": THORIN, "target_id": GOBLIN, "is_hit": True,
|
||||
"total_damage": 7, "target_hp_remaining": 13},
|
||||
)
|
||||
entry = format_engine_event(e, roster=roster, privileged=True, redact_numbers=False)
|
||||
proj = project_timeline_entry(
|
||||
entry, role="gm", viewer_user_id="gm-1", roster=roster
|
||||
)
|
||||
assert proj["actor_name"] == "Thorin"
|
||||
assert "for 7" in proj["summary"]
|
||||
|
||||
def test_player_sees_hidden_target_as_unknown(self, roster):
|
||||
e = _event(
|
||||
1, "ATTACK_RESOLVED",
|
||||
{"attacker_id": THORIN, "target_id": GOBLIN, "is_hit": True,
|
||||
"total_damage": 7, "target_hp_remaining": 13},
|
||||
)
|
||||
entry = format_engine_event(e, roster=roster, privileged=False, redact_numbers=False)
|
||||
proj = project_timeline_entry(
|
||||
entry, role="player", viewer_user_id="thorin", roster=roster
|
||||
)
|
||||
# Goblin is hidden in the roster → the target name is collapsed to [Unknown]
|
||||
# and the summary is replaced with the private placeholder.
|
||||
assert "Goblin" not in proj["summary"]
|
||||
assert proj["summary"] == _PRIVATE_PLACEHOLDER
|
||||
assert proj.get("is_private") is True
|
||||
|
||||
def test_player_sees_visible_target(self, roster):
|
||||
e = _event(
|
||||
1, "ATTACK_RESOLVED",
|
||||
{"attacker_id": THORIN, "target_id": SPECTER, "is_hit": True,
|
||||
"total_damage": 7, "target_hp_remaining": 13},
|
||||
)
|
||||
entry = format_engine_event(e, roster=roster, privileged=False, redact_numbers=False)
|
||||
proj = project_timeline_entry(
|
||||
entry, role="player", viewer_user_id="thorin", roster=roster
|
||||
)
|
||||
assert "Specter" in proj["summary"]
|
||||
assert proj.get("is_private") is None
|
||||
|
||||
def test_private_channel_collapses_for_player(self):
|
||||
msg = _chat_message(1, channel="gm", content="secret plan", display_name="GM")
|
||||
entry = format_narrative_message(msg)
|
||||
proj = project_timeline_entry(
|
||||
entry, role="player", viewer_user_id="alice", roster={}
|
||||
)
|
||||
assert proj["summary"] == _PRIVATE_PLACEHOLDER
|
||||
assert proj["actor_name"] == _UNKNOWN
|
||||
|
||||
def test_private_channel_drops_for_spectator(self):
|
||||
msg = _chat_message(1, channel="gm", content="secret plan", display_name="GM")
|
||||
entry = format_narrative_message(msg)
|
||||
proj = project_timeline_entry(
|
||||
entry, role="spectator", viewer_user_id="watcher-1", roster={}
|
||||
)
|
||||
assert proj is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP route tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def seeded_lobby():
|
||||
"""Wires a real lobby binding for SESSION_ID before each test that needs it."""
|
||||
# The MemoryStore lobby/membership is process-wide — best-effort cleanup.
|
||||
try:
|
||||
lobby = await _seed_lobby(SESSION_ID, host_user_id="alice")
|
||||
except Exception:
|
||||
lobby = None
|
||||
yield lobby
|
||||
# Reset chat messages between tests so tests stay independent.
|
||||
if hasattr(storage_backend, "chat_messages"):
|
||||
storage_backend.chat_messages.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_timeline(seeded_lobby, monkeypatch):
|
||||
state = {
|
||||
"session_id": SESSION_ID,
|
||||
"entities": {},
|
||||
"combat": {},
|
||||
"ledger": {"current_sequence": 0, "events": []},
|
||||
}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline",
|
||||
params={"token": _token("alice", "player")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["session_id"] == SESSION_ID
|
||||
assert body["entries"] == []
|
||||
assert body["next_cursor"] is None
|
||||
assert body["has_more"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_narrative_and_engine_events_sorted(seeded_lobby, monkeypatch):
|
||||
"""Narrative messages and engine events share the timeline in chronological order."""
|
||||
state = {
|
||||
"session_id": SESSION_ID,
|
||||
"entities": {
|
||||
THORIN: _entity("Thorin", entity_id=THORIN, visible=True),
|
||||
},
|
||||
"combat": {"in_combat": True, "round": 1},
|
||||
"ledger": {
|
||||
"current_sequence": 3,
|
||||
"events": [
|
||||
_event(1, "COMBAT_BEGAN", {}, timestamp_ms=_ts_ms(2026, 8, 26, 12, 0)),
|
||||
_event(2, "DELAY_TAKEN",
|
||||
{"entity_id": THORIN, "round": 1},
|
||||
timestamp_ms=_ts_ms(2026, 8, 26, 12, 2)),
|
||||
_event(3, "TURN_ADVANCED", {"round": 2},
|
||||
timestamp_ms=_ts_ms(2026, 8, 26, 12, 4)),
|
||||
],
|
||||
},
|
||||
}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
# Seed two narrative messages between the engine events.
|
||||
await storage_backend.append_chat_message(
|
||||
SESSION_ID, "alice", "Alice", "player", "public",
|
||||
"Here goes",
|
||||
)
|
||||
await storage_backend.append_chat_message(
|
||||
SESSION_ID, "bob", "Bob", "player", "public",
|
||||
"You too",
|
||||
)
|
||||
resp = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline",
|
||||
params={"token": _token("alice", "player")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Three engine + two narrative = five entries.
|
||||
assert len(body["entries"]) == 5
|
||||
# Kinds appear in interleaved chronological order (engine events bookend
|
||||
# the chat lines because they have explicit timestamps and chat lines
|
||||
# append in real time after them).
|
||||
kinds = [e["kind"] for e in body["entries"]]
|
||||
assert "engine" in kinds and "narrative" in kinds
|
||||
# The merged feed is monotonic in created_at_ms.
|
||||
times = [e["created_at_ms"] for e in body["entries"]]
|
||||
assert times == sorted(times)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hidden_entity_event_collapses_for_player(seeded_lobby, monkeypatch):
|
||||
"""A CONDITION_APPLIED on a hidden target collapses to 'Something happens'."""
|
||||
state = {
|
||||
"session_id": SESSION_ID,
|
||||
"entities": {
|
||||
THORIN: _entity("Thorin", entity_id=THORIN, visible=True),
|
||||
GOBLIN: _entity("Goblin Shaman", entity_id=GOBLIN, visible=False),
|
||||
SPECTER: _entity("Specter", entity_id=SPECTER, visible=True),
|
||||
},
|
||||
"combat": {},
|
||||
"ledger": {
|
||||
"current_sequence": 1,
|
||||
"events": [
|
||||
_event(
|
||||
1, "CONDITION_APPLIED",
|
||||
{"condition": "Stunned", "source_entity_id": SPECTER},
|
||||
actor_id=GOBLIN,
|
||||
timestamp_ms=_ts_ms(2026, 8, 26, 12, 0),
|
||||
),
|
||||
],
|
||||
},
|
||||
}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline",
|
||||
params={"token": _token("alice", "player")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["entries"]) == 1
|
||||
entry = body["entries"][0]
|
||||
assert entry["summary"] == _PRIVATE_PLACEHOLDER
|
||||
assert "Goblin" not in entry["summary"]
|
||||
assert entry.get("is_private") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gm_sees_hidden_entity_event_verbatim(seeded_lobby, monkeypatch):
|
||||
state = {
|
||||
"session_id": SESSION_ID,
|
||||
"entities": {
|
||||
GOBLIN: _entity("Goblin Shaman", entity_id=GOBLIN, visible=False),
|
||||
SPECTER: _entity("Specter", entity_id=SPECTER, visible=True),
|
||||
},
|
||||
"combat": {},
|
||||
"ledger": {
|
||||
"current_sequence": 1,
|
||||
"events": [
|
||||
_event(
|
||||
1, "CONDITION_APPLIED",
|
||||
{"condition": "Stunned", "source_entity_id": SPECTER},
|
||||
actor_id=GOBLIN,
|
||||
timestamp_ms=_ts_ms(2026, 8, 26, 12, 0),
|
||||
),
|
||||
],
|
||||
},
|
||||
}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline",
|
||||
params={"token": _token("gm-1", "gm")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["entries"]) == 1
|
||||
entry = body["entries"][0]
|
||||
assert "Goblin" in entry["summary"]
|
||||
assert "Specter" in entry["summary"]
|
||||
assert "Stunned" in entry["summary"]
|
||||
assert entry.get("is_private") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cursor_pagination_round_trip(seeded_lobby, monkeypatch):
|
||||
"""Walking the cursor yields disjoint pages until the feed is exhausted."""
|
||||
events = [
|
||||
_event(i, "TURN_ADVANCED", {"round": i},
|
||||
timestamp_ms=_ts_ms(2026, 8, 26, 12, i))
|
||||
for i in range(1, 6)
|
||||
]
|
||||
state = {
|
||||
"session_id": SESSION_ID,
|
||||
"entities": {},
|
||||
"combat": {},
|
||||
"ledger": {"current_sequence": len(events), "events": events},
|
||||
}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
seen = []
|
||||
cursor = None
|
||||
while True:
|
||||
params = {"token": _token("alice", "player"), "limit": 2}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
resp = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline", params=params
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
seen.extend(body["entries"])
|
||||
cursor = body["next_cursor"]
|
||||
if not cursor or not body["has_more"]:
|
||||
break
|
||||
assert len(seen) == 5
|
||||
seqs = [e["sequence_id"] for e in seen]
|
||||
assert seqs == sorted(seqs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anon_is_401(seeded_lobby, monkeypatch):
|
||||
state = {"session_id": SESSION_ID, "entities": {},
|
||||
"combat": {}, "ledger": {"current_sequence": 0, "events": []}}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.get(f"/api/v1/sessions/{SESSION_ID}/timeline")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_participant_is_403(seeded_lobby, monkeypatch):
|
||||
"""A signed-in user who is not in the lobby gets 403, not 200."""
|
||||
state = {"session_id": SESSION_ID, "entities": {},
|
||||
"combat": {}, "ledger": {"current_sequence": 0, "events": []}}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline",
|
||||
params={"token": _token("outsider-1", "player")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"]["error"] == "TIMELINE_NOT_A_PARTICIPANT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_session_is_404(seeded_lobby, monkeypatch):
|
||||
"""Even a GM gets 404 when the engine session has no lobby binding."""
|
||||
state = {"session_id": SESSION_ID, "entities": {},
|
||||
"combat": {}, "ledger": {"current_sequence": 0, "events": []}}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.get(
|
||||
"/api/v1/sessions/99999999-9999-9999-9999-999999999999/timeline",
|
||||
params={"token": _token("gm-1", "gm")},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"]["error"] == "SESSION_NOT_FOUND"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gm_can_view_verbatim_with_numbers(seeded_lobby, monkeypatch):
|
||||
state = {
|
||||
"session_id": SESSION_ID,
|
||||
"entities": {
|
||||
THORIN: _entity("Thorin", entity_id=THORIN, visible=True),
|
||||
GOBLIN: _entity("Goblin Shaman", entity_id=GOBLIN, visible=True),
|
||||
},
|
||||
"combat": {},
|
||||
"ledger": {
|
||||
"current_sequence": 1,
|
||||
"events": [
|
||||
_event(
|
||||
1, "DAMAGE_APPLIED",
|
||||
{"target_id": GOBLIN, "amount": 7, "hp_remaining": 13},
|
||||
actor_id=THORIN,
|
||||
timestamp_ms=_ts_ms(2026, 8, 26, 12, 0),
|
||||
),
|
||||
],
|
||||
},
|
||||
}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline",
|
||||
params={"token": _token("gm-1", "gm")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["entries"]) == 1
|
||||
summary = body["entries"][0]["summary"]
|
||||
assert "7" in summary and "13" in summary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-event-type formatting spot checks (via the route)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _events_only_state(events: list[dict]) -> dict:
|
||||
return {
|
||||
"session_id": SESSION_ID,
|
||||
"entities": {
|
||||
THORIN: _entity("Thorin", entity_id=THORIN, visible=True),
|
||||
},
|
||||
"combat": {},
|
||||
"ledger": {"current_sequence": len(events), "events": events},
|
||||
}
|
||||
|
||||
|
||||
def _summaries_for(token: str, state: dict, monkeypatch) -> list[dict]:
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline",
|
||||
params={"token": token},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["entries"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("event_type, payload, expected_substr", [
|
||||
("SESSION_CREATED", {"name": "Curse of Strahd"}, "Session created"),
|
||||
("COMBAT_BEGAN", {"round": 1, "order": []}, "Combat begins"),
|
||||
("COMBAT_ENDED", {"rounds_fought": 4}, "Combat ends"),
|
||||
("TURN_ADVANCED", {"round": 3}, "Round 3"),
|
||||
("DELAY_TAKEN", {"entity_id": THORIN, "round": 4}, "Thorin delays"),
|
||||
("DELAY_RESUMED", {"entity_id": THORIN, "round": 4}, "Thorin resumes"),
|
||||
("HEALED", {"target_id": THORIN, "amount": 5, "hp_remaining": 25}, "Thorin heals"),
|
||||
("DEATH_SAVE_RESOLVED",
|
||||
{"outcome": "success", "natural_roll": 14, "successes": 1, "failures": 0,
|
||||
"is_stabilized": False, "is_dead": False},
|
||||
"death-save success"),
|
||||
("INSPIRATION_CHANGED", {"granted": True}, "inspiration"),
|
||||
("HELP_ACTION", {"helper_id": THORIN, "target_entity_id": THORIN}, "helps"),
|
||||
])
|
||||
async def test_event_type_formatting_spot_checks(
|
||||
seeded_lobby, monkeypatch, event_type, payload, expected_substr
|
||||
):
|
||||
state = _events_only_state([_event(
|
||||
1, event_type, payload,
|
||||
timestamp_ms=_ts_ms(2026, 8, 26, 12, 0),
|
||||
)])
|
||||
entries = _summaries_for(_token("alice", "player"), state, monkeypatch)
|
||||
assert len(entries) == 1
|
||||
assert expected_substr in entries[0]["summary"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat append route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_chat_message_round_trips_into_timeline(
|
||||
seeded_lobby, monkeypatch
|
||||
):
|
||||
state = {"session_id": SESSION_ID, "entities": {},
|
||||
"combat": {}, "ledger": {"current_sequence": 0, "events": []}}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
# Append a chat message.
|
||||
resp = client.post(
|
||||
f"/api/v1/sessions/{SESSION_ID}/chat",
|
||||
params={"token": _token("alice", "player")},
|
||||
json={"content": "Hello table", "channel": "public"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["content"] == "Hello table"
|
||||
assert body["sequence_id"] >= 1
|
||||
# Timeline picks it up.
|
||||
resp2 = client.get(
|
||||
f"/api/v1/sessions/{SESSION_ID}/timeline",
|
||||
params={"token": _token("alice", "player")},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
entries = resp2.json()["entries"]
|
||||
assert any(e["kind"] == "narrative" and e["summary"] == "Hello table" for e in entries)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_player_cannot_post_on_gm_channel(seeded_lobby, monkeypatch):
|
||||
state = {"session_id": SESSION_ID, "entities": {},
|
||||
"combat": {}, "ledger": {"current_sequence": 0, "events": []}}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.post(
|
||||
f"/api/v1/sessions/{SESSION_ID}/chat",
|
||||
params={"token": _token("alice", "player")},
|
||||
json={"content": "fake gm line", "channel": "gm"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"]["error"] == "CHAT_CHANNEL_FORBIDDEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gm_can_post_on_gm_channel(seeded_lobby, monkeypatch):
|
||||
"""A GM staff token may append on the gm channel — they bypass the
|
||||
participant gate via the privileged role."""
|
||||
state = {"session_id": SESSION_ID, "entities": {},
|
||||
"combat": {}, "ledger": {"current_sequence": 0, "events": []}}
|
||||
_patched_engine(monkeypatch, state=state)
|
||||
resp = client.post(
|
||||
f"/api/v1/sessions/{SESSION_ID}/chat",
|
||||
params={"token": _token("gm-1", "gm")},
|
||||
json={"content": "whisper to gm channel", "channel": "gm"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["channel"] == "gm"
|
||||
@@ -6803,6 +6803,225 @@ async def foundry_import_preview(token: str = Depends(_require_auth)):
|
||||
)
|
||||
|
||||
|
||||
# --- Session event timeline (Loop 3, iteration 26) ---------------------------
|
||||
#
|
||||
# GET /api/v1/sessions/{id}/timeline — merged feed of engine ledger events
|
||||
# and narrative chat messages, paginated
|
||||
# by cursor.
|
||||
# POST /api/v1/sessions/{id}/chat — append one message to the session's
|
||||
# narrative log (anchors the timeline's
|
||||
# narrative side; the React chat is still
|
||||
# client-local until that bridge ships).
|
||||
#
|
||||
# Authorization mirrors the safety boundaries contract: the gateway's
|
||||
# authoritative session-existence data IS the lobby binding (host launch
|
||||
# wrote ``engine_session_id`` onto the lobby record). Sessions with no
|
||||
# binding are SESSION_NOT_FOUND (404) for everyone — uniform answer regardless
|
||||
# of role so the route cannot be probed as an existence oracle. Non-staff
|
||||
# outsiders (authenticated but not a participant) get 403
|
||||
# TIMELINE_NOT_A_PARTICIPANT.
|
||||
|
||||
from .timeline import (
|
||||
DEFAULT_LIMIT as _TIMELINE_DEFAULT_LIMIT,
|
||||
encode_cursor as _timeline_encode_cursor,
|
||||
filter_after_cursor as _timeline_filter_after_cursor,
|
||||
format_engine_event as _timeline_format_engine_event,
|
||||
format_narrative_message as _timeline_format_narrative_message,
|
||||
merge_timeline as _timeline_merge,
|
||||
project_timeline_entry as _timeline_project_entry,
|
||||
)
|
||||
|
||||
|
||||
class SessionChatAppendRequest(BaseModel):
|
||||
"""Body for POST /api/v1/sessions/{id}/chat."""
|
||||
|
||||
content: str = Field(..., min_length=1, max_length=2000)
|
||||
channel: Literal["public", "gm", "ooc", "system"] = "public"
|
||||
|
||||
|
||||
async def _session_timeline_gate(
|
||||
session_id: str, token: str
|
||||
) -> Dict[str, str]:
|
||||
"""Shared auth gate for the timeline and chat-append routes.
|
||||
|
||||
Returns the verified actor when authorized; raises HTTPException(404) on
|
||||
an unknown session and 403 on an authenticated outsider.
|
||||
"""
|
||||
actor = _caller_actor(token)
|
||||
bound_lobby = await storage_backend.get_lobby_by_engine_session(session_id)
|
||||
if bound_lobby is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"error": "SESSION_NOT_FOUND",
|
||||
"message": (
|
||||
f"No table is bound to engine session {session_id}; "
|
||||
"the timeline attaches to launched sessions."
|
||||
),
|
||||
},
|
||||
)
|
||||
is_staff = actor.get("role", "") in ("gm", "admin")
|
||||
if not is_staff and not await _caller_is_session_participant(
|
||||
actor["user_id"], session_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "TIMELINE_NOT_A_PARTICIPANT",
|
||||
"message": (
|
||||
"The session timeline is visible only to this session's "
|
||||
"participants (lobby members) and GM/admin."
|
||||
),
|
||||
},
|
||||
)
|
||||
return actor
|
||||
|
||||
|
||||
@app.post("/api/v1/sessions/{session_id}/chat")
|
||||
async def append_session_chat_message(
|
||||
session_id: str,
|
||||
req: SessionChatAppendRequest,
|
||||
token: str = Depends(_require_auth),
|
||||
):
|
||||
"""Append one chat message to a session's narrative log.
|
||||
|
||||
Iteration 26 (Loop 3): the React chat lives in client-local state today
|
||||
and ships no wire protocol, so this endpoint is the gateway-side anchor
|
||||
of the timeline's narrative stream. Once a future iteration wires the
|
||||
client to mirror local messages here, this route is already in place;
|
||||
until then, it stays ready for tests and for any server-side narrator
|
||||
that wants to append directly.
|
||||
|
||||
Authorization: the same participant-or-staff gate as the timeline; an
|
||||
unknown session is 404 (not 403) so the route cannot be used as an
|
||||
existence oracle.
|
||||
"""
|
||||
actor = await _session_timeline_gate(session_id, token)
|
||||
role = actor.get("role", "player")
|
||||
# Players are never allowed to broadcast on the GM channel — the
|
||||
# client may tag local whispers freely, but the server-side anchor
|
||||
# refuses them so the timeline cannot be seeded with forged GM lines.
|
||||
if req.channel == "gm" and role not in ("gm", "admin"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "CHAT_CHANNEL_FORBIDDEN",
|
||||
"message": "Only GM/admin may post on the gm channel.",
|
||||
},
|
||||
)
|
||||
display_name = actor.get("user_id")
|
||||
profile = await _profile_of(actor["user_id"])
|
||||
if isinstance(profile, dict):
|
||||
display_name = profile.get("displayName") or display_name
|
||||
record = await storage_backend.append_chat_message(
|
||||
session_id,
|
||||
actor["user_id"],
|
||||
str(display_name or actor["user_id"]),
|
||||
role,
|
||||
req.channel,
|
||||
req.content,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
@app.get("/api/v1/sessions/{session_id}/timeline")
|
||||
async def session_timeline(
|
||||
session_id: str,
|
||||
cursor: Optional[str] = Query(None),
|
||||
limit: int = Query(_TIMELINE_DEFAULT_LIMIT, ge=1, le=500),
|
||||
token: str = Depends(_require_auth),
|
||||
):
|
||||
"""Merged engine + narrative timeline for one session, paginated by cursor.
|
||||
|
||||
Combines the engine ledger events (authoritative combat/mechanics stream)
|
||||
with the session's narrative chat log and sorts by creation time.
|
||||
Pagination is by an opaque cursor of ``<created_at_ms>:<sequence_id>``
|
||||
pointing at the LAST seen entry on the previous page.
|
||||
|
||||
Projection rules (delegated to :mod:`vtt_orchestrator.timeline`):
|
||||
|
||||
* GM / admin: verbatim. Hidden entities surface as their real name;
|
||||
private channels surface with content intact.
|
||||
* Player: public events stay verbatim; events whose ``actor_id`` or
|
||||
resolved target references a hidden entity surface ``[Unknown]`` and
|
||||
no detail. Private (gm/whisper) chat lines collapse to
|
||||
``Something happens`` so the player knows something was said but
|
||||
learns nothing of substance.
|
||||
* Spectator: private channels drop entirely (``None``); hidden entities
|
||||
collapse like the player tier.
|
||||
|
||||
Hidden-entity collapse honors the engine's ``is_visible`` flag — an
|
||||
absent flag defaults to visible (engine contract); only an explicit
|
||||
``False`` hides the entity. The same convention the existing entity
|
||||
projection matrix on ``/api/v1/engine/session-state`` uses.
|
||||
"""
|
||||
actor = await _session_timeline_gate(session_id, token)
|
||||
role = actor.get("role", "player")
|
||||
privileged = role in ("gm", "admin")
|
||||
redact_numbers = role not in _PLAYER_VISIBLE_ROLES
|
||||
|
||||
# Engine ledger is authoritative for combat events. Fetched via the same
|
||||
# proxy path as /api/v1/engine/session-state so the engine's RBAC
|
||||
# authorizes the real participant (a player cannot see entities they
|
||||
# don't own through this surface either).
|
||||
raw = await _engine_call(
|
||||
engine_client.engine_request(
|
||||
"GET",
|
||||
f"/api/v1/sessions/{engine_client._coerce_uuid(session_id)}",
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
raise HTTPException(status_code=502, detail="ENGINE_PAYLOAD_INVALID")
|
||||
entities = raw.get("entities") if isinstance(raw.get("entities"), dict) else {}
|
||||
ledger_events: List[Dict[str, Any]] = []
|
||||
ledger = raw.get("ledger")
|
||||
if isinstance(ledger, dict) and isinstance(ledger.get("events"), list):
|
||||
ledger_events = [e for e in ledger["events"] if isinstance(e, dict)]
|
||||
|
||||
engine_entries = [
|
||||
_timeline_format_engine_event(
|
||||
e,
|
||||
roster=entities,
|
||||
privileged=privileged,
|
||||
redact_numbers=redact_numbers,
|
||||
)
|
||||
for e in ledger_events
|
||||
]
|
||||
|
||||
# Narrative stream from gateway storage. Append-only per-session
|
||||
# monotonic counter; list_chat_messages returns oldest-first.
|
||||
chat_records = await storage_backend.list_chat_messages(session_id)
|
||||
narrative_entries = [
|
||||
_timeline_format_narrative_message(m) for m in chat_records
|
||||
]
|
||||
|
||||
merged = _timeline_merge(engine_entries, narrative_entries)
|
||||
|
||||
viewer_user_id: Optional[str] = actor.get("user_id")
|
||||
projected: List[Dict[str, Any]] = []
|
||||
for entry in merged:
|
||||
projected_entry = _timeline_project_entry(
|
||||
entry,
|
||||
role=role,
|
||||
viewer_user_id=viewer_user_id,
|
||||
roster=entities,
|
||||
)
|
||||
if projected_entry is not None:
|
||||
projected.append(projected_entry)
|
||||
|
||||
page = _timeline_filter_after_cursor(projected, cursor=cursor, limit=limit)
|
||||
next_cursor: Optional[str] = None
|
||||
if len(page) == limit:
|
||||
next_cursor = _timeline_encode_cursor(page[-1])
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"entries": page,
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": next_cursor is not None,
|
||||
}
|
||||
|
||||
|
||||
def start_server():
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
|
||||
@@ -164,6 +164,26 @@ CREATE TABLE IF NOT EXISTS narrative_state.narrations (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_narrations_session_created
|
||||
ON narrative_state.narrations (session_id, created_at DESC);
|
||||
|
||||
-- Per-session chat messages (Loop 3, iteration 26). Anchors the session
|
||||
-- event timeline: narrative messages share the merged feed alongside the
|
||||
-- engine ledger. Sequence numbers are PER-SESSION monotonic so the timeline
|
||||
-- can mix engine (``e_<n>``) and narrative (``n_<n>``) ids without colliding.
|
||||
-- channel is text rather than an enum so the future client/server convention
|
||||
-- can grow (``ooc``, ``whisper``, …) without a schema migration.
|
||||
CREATE TABLE IF NOT EXISTS narrative_state.session_chat_messages (
|
||||
message_id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'player',
|
||||
channel TEXT NOT NULL DEFAULT 'public',
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_chat_messages_session
|
||||
ON narrative_state.session_chat_messages (session_id, message_id);
|
||||
"""
|
||||
|
||||
#: Bound on the stored narration text. The full script goes to the TTS
|
||||
@@ -197,6 +217,11 @@ class MemoryStore:
|
||||
# Spoken-narration log (Loop 3, iteration 6), newest-last insertion
|
||||
# order; list_narrations sorts per session on read.
|
||||
self.narrations: List[Dict[str, Any]] = []
|
||||
# Per-session chat messages (Loop 3, iteration 26), ordered append;
|
||||
# the timeline endpoint paginates after a cursor over the merged
|
||||
# engine+narrative feed. Newest-last insertion order, list_chat
|
||||
# filters per session.
|
||||
self.chat_messages: List[Dict[str, Any]] = []
|
||||
# Periodic autosave policy + progress (iteration 77), keyed by
|
||||
# (owner_user_id, engine_session_id). Progress is deliberately separate
|
||||
# from the policy so disabling a policy does not erase the movement
|
||||
@@ -589,6 +614,40 @@ class MemoryStore:
|
||||
rows.sort(key=lambda n: n["created_at"], reverse=True)
|
||||
return [dict(n) for n in rows[:limit]]
|
||||
|
||||
# -- session chat messages (Loop 3, iteration 26) --
|
||||
|
||||
async def append_chat_message(self, session_id: str, user_id: str,
|
||||
display_name: str, role: str,
|
||||
channel: str, content: str) -> Dict[str, Any]:
|
||||
"""Appends one chat message to a session's narrative log.
|
||||
|
||||
Returns the persisted record including the per-session monotonic
|
||||
``sequence_id``. Per-session, NOT global — the same integer space
|
||||
can be reused across sessions without colliding on the timeline
|
||||
because the timeline key combines session_id (URL-scoped) with the
|
||||
per-session sequence.
|
||||
"""
|
||||
rows = [m for m in self.chat_messages if m["session_id"] == session_id]
|
||||
next_seq = (max((m["sequence_id"] for m in rows), default=0)) + 1
|
||||
record = {
|
||||
"sequence_id": next_seq,
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"display_name": display_name,
|
||||
"role": role,
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
self.chat_messages.append(record)
|
||||
return dict(record)
|
||||
|
||||
async def list_chat_messages(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""Returns one session's chat log in append (oldest-first) order."""
|
||||
rows = [m for m in self.chat_messages if m["session_id"] == session_id]
|
||||
rows.sort(key=lambda m: m["sequence_id"])
|
||||
return [dict(m) for m in rows]
|
||||
|
||||
|
||||
class PostgresStore:
|
||||
"""Durable backend backed by narrative_state.{users,campaign_saves}."""
|
||||
@@ -1133,6 +1192,42 @@ class PostgresStore:
|
||||
)
|
||||
return [self._narration_row(r) for r in rows]
|
||||
|
||||
# -- session chat messages (Loop 3, iteration 26) --
|
||||
|
||||
_CHAT_COLUMNS = """message_id, session_id, user_id, display_name, role,
|
||||
channel, content, created_at"""
|
||||
|
||||
@staticmethod
|
||||
def _chat_row(row: Any) -> Dict[str, Any]:
|
||||
record = dict(row)
|
||||
record["sequence_id"] = int(record.pop("message_id"))
|
||||
record["created_at"] = str(record.get("created_at", ""))
|
||||
return record
|
||||
|
||||
async def append_chat_message(self, session_id: str, user_id: str,
|
||||
display_name: str, role: str,
|
||||
channel: str, content: str) -> Dict[str, Any]:
|
||||
# Per-session monotonic counter via the BIGSERIAL primary key;
|
||||
# list_chat_messages reads them in append order on the same index.
|
||||
row = await self.pool.fetchrow(
|
||||
f"""INSERT INTO narrative_state.session_chat_messages
|
||||
(session_id, user_id, display_name, role, channel, content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING {self._CHAT_COLUMNS}""",
|
||||
session_id, user_id, display_name, role, channel, content,
|
||||
)
|
||||
return self._chat_row(row)
|
||||
|
||||
async def list_chat_messages(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
rows = await self.pool.fetch(
|
||||
f"""SELECT {self._CHAT_COLUMNS}
|
||||
FROM narrative_state.session_chat_messages
|
||||
WHERE session_id = $1
|
||||
ORDER BY message_id ASC""",
|
||||
session_id,
|
||||
)
|
||||
return [self._chat_row(r) for r in rows]
|
||||
|
||||
|
||||
def json_dumps(value: Any) -> str:
|
||||
import json
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
"""Session event timeline — Loop 3, iteration 26.
|
||||
|
||||
Combines the engine's ledger events with the session's narrative chat log
|
||||
into a single chronological feed. The engine is authoritative for combat
|
||||
mechanics (sequence_id, actor_id, event_type, payload); narrative messages
|
||||
are recorded on the gateway side under per-session ``session_chat_messages``
|
||||
storage (also introduced in this iteration).
|
||||
|
||||
Honest framing of the survey findings that drove this design:
|
||||
|
||||
* The engine events DO carry a sequence_id (monotonic u64 from
|
||||
``EventSourcingLedger``). They are projected by the existing
|
||||
``_project_ledger_event`` / ``_event_summary`` helpers, and that helper is
|
||||
the base we compose against — never replace it. We feed each event through
|
||||
it, then post-process the summary string by replacing UUIDs with resolved
|
||||
entity names from the session roster.
|
||||
|
||||
* Narrative chat messages are CLIENT-LOCAL state on the React side
|
||||
(``client/src/components/NarrativeChat.tsx`` consumes them as a prop, never
|
||||
from the network). There was NO server-side chat log before this iteration.
|
||||
We added a small per-session in-memory store (``session_chat_messages``)
|
||||
and a ``POST /api/v1/sessions/{id}/chat`` append endpoint to anchor the
|
||||
timeline; the React client still holds its local-only array and is free
|
||||
to mirror it server-side later. The narrative side gets its own monotonic
|
||||
per-session counter (``n_<int>``) so the two streams can be merged without
|
||||
colliding with engine sequence ids.
|
||||
|
||||
* Hidden entities (``is_visible=false``) are projected to ``[Unknown]`` with
|
||||
no detail, exactly as the entity-projection matrix on
|
||||
``/api/v1/engine/session-state`` already does. A GM sees verbatim; a
|
||||
player sees public events and their own; private events (apply-condition
|
||||
by GM on a hidden target) collapse to ``Something happens``.
|
||||
|
||||
Projection rules live here as plain functions so the test module can exercise
|
||||
them without spinning up the FastAPI app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
#: Namespace prefix for engine ledger sequence ids on the timeline. Engine
|
||||
#: events come pre-sequenced as integers; we prefix them to keep the two
|
||||
#: streams from colliding in sort and cursor comparison.
|
||||
_ENGINE_PREFIX = "e"
|
||||
#: Namespace prefix for narrative chat messages. Per-session monotonic
|
||||
#: counter — see ``storage.append_chat_message``.
|
||||
_NARRATIVE_PREFIX = "n"
|
||||
|
||||
#: Recognized private channels. A narrative message whose channel is in
|
||||
#: ``_PRIVATE_CHANNELS`` is hidden from non-GM viewers.
|
||||
_PRIVATE_CHANNELS = frozenset({"gm", "whisper"})
|
||||
|
||||
#: Recognized public channels. Anything not in PRIVATE_CHANNELS (default
|
||||
#: ``public`` and ``system``) is visible to every participant of the session.
|
||||
_PUBLIC_CHANNELS = frozenset({"public", "system", "ooc"})
|
||||
|
||||
#: Default cursor window — events older than the cursor are skipped on the
|
||||
#: next page. Mirrors the contract the existing
|
||||
#: ``/api/v1/sessions/{id}/replay/export`` already documents.
|
||||
DEFAULT_LIMIT = 100
|
||||
#: Hard cap so a marathon campaign's timeline can never payload-bomb.
|
||||
_MAX_LIMIT = 500
|
||||
|
||||
#: Stub text emitted when a player-tier viewer cannot see a private event.
|
||||
_PRIVATE_PLACEHOLDER = "Something happens"
|
||||
#: Stub text emitted when a non-GM viewer hits an event whose target entity is
|
||||
#: hidden from them. The actor may still be a visible entity (the GM), so we
|
||||
#: only redact the hidden side.
|
||||
_UNKNOWN = "[Unknown]"
|
||||
|
||||
#: A UUID string is a sequence of 8-4-4-4-12 hex digits. The engine emits
|
||||
#: UUIDs for entity ids; the formatter uses this regex to find and replace
|
||||
#: them with resolved names from the session roster. Compiled once at module
|
||||
#: load so the per-event call does not pay a recompile cost.
|
||||
_UUID_RE = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine-event formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_entity_name(
|
||||
entity_id: Optional[str],
|
||||
roster: Dict[str, Any],
|
||||
) -> Optional[str]:
|
||||
"""Resolves one entity id against the roster; ``None`` when unknown.
|
||||
|
||||
The roster is the engine's ``entities`` map (``id -> entity``). Hidden
|
||||
entities (is_visible=false) are still looked up here so GM projections
|
||||
can resolve them; the projection matrix decides whether to redact the
|
||||
name downstream.
|
||||
"""
|
||||
if not entity_id:
|
||||
return None
|
||||
key = str(entity_id)
|
||||
if key in roster and isinstance(roster[key], dict):
|
||||
name = roster[key].get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _replace_uuids_with_names(text: str, roster: Dict[str, Any]) -> str:
|
||||
"""Walks one summary string and replaces UUID tokens with roster names.
|
||||
|
||||
Tokens that resolve to no name (unknown id, hidden id, etc.) are left
|
||||
verbatim — the projection matrix above handles the redaction decision;
|
||||
this helper only resolves what it can resolve.
|
||||
"""
|
||||
def _sub(match: "re.Match[str]") -> str:
|
||||
candidate = match.group(0)
|
||||
name = _resolve_entity_name(candidate, roster)
|
||||
return name if name else candidate
|
||||
|
||||
return _UUID_RE.sub(_sub, text)
|
||||
|
||||
|
||||
def _humanize_engine_event(
|
||||
event_type: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
actor_id: Optional[str],
|
||||
roster: Dict[str, Any],
|
||||
redact_numbers: bool,
|
||||
privileged: bool,
|
||||
referenced_ids: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Builds a condensed one-liner for ONE engine event, in the timeline
|
||||
flavor (entity ids resolved to names, no numeric leaks to non-GMs).
|
||||
|
||||
This helper duplicates a subset of the projection matrix that already
|
||||
lives in ``server._event_summary`` — but in the timeline flavor (id
|
||||
resolution + condensed). Keeping the function local avoids a circular
|
||||
import and keeps the timeline's surface testable in isolation; the
|
||||
server module re-exports the public entry points and reuses them.
|
||||
"""
|
||||
if event_type == "COMBAT_BEGAN":
|
||||
return "Combat begins"
|
||||
if event_type == "COMBAT_ENDED":
|
||||
return "Combat ends"
|
||||
if event_type == "TURN_ADVANCED":
|
||||
round_no = payload.get("round")
|
||||
return f"Round {round_no} begins" if round_no is not None else "Turn advanced"
|
||||
if event_type == "DELAY_TAKEN":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _resolve_entity_name(
|
||||
payload.get("entity_id"), roster
|
||||
) or _UNKNOWN
|
||||
round_no = payload.get("round")
|
||||
suffix = f" (round {round_no})" if round_no is not None else ""
|
||||
return f"{actor} delays{suffix}"
|
||||
if event_type == "DELAY_RESUMED":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _resolve_entity_name(
|
||||
payload.get("entity_id"), roster
|
||||
) or _UNKNOWN
|
||||
return f"{actor} resumes"
|
||||
if event_type == "CONDITION_EXPIRED":
|
||||
condition = payload.get("condition")
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
if condition is None:
|
||||
return f"A condition wears off: {actor}"
|
||||
return f"{condition} wears off: {actor}"
|
||||
if event_type == "CONDITION_APPLIED":
|
||||
condition = payload.get("condition")
|
||||
target = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
source_id = payload.get("source_entity_id")
|
||||
source = _resolve_entity_name(source_id, roster)
|
||||
if condition is None:
|
||||
return f"Something is happening to {target}"
|
||||
source_name = source or _UNKNOWN
|
||||
# "GM applies Stunned to Goblin Shaman" reads better than conjugating
|
||||
# arbitrary condition names ("stunneds", "frighteneds").
|
||||
return f"{source_name} applies {condition} to {target}"
|
||||
if event_type == "ATTACK_RESOLVED":
|
||||
attacker = _resolve_entity_name(payload.get("attacker_id"), roster)
|
||||
target = _resolve_entity_name(payload.get("target_id"), roster)
|
||||
is_hit = payload.get("is_hit")
|
||||
verb = {True: "hits", False: "misses"}.get(is_hit, "attacks")
|
||||
a_name = attacker or _UNKNOWN
|
||||
t_name = target or _UNKNOWN
|
||||
line = f"{a_name} {verb} {t_name}"
|
||||
if not redact_numbers:
|
||||
damage = payload.get("total_damage")
|
||||
hp = payload.get("target_hp_remaining")
|
||||
if damage is not None and damage:
|
||||
line += f" for {damage}"
|
||||
if hp is not None:
|
||||
line += f" (HP→{hp})"
|
||||
return line
|
||||
if event_type == "DAMAGE_APPLIED":
|
||||
target = _resolve_entity_name(payload.get("target_id"), roster) or _UNKNOWN
|
||||
amount = payload.get("amount")
|
||||
hp = payload.get("hp_remaining")
|
||||
if redact_numbers or amount is None:
|
||||
return f"{target} takes damage"
|
||||
line = f"{target} takes {amount} damage"
|
||||
if hp is not None:
|
||||
line += f" (HP→{hp})"
|
||||
return line
|
||||
if event_type == "HEALED":
|
||||
target = _resolve_entity_name(payload.get("target_id"), roster) or _UNKNOWN
|
||||
amount = payload.get("amount")
|
||||
hp = payload.get("hp_remaining")
|
||||
if redact_numbers or amount is None:
|
||||
return f"{target} is healed"
|
||||
line = f"{target} heals {amount}"
|
||||
if hp is not None:
|
||||
line += f" (HP→{hp})"
|
||||
return line
|
||||
if event_type == "DEATH_SAVE_RESOLVED":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
roll = payload.get("natural_roll")
|
||||
outcome = payload.get("outcome")
|
||||
if roll is None and outcome is None:
|
||||
return f"{actor} faces death"
|
||||
if outcome is None:
|
||||
return f"{actor} rolls a death save: {roll}"
|
||||
return f"{actor} death-save {outcome} (rolled {roll})" if roll is not None \
|
||||
else f"{actor} death-save {outcome}"
|
||||
if event_type == "SPELL_CAST":
|
||||
caster = _resolve_entity_name(payload.get("caster_id"), roster) or _UNKNOWN
|
||||
spell = payload.get("spell_id")
|
||||
target = _resolve_entity_name(payload.get("target_id"), roster)
|
||||
head = f"{caster} casts {spell}" if spell else f"{caster} casts a spell"
|
||||
if target:
|
||||
head += f" at {target}"
|
||||
if not redact_numbers:
|
||||
damage = payload.get("damage_total")
|
||||
hp = payload.get("target_hp_remaining")
|
||||
if damage is not None and damage:
|
||||
head += f" for {damage}"
|
||||
if hp is not None:
|
||||
head += f" (HP→{hp})"
|
||||
return head
|
||||
if event_type == "SPELL_COUNTERSPELLED":
|
||||
caster = _resolve_entity_name(payload.get("caster_id"), roster) or _UNKNOWN
|
||||
spell = payload.get("spell_id") or "a spell"
|
||||
return f"{caster}'s {spell} is countered"
|
||||
if event_type == "MOVE_ENTITY":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
triggers = payload.get("opportunity_attacks")
|
||||
trigger_count = len(triggers) if isinstance(triggers, list) else 0
|
||||
line = f"{actor} moves"
|
||||
if trigger_count:
|
||||
line += (
|
||||
f" (provoked {trigger_count} opportunit"
|
||||
f"{'y' if trigger_count == 1 else 'ies'})"
|
||||
)
|
||||
return line
|
||||
if event_type == "OPPORTUNITY_ATTACK_RESOLVED":
|
||||
attacker = _resolve_entity_name(payload.get("attacker_id"), roster) or _UNKNOWN
|
||||
mover = _resolve_entity_name(payload.get("mover_id"), roster) or _UNKNOWN
|
||||
resolution = payload.get("resolution")
|
||||
is_hit = None
|
||||
if isinstance(resolution, dict):
|
||||
is_hit = resolution.get("is_hit")
|
||||
verb = {True: "hits", False: "misses"}.get(is_hit, "opportunities")
|
||||
return f"{attacker} {verb} {mover}"
|
||||
if event_type == "READY_ACTION_SET":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
return f"{actor} readies an action"
|
||||
if event_type == "READY_ACTION_RELEASED":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
return f"{actor} releases a readied action"
|
||||
if event_type == "READIED_ACTION_EXPIRED":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
return f"{actor}'s readied action expires"
|
||||
if event_type == "REACTION_ARMED":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
reaction = payload.get("reaction")
|
||||
return f"{actor} readies a {reaction} reaction" if reaction else f"{actor} readies a reaction"
|
||||
if event_type == "REACTION_CONSUMED":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
reaction = payload.get("reaction")
|
||||
return f"{actor} spends a {reaction} reaction" if reaction else f"{actor} spends a reaction"
|
||||
if event_type == "HELP_ACTION":
|
||||
actor = _resolve_entity_name(payload.get("helper_id"), roster) or _UNKNOWN
|
||||
target = _resolve_entity_name(payload.get("target_entity_id"), roster) or _UNKNOWN
|
||||
return f"{actor} helps {target}"
|
||||
if event_type == "INSPIRATION_CHANGED":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
granted = payload.get("granted")
|
||||
if granted is True:
|
||||
return f"{actor} gains inspiration"
|
||||
if granted is False:
|
||||
return f"{actor} spends inspiration"
|
||||
return f"{actor}'s inspiration changes"
|
||||
if event_type == "ITEM_TRANSFERRED":
|
||||
actor = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
return f"{actor} moves an item between containers"
|
||||
if event_type == "CONCENTRATION_BROKEN":
|
||||
target = _resolve_entity_name(payload.get("target_id"), roster) or _UNKNOWN
|
||||
spell = payload.get("spell_id")
|
||||
return f"{target} loses concentration on {spell}" if spell else f"{target} loses concentration"
|
||||
if event_type == "SAFETY_REWIND_APPLIED":
|
||||
player = payload.get("triggered_by")
|
||||
topic = payload.get("topic")
|
||||
if topic:
|
||||
return f"X-card rewind on '{topic}' (by {player})"
|
||||
return f"X-card rewind by {player}" if player else "X-card rewind"
|
||||
if event_type == "ENTITY_SPAWN":
|
||||
return f"A new entity joins"
|
||||
if event_type == "ENTITY_DESPAWN":
|
||||
return "An entity leaves the board"
|
||||
if event_type == "SESSION_CREATED":
|
||||
name = payload.get("name")
|
||||
return f"Session created: {name}" if name else "Session created"
|
||||
# Unknown / future event type: render the raw payload for GM verbatim,
|
||||
# withhold for everyone else so a new event type cannot accidentally
|
||||
# leak private numbers through the timeline.
|
||||
if privileged:
|
||||
return f"{event_type}: {json.dumps(payload, sort_keys=True)}"
|
||||
return f"{event_type or 'UNKNOWN_EVENT'} occurred"
|
||||
|
||||
|
||||
def format_engine_event(
|
||||
event: Dict[str, Any],
|
||||
*,
|
||||
roster: Dict[str, Any],
|
||||
privileged: bool = False,
|
||||
redact_numbers: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""One engine ledger event -> one timeline entry.
|
||||
|
||||
Returns a dict with the timeline shape (``sequence_id``, ``kind``,
|
||||
``created_at_ms``, ``actor_id``, ``actor_name``, ``channel``, ``role``,
|
||||
``summary``, ``event_type``, ``is_reverted``, ``referenced_entity_ids``).
|
||||
|
||||
The extra ``referenced_entity_ids`` field lists every entity id the
|
||||
formatter resolved into the summary — the projection layer uses this
|
||||
to decide whether to redact the entry against the session's hidden
|
||||
roster without having to regex-recover ids from the rendered text.
|
||||
"""
|
||||
event_type = str(event.get("event_type") or "")
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
sequence_id = event.get("sequence_id")
|
||||
actor_id = event.get("actor_id")
|
||||
is_reverted = bool(event.get("is_reverted"))
|
||||
timestamp = event.get("timestamp")
|
||||
created_at_ms = _timestamp_to_ms(timestamp)
|
||||
actor_name = _resolve_entity_name(actor_id, roster) or _UNKNOWN
|
||||
referenced = _extract_payload_entity_ids(event_type, payload, actor_id)
|
||||
summary = _humanize_engine_event(
|
||||
event_type,
|
||||
payload,
|
||||
actor_id=actor_id,
|
||||
roster=roster,
|
||||
redact_numbers=redact_numbers,
|
||||
privileged=privileged,
|
||||
referenced_ids=referenced,
|
||||
)
|
||||
seq_str = _encode_sequence(_ENGINE_PREFIX, sequence_id) if sequence_id is not None else None
|
||||
return {
|
||||
"sequence_id": seq_str,
|
||||
"kind": "engine",
|
||||
"created_at_ms": created_at_ms,
|
||||
"actor_id": str(actor_id) if actor_id is not None else None,
|
||||
"actor_name": actor_name,
|
||||
"channel": "public",
|
||||
"role": "system",
|
||||
"summary": summary,
|
||||
"event_type": event_type or None,
|
||||
"is_reverted": is_reverted,
|
||||
"referenced_entity_ids": sorted(set(str(r) for r in referenced if r)),
|
||||
}
|
||||
|
||||
|
||||
def _extract_payload_entity_ids(
|
||||
event_type: str,
|
||||
payload: Dict[str, Any],
|
||||
actor_id: Optional[str],
|
||||
) -> List[str]:
|
||||
"""Pulls every entity id referenced by one event payload.
|
||||
|
||||
Returns a list of stringified ids — the caller is responsible for
|
||||
deduping. The set is what the projection layer checks against the
|
||||
roster's ``is_visible`` flag.
|
||||
"""
|
||||
ids: List[str] = []
|
||||
if actor_id:
|
||||
ids.append(str(actor_id))
|
||||
|
||||
def _push(key: str) -> None:
|
||||
val = payload.get(key)
|
||||
if val is None:
|
||||
return
|
||||
if isinstance(val, str):
|
||||
ids.append(val)
|
||||
elif isinstance(val, dict):
|
||||
ids.append(str(val.get("attacker_id") or val.get("id") or ""))
|
||||
|
||||
# Per-event-type payload keys that carry an entity id we surface in
|
||||
# the humanized summary.
|
||||
if event_type == "ATTACK_RESOLVED":
|
||||
_push("attacker_id")
|
||||
_push("target_id")
|
||||
elif event_type == "DAMAGE_APPLIED":
|
||||
_push("target_id")
|
||||
elif event_type == "HEALED":
|
||||
_push("target_id")
|
||||
elif event_type == "MOVE_ENTITY":
|
||||
# Provoked enemies live inside the opportunity_attacks array; we
|
||||
# surface only the COUNT in the summary (see _humanize_engine_event)
|
||||
# so don't expose them via referenced_entity_ids either.
|
||||
pass
|
||||
elif event_type == "CONDITION_APPLIED":
|
||||
_push("source_entity_id")
|
||||
elif event_type == "CONDITION_EXPIRED":
|
||||
# actor_id IS the target here.
|
||||
pass
|
||||
elif event_type == "DELAY_TAKEN":
|
||||
_push("entity_id")
|
||||
elif event_type == "DELAY_RESUMED":
|
||||
_push("entity_id")
|
||||
elif event_type == "OPPORTUNITY_ATTACK_RESOLVED":
|
||||
_push("attacker_id")
|
||||
_push("mover_id")
|
||||
elif event_type == "DEATH_SAVE_RESOLVED":
|
||||
pass # actor_id is the target.
|
||||
elif event_type == "SPELL_CAST":
|
||||
_push("caster_id")
|
||||
_push("target_id")
|
||||
elif event_type == "SPELL_COUNTERSPELLED":
|
||||
_push("caster_id")
|
||||
elif event_type == "CONCENTRATION_BROKEN":
|
||||
_push("target_id")
|
||||
elif event_type == "HELP_ACTION":
|
||||
_push("helper_id")
|
||||
_push("target_entity_id")
|
||||
elif event_type == "REACTION_ARMED":
|
||||
pass # no entity id surface.
|
||||
elif event_type == "REACTION_CONSUMED":
|
||||
pass
|
||||
elif event_type == "INSPIRATION_CHANGED":
|
||||
pass
|
||||
elif event_type == "ITEM_TRANSFERRED":
|
||||
pass
|
||||
elif event_type == "READY_ACTION_SET":
|
||||
pass
|
||||
elif event_type == "READY_ACTION_RELEASED":
|
||||
pass
|
||||
elif event_type == "READIED_ACTION_EXPIRED":
|
||||
pass
|
||||
elif event_type == "ENTITY_SPAWN":
|
||||
_push("entity_id")
|
||||
elif event_type == "ENTITY_DESPAWN":
|
||||
_push("entity_id")
|
||||
elif event_type == "SAFETY_REWIND_APPLIED":
|
||||
# triggered_by is a user_id, not an entity id.
|
||||
pass
|
||||
elif event_type == "TURN_ADVANCED":
|
||||
pass
|
||||
elif event_type == "COMBAT_BEGAN":
|
||||
# The initiative array names every combatant; the summary just says
|
||||
# "Combat begins" so don't reference anyone specifically.
|
||||
pass
|
||||
elif event_type == "COMBAT_ENDED":
|
||||
pass
|
||||
return [i for i in ids if i]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Narrative-message formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_narrative_message(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""One chat message -> one timeline entry.
|
||||
|
||||
The narrative side was a client-local store before this iteration; the
|
||||
message schema mirrors the React ``ChatMessage`` shape plus the gateway's
|
||||
server-only fields (sequence_id, created_at_ms).
|
||||
"""
|
||||
sequence_id = message.get("sequence_id")
|
||||
seq_str = (
|
||||
_encode_sequence(_NARRATIVE_PREFIX, sequence_id)
|
||||
if sequence_id is not None
|
||||
else None
|
||||
)
|
||||
user_id = message.get("user_id")
|
||||
role = message.get("role") or "player"
|
||||
channel = message.get("channel") or "public"
|
||||
content = message.get("content") or ""
|
||||
created_at_ms = _timestamp_to_ms(message.get("created_at"))
|
||||
return {
|
||||
"sequence_id": seq_str,
|
||||
"kind": "narrative",
|
||||
"created_at_ms": created_at_ms,
|
||||
"actor_id": str(user_id) if user_id is not None else None,
|
||||
"actor_name": message.get("display_name") or (str(user_id) if user_id else _UNKNOWN),
|
||||
"channel": channel,
|
||||
"role": role,
|
||||
"summary": content,
|
||||
"event_type": None,
|
||||
"is_reverted": False,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role projection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_hidden_entity(entity_id: Optional[str], roster: Dict[str, Any]) -> bool:
|
||||
if not entity_id:
|
||||
return False
|
||||
entry = roster.get(str(entity_id))
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
# Absent flag defaults to visible (the engine's contract); only an
|
||||
# explicit False hides the entity.
|
||||
return entry.get("is_visible") is False
|
||||
|
||||
|
||||
def project_timeline_entry(
|
||||
entry: Dict[str, Any],
|
||||
*,
|
||||
role: str,
|
||||
viewer_user_id: Optional[str],
|
||||
roster: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Applies the role projection matrix to one timeline entry.
|
||||
|
||||
Returns ``None`` when the viewer is not entitled to see the entry at all
|
||||
(e.g. a GM-private whisper addressed to a different user). Otherwise
|
||||
returns the projected entry with hidden details collapsed.
|
||||
|
||||
Rules:
|
||||
|
||||
* gm / admin: verbatim. Hidden entities surface as their actual name,
|
||||
private channels surface with their content intact.
|
||||
* player / participant: public events stay verbatim; events whose
|
||||
``actor_id`` or resolved target references a hidden entity surface
|
||||
``[Unknown]`` for that name and no detail. GM-channel chat messages
|
||||
collapse to a generic ``Something happens`` line.
|
||||
* spectator / unknown role: same as player for hidden-entity collapse;
|
||||
private channels drop entirely (return ``None``).
|
||||
"""
|
||||
is_privileged = role in ("gm", "admin")
|
||||
channel = entry.get("channel") or "public"
|
||||
actor_id = entry.get("actor_id")
|
||||
summary = entry.get("summary") or ""
|
||||
event_type = entry.get("event_type")
|
||||
|
||||
# Non-GM viewer: redact private channels.
|
||||
if not is_privileged and channel not in _PUBLIC_CHANNELS:
|
||||
if role == "spectator":
|
||||
return None
|
||||
# Player tier: collapse private narrative to a generic placeholder
|
||||
# so the player can tell SOMETHING happened without learning who or
|
||||
# what. GM-channel events stay visible to other GMs in the lobby.
|
||||
projected = dict(entry)
|
||||
projected["actor_name"] = _UNKNOWN
|
||||
projected["summary"] = _PRIVATE_PLACEHOLDER
|
||||
projected["event_type"] = None
|
||||
projected["is_private"] = True
|
||||
return projected
|
||||
|
||||
# Player tier: collapse events that reference a hidden entity.
|
||||
if not is_privileged:
|
||||
hidden_actor = _is_hidden_entity(actor_id, roster)
|
||||
target_ids = _extract_event_target_ids(summary, event_type, entry)
|
||||
hidden_target = any(_is_hidden_entity(t, roster) for t in target_ids)
|
||||
if hidden_actor or hidden_target:
|
||||
projected = dict(entry)
|
||||
projected["actor_name"] = _UNKNOWN if hidden_actor else entry.get("actor_name")
|
||||
# Drop the summary entirely when a hidden entity is involved
|
||||
# so a player never learns what the GM did to the hidden token.
|
||||
projected["summary"] = _PRIVATE_PLACEHOLDER
|
||||
projected["event_type"] = None
|
||||
projected["is_private"] = True
|
||||
return projected
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def _extract_event_target_ids(
|
||||
summary: str,
|
||||
event_type: Optional[str],
|
||||
entry: Dict[str, Any],
|
||||
) -> List[str]:
|
||||
"""Returns the entity ids the entry references.
|
||||
|
||||
Engine entries carry the id set on ``referenced_entity_ids`` (populated
|
||||
by :func:`format_engine_event` from the raw payload, BEFORE the names
|
||||
were resolved into the summary). Narrative entries have no target
|
||||
concept — only an actor user id.
|
||||
"""
|
||||
if event_type is None:
|
||||
return []
|
||||
referenced = entry.get("referenced_entity_ids")
|
||||
if isinstance(referenced, list):
|
||||
return [str(r) for r in referenced if r]
|
||||
# Backward-compat fallback for hand-rolled entries: regex the summary.
|
||||
return _UUID_RE.findall(summary or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge + cursor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def merge_timeline(
|
||||
engine_entries: Iterable[Dict[str, Any]],
|
||||
narrative_entries: Iterable[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Chronological merge of two already-formatted streams.
|
||||
|
||||
Sort order: ``created_at_ms`` ascending; ties broken by ``kind``
|
||||
(``engine`` before ``narrative`` so an engine combat event beats a
|
||||
same-millisecond chat line), then by ``sequence_id`` for stable
|
||||
ordering across pages.
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
out.extend(engine_entries)
|
||||
out.extend(narrative_entries)
|
||||
out.sort(
|
||||
key=lambda e: (
|
||||
int(e.get("created_at_ms") or 0),
|
||||
0 if e.get("kind") == "engine" else 1,
|
||||
str(e.get("sequence_id") or ""),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def encode_cursor(entry: Dict[str, Any]) -> str:
|
||||
"""Encode a cursor pointing AT the given entry (next page starts AFTER it)."""
|
||||
if not entry.get("sequence_id"):
|
||||
return ""
|
||||
return f"{int(entry.get('created_at_ms') or 0)}:{entry['sequence_id']}"
|
||||
|
||||
|
||||
def decode_cursor(cursor: Optional[str]) -> Tuple[int, str]:
|
||||
"""Inverse of :func:`encode_cursor`. Returns ``(created_at_ms, sequence_id)``."""
|
||||
if not cursor:
|
||||
return (0, "")
|
||||
parts = cursor.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
return (0, "")
|
||||
try:
|
||||
ms = int(parts[0])
|
||||
except (TypeError, ValueError):
|
||||
return (0, "")
|
||||
return (ms, parts[1])
|
||||
|
||||
|
||||
def filter_after_cursor(
|
||||
entries: List[Dict[str, Any]],
|
||||
cursor: Optional[str],
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Paginate a chronologically-sorted timeline.
|
||||
|
||||
Pagination is on the merged order: skip everything whose
|
||||
``(created_at_ms, sequence_id)`` sorts at-or-before the cursor, then
|
||||
return the next ``limit`` entries.
|
||||
"""
|
||||
if limit <= 0:
|
||||
limit = DEFAULT_LIMIT
|
||||
if limit > _MAX_LIMIT:
|
||||
limit = _MAX_LIMIT
|
||||
cur_ms, cur_seq = decode_cursor(cursor)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
ms = int(entry.get("created_at_ms") or 0)
|
||||
seq = str(entry.get("sequence_id") or "")
|
||||
# Tuple comparison: skip if (ms, seq) <= cursor (strict-ascending).
|
||||
if (ms, seq) <= (cur_ms, cur_seq):
|
||||
continue
|
||||
out.append(entry)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _encode_sequence(prefix: str, seq: Any) -> Optional[str]:
|
||||
"""Encodes an engine or narrative sequence id with a namespace prefix."""
|
||||
if seq is None:
|
||||
return None
|
||||
try:
|
||||
n = int(seq)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f"{prefix}_{n}"
|
||||
|
||||
|
||||
def _timestamp_to_ms(value: Any) -> int:
|
||||
"""Accepts an ISO-8601 string or numeric timestamp and returns ms-since-epoch.
|
||||
|
||||
A missing / unparseable timestamp sorts as ``0`` (oldest possible), which
|
||||
is the honest answer — we do not invent a timestamp the engine never
|
||||
produced.
|
||||
"""
|
||||
if value is None:
|
||||
return 0
|
||||
if isinstance(value, (int, float)):
|
||||
# Engine emits seconds-since-epoch floats; promote to ms.
|
||||
seconds = float(value)
|
||||
if seconds > 10_000_000_000:
|
||||
return int(seconds) # already ms
|
||||
return int(seconds * 1000)
|
||||
if isinstance(value, str):
|
||||
# ISO-8601 with optional trailing 'Z'. datetime.fromisoformat on
|
||||
# Python 3.11+ accepts the trailing 'Z' only when reformatted.
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return 0
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
iso = candidate.replace("Z", "+00:00") if candidate.endswith("Z") else candidate
|
||||
parsed = datetime.fromisoformat(iso)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return int(parsed.timestamp() * 1000)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return 0
|
||||
Reference in new issue
Block a user