Commit Graph

15 Commits

Author SHA1 Message Date
Thorsten f9b9785857 Auto-generate an adventure outline when no adventure text is provided
When a player creates a game without pasting their own adventure, the
DM previously improvised turn-by-turn with no overall arc, ending, or
calibrated difficulty. Now the setup-chat interview generates a full
outline (hook, three-to-four-part structure, milestone leveling,
explicit ending condition, GM guidance) as a follow-up LLM call once
genre/length/experience are known, and feeds it through the existing
adventure_text -> RAG ingestion -> "follow strictly" pipeline exactly
like a pasted adventure. Falls back silently to today's improvised
behavior if generation fails.

Verified end-to-end in the browser: setup interview -> outline
generated and previewed -> game created -> RAG chunks ingested ->
has_adventure true -> cascade-cleaned on delete.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:58:42 +02:00
Thorsten cc680dc392 Let the DM reuse an existing character instead of re-creating it
Characters are already owned by the player, not the game, and were
meant to be reusable — but the DM had no way to actually retrieve one.
upsert_character_sheet is write-only, so telling the DM "I'll bring my
character Tillo Eichenherz" led it to create a second, blank
"Tillo Eichenherz" (empty stats, thinner ability text) instead of
reusing the real one, then ask for all six ability scores again as if
nothing existed.

Two fixes:

