backend/tavern/
├── main.py # FastAPI app factory; mounts routers, static files, error handlers
├── db.py # Async SQLAlchemy engine and session factory
├── srd_db.py # AsyncIOMotorClient lifecycle (connect_srd_db / close_srd_db / get_srd_db)
├── core/ # Rules Engine — SRD 5.2.1 mechanics, no LLM dependency
│ ├── dice.py # Dice rolling, advantage/disadvantage, NdX notation parser, deterministic seeds
│ ├── characters.py # Ability modifiers, HP, spell slots, proficiency bonus, standard array validation (async)
│ ├── combat.py # Attack resolution, damage application, initiative, grapple/shove, death saves; CombatParticipant dataclass (one combatant's initiative state; surprised flag set once, read once); CombatSnapshotCharacter dataclass (minimal per-character data for Surprise: WIS mod, Perception proficiency, feats); CombatSnapshot dataclass ({character_id: CombatSnapshotCharacter}, used instead of StateSnapshot to preserve core/→dm/ direction); determine_surprise(potential_surprised, stealth_results, snapshot: CombatSnapshot) → dict[str, bool] (all-concealers-must-succeed rule, SRD 5.2.1 RAW); _has_surprise_immunity(character_id, snapshot: CombatSnapshot) → bool (Alert feat pre-filter); roll_initiative_order(participants, *, surprised_map, dex_modifiers, seeds) → list[CombatParticipant] (Disadvantage for surprised participants)
│ ├── conditions.py # 15 SRD conditions, attack/save modifiers, speed, incapacitation logic
│ ├── action_analyzer.py # Keyword-based action classification (no LLM): ActionCategory enum, ActionAnalysis dataclass, analyze_action()
│ ├── spells.py # Spell resolution orchestrator: slot validation, attack/save/auto-hit routing, damage/healing calculation, condition application
│ ├── srd_data.py # SRD Data Access Layer: three-tier lookup (Campaign Override → Instance Library → SRD Baseline); resolve_npc_stat_block(stat_block_ref: str, campaign_id: UUID) → dict | None (three-tier monster lookup for NPC stat block population; logs warning not error on miss)
│ └── scene.py # Scene identifier utilities (ADR-0017): normalise_scene_id(raw: str) -> str (strip/lowercase/replace spaces+hyphens with underscore/strip non-conforming chars/collapse underscores; raises ValueError on empty result or >64 chars); validate_scene_id(scene_id: str) -> bool (returns True if already canonical; does not raise)
├── dm/ # DM layer — Narrator, Context Builder, LLM provider abstraction
│ ├── narrator.py # Narrator class; model routing (Sonnet/Haiku); streaming narration and summary compression; GMSignals delimiter buffering (stops forwarding to clients after ---GM_SIGNALS---); parse_gm_signals() integration; narrate_turn_stream() returns tuple[str, GMSignals, dict] (dict = LLM metadata); system prompt includes _GM_SIGNALS_INSTRUCTION (all 5 GMSignals fields), _NPC_CONSISTENCY_INSTRUCTION (ADR-0013), _LOCATION_CHANGE_INSTRUCTIONS (ADR-0019 — when/how to emit location_change), _TIME_PROGRESSION_INSTRUCTIONS (ADR-0019 — 8-value enum, no rest mechanics)
│ ├── context_builder.py # StateSnapshot, TurnContext; builds and serializes game state for the Narrator; TurnContext.stealth_rolls: dict[str, int] (Path B Surprise, ADR-0014); StateSnapshot.session_mode: str (guards CombatClassifier, ADR-0011); StateSnapshot.npcs: list[dict] (compact NPC records, ADR-0013, scene-scoped via current_scene_id column and recency-filtered last 10 turns, excludes dead/fled unless plot_significant); build_system_prompt() includes _SUGGESTED_ACTIONS_INSTRUCTIONS (ADR-0015); serialised scene block uses "Scene:" label (current_scene_id) and always-present "Time:" field (time_of_day) per ADR-0019 §5
│ ├── summary.py # Rolling summary helpers: build_turn_summary_input(), trim_summary(); enforces 500-token budget
│ ├── combat_classifier.py # CombatClassifier — Haiku-based binary LLM classifier for combat initiation detection; classify(action_text: str, snapshot: StateSnapshot) → CombatClassification; called pre-narration in exploration mode only; raises RuntimeError in combat mode (ADR-0011); no dependency on core/
│ └── gm_signals.py # GMSignals, SceneTransition, NPCUpdate, LocationChange, TimeProgression dataclasses; GM_SIGNALS_DELIMITER = "---GM_SIGNALS---" constant; parse_gm_signals(raw: str) → GMSignals — safe-default on any parse failure; safe_default() → GMSignals; GMSignals fields: scene_transition, npc_updates, suggested_actions: list[str] (0–3 suggestions, ADR-0015), location_change: LocationChange | None (ADR-0019 — new_location raw str + optional reason), time_progression: TimeProgression | None (ADR-0019 — new_time_of_day from 8-value enum + optional reason)
├── observability.py # Turn pipeline telemetry (ADR-0018): PipelineStep, LLMCallRecord, TurnEventLog dataclasses; TurnEventLogAccumulator (mutable accumulator per turn); turn_event_log_to_dict() for JSONB serialisation. No dm/ or api/ imports — stdlib only.
├── api/ # FastAPI REST endpoints and WebSocket handler
│ ├── campaigns.py # Campaign CRUD + session lifecycle; calls Narrator for Claude-generated opening scene on create; sets current_scene_id (normalised via normalise_scene_id()) and time_of_day on CampaignState at creation (ADR-0019); world_state["location"] still written but deprecated
│ ├── characters.py # Character creation and retrieval
│ ├── turns.py # Turn submission (202) and retrieval; wires action_analyzer + Rules Engine; broadcasts character.updated; full combat lifecycle: CombatClassifier invoked pre-narration (exploration mode only); GMSignals processed post-narration in order: (1) npc_updates — spawned NPCs get scene_location=NULL, (2) location_change_apply — normalises new_location → current_scene_id, auto-assigns NULL-location NPCs from this turn, (3) time_progression_apply — updates time_of_day, (4) scene_transition — combat mode changes, (5) NPC location finalise — assigns final current_scene_id to any remaining NULL-location NPCs from this turn (ADR-0019); engine combat_end takes precedence over Narrator; turn.location_change and turn.time_progression events emitted after narrative_end before suggested_actions (ADR-0019 §6); TurnEventLogAccumulator instruments every step; emits turn.event_log and session.telemetry (ADR-0018)
│ ├── inspect.py # Observability REST endpoints (ADR-0018): GET /campaigns/{id}/turns/{turn_id}/event_log (returns Turn.event_log JSONB); GET /campaigns/{id}/sessions/{session_id}/telemetry (aggregated session metrics from turn event logs; warns if query >200ms)
│ ├── npcs.py # NPC roster CRUD for campaign: POST (201), GET list, GET single, PATCH; PATCH enforces immutability — returns 422 if name, species, or appearance in request body; scoped to /api/campaigns/{campaign_id}/npcs
│ ├── ws.py # WebSocket endpoint + ConnectionManager; session.state recent_turns payload includes mechanical_results per turn; sends session.telemetry on connect (ADR-0018)
│ ├── srd.py # Custom SRD content: Instance Library CRUD + Campaign Override CRUD
│ ├── dependencies.py # Shared FastAPI dependencies (get_db_session, get_narrator, get_session_factory)
│ ├── schemas.py # Pydantic request/response schemas
│ └── errors.py # APIError + error handlers
├── discord_bot/ # Discord client — connects to Tavern API, translates Discord interactions
│ ├── bot.py # TavernBot (commands.Bot subclass); loads cogs, syncs slash commands
│ ├── config.py # BotConfig dataclass; validates required env vars on init
│ ├── __main__.py # Entry point: python -m tavern.discord_bot
│ ├── cogs/ # discord.py Cog modules (one per command group)
│ │ ├── campaign.py # /campaign create|info|config|recap|scene; /session start|end; /tavern delete (with confirmation)
│ │ ├── character.py # /character create|sheet|inventory|spells; guided creation threads
│ │ ├── gameplay.py # /action, /roll, /pass; WebSocket event → Discord message routing
│ │ ├── lfg.py # /lfg — bind a campaign to a Discord text channel
│ │ ├── ping.py # /tavern ping — health check for bot + API
│ │ ├── voice.py # Voice channel integration (stub)
│ │ └── websocket.py # WebSocketCog — persistent WS connection; dispatches bot events
│ ├── embeds/ # Pure functions: raw API dict → discord.Embed
│ │ ├── character_sheet.py # build_character_sheet_embed, build_inventory_embed, build_spells_embed
│ │ ├── combat.py # build_combat_embed, build_party_status
│ │ ├── lfg.py # build_lfg_embed
│ │ ├── narrative.py # build_narrative_embed
│ │ ├── rolls.py # build_roll_embed, build_reaction_window_embed, ReactionWindowView, SelfReactionView
│ │ └── status.py # build_status_embed
│ ├── models/
│ │ └── state.py # BotState, ChannelBinding, PendingRoll, ReactionWindow — in-memory runtime state
│ └── services/
│ ├── api_client.py # TavernAPI — async httpx client wrapping all REST endpoints
│ ├── channel_manager.py # ChannelManager — Discord channel lifecycle helpers
│ └── identity.py # IdentityService — Discord user ↔ Tavern user/character mapping (cached)
├── models/ # SQLAlchemy ORM models (database schema)
│ ├── base.py # DeclarativeBase; JSONB custom type (JSONB on PostgreSQL, JSON on SQLite)
│ ├── campaign.py # Campaign, CampaignState
│ ├── character.py # Character, InventoryItem, CharacterCondition
│ ├── session.py # Session
│ ├── turn.py # Turn
│ ├── npc.py # NPC — campaign-scoped (campaign_id FK with CASCADE); immutable fields (name, species, appearance) enforced at model layer; mutable state (hp_current, hp_max, ac, disposition, status, scene_location, motivation, creature_type, stat_block_ref, first_appeared_turn, last_seen_turn); identity-adjacent fields: role (immutable intent, set at spawn); origin: "predefined"|"narrator_spawned"; plot_significant: bool (persists in snapshot after death/flight when True); validate_immutable_update(updates: dict) classmethod — raises ValueError on immutable field update
├── alembic/ # Database migrations
│ ├── env.py # Async migration runner (asyncpg)
│ ├── script.py.mako # Migration file template
│ └── versions/
│ ├── 0001_initial.py # Initial schema
│ ├── 0002_add_campaign_session_character_turn_models.py # Campaign, Session, Character, Turn tables
│ ├── 0003_add_srd_reference_tables.py # 15 SRD reference tables (superseded)
│ ├── 0004_drop_srd_reference_tables.py # Drop all SRD PostgreSQL tables (data now in MongoDB)
│ ├── 0005_add_npcs_table.py # NPC table (campaign-scoped roster)
│ ├── 0006_add_mechanical_results_jsonb_to_turns.py # Add mechanical_results JSONB column to turns
│ ├── 0007_add_event_log_jsonb_to_turns.py # Add event_log JSONB column to turns (ADR-0018)
│ └── 0008_add_current_scene_id_and_time_of_day.py # Add current_scene_id and time_of_day columns to campaign_states (ADR-0019)
├── auth/ # Placeholder — Phase 6 authentication (not yet implemented)
└── multiplayer/ # Placeholder — future multiplayer support
frontend/src/
├── App.tsx # Screen router: campaigns → campaign detail → character creation → game session
├── main.tsx # Vite entry point
├── types.ts # Shared TypeScript types: Campaign, CampaignDetail, CharacterState (extended with optional fields: species, speed, initiative_modifier, proficiency_bonus, ability_scores, ability_modifiers, proficiencies, languages, background, spell_slots_max, spells, class_features, inventory, conditions), InventoryItem, SpellEntry, SessionState, WsEvent union (incl. character.updated)
├── constants.ts # SRD constants: classes, species, backgrounds (with eligible abilities), standard array, tone presets, SKILL_ABILITY_MAP (18 SRD skills → ability), CONDITION_SUMMARIES (15 SRD conditions → one-line summary), ABILITY_EMOJIS (6 abilities → emoji)
├── index.css # Tavern design tokens, global resets, blink keyframe
├── hooks/
│ └── useWebSocket.ts # WS lifecycle, reconnect with configurable delay, JSON parsing
└── components/
├── CampaignList.tsx # Screen 1: campaign list + new campaign form (name, tone preset)
├── CampaignDetail.tsx # Screen 2: campaign view, character list, start/rejoin session button
├── CharacterCreation.tsx # Screen 3: 2-step wizard (class/species/background/bonuses → standard array assignment)
├── GameSession.tsx # Screen 4: game loop with sidebar, chat, WS streaming, end session; characterSheetOpen state drives CharacterSheetOverlay; normalizeCharacter() extracts species/languages/background/ability_modifiers/proficiency_bonus from features{} grab-bag and populates class_features with the remainder; applied on session.state and character.updated (merge, not replace)
├── CampaignHeader.tsx # Campaign title, turn count, WS status dot
├── CharacterPanel.tsx # Character card: HP bar, AC, spell slots; click opens CharacterSheetOverlay (also sets active character); hover border affordance
├── CharacterSheetOverlay.tsx # Full-screen modal: emoji+colored ability grid, languages section, background in header, saving throws + skills with colored modifiers, spell slots with pips and N/max count, class_features (not raw features{}), equipment, conditions; getMod() prefers server ability_modifiers over local calculation; read-only; closes on Escape or backdrop click
├── ChatLog.tsx # Turn history with rules_result (monospace) + narrative; streaming cursor
└── ChatInput.tsx # Textarea + Act button; disabled while streaming or disconnected
scripts/
└── setup-repo.sh # GitHub repository configuration (labels, branch protection, issue templates)
Infrastructure/
├── Dockerfile # Multi-stage: Node 20 frontend build → Python 3.12 runtime; serves on :3000
├── docker-compose.yml # Four services: tavern (app), postgres (16), 5e-database (MongoDB), discord-bot
└── .github/
├── workflows/
│ ├── ci.yml # Lint (ruff), type check (mypy), test (pytest) on push/PR
│ ├── claude-review.yml # Claude Code automated PR review
│ └── deploy-docs.yml # MkDocs site deploy to GitHub Pages
└── ISSUE_TEMPLATE/
├── bug_report.yml
├── feature_request.yml
├── srd_correction.yml
└── world_preset.yml
SRD reference data is no longer stored in PostgreSQL. It is served from the
t11z/5e-database MongoDB container via core/srd_data.py.