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:
@@ -0,0 +1,34 @@
|
||||
"""add self_rolls and pending_roll to game_participants
|
||||
|
||||
Revision ID: a3c7e9f1b204
|
||||
Revises: f1a2b3c4d5e6
|
||||
Create Date: 2026-09-03 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a3c7e9f1b204'
|
||||
down_revision: Union[str, None] = 'f1a2b3c4d5e6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'game_participants',
|
||||
sa.Column('self_rolls', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
'game_participants',
|
||||
sa.Column('pending_roll', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('game_participants', 'pending_roll')
|
||||
op.drop_column('game_participants', 'self_rolls')
|
||||
@@ -14,7 +14,7 @@ from app.models.game import Game, GameParticipant
|
||||
from app.models.message import Message
|
||||
from app.models.user import User
|
||||
from app.rag.adventure_ingestion import ingest_adventure_text
|
||||
from app.schemas.game import GameCreate, GameJoin, GameRead
|
||||
from app.schemas.game import GameCreate, GameJoin, GameRead, SelfRollsUpdate
|
||||
from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse
|
||||
from app.schemas.message import MessageRead
|
||||
from app.ws_tickets import issue_ticket
|
||||
@@ -43,17 +43,28 @@ async def _serialize_games(
|
||||
|
||||
participant_rows = (
|
||||
await session.execute(
|
||||
select(GameParticipant.game_id, GameParticipant.user_id, GameParticipant.character_id, User.name)
|
||||
select(
|
||||
GameParticipant.game_id,
|
||||
GameParticipant.user_id,
|
||||
GameParticipant.character_id,
|
||||
GameParticipant.self_rolls,
|
||||
GameParticipant.pending_roll,
|
||||
User.name,
|
||||
)
|
||||
.join(User, User.id == GameParticipant.user_id)
|
||||
.where(GameParticipant.game_id.in_(game_ids))
|
||||
)
|
||||
).all()
|
||||
participants_by_game: dict[uuid.UUID, list[str]] = {}
|
||||
viewer_participation: dict[uuid.UUID, uuid.UUID | None] = {}
|
||||
for game_id, participant_user_id, character_id, name in participant_rows:
|
||||
viewer_self_rolls: dict[uuid.UUID, bool] = {}
|
||||
viewer_pending_roll: dict[uuid.UUID, dict | None] = {}
|
||||
for game_id, participant_user_id, character_id, self_rolls, pending_roll, name in participant_rows:
|
||||
participants_by_game.setdefault(game_id, []).append(name)
|
||||
if participant_user_id == viewer_id:
|
||||
viewer_participation[game_id] = character_id
|
||||
viewer_self_rolls[game_id] = self_rolls
|
||||
viewer_pending_roll[game_id] = pending_roll
|
||||
|
||||
result = []
|
||||
for game in games:
|
||||
@@ -75,6 +86,8 @@ async def _serialize_games(
|
||||
status=game.status,
|
||||
ended_reason=game.ended_reason,
|
||||
has_adventure=bool(game.adventure_text.strip()),
|
||||
my_self_rolls=viewer_self_rolls.get(game.id, False),
|
||||
my_pending_roll=viewer_pending_roll.get(game.id),
|
||||
)
|
||||
)
|
||||
return result
|
||||
@@ -277,6 +290,24 @@ async def join_game(
|
||||
return serialized[0]
|
||||
|
||||
|
||||
@router.patch("/{game_id}/self-rolls", response_model=GameRead)
|
||||
async def update_self_rolls(
|
||||
game_id: uuid.UUID,
|
||||
payload: SelfRollsUpdate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
) -> GameRead:
|
||||
participant = await _require_participant(session, game_id, user.id)
|
||||
participant.self_rolls = payload.self_rolls
|
||||
if not payload.self_rolls:
|
||||
participant.pending_roll = None
|
||||
await session.commit()
|
||||
|
||||
game = await session.get(Game, game_id)
|
||||
serialized = await _serialize_games(session, [game], user.id)
|
||||
return serialized[0]
|
||||
|
||||
|
||||
@router.post("/{game_id}/ws-ticket")
|
||||
async def create_ws_ticket(
|
||||
game_id: uuid.UUID,
|
||||
|
||||
@@ -74,13 +74,16 @@ async def _serialize_message(session, message: Message) -> dict:
|
||||
|
||||
|
||||
async def _run_and_broadcast_dm_turn(
|
||||
session: AsyncSession, game_id: uuid.UUID, latest_player_message: str
|
||||
session: AsyncSession, game_id: uuid.UUID, latest_player_message: str, user_id: uuid.UUID | None = None
|
||||
) -> None:
|
||||
await manager.broadcast(game_id, {"type": "typing"})
|
||||
|
||||
async def _broadcast_roll(notation: str) -> None:
|
||||
await manager.broadcast(game_id, {"type": "rolling", "notation": notation})
|
||||
|
||||
async def _broadcast_awaiting_roll(notation: str, reason: str) -> None:
|
||||
await manager.broadcast(game_id, {"type": "awaiting_roll", "notation": notation, "reason": reason})
|
||||
|
||||
async def _broadcast_game_ended(reason: str) -> None:
|
||||
await manager.broadcast(game_id, {"type": "game_ended", "reason": reason})
|
||||
|
||||
@@ -89,7 +92,9 @@ async def _run_and_broadcast_dm_turn(
|
||||
session,
|
||||
game_id,
|
||||
latest_player_message=latest_player_message,
|
||||
user_id=user_id,
|
||||
on_roll=_broadcast_roll,
|
||||
on_awaiting_roll=_broadcast_awaiting_roll,
|
||||
on_game_ended=_broadcast_game_ended,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -123,7 +128,7 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
|
||||
await session.execute(select(Message.id).where(Message.game_id == game_id).limit(1))
|
||||
).first() is not None
|
||||
if not has_messages:
|
||||
await _run_and_broadcast_dm_turn(session, game_id, KICKOFF_QUERY)
|
||||
await _run_and_broadcast_dm_turn(session, game_id, KICKOFF_QUERY, user_id=user_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -164,7 +169,7 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
|
||||
{"type": "message", "message": await _serialize_message(session, player_message)},
|
||||
)
|
||||
|
||||
await _run_and_broadcast_dm_turn(session, game_id, content)
|
||||
await _run_and_broadcast_dm_turn(session, game_id, content, user_id=user_id)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
|
||||
@@ -54,6 +54,34 @@ async def _execute_tool_call(
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
async def _request_player_roll(
|
||||
session: AsyncSession, participant: GameParticipant, notation: str, reason: str
|
||||
) -> dict:
|
||||
"""Handles a roll_dice call for a player who rolls their own physical dice: validates the
|
||||
notation without touching the RNG, records it as the participant's pending roll (picked up
|
||||
and cleared at the top of their next turn), and returns a tool result that tells the DM to
|
||||
ask for it and wait instead of rolling itself."""
|
||||
try:
|
||||
dice.parse_notation(notation)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
participant.pending_roll = {"notation": notation, "reason": reason}
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"awaiting_player_roll": True,
|
||||
"notation": notation,
|
||||
"reason": reason,
|
||||
"note": (
|
||||
f"Der Spieler würfelt selbst mit physischen Würfeln. Nenne ihm klar und knapp, was er "
|
||||
f"würfeln soll ({notation}" + (f", {reason}" if reason else "") + "), und warte auf "
|
||||
"seine Antwort mit dem Ergebnis in einer künftigen Nachricht — würfle nicht selbst und "
|
||||
"erfinde kein Ergebnis."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _check_hp_game_over(session: AsyncSession, game_id: uuid.UUID, result: dict) -> str | None:
|
||||
"""After an upsert_character_sheet call, auto-ends the game if the character it touched
|
||||
dropped to 0 HP or below. Returns the end reason if the game was just ended, else None."""
|
||||
@@ -76,12 +104,38 @@ async def run_dm_turn(
|
||||
session: AsyncSession,
|
||||
game_id: uuid.UUID,
|
||||
latest_player_message: str | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
on_roll: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_awaiting_roll: Callable[[str, str], Awaitable[None]] | None = None,
|
||||
on_game_ended: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> Message:
|
||||
client = get_llm_client()
|
||||
system_prompt = get_dm_system_prompt()
|
||||
|
||||
participant: GameParticipant | None = None
|
||||
if user_id is not None:
|
||||
participant = (
|
||||
await session.execute(
|
||||
select(GameParticipant).where(
|
||||
GameParticipant.game_id == game_id, GameParticipant.user_id == user_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if participant is not None and participant.pending_roll:
|
||||
pending = participant.pending_roll
|
||||
system_prompt += (
|
||||
f"\n\nOffene Würfelanfrage: Du hast {pending.get('notation')} "
|
||||
f"({pending.get('reason') or 'ohne Angabe'}) angefragt, der Spieler würfelt selbst. "
|
||||
"Seine gerade eingegangene Nachricht enthält vermutlich das Ergebnis dieses Wurfs — "
|
||||
"nimm die genannte Zahl direkt als Wurfergebnis, würfle NICHT selbst und frag nicht "
|
||||
"erneut danach, außer die Nachricht beantwortet die Anfrage erkennbar nicht."
|
||||
)
|
||||
# Consumed either way — if the message didn't actually answer it, the DM asking again
|
||||
# via roll_dice creates a fresh pending_roll for next time.
|
||||
participant.pending_roll = None
|
||||
await session.commit()
|
||||
|
||||
game = await session.get(Game, game_id)
|
||||
if game is not None:
|
||||
system_prompt = (
|
||||
@@ -145,6 +199,14 @@ async def run_dm_turn(
|
||||
|
||||
for tool_call in message.tool_calls:
|
||||
tool_input = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
|
||||
|
||||
if tool_call.function.name == "roll_dice" and participant is not None and participant.self_rolls:
|
||||
notation = tool_input.get("notation", "")
|
||||
reason = tool_input.get("reason", "")
|
||||
result = await _request_player_roll(session, participant, notation, reason)
|
||||
if "error" not in result and on_awaiting_roll is not None:
|
||||
await on_awaiting_roll(notation, reason)
|
||||
else:
|
||||
if tool_call.function.name == "roll_dice" and on_roll is not None:
|
||||
await on_roll(tool_input.get("notation", ""))
|
||||
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
||||
|
||||
@@ -19,7 +19,7 @@ Phase 2: Das Spiel leiten
|
||||
- Fertigkeits-/Rettungswurf gegen einen bekannten Schwierigkeitsgrad: nenne Fähigkeit und SG direkt (z. B. „B) Die Läden zuwerfen (Stärke, SG 12)"), damit der Spieler seine Chancen vorher einschätzen kann.
|
||||
- Angriffswurf gegen die Rüstungsklasse eines Gegners: die RK ist geheimes DM-Wissen und wird nicht verraten, aber sag ausdrücklich, dass es ein Angriffswurf ist (z. B. „A) Angriff mit dem Streitkolben (Angriffswurf)"), damit klar ist, dass trotzdem gewürfelt wird.
|
||||
- Keine Probe nötig (z. B. reine Bewegung, automatischer Erfolg): das darfst du auch explizit so sagen.
|
||||
* Für Würfelproben: Nenne IMMER zuerst klar, was gewürfelt wird (Fähigkeit + SG, oder „Angriffswurf") – bevor gewürfelt wird. Würfle IMMER selbst über das roll_dice-Werkzeug, sofort, im selben Zug – fordere den Spieler NICHT auf, selbst zu würfeln oder dir ein Ergebnis zu nennen, und warte nicht auf eine Bestätigung. Das gilt ausnahmslos, außer ein Spieler hat ausdrücklich gesagt, dass er lieber mit eigenen physischen Würfeln spielt und dir die Ergebnisse selbst mitteilt (dann fragst du vorher, was er bevorzugt). Ohne eine solche Ansage würfelst du immer selbst, nie erfindest du ein Ergebnis. Erkläre das Ergebnis danach ausführlich in dieser Reihenfolge: (1) wogegen gewürfelt wurde (SG bei Proben; bei Angriffswürfen ohne die RK zu nennen), (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht/trifft oder nicht – erst danach folgt die erzählerische Konsequenz.
|
||||
* Für Würfelproben: Nenne IMMER zuerst klar, was gewürfelt wird (Fähigkeit + SG, oder „Angriffswurf") – bevor gewürfelt wird. Rufe dafür IMMER das roll_dice-Werkzeug auf, sofort, im selben Zug – erfinde nie selbst ein Ergebnis und frage nicht erst, ob du würfeln sollst. Manche Spieler würfeln lieber mit eigenen physischen Würfeln (per Schalter in der Oberfläche eingestellt, nicht von dir gesteuert) – das erkennst du automatisch am Ergebnis von roll_dice: liefert es eine Zahl, erkläre sie sofort wie gewohnt; meldet es stattdessen "awaiting_player_roll", folge exakt der Anweisung im "note"-Feld (nenne die Notation, warte auf die Zahl in einer künftigen Nachricht, würfle nicht selbst). Erkläre ein echtes Ergebnis danach ausführlich in dieser Reihenfolge: (1) wogegen gewürfelt wurde (SG bei Proben; bei Angriffswürfen ohne die RK zu nennen), (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht/trifft oder nicht – erst danach folgt die erzählerische Konsequenz.
|
||||
* Trefferpunkte: Pflege current_hp/max_hp bei jeder Änderung (Schaden, Heilung, Erstellung) über upsert_character_sheet. Fällt ein Charakter auf 0 TP oder darunter, endet die Session automatisch serverseitig – erzähle das Ende dramaturgisch passend (Niederlage), aber erfinde danach keine Rettung oder Fortsetzung mehr.
|
||||
* Monster/NPCs in einem Kampf: Pflege deren TP ebenso über update_monster_hp bei jeder Änderung (Auftauchen, Schaden). Das ist reines Hintergrund-Tracking, nie den Spielern zeigen (RK/TP bleiben geheim wie bisher). Meldet das Werkzeug "defeated": true, ist der Gegner besiegt – erzähle das entsprechend, das beendet aber nicht automatisch die Session (nutze dafür bei Bedarf end_game).
|
||||
* Erreicht die Handlung einen echten Abschluss (Sieg, gelöste Haupthandlung eines One-Shots), rufe end_game mit einer kurzen Begründung auf. Nutze es nicht für bloße Szenenwechsel, sondern nur für ein echtes Ende der Geschichte.
|
||||
|
||||
@@ -10,6 +10,8 @@ TOOL_SCHEMA = {
|
||||
"description": (
|
||||
"Roll dice using standard D&D notation (e.g. '1d20+5', '2d6', 'd8-1'). "
|
||||
"Always use this tool for any dice roll that affects the game — never invent or guess a result yourself. "
|
||||
"If the player rolls their own physical dice, this tool won't return a number — it tells you to ask for "
|
||||
"the roll instead; see the tool result for exact wording."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -28,7 +30,10 @@ TOOL_SCHEMA = {
|
||||
}
|
||||
|
||||
|
||||
def roll(notation: str) -> dict:
|
||||
def parse_notation(notation: str) -> tuple[int, int, int]:
|
||||
"""Validates and parses dice notation into (count, sides, modifier). Raises ValueError on
|
||||
anything malformed or out of range — used both by roll() and to validate a notation when
|
||||
the player is rolling it themselves, before we ever touch the RNG."""
|
||||
match = _NOTATION_RE.match(notation)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid dice notation: {notation!r}")
|
||||
@@ -44,6 +49,12 @@ def roll(notation: str) -> dict:
|
||||
if sign == "-":
|
||||
modifier = -modifier
|
||||
|
||||
return count, sides, modifier
|
||||
|
||||
|
||||
def roll(notation: str) -> dict:
|
||||
count, sides, modifier = parse_notation(notation)
|
||||
|
||||
rolls = [_rng.randint(1, sides) for _ in range(count)]
|
||||
total = sum(rolls) + modifier
|
||||
|
||||
|
||||
@@ -51,4 +51,10 @@ class GameParticipant(Base):
|
||||
character_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("characters.id"), nullable=True
|
||||
)
|
||||
# Player preference: roll their own physical dice and report the result, instead of the
|
||||
# DM rolling for them via roll_dice.
|
||||
self_rolls: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
# Set by roll_dice when self_rolls is on: {"notation": ..., "reason": ...} for the roll the
|
||||
# DM is waiting on this player to report. Cleared once the next turn consumes it.
|
||||
pending_roll: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
joined_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
||||
|
||||
@@ -25,8 +25,14 @@ class GameRead(BaseModel):
|
||||
status: str
|
||||
ended_reason: str | None
|
||||
has_adventure: bool
|
||||
my_self_rolls: bool
|
||||
my_pending_roll: dict | None
|
||||
|
||||
|
||||
class GameJoin(BaseModel):
|
||||
participation_code: str
|
||||
character_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SelfRollsUpdate(BaseModel):
|
||||
self_rolls: bool
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface Game {
|
||||
status: "active" | "ended";
|
||||
ended_reason: string | null;
|
||||
has_adventure: boolean;
|
||||
my_self_rolls: boolean;
|
||||
my_pending_roll: { notation: string; reason: string } | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -62,6 +64,12 @@ export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`);
|
||||
|
||||
export const deleteGame = (id: string) => apiFetch<void>(`/api/games/${id}`, { method: "DELETE" });
|
||||
|
||||
export const setSelfRolls = (id: string, selfRolls: boolean) =>
|
||||
apiFetch<Game>(`/api/games/${id}/self-rolls`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ self_rolls: selfRolls }),
|
||||
});
|
||||
|
||||
export const joinGame = (id: string, participation_code: string, character_id?: string) =>
|
||||
apiFetch<Game>(`/api/games/${id}/join`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Message } from "../api/games";
|
||||
import { AwaitingRoll } from "../hooks/useGameSocket";
|
||||
import MarkdownContent from "./MarkdownContent";
|
||||
|
||||
const DICE_FACES = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"];
|
||||
@@ -37,16 +38,18 @@ export default function TranscriptPane({
|
||||
messages,
|
||||
typing,
|
||||
rolling,
|
||||
awaitingRoll,
|
||||
}: {
|
||||
messages: Message[];
|
||||
typing?: boolean;
|
||||
rolling?: string | null;
|
||||
awaitingRoll?: AwaitingRoll | null;
|
||||
}) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [messages, typing, rolling]);
|
||||
}, [messages, typing, rolling, awaitingRoll]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
|
||||
@@ -66,7 +69,16 @@ export default function TranscriptPane({
|
||||
<MarkdownContent content={m.content} />
|
||||
</div>
|
||||
))}
|
||||
{rolling != null ? (
|
||||
{awaitingRoll ? (
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3">
|
||||
<span className="font-semibold text-amber-400">🎲 Du bist am Zug — würfle:</span>{" "}
|
||||
<span className="font-mono text-slate-100">{awaitingRoll.notation}</span>
|
||||
{awaitingRoll.reason && <span className="text-sm text-slate-400"> ({awaitingRoll.reason})</span>}
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Wirf deinen Würfel und schreib das Ergebnis als Nachricht.
|
||||
</p>
|
||||
</div>
|
||||
) : rolling != null ? (
|
||||
<DiceRollAnimation notation={rolling} />
|
||||
) : (
|
||||
typing && (
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useEffect, useRef } from "react";
|
||||
import { apiFetch } from "../api/client";
|
||||
import { Message } from "../api/games";
|
||||
|
||||
export interface AwaitingRoll {
|
||||
notation: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function useGameSocket(
|
||||
gameId: string | undefined,
|
||||
onMessage: (message: Message) => void,
|
||||
@@ -10,6 +15,7 @@ export function useGameSocket(
|
||||
onTyping: () => void,
|
||||
onRolling: (notation: string) => void,
|
||||
onGameEnded: (reason: string) => void,
|
||||
onAwaitingRoll: (roll: AwaitingRoll) => void,
|
||||
) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
@@ -22,6 +28,8 @@ export function useGameSocket(
|
||||
onRollingRef.current = onRolling;
|
||||
const onGameEndedRef = useRef(onGameEnded);
|
||||
onGameEndedRef.current = onGameEnded;
|
||||
const onAwaitingRollRef = useRef(onAwaitingRoll);
|
||||
onAwaitingRollRef.current = onAwaitingRoll;
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameId) return;
|
||||
@@ -43,6 +51,8 @@ export function useGameSocket(
|
||||
else if (data.type === "typing") onTypingRef.current();
|
||||
else if (data.type === "rolling") onRollingRef.current(data.notation ?? "");
|
||||
else if (data.type === "game_ended") onGameEndedRef.current(data.reason ?? "");
|
||||
else if (data.type === "awaiting_roll")
|
||||
onAwaitingRollRef.current({ notation: data.notation ?? "", reason: data.reason ?? "" });
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose) onErrorRef.current("connection_lost");
|
||||
|
||||
@@ -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