1. New link_existing_character tool: given a player name and a
   character name, looks it up among that player's own characters
   (ownership enforced — never someone else's) and links it to
   game_participants.character_id, returning the full sheet so the DM
   can continue with real data immediately. System prompt now tells
   the DM to call this the moment a player names a previously-played
   character, instead of asking for stats.

2. run_dm_turn now auto-injects the full sheet of every already-linked
   character into the system prompt every turn (name, stats, combat
   stats, HP, abilities, equipment, background), with an explicit
   "already exists, don't ask again" instruction — this is the actual
   root-cause fix: even a character linked some other way (the
   existing join-with-character_id flow, or a future UI) now stays
   visible to the DM turn after turn instead of only living in
   whatever's left of the truncated chat window.

Also manually repaired the user's real "Schwarze Segel" game, which
had already hit this bug — re-pointed its participant back to the
real Tillo Eichenherz instead of the accidental duplicate (left the
duplicate in place rather than deleting data; it's a normal delete
away via the character-delete feature if unwanted).

Verified live against x.ai: a fresh game where the player said "Ich
nehme meinen alten Charakter Tillo Eichenherz" linked to the real
character on the first message (no duplicate created — confirmed by
row count), and a follow-up question about AC/Strength got the exact
real values (RK 18, Stärke 15) pulled from the injected sheet, not
re-asked or invented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:48:22 +02:00
Thorsten f5c62d27fd Let players roll their own physical dice instead of the DM
Adds the "Ich würfle selbst" checkbox the user asked for, plus the
structural piece that makes it actually reliable: a tool call can't
block mid-turn waiting for a human to go find a d20, so this can't be
a pure client-side toggle — the DM has to think in two turns (ask,
then later recognize the answer), and it's easy for an LLM to lose
track of that across a real gap in the conversation.

game_participants gets self_rolls (the toggle) and pending_roll (what
roll is currently awaited, if any). When self_rolls is on, roll_dice
doesn't touch the RNG — it validates the notation, stores it as
pending_roll, and returns an "awaiting_player_roll" result that tells
the DM to ask for that exact roll and wait, never inventing a number.
At the top of the player's next turn, orchestrator.run_dm_turn reads
back any pending_roll, injects it as an explicit reminder into the
system prompt ("the player's message is probably answering this"),
and clears it — so the DM doesn't have to rely on remembering what it
asked for several messages ago.

Frontend: the checkbox lives in the game header (PATCH
/api/games/{id}/self-rolls, per-participant). A persistent "🎲 Du bist
am Zug" banner shows the pending notation/reason — persistent, not
transient like the typing/rolling indicators, since answering it means
physically finding a die and rolling, which takes real time. It's
seeded from GameRead.my_pending_roll on load so it survives a page
reload, and only clears when the player actually sends their next
message (not when the DM's message arrives, which would make it
disappear before there was time to read it).

Verified live end-to-end against x.ai in a real session: toggled the
setting, asked for something requiring a check, got "würfle bitte
1d20+1" instead of an auto-rolled result, confirmed the banner
survived a full page reload, replied "14", and the DM used that
number directly ("Du hast 14 gewürfelt — das reicht") without
re-rolling — with pending_roll correctly cleared in the DB afterward.
Also confirmed per-participant scoping (a second participant's flag
stayed off) and that toggling back off works cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:59:48 +02:00
Thorsten 7bbdee868a DM speaks first automatically; stop asking who's playing
Two issues from live testing: (1) the DM only ever spoke after a
player sent something, so starting a fresh game meant typing a
throwaway message like "start" just to get it going; (2) Session 0
asked for player headcount and names even though that's already
known — everyone who wants in has already logged in and joined via
the invite link/code, tracked in game_participants.

Fix 1: ws_game.py now runs a DM turn immediately on the first-ever
WebSocket connection to a game with zero messages, before entering
the normal receive loop — guarded by the game's existing lock plus a
"does this game have messages yet" check, so two tabs opening around
the same time can't produce two greetings. Extracted the
run-turn-then-broadcast logic (typing/rolling/game_ended/message)
into _run_and_broadcast_dm_turn, shared between the kickoff and the
normal per-message path instead of duplicated.

Fix 2: orchestrator.run_dm_turn() now queries game_participants and
injects the actual joined-player names into the system prompt
alongside the game's name/description. dm_system_prompt.txt drops the
"ask for player count and names" question entirely and instructs the
DM to greet whoever's already listed instead, since more players can
join mid-session and get recognized automatically once they speak.

Verified live: a freshly created game showed "Dungeon Master schreibt
…" and then a real opening message addressed to "Thorsten" by name
before any player message existed, skipped straight to the character
question, and reloading the page didn't produce a duplicate greeting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:04:42 +02:00
Thorsten 07665595a2 Move adventure-text field to the front of game setup
The adventure-text field was buried at the bottom of the proposal
form, after the whole interview — the user wanted it to be the very
first thing shown when clicking "Neues Spiel", since pasting an
adventure should drive the proposal rather than follow it.

GameSetupChat now opens on an "Eigenes Abenteuer (optional)" step:
paste the text (or skip) before the wizard chat starts at all. The
setup-chat backend call now receives that text and, when present,
infers genre/world/scope from its opening directly instead of asking
about them — the proposed name/description come out already specific
to the pasted adventure. Removed the now-redundant adventure textarea
from the proposal form (it's collected once, upfront).

Verified live: pasting a short pirate one-shot produced "Klingt nach
einem klassischen Piraten-One-Shot rund um Port Royal – das Genre und
den Umfang leite ich direkt daraus ab", skipping straight to the one
remaining question (experience level), and the final proposal named
Isabella Steel and Blackfin's fortress directly from the text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 13:35:45 +02:00
Thorsten b77f066259 Add per-game adventure text with RAG-based retrieval
Players can now paste the full text of a freely available adventure
when creating a game, and the DM will follow it instead of inventing
its own plot/NPCs/locations. Reuses the existing rulebook RAG
pipeline (chunking, local embedding) rather than injecting the raw
text into every turn, since real adventure modules range from a few
pages to hundreds — far beyond what fits in a prompt.

- New adventure_chunks table (game-scoped, unlike the global
  rulebook_chunks) + Game.adventure_text storing the raw source.
- ingest_adventure_text() chunks/embeds synchronously during
  POST /api/games when adventure_text is non-empty.
- build_adventure_rag_block() retrieves by similarity to the latest
  player message, scoped to game_id — plus always includes chunk 0
  (the adventure's opening) regardless of the query, since early
  Session-0 messages rarely resemble the adventure's actual hook and
  pure similarity search could miss the beginning entirely.
- System prompt instructs the DM to follow provided adventure
  excerpts strictly, deviating only when players clearly go off-script.
- Frontend: optional adventure-text field in the game creation form,
  a has_adventure flag surfaced as a small badge on the game card and
  session header.

Verified with an isolated two-game test that chunks/retrieval never
leak across games (the critical failure mode here), and live against
x.ai: a custom-written one-shot's specific NPCs, location, and hook
appeared verbatim in the DM's opening scene instead of invented ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 13:13:59 +02:00
Thorsten a8763e175f Add a hard trigger for world-state summarization
update_world_state relied entirely on the DM remembering to call it —
no code checked whether it actually happened. Add refresh_if_due(),
called at the top of every DM turn: it counts messages since the
summary was last touched (by the tool or a prior auto-refresh), and
once AUTO_REFRESH_MESSAGE_THRESHOLD (10) is crossed, forces a
dedicated summarization call — a separate, tool-free completion whose
only job is to fold the new messages into the existing summary — and
persists the result directly, without waiting on the main DM turn's
discretion.

The trigger condition (message count) is deterministic; only the
summary text itself still needs an LLM, which is unavoidable for a
task that requires understanding, not just counting.

context.py gained count_messages_since() and build_transcript_text()
(an uncapped, since-filtered plain-text transcript for the
summarizer, as opposed to build_context()'s char-budget-capped chat
list for the main DM call) — factored out of the same underlying
message-labeling logic to avoid duplicating the name-resolution joins.

Verified with a fake LLM client: confirmed the trigger fires exactly
at the threshold and not before, resets after firing, and that the
second refresh's prompt carries the prior summary forward while only
including messages since that refresh (not the whole history again).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:12:10 +02:00
Thorsten 81eaf667cd Implement the world-state summarization from the Phase 2 plan
Context was pure full-text replay of every message, capped at 40k
chars with oldest-dropped-first — no summarization/world-state layer,
as called out as still-missing in an earlier conversation.

Add a new update_world_state DM tool that persists a compact,
DM-authored recap (key NPCs, current location, open plot threads,
party/inventory state) to a new world_state table, one row per game.
It's injected into the system prompt every turn, ahead of the raw
message window. The DM is instructed to call it regularly — at every
scene change or major event, not just at the end — sending the full
current picture each time (matches the replace-not-merge pattern
already used for combat_stats/abilities/equipment).

Since the summary now backs up everything older, shrink the raw
message window from 40k to 16k chars — it's recent continuity now,
not the sole memory of the session. Full history remains available
via "Volltext laden" regardless, since that reads the messages table
directly rather than through this context builder.

Simplified from the original plan sketch (state JSONB) to a single
free-text summary field — natural-language recaps are something an
LLM authors well; a structured world model would need a schema the
DM would have to conform to for no real benefit here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:59:57 +02:00
Thorsten 2b2af058b8 Add monster HP tracking, separate from the game-ending logic
Only the player character's HP was tracked before, so a monster's
death was never detected — only the DM's own (unenforced) narration
of it. Add a new update_monster_hp tool the DM calls whenever an
NPC/monster is introduced or takes damage, stored as DM-only
bookkeeping on Game (never exposed via GameRead, since HP/AC of NPCs
is meant to stay secret from players). The tool reports back
"defeated": true once HP drops to 0 so the DM can react to it — but
unlike a character dying, this does NOT auto-end the session; whether
a monster's death should end the game is a separate decision left to
the DM's own end_game call, or to a future rule.

Building an isolated test for this caught a real bug along the way:
the update mutated the matched monster's dict in place before
reassigning the list, which made SQLAlchemy's old-vs-new JSONB
comparison see identical content and silently skip writing the
change. Fixed by building fresh dicts instead of mutating shared ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:50:39 +02:00
Thorsten 8da834c5c1 Add game end status with an end_game DM tool and automatic 0-HP detection
Games now have a status/ended_reason pair. The DM can call the new
end_game tool when the story reaches a real conclusion (victory,
defeat, or a resolved one-shot), and the backend independently ends
the game whenever a character's HP drops to 0 or below, regardless of
whether the DM narrates it. The frontend shows a banner and a
"Beendet" badge once a game ends.

HP moves from the freeform combat_stats bag into dedicated
current_hp/max_hp columns on Character, since reliably detecting 0 HP
requires a real integer rather than parsing strings like "3/10" out of
an LLM-authored key/value dict. Also fixed combat_stats to fully
replace on each upsert instead of merging, matching its documented
contract — the merge was leaving stale keys (old HP/TP text) behind
after the model stopped sending them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:44:46 +02:00
Thorsten 2b99451fdf Add dice-roll animation and tighten attack-roll prompt rules
Broadcast a "rolling" WS event when the DM invokes roll_dice, and show
a cycling dice-emoji animation with the roll notation in the chat while
waiting for the result, instead of just the generic typing indicator.

Also split the dice-roll prompt rule into skill/save checks (DC shown
to the player) vs. attack rolls (AC stays secret but must still be
labeled as a roll), and made the "always roll yourself, never ask the
player to roll" instruction unconditional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:28:37 +02:00
Thorsten 624644619c Add character detail, markdown rendering, wider layout, and LLM provider switch
- Character sheets gain structured combat_stats, abilities (name +
  plain-language explanation), and equipment fields, populated by the
  upsert_character_sheet tool; the detail page renders them as their
  own sections instead of one prose block, with a player-card-style
  attribute grid (full ability names + modifiers).
- Fix double-escaped unicode occasionally left in tool-call JSON
  arguments (a model quirk) and stop the app's own json.dumps calls
  from re-introducing it (ensure_ascii=False).
- Render chat messages as markdown (react-markdown + Tailwind
  typography) instead of raw text, and widen the games/characters/
  chat layout to use more of the screen on desktop.
- Sharpen the DM system prompt: state the ability + DC for each
  offered action option before the player commits, and narrate rolls
  as DC → result → pass/fail before the story consequence; also stop
  re-asking Session-0 questions already answered in the game's
  name/description.
- Make the DM's LLM provider configurable (xai default, lmstudio for
  a local OpenAI-compatible server via host.docker.internal) instead
  of hardcoded to x.ai.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:18:15 +02:00
Thorsten 9d378a1eb0 Stop DM from re-asking setup questions already answered in the wizard
run_dm_turn now loads the game's name/description into the system
prompt on every turn, and the Session-0 instructions tell the DM to
check those framing details first and skip re-asking genre/world,
scope, or experience level when they're already covered there —
only the still-open points (player count, characters, rule-depth
preference, content boundaries) get asked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 20:14:22 +02:00
Thorsten 419f5e3a89 Add rulebook RAG pipeline and LLM-driven game setup wizard
RAG: switch Postgres to pgvector, chunk and embed the three D&D
rulebooks locally via sentence-transformers, and retrieve relevant
excerpts per DM turn (query = latest player message) to ground the
system prompt. Retrieval runs off the event loop and is capped by a
relevance threshold and a max character budget so it can't blow up
context size or cost.

Game setup wizard: creating a game now opens a short chat where the
DM asks about genre, length, and the player's experience level, then
proposes a name and description via a tool call. The player can edit
both before creating the game. Stateless endpoint — the frontend
carries the conversation, no DB needed since the game doesn't exist
yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 20:03:05 +02:00
Thorsten f37dc9fa76 Add Phase 1 MVP: D&D text-adventure server
FastAPI + Postgres backend with fastapi-users auth, games/characters
CRUD, and a WebSocket chat endpoint where an x.ai Grok DM narrates
play, rolls dice, and maintains character sheets via tool calls.
React + Tailwind frontend covers registration/login, game list and
creation, participation-code join flow, character viewer, and a
real-time chat session view. Docker Compose skeleton (Postgres +
backend) included; Caddy/frontend container wiring is deferred to
the deploy milestone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 18:43:08 +02:00