961fe8655a
The first user-visible slice of the AI DM / AI Player "director": a new
/director page where the AI narrates the scene, voices NPCs/monsters, and runs
enemies — grounded entirely in the campaign's real party and active encounter,
and degrading to a deterministic narrator when no AI key is set.
Engine (pure, framework-free) in src/lib/assistant/director/:
- schema.ts: DeepSeek-tolerant directorTurnSchema (z.preprocess structural
repair + z.coerce + .catch() enums + per-element safeParse-drop) → a single
validated turn { narration, rollRequests[], actions[], suggestions[] }.
- context.ts: buildDirectorScene assembles a CLOSED roster from the active
encounter (or the party) with deriveState badges — the only entities the
director may name.
- prompt.ts: persona-aware system prompt (DM/player) leading with the
systemConstraint; multi-turn message mapping from the transcript.
- engine.ts: runDirectorTurn + sanitizeTurn — the anti-hallucination gate that
drops any action referencing an off-roster entity (and ungrounded
addCombatant), mirroring the encounter advisor's candidate filter.
- fallback.ts: deterministic director that still surfaces roll buttons.
Hard rules, enforced:
- NEVER auto-roll: a roll fires only from RollRequestCard's onClick (rollAndShow).
A unit test asserts the engine layer never imports the roll seam, making
auto-roll structurally impossible.
- Approve-each: D1 is read-only (actions render as previews; the Apply bridge
lands in D2). The director never writes game state.
- Always-on fallback: works with no API key.
Also: Dexie v18 `aiSessions` table + aiSessionsRepo (+ cascade delete), a small
directorStore, a Settings card, and the /director route + nav entry.
Verified in-app on the sample 5e campaign: grounded narration named the real
current combatant; on an enemy's turn a "Aller Rosk attack vs Pip Underbough"
roll card appeared with diceRolls UNCHANGED until the button was clicked, which
then fired exactly one roll and recorded the result back into the transcript.
381 unit tests green; lint clean (3 pre-existing); build OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
3.9 KiB
TypeScript
76 lines
3.9 KiB
TypeScript
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
|
|
import { RootLayout } from '@/app/RootLayout';
|
|
import { NotFound } from '@/app/NotFound';
|
|
import { CampaignsPage } from '@/features/campaigns/CampaignsPage';
|
|
import { CharactersPage } from '@/features/characters/CharactersPage';
|
|
import { CharacterSheetPage } from '@/features/characters/CharacterSheetPage';
|
|
import { CombatPage } from '@/features/combat/CombatPage';
|
|
import { DicePage } from '@/features/dice/DicePage';
|
|
import { CompendiumPage } from '@/features/compendium/CompendiumPage';
|
|
import { DashboardPage } from '@/features/world/DashboardPage';
|
|
import { NotesPage } from '@/features/world/NotesPage';
|
|
import { NpcsPage } from '@/features/world/NpcsPage';
|
|
import { QuestsPage } from '@/features/world/QuestsPage';
|
|
import { CalendarPage } from '@/features/world/CalendarPage';
|
|
import { MapsPage } from '@/features/world/MapsPage';
|
|
import { PlayerViewPage } from '@/features/player/PlayerViewPage';
|
|
import { PlayerSetupPage } from '@/features/player/PlayerSetupPage';
|
|
import { HomebrewPage } from '@/features/world/HomebrewPage';
|
|
import { SettingsPage } from '@/features/settings/SettingsPage';
|
|
import { AssistantPage } from '@/features/assistant/AssistantPage';
|
|
import { DirectorPage } from '@/features/assistant/director/DirectorPage';
|
|
|
|
const rootRoute = createRootRoute({ component: RootLayout });
|
|
|
|
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: CampaignsPage });
|
|
const charactersRoute = createRoute({ getParentRoute: () => rootRoute, path: '/characters', component: CharactersPage });
|
|
const characterSheetRoute = createRoute({
|
|
getParentRoute: () => rootRoute,
|
|
path: '/characters/$characterId',
|
|
component: CharacterSheetPage,
|
|
});
|
|
const combatRoute = createRoute({ getParentRoute: () => rootRoute, path: '/combat', component: CombatPage });
|
|
const diceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dice', component: DicePage });
|
|
const compendiumRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compendium', component: CompendiumPage });
|
|
const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dashboard', component: DashboardPage });
|
|
const notesRoute = createRoute({ getParentRoute: () => rootRoute, path: '/notes', component: NotesPage });
|
|
const npcsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/npcs', component: NpcsPage });
|
|
const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quests', component: QuestsPage });
|
|
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
|
|
const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
|
|
const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage });
|
|
const playerRoute = createRoute({ getParentRoute: () => rootRoute, path: '/player', component: PlayerSetupPage });
|
|
const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage });
|
|
const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
|
|
const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
|
|
const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage });
|
|
|
|
const routeTree = rootRoute.addChildren([
|
|
indexRoute,
|
|
dashboardRoute,
|
|
charactersRoute,
|
|
characterSheetRoute,
|
|
combatRoute,
|
|
diceRoute,
|
|
compendiumRoute,
|
|
notesRoute,
|
|
npcsRoute,
|
|
questsRoute,
|
|
calendarRoute,
|
|
mapsRoute,
|
|
playRoute,
|
|
playerRoute,
|
|
homebrewRoute,
|
|
settingsRoute,
|
|
assistantRoute,
|
|
directorRoute,
|
|
]);
|
|
|
|
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
|
|
|
|
declare module '@tanstack/react-router' {
|
|
interface Register {
|
|
router: typeof router;
|
|
}
|
|
}
|