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:
John SmithandClaude committed 2026-08-26 11:38:24 -04:00
1 parent c467301f12
commit fd0f36ba5c
4 files changed
+1795

No files matched your search

+751
View File
@@ -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"
+219
View File
@@ -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)
+95
View File
@@ -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
+730
View File
@@ -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