mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
74b5808496
commit
f24438f778
13 changed files with 1018 additions and 0 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
206
apps/mana/apps/web/src/lib/modules/who/ListView.svelte
Normal file
206
apps/mana/apps/web/src/lib/modules/who/ListView.svelte
Normal 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>
|
||||
12
apps/mana/apps/web/src/lib/modules/who/collections.ts
Normal file
12
apps/mana/apps/web/src/lib/modules/who/collections.ts
Normal 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');
|
||||
25
apps/mana/apps/web/src/lib/modules/who/index.ts
Normal file
25
apps/mana/apps/web/src/lib/modules/who/index.ts
Normal 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';
|
||||
9
apps/mana/apps/web/src/lib/modules/who/module.config.ts
Normal file
9
apps/mana/apps/web/src/lib/modules/who/module.config.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
86
apps/mana/apps/web/src/lib/modules/who/queries.ts
Normal file
86
apps/mana/apps/web/src/lib/modules/who/queries.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
240
apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts
Normal file
240
apps/mana/apps/web/src/lib/modules/who/stores/games.svelte.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
96
apps/mana/apps/web/src/lib/modules/who/types.ts
Normal file
96
apps/mana/apps/web/src/lib/modules/who/types.ts
Normal 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;
|
||||
}
|
||||
304
apps/mana/apps/web/src/lib/modules/who/views/PlayView.svelte
Normal file
304
apps/mana/apps/web/src/lib/modules/who/views/PlayView.svelte
Normal 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}
|
||||
9
apps/mana/apps/web/src/routes/(app)/who/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/who/+page.svelte
Normal 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 />
|
||||
|
|
@ -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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue