feat(mana/web): who module — frontend (game store + UI + routes)

Client side of the who module. Standard Mana module pattern: types,
collections (Dexie), queries (live), store (mutations), UI components,
routes. Plus three integration points (data layer registries).

Module files
------------
  types.ts
    Two Dexie record interfaces (LocalWhoGame, LocalWhoMessage) and
    matching view types. Server response shapes (WhoChatResponse,
    WhoRandomResponse, WhoGuessResponse) live here too so the store
    and UI both type-check against the same wire contract.

  collections.ts
    Dexie table accessors. No guest seed — the picker handles empty
    state directly.

  queries.ts
    Three liveQueries (allGames$, gameByIdLive, messagesForGameLive)
    that decrypt the encrypted-at-rest fields before returning view
    types. The messages query uses the [gameId+createdAt] composite
    index for ordering. toWhoGame / toWhoMessage converters bridge
    the BaseRecord-extended local types to the public view types.

  module.config.ts
    Standard ModuleConfig: appId='who', tables=[whoGames as 'games',
    whoMessages as 'messages']. The syncName remap means the unified
    Dexie table whoGames syncs to mana-sync's `games` collection
    under appId 'who' — keeps the wire format clean.

  stores/games.svelte.ts
    The mutation surface. Five public methods:
      - start(deckId)        → POST /who/random + insert LocalWhoGame
      - sendMessage(id, txt) → optimistic insert + POST /who/chat +
                               insert NPC reply + (on win) flip status
      - submitGuess(id, txt) → POST /who/guess + (on match) flip
      - surrender(id)        → status=surrendered + finishedAt
      - setNotes(id, notes)  → encrypted post-game notes
      - deleteGame(id)       → soft-delete game + cascade messages
    All writes go through encryptRecord for encrypted-at-rest fields.

UI components
-------------
  ListView.svelte
    Module landing page. Header + 4 deck cards (loaded from
    GET /api/v1/who/decks on mount) + past-games list. Picking a
    deck calls store.start() and navigates to the play view. Past
    games are clickable (read-only for finished games) and
    deletable.

  views/PlayView.svelte
    The chat-loop screen. Header with deck/difficulty + back button
    + Tippen/Aufgeben actions while playing. Scrollable message
    area with bubbles (user purple-tinted, NPC white-tinted).
    Textarea input with Enter-to-send + sending disabled state.
    On reveal: result banner with "Erraten in N Nachrichten!" and
    the resolved name. Post-game: input area swaps to a notes
    textarea with debounced auto-save. Explicit guess modal as
    fallback when the LLM forgets to emit the sentinel.

Routes
------
  /(app)/who                 → ListView wrapper
  /(app)/who/play/[gameId]   → PlayView wrapper, $page.params.gameId

Registry plumbing
-----------------
  database.ts
    Two new Dexie tables in version(1):
      whoGames: 'id, status, deckId, startedAt, finishedAt, [status+startedAt]'
      whoMessages: 'id, gameId, sender, createdAt, [gameId+createdAt]'

  module-registry.ts
    Imports whoModuleConfig and adds to MODULE_CONFIGS. The sync
    engine picks up the appId/table mapping automatically — no
    edits needed in sync.ts.

  crypto/registry.ts
    Two entries:
      whoGames:    { enabled: true, fields: ['revealedName', 'notes'] }
      whoMessages: { enabled: true, fields: ['content'] }
    All other fields stay plaintext for index/sort/filter.

Closes Phase A.2 / A.3 / A.4 / A.5 of docs/WHO_MODULE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 13:10:34 +02:00
parent 74b5808496
commit f24438f778
13 changed files with 1018 additions and 0 deletions

View file

@ -47,6 +47,13 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
fields: ['name', 'description', 'systemPrompt', 'initialQuestion'],
},
// ─── Who (LLM character guessing game) ──────────────────
// Conversation content + the revealed character name + free-form
// notes count as user-typed content. Plaintext: ids, FK, status,
// timestamps, message counts (all needed for query/sort/filter).
whoGames: { enabled: true, fields: ['revealedName', 'notes'] },
whoMessages: { enabled: true, fields: ['content'] },
// ─── Notes ───────────────────────────────────────────────
// Phase 4 pilot — first table flipped to enabled:true. The schema
// uses `title` + `content` (no separate `body` column).

