diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 26838a363..9a04342bc 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -47,6 +47,13 @@ export const ENCRYPTION_REGISTRY: Record = { 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). diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 7ceb3242b..ea860ab3a 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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', diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 803d52c22..4a3991d52 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -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 ────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/who/ListView.svelte b/apps/mana/apps/web/src/lib/modules/who/ListView.svelte new file mode 100644 index 000000000..8d2145184 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/who/ListView.svelte @@ -0,0 +1,206 @@ + + + +
+ +
+

Who?

+

+ Errate die historische Persönlichkeit. Eine KI verkörpert sie ohne den Namen zu verraten. +

+
+ + +
+

+ Neues Spiel starten +

+ {#if loadingDecks} +
+ {#each Array(4) as _, i (i)} +
+ {/each} +
+ {:else if decks.length === 0} +

Keine Decks verfügbar.

+ {:else} +
+ {#each decks as deck (deck.id)} + + {/each} +
+ {/if} +
+ + + {#if games.length > 0} +
+

+ Vergangene Spiele +

+
    + {#each games as game (game.id)} +
  • + {statusEmoji(game.status)} + + +
  • + {/each} +
+
+ {/if} + + {#if error} +
+ {error} +
+ {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/who/collections.ts b/apps/mana/apps/web/src/lib/modules/who/collections.ts new file mode 100644 index 000000000..2d8377be9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/who/collections.ts @@ -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('whoGames'); +export const whoMessageTable = db.table('whoMessages'); diff --git a/apps/mana/apps/web/src/lib/modules/who/index.ts b/apps/mana/apps/web/src/lib/modules/who/index.ts new file mode 100644 index 000000000..2a35bcf7d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/who/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/who/module.config.ts b/apps/mana/apps/web/src/lib/modules/who/module.config.ts new file mode 100644 index 000000000..d646c1674 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/who/module.config.ts @@ -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' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/who/queries.ts b/apps/mana/apps/web/src/lib/modules/who/queries.ts new file mode 100644 index 000000000..b0e490612 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/who/queries.ts @@ -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('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('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('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'; + } +} diff --git a/apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts b/apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts new file mode 100644 index 000000000..a2aae659e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts @@ -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(path: string, body: unknown): Promise { + 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 { + const random = await postJson('/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 { + 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('/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 = { + 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 { + const game = await whoGameTable.get(gameId); + if (!game) throw new Error('game not found'); + if (game.status !== 'playing') return false; + + const result = await postJson('/guess', { + gameId, + characterId: game.characterId, + guess, + }); + + if (!result.matched || !result.characterName) return false; + + const updates: Partial = { + 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 { + 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 { + const updates: Partial = { 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 { + 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 }); + } + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/who/types.ts b/apps/mana/apps/web/src/lib/modules/who/types.ts new file mode 100644 index 000000000..e72cf56fa --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/who/types.ts @@ -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; +} diff --git a/apps/mana/apps/web/src/lib/modules/who/views/PlayView.svelte b/apps/mana/apps/web/src/lib/modules/who/views/PlayView.svelte new file mode 100644 index 000000000..b90ffb46e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/who/views/PlayView.svelte @@ -0,0 +1,304 @@ + + + +
+ +
+ +
+
+ {#if game?.status === 'won'} + ✅ {game.revealedName} + {:else if game?.status === 'surrendered'} + 🏳️ Aufgegeben + {:else if game} + Wer bin ich? + {/if} +
+
+ {#if game} + {game.deckId} · {difficultyEmoji(game.difficulty)} · {game.messageCount} Fragen + {/if} +
+
+ {#if game?.status === 'playing'} + + + {/if} +
+ + + {#if game && game.status !== 'playing'} +
+ {#if game.status === 'won'} +

+ Erraten in {game.messageCount} Nachrichten! +

+

+ Das war {game.revealedName}. +

+ {:else} +

Spiel beendet — aufgegeben.

+ {/if} +
+ {/if} + + +
+ {#if messages.length === 0} +
+

+ Stell die erste Frage.
+ Versuche, die Persönlichkeit durch geschickte Fragen herauszufinden. +

+
+ {:else} +
+ {#each messages as msg (msg.id)} +
+
+ {msg.content} +
+
+ {/each} +
+ {/if} +
+ + {#if error} +
+ {error} +
+ {/if} + + + {#if game?.status === 'playing'} +
+
+ + +
+
+ {:else if game} +
+
+ + +
+
+ {/if} +
+ + +{#if showGuessModal} +
e.target === e.currentTarget && (showGuessModal = false)} + onkeydown={(e) => e.key === 'Escape' && (showGuessModal = false)} + role="presentation" + > +
+

Wer ist es?

+

+ Wenn die KI deine Vermutung nicht erkannt hat, kannst du den Namen hier direkt eintragen. +

+ 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 + /> +
+ + +
+
+
+{/if} diff --git a/apps/mana/apps/web/src/routes/(app)/who/+page.svelte b/apps/mana/apps/web/src/routes/(app)/who/+page.svelte new file mode 100644 index 000000000..19a1554b3 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/who/+page.svelte @@ -0,0 +1,9 @@ + + + + Who? — Mana + + + diff --git a/apps/mana/apps/web/src/routes/(app)/who/play/[gameId]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/who/play/[gameId]/+page.svelte new file mode 100644 index 000000000..85c443ed9 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/who/play/[gameId]/+page.svelte @@ -0,0 +1,14 @@ + + + + Who? — Spiel — Mana + + +{#if gameId} + +{/if}