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>
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
"""add game status/ended_reason and character current_hp/max_hp
|
||||||
|
|
||||||
|
Revision ID: c9a1d5e7f316
|
||||||
|
Revises: b8e4f1a9c203
|
||||||
|
Create Date: 2026-09-01 16:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c9a1d5e7f316'
|
||||||
|
down_revision: Union[str, None] = 'b8e4f1a9c203'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'games',
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=False, server_default='active'),
|
||||||
|
)
|
||||||
|
op.add_column('games', sa.Column('ended_reason', sa.Text(), nullable=True))
|
||||||
|
op.add_column('characters', sa.Column('current_hp', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('characters', sa.Column('max_hp', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('characters', 'max_hp')
|
||||||
|
op.drop_column('characters', 'current_hp')
|
||||||
|
op.drop_column('games', 'ended_reason')
|
||||||
|
op.drop_column('games', 'status')
|
||||||
@@ -71,6 +71,8 @@ async def _serialize_games(
|
|||||||
created_at=game.created_at,
|
created_at=game.created_at,
|
||||||
is_participant=is_participant,
|
is_participant=is_participant,
|
||||||
my_character_id=viewer_participation.get(game.id),
|
my_character_id=viewer_participation.get(game.id),
|
||||||
|
status=game.status,
|
||||||
|
ended_reason=game.ended_reason,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -123,9 +123,16 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
|
|||||||
async def _broadcast_roll(notation: str) -> None:
|
async def _broadcast_roll(notation: str) -> None:
|
||||||
await manager.broadcast(game_id, {"type": "rolling", "notation": notation})
|
await manager.broadcast(game_id, {"type": "rolling", "notation": notation})
|
||||||
|
|
||||||
|
async def _broadcast_game_ended(reason: str) -> None:
|
||||||
|
await manager.broadcast(game_id, {"type": "game_ended", "reason": reason})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dm_message = await run_dm_turn(
|
dm_message = await run_dm_turn(
|
||||||
session, game_id, latest_player_message=content, on_roll=_broadcast_roll
|
session,
|
||||||
|
game_id,
|
||||||
|
latest_player_message=content,
|
||||||
|
on_roll=_broadcast_roll,
|
||||||
|
on_game_ended=_broadcast_game_ended,
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from app.config import settings
|
|||||||
from app.llm.client import get_dm_system_prompt, get_llm_client
|
from app.llm.client import get_dm_system_prompt, get_llm_client
|
||||||
from app.llm.context import build_context
|
from app.llm.context import build_context
|
||||||
from app.llm.json_utils import fix_double_escaped_unicode
|
from app.llm.json_utils import fix_double_escaped_unicode
|
||||||
from app.llm.tools import character_sheet, dice
|
from app.llm.tools import character_sheet, dice, end_game
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.rag.retrieval import build_rag_block
|
from app.rag.retrieval import build_rag_block
|
||||||
@@ -22,6 +22,7 @@ MAX_TOKENS = 4096
|
|||||||
TOOLS = [
|
TOOLS = [
|
||||||
{"type": "function", "function": dice.TOOL_SCHEMA},
|
{"type": "function", "function": dice.TOOL_SCHEMA},
|
||||||
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
|
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
|
||||||
|
{"type": "function", "function": end_game.TOOL_SCHEMA},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ async def _execute_tool_call(
|
|||||||
return dice.roll(tool_input["notation"])
|
return dice.roll(tool_input["notation"])
|
||||||
if tool_name == "upsert_character_sheet":
|
if tool_name == "upsert_character_sheet":
|
||||||
return await character_sheet.upsert(session, game_id, tool_input)
|
return await character_sheet.upsert(session, game_id, tool_input)
|
||||||
|
if tool_name == "end_game":
|
||||||
|
return await end_game.end(session, game_id, tool_input)
|
||||||
return {"error": f"Unknown tool {tool_name!r}"}
|
return {"error": f"Unknown tool {tool_name!r}"}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Tool call %s failed: %s", tool_name, exc)
|
logger.warning("Tool call %s failed: %s", tool_name, exc)
|
||||||
@@ -42,11 +45,30 @@ async def _execute_tool_call(
|
|||||||
return {"error": str(exc)}
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
current_hp = result.get("current_hp")
|
||||||
|
if current_hp is None or current_hp > 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
game = await session.get(Game, game_id)
|
||||||
|
if game is None or game.status == "ended":
|
||||||
|
return None
|
||||||
|
|
||||||
|
reason = f"{result.get('name', 'Ein Charakter')} ist bei {current_hp} Trefferpunkten zusammengebrochen."
|
||||||
|
game.status = "ended"
|
||||||
|
game.ended_reason = reason
|
||||||
|
await session.commit()
|
||||||
|
return reason
|
||||||
|
|
||||||
|
|
||||||
async def run_dm_turn(
|
async def run_dm_turn(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
game_id: uuid.UUID,
|
game_id: uuid.UUID,
|
||||||
latest_player_message: str | None = None,
|
latest_player_message: str | None = None,
|
||||||
on_roll: Callable[[str], Awaitable[None]] | None = None,
|
on_roll: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_game_ended: 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()
|
||||||
@@ -90,6 +112,17 @@ async def run_dm_turn(
|
|||||||
if tool_call.function.name == "roll_dice" and on_roll is not None:
|
if tool_call.function.name == "roll_dice" and on_roll is not None:
|
||||||
await on_roll(tool_input.get("notation", ""))
|
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)
|
||||||
|
|
||||||
|
end_reason = None
|
||||||
|
if tool_call.function.name == "end_game" and "reason" in result:
|
||||||
|
end_reason = result["reason"]
|
||||||
|
elif tool_call.function.name == "upsert_character_sheet":
|
||||||
|
end_reason = await _check_hp_game_over(session, game_id, result)
|
||||||
|
if end_reason:
|
||||||
|
result = {**result, "game_ended": True, "end_reason": end_reason}
|
||||||
|
if end_reason and on_game_ended is not None:
|
||||||
|
await on_game_ended(end_reason)
|
||||||
|
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Phase 2: Das Spiel leiten
|
|||||||
- 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.
|
- 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.
|
- 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. 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.
|
||||||
|
* 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.
|
||||||
|
* 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.
|
||||||
* 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.
|
||||||
|
|||||||
@@ -32,12 +32,24 @@ TOOL_SCHEMA = {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Ability scores, e.g. {\"STR\": 15, \"DEX\": 12, \"CON\": 14, \"INT\": 10, \"WIS\": 8, \"CHA\": 13}",
|
"description": "Ability scores, e.g. {\"STR\": 15, \"DEX\": 12, \"CON\": 14, \"INT\": 10, \"WIS\": 8, \"CHA\": 13}",
|
||||||
},
|
},
|
||||||
|
"current_hp": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": (
|
||||||
|
"Current hit points. Always send this together with max_hp whenever HP changes "
|
||||||
|
"(damage, healing, character creation) — the server uses it to detect when a "
|
||||||
|
"character goes down. Do NOT also put HP/TP in combat_stats, this is the only place for it."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"max_hp": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum hit points. Send together with current_hp.",
|
||||||
|
},
|
||||||
"combat_stats": {
|
"combat_stats": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": (
|
"description": (
|
||||||
"Key/value readouts, e.g. {\"HP\": \"13/13\", \"RK\": \"19\", \"Initiative\": \"+1\", "
|
"Key/value readouts EXCLUDING hit points (use current_hp/max_hp for those), e.g. "
|
||||||
"\"Tempo\": \"7,5 m\", \"Übungsbonus\": \"+2\"}. Send the full current set each time — "
|
"{\"RK\": \"19\", \"Initiative\": \"+1\", \"Tempo\": \"7,5 m\", \"Übungsbonus\": \"+2\"}. "
|
||||||
"this replaces whatever was stored before."
|
"Send the full current set each time — this replaces whatever was stored before."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"abilities": {
|
"abilities": {
|
||||||
@@ -108,7 +120,11 @@ async def upsert(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) ->
|
|||||||
if "stats" in tool_input and tool_input["stats"] is not None:
|
if "stats" in tool_input and tool_input["stats"] is not None:
|
||||||
character.stats = {**(character.stats or {}), **tool_input["stats"]}
|
character.stats = {**(character.stats or {}), **tool_input["stats"]}
|
||||||
if "combat_stats" in tool_input and tool_input["combat_stats"] is not None:
|
if "combat_stats" in tool_input and tool_input["combat_stats"] is not None:
|
||||||
character.combat_stats = {**(character.combat_stats or {}), **tool_input["combat_stats"]}
|
character.combat_stats = tool_input["combat_stats"]
|
||||||
|
if "current_hp" in tool_input and tool_input["current_hp"] is not None:
|
||||||
|
character.current_hp = tool_input["current_hp"]
|
||||||
|
if "max_hp" in tool_input and tool_input["max_hp"] is not None:
|
||||||
|
character.max_hp = tool_input["max_hp"]
|
||||||
if "abilities" in tool_input and tool_input["abilities"] is not None:
|
if "abilities" in tool_input and tool_input["abilities"] is not None:
|
||||||
character.abilities = tool_input["abilities"]
|
character.abilities = tool_input["abilities"]
|
||||||
if "equipment" in tool_input and tool_input["equipment"] is not None:
|
if "equipment" in tool_input and tool_input["equipment"] is not None:
|
||||||
@@ -122,4 +138,9 @@ async def upsert(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) ->
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(character)
|
await session.refresh(character)
|
||||||
|
|
||||||
return {"character_id": str(character.id), "name": character.name}
|
return {
|
||||||
|
"character_id": str(character.id),
|
||||||
|
"name": character.name,
|
||||||
|
"current_hp": character.current_hp,
|
||||||
|
"max_hp": character.max_hp,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.game import Game
|
||||||
|
|
||||||
|
TOOL_SCHEMA = {
|
||||||
|
"name": "end_game",
|
||||||
|
"description": (
|
||||||
|
"Call when the story reaches a real conclusion — the party wins, loses, or a one-shot's "
|
||||||
|
"plot is resolved. Marks the game as ended so players see a clear ending. Do not call this "
|
||||||
|
"just because a scene ends; only for a genuine story conclusion."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"A short narrative summary of how/why the story ended (victory, defeat, or "
|
||||||
|
"resolution), shown to the players, e.g. 'Das Dorf ist gerettet, die Kerze brennt wieder.'"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["reason"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def end(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> dict:
|
||||||
|
game = await session.get(Game, game_id)
|
||||||
|
if game is None:
|
||||||
|
raise ValueError(f"Game {game_id} not found")
|
||||||
|
|
||||||
|
if game.status != "ended":
|
||||||
|
game.status = "ended"
|
||||||
|
game.ended_reason = tool_input["reason"]
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": "ended", "reason": game.ended_reason}
|
||||||
@@ -24,6 +24,8 @@ class Character(Base):
|
|||||||
stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
combat_stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
combat_stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
current_hp: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
max_hp: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
abilities: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
abilities: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
equipment: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
equipment: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class Game(Base):
|
|||||||
participation_code: Mapped[str] = mapped_column(
|
participation_code: Mapped[str] = mapped_column(
|
||||||
String(length=16), unique=True, nullable=False, index=True
|
String(length=16), unique=True, nullable=False, index=True
|
||||||
)
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(length=20), nullable=False, default="active")
|
||||||
|
ended_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
server_default=func.now(), nullable=False, index=True
|
server_default=func.now(), nullable=False, index=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class CharacterRead(BaseModel):
|
|||||||
level: int
|
level: int
|
||||||
stats: dict
|
stats: dict
|
||||||
combat_stats: dict
|
combat_stats: dict
|
||||||
|
current_hp: int | None
|
||||||
|
max_hp: int | None
|
||||||
abilities: list
|
abilities: list
|
||||||
equipment: list
|
equipment: list
|
||||||
description: str
|
description: str
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class GameRead(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
is_participant: bool
|
is_participant: bool
|
||||||
my_character_id: uuid.UUID | None
|
my_character_id: uuid.UUID | None
|
||||||
|
status: str
|
||||||
|
ended_reason: str | None
|
||||||
|
|
||||||
|
|
||||||
class GameJoin(BaseModel):
|
class GameJoin(BaseModel):
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface Character {
|
|||||||
level: number;
|
level: number;
|
||||||
stats: Record<string, number>;
|
stats: Record<string, number>;
|
||||||
combat_stats: Record<string, string>;
|
combat_stats: Record<string, string>;
|
||||||
|
current_hp: number | null;
|
||||||
|
max_hp: number | null;
|
||||||
abilities: CharacterAbility[];
|
abilities: CharacterAbility[];
|
||||||
equipment: string[];
|
equipment: string[];
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface Game {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
is_participant: boolean;
|
is_participant: boolean;
|
||||||
my_character_id: string | null;
|
my_character_id: string | null;
|
||||||
|
status: "active" | "ended";
|
||||||
|
ended_reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export default function GameCard({ game }: { game: Game }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h2 className="text-lg font-semibold text-slate-100">{game.name}</h2>
|
<h2 className="text-lg font-semibold text-slate-100">{game.name}</h2>
|
||||||
|
{game.status === "ended" && (
|
||||||
|
<span className="shrink-0 rounded bg-slate-700 px-2 py-1 text-xs font-semibold text-slate-300">
|
||||||
|
Beendet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{game.participation_code && (
|
{game.participation_code && (
|
||||||
<span className="shrink-0 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300">
|
<span className="shrink-0 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300">
|
||||||
Code: {game.participation_code}
|
Code: {game.participation_code}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function useGameSocket(
|
|||||||
onError: (detail: string) => void,
|
onError: (detail: string) => void,
|
||||||
onTyping: () => void,
|
onTyping: () => void,
|
||||||
onRolling: (notation: string) => void,
|
onRolling: (notation: string) => void,
|
||||||
|
onGameEnded: (reason: string) => void,
|
||||||
) {
|
) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
@@ -19,6 +20,8 @@ export function useGameSocket(
|
|||||||
onTypingRef.current = onTyping;
|
onTypingRef.current = onTyping;
|
||||||
const onRollingRef = useRef(onRolling);
|
const onRollingRef = useRef(onRolling);
|
||||||
onRollingRef.current = onRolling;
|
onRollingRef.current = onRolling;
|
||||||
|
const onGameEndedRef = useRef(onGameEnded);
|
||||||
|
onGameEndedRef.current = onGameEnded;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
@@ -39,6 +42,7 @@ export function useGameSocket(
|
|||||||
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 ?? "");
|
else if (data.type === "rolling") onRollingRef.current(data.notation ?? "");
|
||||||
|
else if (data.type === "game_ended") onGameEndedRef.current(data.reason ?? "");
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (!intentionalClose) onErrorRef.current("connection_lost");
|
if (!intentionalClose) onErrorRef.current("connection_lost");
|
||||||
|
|||||||
@@ -87,12 +87,31 @@ export default function CharacterDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.keys(character.combat_stats).length > 0 && (
|
{(character.current_hp !== null || Object.keys(character.combat_stats).length > 0) && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
Kampfwerte
|
Kampfwerte
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{character.current_hp !== null && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border px-4 py-2 text-center ${
|
||||||
|
character.current_hp <= 0
|
||||||
|
? "border-red-500/50 bg-red-500/10"
|
||||||
|
: "border-slate-700 bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Trefferpunkte
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-lg font-bold ${character.current_hp <= 0 ? "text-red-400" : "text-slate-100"}`}
|
||||||
|
>
|
||||||
|
{character.current_hp}
|
||||||
|
{character.max_hp !== null ? ` / ${character.max_hp}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{Object.entries(character.combat_stats).map(([key, value]) => (
|
{Object.entries(character.combat_stats).map(([key, value]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ export default function GameSession() {
|
|||||||
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);
|
const [rollingNotation, setRollingNotation] = useState<string | null>(null);
|
||||||
|
const [endedReason, setEndedReason] = useState<string | null>(null);
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
const g = await getGame(gameId);
|
const g = await getGame(gameId);
|
||||||
setGame(g);
|
setGame(g);
|
||||||
|
setEndedReason(g.status === "ended" ? g.ended_reason : null);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ export default function GameSession() {
|
|||||||
},
|
},
|
||||||
() => setIsTyping(true),
|
() => setIsTyping(true),
|
||||||
(notation) => setRollingNotation(notation),
|
(notation) => setRollingNotation(notation),
|
||||||
|
(reason) => setEndedReason(reason),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleSend(content: string) {
|
async function handleSend(content: string) {
|
||||||
@@ -180,6 +183,12 @@ export default function GameSession() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{endedReason && (
|
||||||
|
<div className="mb-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
||||||
|
<span className="font-semibold">Spiel beendet.</span> {endedReason}
|
||||||
|
</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} rolling={rollingNotation} />
|
<TranscriptPane messages={messages} typing={isTyping} rolling={rollingNotation} />
|
||||||
<SendMessageForm onSend={handleSend} />
|
<SendMessageForm onSend={handleSend} />
|
||||||
|
|||||||
Reference in New Issue
Block a user