View file

@ -99,6 +99,14 @@ db.version(1).stores({
cards: 'id, deckId, difficulty, nextReview, order, [deckId+order]',
deckTags: 'id, deckId, tagId, [deckId+tagId]',
// ─── Who (appId: 'who') ───
// LLM character-guessing game. whoGames holds one row per session
// (status, deck, character id, message count); whoMessages holds the
// chat scrollback. Standard plaintext index pattern: id, FK, status,
// timestamps for sort/filter; content + revealedName encrypted.
whoGames: 'id, status, deckId, startedAt, finishedAt, [status+startedAt]',
whoMessages: 'id, gameId, sender, createdAt, [gameId+createdAt]',
// ─── Zitare (appId: 'zitare') ───
zitareFavorites: 'id, quoteId',
zitareLists: 'id',

View file

@ -83,6 +83,7 @@ import { eventsModuleConfig } from '$lib/modules/events/module.config';
import { financeModuleConfig } from '$lib/modules/finance/module.config';
import { placesModuleConfig } from '$lib/modules/places/module.config';
import { playgroundModuleConfig } from '$lib/modules/playground/module.config';
import { whoModuleConfig } from '$lib/modules/who/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
manaCoreConfig,
@ -121,6 +122,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
financeModuleConfig,
placesModuleConfig,
playgroundModuleConfig,
whoModuleConfig,
];
// ─── Derived Maps ──────────────────────────────────────────

View file

@ -0,0 +1,206 @@
<!--
Who — main module view.
Shows the four decks at the top, the user's past games below.
Picking a deck calls whoGamesStore.start() and navigates to the
play view. Past games can be reopened (won/surrendered show the
full chat read-only) or deleted.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { allGames$, gameStatusLabel } from './queries';
import { whoGamesStore } from './stores/games.svelte';
import type { WhoDeckId, WhoGame, WhoDeckMeta } from './types';
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
let games = $state<WhoGame[]>([]);
let decks = $state<WhoDeckMeta[]>([]);
let loadingDecks = $state(true);
let starting = $state<WhoDeckId | null>(null);
let error = $state<string | null>(null);
$effect(() => {
const sub = allGames$.subscribe((val) => {
games = val ?? [];
});
return () => sub.unsubscribe();
});
onMount(async () => {
try {
const token = await authStore.getAccessToken();
if (!token) {
loadingDecks = false;
return;
}
const res = await fetch(`${getManaApiUrl()}/api/v1/who/decks`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = (await res.json()) as { decks: WhoDeckMeta[] };
decks = data.decks;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Decks konnten nicht geladen werden';
} finally {
loadingDecks = false;
}
});
async function startGame(deckId: WhoDeckId) {
starting = deckId;
error = null;
try {
const gameId = await whoGamesStore.start(deckId);
await goto(`/who/play/${gameId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Spiel konnte nicht gestartet werden';
} finally {
starting = null;
}
}
async function deleteGame(gameId: string) {
if (!confirm('Spiel wirklich löschen?')) return;
await whoGamesStore.deleteGame(gameId);
}
function deckColor(id: WhoDeckId): string {
switch (id) {
case 'historical':
return '#a855f7';
case 'women':
return '#ec4899';
case 'antiquity':
return '#f59e0b';
case 'inventors':
return '#0ea5e9';
}
}
function difficultyLabel(d: 'easy' | 'medium' | 'hard'): string {
switch (d) {
case 'easy':
return 'leicht';
case 'medium':
return 'mittel';
case 'hard':
return 'schwer';
}
}
function statusEmoji(s: WhoGame['status']): string {
switch (s) {
case 'playing':
return '⏳';
case 'won':
return '✅';
case 'surrendered':
return '🏳️';
}
}
</script>
<div class="flex h-full flex-col gap-6 p-3 sm:p-4">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-white/90">Who?</h1>
<p class="mt-1 text-sm text-white/60">
Errate die historische Persönlichkeit. Eine KI verkörpert sie ohne den Namen zu verraten.
</p>
</div>
<!-- Deck picker -->
<section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-white/50">
Neues Spiel starten
</h2>
{#if loadingDecks}
<div class="grid gap-3 sm:grid-cols-2">
{#each Array(4) as _, i (i)}
<div class="h-24 animate-pulse rounded-lg bg-white/5"></div>
{/each}
</div>
{:else if decks.length === 0}
<p class="text-sm text-white/40">Keine Decks verfügbar.</p>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each decks as deck (deck.id)}
<button
type="button"
onclick={() => startGame(deck.id)}
disabled={starting !== null}
class="group flex flex-col items-start gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-4 text-left transition hover:border-white/20 hover:bg-white/[0.05] disabled:cursor-wait disabled:opacity-50"
style="border-left: 3px solid {deckColor(deck.id)}"
>
<div class="flex w-full items-center justify-between">
<span class="text-base font-medium text-white/90">{deck.name.de}</span>
<span
class="rounded-full bg-white/5 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white/50"
>
{difficultyLabel(deck.difficulty)}
</span>
</div>
<p class="text-xs text-white/60">{deck.description.de}</p>
<p class="text-[11px] text-white/40">
{deck.characterCount} Personen · {deck.categories.join(', ')}
</p>
{#if starting === deck.id}
<p class="text-xs text-white/70">Starte…</p>
{/if}
</button>
{/each}
</div>
{/if}
</section>
<!-- Past games -->
{#if games.length > 0}
<section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-white/50">
Vergangene Spiele
</h2>
<ul class="divide-y divide-white/5 rounded-lg border border-white/10 bg-white/[0.02]">
{#each games as game (game.id)}
<li class="flex items-center gap-3 px-3 py-2.5">
<span class="text-lg">{statusEmoji(game.status)}</span>
<button
type="button"
class="flex-1 text-left"
onclick={() => goto(`/who/play/${game.id}`)}
>
<div class="text-sm text-white/90">
{#if game.revealedName}
<span class="font-medium">{game.revealedName}</span>
{:else if game.status === 'playing'}
<span class="text-white/60">Laufendes Spiel</span>
{:else}
<span class="text-white/60">Aufgegeben</span>
{/if}
</div>
<div class="text-[11px] text-white/40">
{game.deckId} · {game.messageCount} Nachrichten · {gameStatusLabel(game.status)}
</div>
</button>
<button
type="button"
class="rounded p-1 text-white/30 hover:bg-white/5 hover:text-white/60"
onclick={() => deleteGame(game.id)}
title="Löschen"
>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if error}
<div class="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
{error}
</div>
{/if}
</div>

View file

@ -0,0 +1,12 @@
/**
* Who module collection accessors.
*
* Two tables: whoGames (game sessions) and whoMessages (chat
* scrollback). No guest seed the picker UI handles empty state.
*/
import { db } from '$lib/data/database';
import type { LocalWhoGame, LocalWhoMessage } from './types';
export const whoGameTable = db.table<LocalWhoGame>('whoGames');
export const whoMessageTable = db.table<LocalWhoMessage>('whoMessages');

View file

@ -0,0 +1,25 @@
/**
* Who module barrel export.
*/
export { whoGamesStore } from './stores/games.svelte';
export {
allGames$,
gameByIdLive,
messagesForGameLive,
gameStatusLabel,
toWhoGame,
toWhoMessage,
} from './queries';
export type {
WhoDeckId,
WhoGameStatus,
WhoGame,
WhoMessage,
WhoDeckMeta,
WhoChatResponse,
WhoRandomResponse,
WhoGuessResponse,
LocalWhoGame,
LocalWhoMessage,
} from './types';

View file

@ -0,0 +1,9 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const whoModuleConfig: ModuleConfig = {
appId: 'who',
tables: [
{ name: 'whoGames', syncName: 'games' },
{ name: 'whoMessages', syncName: 'messages' },
],
};

View file

@ -0,0 +1,86 @@
/**
* Who module reactive queries + type converters.
*
* All reads go through liveQuery so the UI updates automatically when
* the store mutates the underlying Dexie tables. Encrypted fields
* (whoGames.revealedName, whoGames.notes, whoMessages.content) get
* decrypted before the type converter runs.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalWhoGame, LocalWhoMessage, WhoGame, WhoMessage } from './types';
// ─── Type converters ──────────────────────────────────────────
export function toWhoGame(local: LocalWhoGame): WhoGame {
return {
id: local.id,
characterId: local.characterId,
deckId: local.deckId,
difficulty: local.difficulty,
category: local.category,
status: local.status,
startedAt: local.startedAt,
finishedAt: local.finishedAt,
messageCount: local.messageCount,
hintsUsed: local.hintsUsed,
revealedName: local.revealedName,
notes: local.notes,
};
}
export function toWhoMessage(local: LocalWhoMessage): WhoMessage {
return {
id: local.id,
gameId: local.gameId,
sender: local.sender,
content: local.content,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live queries ─────────────────────────────────────────────
export const allGames$ = liveQuery(async () => {
const locals = await db.table<LocalWhoGame>('whoGames').orderBy('startedAt').reverse().toArray();
const visible = locals.filter((g) => !g.deletedAt);
const decrypted = await decryptRecords('whoGames', visible);
return decrypted.map(toWhoGame);
});
export function gameByIdLive(gameId: string) {
return liveQuery(async () => {
const local = await db.table<LocalWhoGame>('whoGames').get(gameId);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('whoGames', [local]);
return decrypted ? toWhoGame(decrypted) : null;
});
}
export function messagesForGameLive(gameId: string) {
return liveQuery(async () => {
const locals = await db
.table<LocalWhoMessage>('whoMessages')
.where('[gameId+createdAt]')
.between([gameId, ''], [gameId, '\uffff'])
.toArray();
const visible = locals.filter((m) => !m.deletedAt);
const decrypted = await decryptRecords('whoMessages', visible);
return decrypted.map(toWhoMessage);
});
}
// ─── Pure helpers ─────────────────────────────────────────────
export function gameStatusLabel(status: WhoGame['status']): string {
switch (status) {
case 'playing':
return 'läuft';
case 'won':
return 'gewonnen';
case 'surrendered':
return 'aufgegeben';
}
}

View file

@ -0,0 +1,240 @@
/**
* Who module game store (mutations).
*
* Reads come from the live queries in queries.ts. This file owns the
* write side: starting a game, sending a chat message, submitting an
* explicit guess, and surrendering.
*
* The chat-message flow is the interesting one: the user message is
* inserted optimistically into Dexie, the server is called, and on
* success the NPC reply is inserted as a second message. If the
* server returns identityRevealed=true (or the explicit guess
* endpoint matches), the game transitions to 'won' and the resolved
* character name is stored on the game row.
*/
import { db } from '$lib/data/database';
import { authStore } from '$lib/stores/auth.svelte';
import { encryptRecord } from '$lib/data/crypto';
import { whoGameTable, whoMessageTable } from '../collections';
import type {
LocalWhoGame,
LocalWhoMessage,
WhoChatResponse,
WhoDeckId,
WhoGuessResponse,
WhoRandomResponse,
} from '../types';
import { getManaApiUrl } from '$lib/api/config';
const apiBase = () => `${getManaApiUrl()}/api/v1/who`;
/**
* Authenticated fetch helper. Mirrors the shape used elsewhere in
* this app Bearer token from authStore, JSON body, structured
* error throwing. Kept inline (no wrapping client) because the who
* module has only three endpoints; a full client would be overkill.
*/
async function postJson<T>(path: string, body: unknown): Promise<T> {
const token = await authStore.getAccessToken();
if (!token) throw new Error('not authenticated');
const res = await fetch(`${apiBase()}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`who ${path} failed: ${res.status} ${text}`);
}
return (await res.json()) as T;
}
export const whoGamesStore = {
/**
* Start a new game in the given deck. Server picks a random
* character from the deck so the client never knows the personality
* pool ahead of time. Returns the new gameId so the route can
* navigate into the play view.
*/
async start(deckId: WhoDeckId): Promise<string> {
const random = await postJson<WhoRandomResponse>('/random', { deckId });
const gameId = crypto.randomUUID();
const now = new Date().toISOString();
const newLocal: LocalWhoGame = {
id: gameId,
characterId: random.characterId,
deckId,
difficulty: random.difficulty,
category: random.category,
status: 'playing',
startedAt: now,
finishedAt: null,
messageCount: 0,
hintsUsed: 0,
revealedName: null,
notes: '',
};
await encryptRecord('whoGames', newLocal);
await whoGameTable.add(newLocal);
return gameId;
},
/**
* Send a user message in an active game. Inserts the user message
* locally, calls the server for the NPC reply, inserts the NPC
* message, and updates the game's messageCount. If the server
* detects the player has guessed the name, transitions to 'won'.
*
* If the server call fails, the user message stays inserted (so
* the user sees their input was registered) and an error is
* thrown so the caller can show a retry hint.
*/
async sendMessage(gameId: string, text: string): Promise<void> {
const game = await whoGameTable.get(gameId);
if (!game) throw new Error('game not found');
if (game.status !== 'playing') throw new Error('game already ended');
const trimmed = text.trim();
if (!trimmed) return;
// 1. Optimistic insert of the user message.
const userMsg: LocalWhoMessage = {
id: crypto.randomUUID(),
gameId,
sender: 'user',
content: trimmed,
};
await encryptRecord('whoMessages', userMsg);
await whoMessageTable.add(userMsg);
// 2. Pull recent message history to send to the server. Decrypt
// on the way out — the wire format is plaintext to/from
// apps/api, encryption happens at-rest in Dexie.
const allMessages = await whoMessageTable
.where('[gameId+createdAt]')
.between([gameId, ''], [gameId, '\uffff'])
.toArray();
const { decryptRecords } = await import('$lib/data/crypto');
const decrypted = await decryptRecords('whoMessages', allMessages);
// Drop the just-inserted user message from the history payload —
// the server takes the current `message` separately.
const history = decrypted
.filter((m) => m.id !== userMsg.id && !m.deletedAt)
.map((m) => ({ sender: m.sender, content: m.content }));
// 3. Server round-trip.
const response = await postJson<WhoChatResponse>('/chat', {
gameId,
characterId: game.characterId,
message: trimmed,
history,
});
// 4. Insert the NPC reply.
const npcMsg: LocalWhoMessage = {
id: crypto.randomUUID(),
gameId,
sender: 'npc',
content: response.reply,
};
await encryptRecord('whoMessages', npcMsg);
await whoMessageTable.add(npcMsg);
// 5. Update the game row: messageCount, and on win flip status
// + reveal name + stamp finishedAt.
const updates: Partial<LocalWhoGame> = {
messageCount: (game.messageCount ?? 0) + 1,
};
if (response.identityRevealed && response.characterName) {
updates.status = 'won';
updates.revealedName = response.characterName;
updates.finishedAt = new Date().toISOString();
await encryptRecord('whoGames', updates);
}
await whoGameTable.update(gameId, updates);
},
/**
* Explicit "I guess this is X" submission. Used as a fallback when
* the LLM forgot to emit the [IDENTITY_REVEALED] sentinel even
* though the user clearly said the right name. Server does a
* deterministic lowercase match against the canonical name.
*
* Returns true if the guess matched (and the game transitioned to
* 'won'), false otherwise. The store does NOT charge credits for
* this it's a metadata check, not an LLM call.
*/
async submitGuess(gameId: string, guess: string): Promise<boolean> {
const game = await whoGameTable.get(gameId);
if (!game) throw new Error('game not found');
if (game.status !== 'playing') return false;
const result = await postJson<WhoGuessResponse>('/guess', {
gameId,
characterId: game.characterId,
guess,
});
if (!result.matched || !result.characterName) return false;
const updates: Partial<LocalWhoGame> = {
status: 'won',
revealedName: result.characterName,
finishedAt: new Date().toISOString(),
};
await encryptRecord('whoGames', updates);
await whoGameTable.update(gameId, updates);
return true;
},
/**
* Give up on a game. Locks the row in 'surrendered' state and
* stamps finishedAt so it shows up in the result screen and the
* past-games list.
*/
async surrender(gameId: string): Promise<void> {
const game = await whoGameTable.get(gameId);
if (!game) throw new Error('game not found');
if (game.status !== 'playing') return;
await whoGameTable.update(gameId, {
status: 'surrendered',
finishedAt: new Date().toISOString(),
});
},
/**
* Update post-game notes. Used by the result view's "Notiz
* hinzufügen" textarea. Encrypted at rest.
*/
async setNotes(gameId: string, notes: string): Promise<void> {
const updates: Partial<LocalWhoGame> = { notes };
await encryptRecord('whoGames', updates);
await whoGameTable.update(gameId, updates);
},
/**
* Soft-delete a game (and its messages cascade by gameId in the
* UI filter, no actual cascade in Dexie). Used by the past-games
* list's swipe-to-delete.
*/
async deleteGame(gameId: string): Promise<void> {
const now = new Date().toISOString();
await db.transaction('rw', whoGameTable, whoMessageTable, async () => {
await whoGameTable.update(gameId, { deletedAt: now });
const messages = await whoMessageTable.where('gameId').equals(gameId).toArray();
for (const m of messages) {
await whoMessageTable.update(m.id, { deletedAt: now });
}
});
},
};

View file

@ -0,0 +1,96 @@
/**
* Who module types.
*
* Game state lives in two Dexie tables: whoGames (one row per
* session) and whoMessages (chat scrollback). The character pool
* itself is server-only we never store the personality strings
* locally, only the numeric character id and (post-win) the
* resolved name.
*/
import type { BaseRecord } from '@mana/local-store';
export type WhoDeckId = 'historical' | 'women' | 'antiquity' | 'inventors';
export type WhoGameStatus = 'playing' | 'won' | 'surrendered';
export interface LocalWhoGame extends BaseRecord {
/** Server-side character id. The actual name only arrives once won. */
characterId: number;
/** Which deck this game was started from. */
deckId: WhoDeckId;
/** Difficulty hint surfaced by the picker (echoed back from server). */
difficulty: 'easy' | 'medium' | 'hard';
/** Category tag from the picker, used in result screen. */
category: 'inventor' | 'scientist' | 'artist' | 'thinker' | 'ruler';
status: WhoGameStatus;
startedAt: string;
finishedAt: string | null;
/** Number of user messages — denormalized for ListView sort + result screen. */
messageCount: number;
hintsUsed: number;
/** Encrypted at rest. Null while playing, the historical name once won. */
revealedName: string | null;
/** Encrypted at rest. Optional user notes after the game ends. */
notes: string;
}
export interface LocalWhoMessage extends BaseRecord {
gameId: string;
sender: 'user' | 'npc';
/** Encrypted at rest. */
content: string;
}
// ─── View types (decoupled from BaseRecord) ───────────────────
export interface WhoGame {
id: string;
characterId: number;
deckId: WhoDeckId;
difficulty: 'easy' | 'medium' | 'hard';
category: 'inventor' | 'scientist' | 'artist' | 'thinker' | 'ruler';
status: WhoGameStatus;
startedAt: string;
finishedAt: string | null;
messageCount: number;
hintsUsed: number;
revealedName: string | null;
notes: string;
}
export interface WhoMessage {
id: string;
gameId: string;
sender: 'user' | 'npc';
content: string;
createdAt: string;
}
// ─── Server response shapes ───────────────────────────────────
export interface WhoDeckMeta {
id: WhoDeckId;
name: { de: string; en: string };
description: { de: string; en: string };
difficulty: 'easy' | 'medium' | 'hard';
characterCount: number;
categories: string[];
}
export interface WhoChatResponse {
reply: string;
identityRevealed: boolean;
characterName?: string;
}
export interface WhoRandomResponse {
characterId: number;
category: 'inventor' | 'scientist' | 'artist' | 'thinker' | 'ruler';
difficulty: 'easy' | 'medium' | 'hard';
}
export interface WhoGuessResponse {
matched: boolean;
characterName?: string;
}

View file

@ -0,0 +1,304 @@
<!--
Who — play view.
The chat-loop screen for an active game. Shows scrollback, an input
box, a "I think it's..." submit button as escape hatch when the LLM
forgets to emit the sentinel, and a "give up" button. After the
game ends, transitions to read-only mode and shows the result
banner inline.
-->
<script lang="ts">
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { whoGamesStore } from '../stores/games.svelte';
import { gameByIdLive, messagesForGameLive } from '../queries';
import type { WhoGame, WhoMessage } from '../types';
let { gameId }: { gameId: string } = $props();
let game = $state<WhoGame | null>(null);
let messages = $state<WhoMessage[]>([]);
let inputText = $state('');
let sending = $state(false);
let error = $state<string | null>(null);
let showGuessModal = $state(false);
let guessText = $state('');
let scrollContainer: HTMLDivElement | null = $state(null);
let notesText = $state('');
let notesDirty = $state(false);
$effect(() => {
const sub = gameByIdLive(gameId).subscribe((val) => {
game = val ?? null;
if (val && !notesDirty) {
notesText = val.notes ?? '';
}
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = messagesForGameLive(gameId).subscribe((val) => {
messages = (val ?? []).slice().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
tick().then(() => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
});
});
return () => sub.unsubscribe();
});
async function send() {
const text = inputText.trim();
if (!text || !game || game.status !== 'playing') return;
sending = true;
error = null;
try {
inputText = '';
await whoGamesStore.sendMessage(gameId, text);
} catch (e) {
error = e instanceof Error ? e.message : 'Nachricht fehlgeschlagen';
} finally {
sending = false;
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}
async function submitGuess() {
const guess = guessText.trim();
if (!guess || !game || game.status !== 'playing') return;
try {
const matched = await whoGamesStore.submitGuess(gameId, guess);
showGuessModal = false;
guessText = '';
if (!matched) {
error = 'Das war nicht der richtige Name.';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Tipp fehlgeschlagen';
}
}
async function surrender() {
if (!confirm('Spiel wirklich aufgeben?')) return;
await whoGamesStore.surrender(gameId);
}
let saveNotesTimer: ReturnType<typeof setTimeout> | null = null;
function onNotesInput() {
notesDirty = true;
if (saveNotesTimer) clearTimeout(saveNotesTimer);
saveNotesTimer = setTimeout(async () => {
await whoGamesStore.setNotes(gameId, notesText);
notesDirty = false;
}, 800);
}
onMount(() => {
return () => {
if (saveNotesTimer) {
clearTimeout(saveNotesTimer);
}
};
});
function difficultyEmoji(d: 'easy' | 'medium' | 'hard'): string {
return d === 'easy' ? '⭐' : d === 'medium' ? '⭐⭐' : '⭐⭐⭐';
}
</script>
<div class="flex h-full flex-col">
<!-- Header -->
<header class="flex items-center gap-2 border-b border-white/5 px-3 py-2">
<button
type="button"
class="rounded p-1.5 text-white/60 hover:bg-white/5 hover:text-white/90"
onclick={() => goto('/who')}
aria-label="Zurück"
>
</button>
<div class="flex-1">
<div class="text-sm font-medium text-white/90">
{#if game?.status === 'won'}
{game.revealedName}
{:else if game?.status === 'surrendered'}
🏳️ Aufgegeben
{:else if game}
Wer bin ich?
{/if}
</div>
<div class="text-[11px] text-white/40">
{#if game}
{game.deckId} · {difficultyEmoji(game.difficulty)} · {game.messageCount} Fragen
{/if}
</div>
</div>
{#if game?.status === 'playing'}
<button
type="button"
class="rounded px-2 py-1 text-xs text-white/60 hover:bg-white/5 hover:text-white/90"
onclick={() => (showGuessModal = true)}
>
Tippen
</button>
<button
type="button"
class="rounded px-2 py-1 text-xs text-white/40 hover:bg-white/5 hover:text-white/70"
onclick={surrender}
>
Aufgeben
</button>
{/if}
</header>
<!-- Result banner (post-game only) -->
{#if game && game.status !== 'playing'}
<div
class="border-b border-white/5 px-4 py-3"
class:bg-emerald-500={game.status === 'won'}
class:bg-opacity-10={game.status === 'won'}
class:bg-amber-500={game.status === 'surrendered'}
class:bg-amber-500-10={game.status === 'surrendered'}
>
{#if game.status === 'won'}
<p class="text-sm font-medium text-emerald-300">
Erraten in {game.messageCount} Nachrichten!
</p>
<p class="mt-0.5 text-xs text-white/60">
Das war {game.revealedName}.
</p>
{:else}
<p class="text-sm font-medium text-amber-300">Spiel beendet — aufgegeben.</p>
{/if}
</div>
{/if}
<!-- Messages scroll -->
<div bind:this={scrollContainer} class="flex-1 overflow-y-auto px-3 py-4">
{#if messages.length === 0}
<div class="flex h-full items-center justify-center text-center text-sm text-white/40">
<p>
Stell die erste Frage.<br />
Versuche, die Persönlichkeit durch geschickte Fragen herauszufinden.
</p>
</div>
{:else}
<div class="mx-auto flex max-w-2xl flex-col gap-3">
{#each messages as msg (msg.id)}
<div class:flex-row-reverse={msg.sender === 'user'} class="flex gap-2">
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm leading-relaxed"
class:bg-purple-500={msg.sender === 'user'}
class:bg-opacity-20={msg.sender === 'user'}
class:text-white={msg.sender === 'user'}
class:bg-white={msg.sender === 'npc'}
class:bg-opacity-5={msg.sender === 'npc'}
class:text-white-90={msg.sender === 'npc'}
>
{msg.content}
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if error}
<div class="border-t border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{error}
</div>
{/if}
<!-- Input or notes -->
{#if game?.status === 'playing'}
<div class="border-t border-white/5 p-3">
<div class="mx-auto flex max-w-2xl items-end gap-2">
<textarea
bind:value={inputText}
onkeydown={onInputKeydown}
placeholder="Frag mich etwas…"
rows="1"
class="flex-1 resize-none rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 placeholder-white/30 focus:border-purple-400/50 focus:outline-none"
disabled={sending}
></textarea>
<button
type="button"
onclick={send}
disabled={sending || !inputText.trim()}
class="rounded-lg bg-purple-500 px-4 py-2 text-sm font-medium text-white hover:bg-purple-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{sending ? '…' : 'Senden'}
</button>
</div>
</div>
{:else if game}
<div class="border-t border-white/5 p-3">
<div class="mx-auto max-w-2xl">
<label for="who-notes" class="mb-1 block text-[11px] uppercase tracking-wide text-white/40">
Notiz {notesDirty ? '(speichert…)' : ''}
</label>
<textarea
id="who-notes"
bind:value={notesText}
oninput={onNotesInput}
placeholder="Notiz zum Spiel…"
rows="2"
class="w-full resize-none rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 placeholder-white/30 focus:border-white/20 focus:outline-none"
></textarea>
</div>
</div>
{/if}
</div>
<!-- Guess modal -->
{#if showGuessModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onclick={(e) => e.target === e.currentTarget && (showGuessModal = false)}
onkeydown={(e) => e.key === 'Escape' && (showGuessModal = false)}
role="presentation"
>
<div class="w-full max-w-md rounded-lg bg-zinc-900 p-5">
<h3 class="mb-3 text-base font-medium text-white/90">Wer ist es?</h3>
<p class="mb-3 text-xs text-white/50">
Wenn die KI deine Vermutung nicht erkannt hat, kannst du den Namen hier direkt eintragen.
</p>
<input
type="text"
bind:value={guessText}
onkeydown={(e) => e.key === 'Enter' && submitGuess()}
placeholder="z.B. Marie Curie"
class="mb-3 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 placeholder-white/30 focus:border-purple-400/50 focus:outline-none"
autofocus
/>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded px-3 py-1.5 text-sm text-white/60 hover:bg-white/5"
onclick={() => {
showGuessModal = false;
guessText = '';
}}
>
Abbrechen
</button>
<button
type="button"
class="rounded bg-purple-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-purple-600 disabled:opacity-50"
onclick={submitGuess}
disabled={!guessText.trim()}
>
Tippen
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/who/ListView.svelte';
</script>
<svelte:head>
<title>Who? — Mana</title>
</svelte:head>
<ListView />

View file

@ -0,0 +1,14 @@
<script lang="ts">
import { page } from '$app/stores';
import PlayView from '$lib/modules/who/views/PlayView.svelte';
const gameId = $derived($page.params.gameId ?? '');
</script>
<svelte:head>
<title>Who? — Spiel — Mana</title>
</svelte:head>
{#if gameId}
<PlayView {gameId} />
{/if}