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>
This commit is contained in:
@@ -2,10 +2,18 @@ import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { listCharacters, Character } from "../api/characters";
|
||||
import { Game, getFullMessages, getGame, getRecentMessages, joinGame, Message } from "../api/games";
|
||||
import {
|
||||
Game,
|
||||
getFullMessages,
|
||||
getGame,
|
||||
getRecentMessages,
|
||||
joinGame,
|
||||
Message,
|
||||
setSelfRolls,
|
||||
} from "../api/games";
|
||||
import SendMessageForm from "../components/SendMessageForm";
|
||||
import TranscriptPane from "../components/TranscriptPane";
|
||||
import { useGameSocket } from "../hooks/useGameSocket";
|
||||
import { AwaitingRoll, useGameSocket } from "../hooks/useGameSocket";
|
||||
|
||||
export default function GameSession() {
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
@@ -23,12 +31,15 @@ export default function GameSession() {
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [rollingNotation, setRollingNotation] = useState<string | null>(null);
|
||||
const [endedReason, setEndedReason] = useState<string | null>(null);
|
||||
const [awaitingRoll, setAwaitingRoll] = useState<AwaitingRoll | null>(null);
|
||||
const [selfRollsSaving, setSelfRollsSaving] = useState(false);
|
||||
|
||||
async function loadGame() {
|
||||
if (!gameId) return;
|
||||
const g = await getGame(gameId);
|
||||
setGame(g);
|
||||
setEndedReason(g.status === "ended" ? g.ended_reason : null);
|
||||
setAwaitingRoll(g.my_pending_roll ?? null);
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -65,6 +76,7 @@ export default function GameSession() {
|
||||
try {
|
||||
const g = await joinGame(gameId, joinCode, selectedCharacterId || undefined);
|
||||
setGame(g);
|
||||
setAwaitingRoll(g.my_pending_roll ?? null);
|
||||
await loadRecent();
|
||||
} catch {
|
||||
setJoinError("Beitritt fehlgeschlagen — Teilnahmecode prüfen.");
|
||||
@@ -106,9 +118,23 @@ export default function GameSession() {
|
||||
() => setIsTyping(true),
|
||||
(notation) => setRollingNotation(notation),
|
||||
(reason) => setEndedReason(reason),
|
||||
(roll) => setAwaitingRoll(roll),
|
||||
);
|
||||
|
||||
async function handleToggleSelfRolls(checked: boolean) {
|
||||
if (!gameId) return;
|
||||
setSelfRollsSaving(true);
|
||||
try {
|
||||
const updated = await setSelfRolls(gameId, checked);
|
||||
setGame(updated);
|
||||
if (!checked) setAwaitingRoll(null);
|
||||
} finally {
|
||||
setSelfRollsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(content: string) {
|
||||
setAwaitingRoll(null);
|
||||
send(content);
|
||||
}
|
||||
|
||||
@@ -168,7 +194,17 @@ export default function GameSession() {
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500">{game.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-sm text-slate-400" title="Statt dass der Dungeon Master für dich würfelt, sagt er dir nur noch, was zu würfeln ist — du würfelst selbst und teilst das Ergebnis mit.">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={game.my_self_rolls}
|
||||
disabled={selfRollsSaving}
|
||||
onChange={(e) => handleToggleSelfRolls(e.target.checked)}
|
||||
className="h-4 w-4 accent-amber-500"
|
||||
/>
|
||||
Ich würfle selbst
|
||||
</label>
|
||||
{game.my_character_id && (
|
||||
<Link
|
||||
to={`/characters/${game.my_character_id}?back=${gameId}`}
|
||||
@@ -193,7 +229,12 @@ export default function GameSession() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
|
||||
<TranscriptPane messages={messages} typing={isTyping} rolling={rollingNotation} />
|
||||
<TranscriptPane
|
||||
messages={messages}
|
||||
typing={isTyping}
|
||||
rolling={rollingNotation}
|
||||
awaitingRoll={awaitingRoll}
|
||||
/>
|
||||
<SendMessageForm onSend={handleSend} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user