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>
This commit is contained in:
@@ -120,8 +120,13 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
|
|||||||
)
|
)
|
||||||
await manager.broadcast(game_id, {"type": "typing"})
|
await manager.broadcast(game_id, {"type": "typing"})
|
||||||
|
|
||||||
|
async def _broadcast_roll(notation: str) -> None:
|
||||||
|
await manager.broadcast(game_id, {"type": "rolling", "notation": notation})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dm_message = await run_dm_turn(session, game_id, latest_player_message=content)
|
dm_message = await run_dm_turn(
|
||||||
|
session, game_id, latest_player_message=content, on_roll=_broadcast_roll
|
||||||
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("DM turn failed for game %s", game_id)
|
logger.exception("DM turn failed for game %s", game_id)
|
||||||
await manager.broadcast(game_id, {"type": "error", "detail": "dm_turn_failed"})
|
await manager.broadcast(game_id, {"type": "error", "detail": "dm_turn_failed"})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -42,7 +43,10 @@ async def _execute_tool_call(
|
|||||||
|
|
||||||
|
|
||||||
async def run_dm_turn(
|
async def run_dm_turn(
|
||||||
session: AsyncSession, game_id: uuid.UUID, latest_player_message: str | None = None
|
session: AsyncSession,
|
||||||
|
game_id: uuid.UUID,
|
||||||
|
latest_player_message: str | None = None,
|
||||||
|
on_roll: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> Message:
|
) -> Message:
|
||||||
client = get_llm_client()
|
client = get_llm_client()
|
||||||
system_prompt = get_dm_system_prompt()
|
system_prompt = get_dm_system_prompt()
|
||||||
@@ -83,6 +87,8 @@ async def run_dm_turn(
|
|||||||
|
|
||||||
for tool_call in message.tool_calls:
|
for tool_call in message.tool_calls:
|
||||||
tool_input = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
|
tool_input = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
|
||||||
|
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)
|
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ Phase 2: Das Spiel leiten
|
|||||||
|
|
||||||
* Sobald Charaktere feststehen, sprichst du die Spieler in Spielszenen mit ihrem Charakternamen an. Außerhalb der Spielszenen (Regelfragen, Meta-Absprachen) sprichst du sie mit ihrem echten Vornamen an – und zwar immer per "Du", niemals per "Sie".
|
* Sobald Charaktere feststehen, sprichst du die Spieler in Spielszenen mit ihrem Charakternamen an. Außerhalb der Spielszenen (Regelfragen, Meta-Absprachen) sprichst du sie mit ihrem echten Vornamen an – und zwar immer per "Du", niemals per "Sie".
|
||||||
* Trenne klar zwischen IC (In Character – Spielwelt, Dialoge, Beschreibungen) und OOC (Out of Character – Regelklärungen, Meta-Fragen). Kennzeichne OOC-Kommentare deutlich, z. B. mit "(OOC: ...)".
|
* Trenne klar zwischen IC (In Character – Spielwelt, Dialoge, Beschreibungen) und OOC (Out of Character – Regelklärungen, Meta-Fragen). Kennzeichne OOC-Kommentare deutlich, z. B. mit "(OOC: ...)".
|
||||||
* Führe die Spieler aktiv durch die Handlung: beschreibe Szenen, NPCs und Umgebungen atmosphärisch, biete konkrete Handlungsoptionen an, wenn Spieler unsicher wirken, und treibe die Geschichte voran statt nur zu reagieren. Erfordert eine Option eine Probe, nenne die betroffene Fähigkeit und den Schwierigkeitsgrad direkt bei der Option (z. B. „B) Die Läden zuwerfen (Stärke, SG 12)"), damit der Spieler seine Chancen vorher einschätzen kann.
|
* Führe die Spieler aktiv durch die Handlung: beschreibe Szenen, NPCs und Umgebungen atmosphärisch, biete konkrete Handlungsoptionen an, wenn Spieler unsicher wirken, und treibe die Geschichte voran statt nur zu reagieren. Erfordert eine Option eine Probe, markiere das IMMER direkt bei der Option, damit nie unklar ist, ob und wie gewürfelt wird:
|
||||||
* Für Würfelproben: Nenne IMMER zuerst klar, welche Fähigkeit/Fertigkeit gewürfelt wird und welcher Schwierigkeitsgrad (SG) nötig ist – bevor gewürfelt wird. Nutze für jeden spielrelevanten Wurf das roll_dice-Werkzeug, nie ein erfundenes Ergebnis. Erkläre das Ergebnis danach ausführlich in dieser Reihenfolge: (1) benötigter SG, (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht oder nicht – erst danach folgt die erzählerische Konsequenz. Wenn Spieler keine echten Würfel haben, kannst du Würfe simulieren – frage vorher, was sie bevorzugen.
|
- 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.
|
||||||
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent.
|
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent.
|
||||||
* Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden.
|
* Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden.
|
||||||
* Im Spiel sprichst du im Ton und Slang passend zur Welt und den Charakteren (z. B. archaisch-episch in High Fantasy, derb-direkt bei Piraten). Außerhalb bleibst du locker, freundlich und klar in der Ansprache.
|
* Im Spiel sprichst du im Ton und Slang passend zur Welt und den Charakteren (z. B. archaisch-episch in High Fantasy, derb-direkt bei Piraten). Außerhalb bleibst du locker, freundlich und klar in der Ansprache.
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Message } from "../api/games";
|
import { Message } from "../api/games";
|
||||||
import MarkdownContent from "./MarkdownContent";
|
import MarkdownContent from "./MarkdownContent";
|
||||||
|
|
||||||
|
const DICE_FACES = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"];
|
||||||
|
|
||||||
function senderLabel(message: Message): string {
|
function senderLabel(message: Message): string {
|
||||||
if (message.sender_type === "dm") return "Dungeon Master";
|
if (message.sender_type === "dm") return "Dungeon Master";
|
||||||
if (message.sender_type === "system") return "System";
|
if (message.sender_type === "system") return "System";
|
||||||
@@ -10,18 +12,41 @@ function senderLabel(message: Message): string {
|
|||||||
return message.player_name ?? "Spieler";
|
return message.player_name ?? "Spieler";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiceRollAnimation({ notation }: { notation: string }) {
|
||||||
|
const [faceIndex, setFaceIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setFaceIndex((i) => (i + 1) % DICE_FACES.length);
|
||||||
|
}, 120);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-slate-100">
|
||||||
|
<span className="font-semibold text-amber-400">Dungeon Master würfelt</span>{" "}
|
||||||
|
<span className="inline-block text-xl" aria-hidden="true">
|
||||||
|
{DICE_FACES[faceIndex]}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-sm text-slate-400">{notation && `(${notation})`}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TranscriptPane({
|
export default function TranscriptPane({
|
||||||
messages,
|
messages,
|
||||||
typing,
|
typing,
|
||||||
|
rolling,
|
||||||
}: {
|
}: {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
typing?: boolean;
|
typing?: boolean;
|
||||||
|
rolling?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||||
}, [messages, typing]);
|
}, [messages, typing, rolling]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
|
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
|
||||||
@@ -41,10 +66,14 @@ export default function TranscriptPane({
|
|||||||
<MarkdownContent content={m.content} />
|
<MarkdownContent content={m.content} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{typing && (
|
{rolling != null ? (
|
||||||
<div className="text-slate-100">
|
<DiceRollAnimation notation={rolling} />
|
||||||
<span className="animate-pulse font-semibold text-amber-400">Dungeon Master schreibt …</span>
|
) : (
|
||||||
</div>
|
typing && (
|
||||||
|
<div className="text-slate-100">
|
||||||
|
<span className="animate-pulse font-semibold text-amber-400">Dungeon Master schreibt …</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function useGameSocket(
|
|||||||
onMessage: (message: Message) => void,
|
onMessage: (message: Message) => void,
|
||||||
onError: (detail: string) => void,
|
onError: (detail: string) => void,
|
||||||
onTyping: () => void,
|
onTyping: () => void,
|
||||||
|
onRolling: (notation: string) => void,
|
||||||
) {
|
) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
@@ -16,6 +17,8 @@ export function useGameSocket(
|
|||||||
onErrorRef.current = onError;
|
onErrorRef.current = onError;
|
||||||
const onTypingRef = useRef(onTyping);
|
const onTypingRef = useRef(onTyping);
|
||||||
onTypingRef.current = onTyping;
|
onTypingRef.current = onTyping;
|
||||||
|
const onRollingRef = useRef(onRolling);
|
||||||
|
onRollingRef.current = onRolling;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
@@ -35,6 +38,7 @@ export function useGameSocket(
|
|||||||
if (data.type === "message") onMessageRef.current(data.message);
|
if (data.type === "message") onMessageRef.current(data.message);
|
||||||
else if (data.type === "error") onErrorRef.current(data.detail);
|
else if (data.type === "error") onErrorRef.current(data.detail);
|
||||||
else if (data.type === "typing") onTypingRef.current();
|
else if (data.type === "typing") onTypingRef.current();
|
||||||
|
else if (data.type === "rolling") onRollingRef.current(data.notation ?? "");
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (!intentionalClose) onErrorRef.current("connection_lost");
|
if (!intentionalClose) onErrorRef.current("connection_lost");
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function GameSession() {
|
|||||||
const [selectedCharacterId, setSelectedCharacterId] = useState("");
|
const [selectedCharacterId, setSelectedCharacterId] = useState("");
|
||||||
const [joinError, setJoinError] = useState<string | null>(null);
|
const [joinError, setJoinError] = useState<string | null>(null);
|
||||||
const [isTyping, setIsTyping] = useState(false);
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [rollingNotation, setRollingNotation] = useState<string | null>(null);
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
@@ -71,6 +72,7 @@ export default function GameSession() {
|
|||||||
game?.is_participant ? gameId : undefined,
|
game?.is_participant ? gameId : undefined,
|
||||||
(incoming) => {
|
(incoming) => {
|
||||||
setIsTyping(false);
|
setIsTyping(false);
|
||||||
|
setRollingNotation(null);
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const next = [...prev, incoming];
|
const next = [...prev, incoming];
|
||||||
return showingFull ? next : next.slice(-5);
|
return showingFull ? next : next.slice(-5);
|
||||||
@@ -78,6 +80,7 @@ export default function GameSession() {
|
|||||||
},
|
},
|
||||||
(detail) => {
|
(detail) => {
|
||||||
setIsTyping(false);
|
setIsTyping(false);
|
||||||
|
setRollingNotation(null);
|
||||||
const text =
|
const text =
|
||||||
detail === "connection_lost"
|
detail === "connection_lost"
|
||||||
? "Verbindung zum Server verloren. Bitte lade die Seite neu."
|
? "Verbindung zum Server verloren. Bitte lade die Seite neu."
|
||||||
@@ -98,6 +101,7 @@ export default function GameSession() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => setIsTyping(true),
|
() => setIsTyping(true),
|
||||||
|
(notation) => setRollingNotation(notation),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleSend(content: string) {
|
async function handleSend(content: string) {
|
||||||
@@ -177,7 +181,7 @@ export default function GameSession() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
|
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
|
||||||
<TranscriptPane messages={messages} typing={isTyping} />
|
<TranscriptPane messages={messages} typing={isTyping} rolling={rollingNotation} />
|
||||||
<SendMessageForm onSend={handleSend} />
|
<SendMessageForm onSend={handleSend} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user