This commit is contained in:
24
apps/web/server/api/rounds/demo/resolve.post.ts
Normal file
24
apps/web/server/api/rounds/demo/resolve.post.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { resolveCheck } from '@dng/game-engine'
|
||||
import { CharacterSchema, moderate13Plus } from '@dng/shared'
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const body = await readBody(event)
|
||||
const action = String(body?.action ?? '')
|
||||
if (!action.trim()) throw createError({ statusCode: 400, statusMessage: 'An action is required.' })
|
||||
if (!moderate13Plus(action).allowed) throw createError({ statusCode: 422, statusMessage: 'The action falls outside the 13+ boundary.' })
|
||||
|
||||
const actor = CharacterSchema.parse({
|
||||
id: 'char_mara', name: 'Mara Vale', concept: 'Salvage pilot', controller: 'human', userId: 'demo',
|
||||
abilities: { str: 10, dex: 16, con: 13, int: 12, wis: 14, cha: 11 }, hp: 11, maxHp: 11,
|
||||
defense: 14, proficiency: 2, inventory: ['Pulse cutter', 'Vacuum cloak'], statuses: [],
|
||||
})
|
||||
const check = resolveCheck({ actorId: actor.id, kind: 'ability', ability: 'int', difficulty: 13, mode: 'normal', reason: action }, actor)
|
||||
const success = check.success === true
|
||||
return {
|
||||
roll: `Intelligence check · ${check.formula} = ${check.total}`,
|
||||
outcome: `${success ? 'SUCCESS' : 'COMPLICATION'} · DC ${check.difficulty}`,
|
||||
narration: success
|
||||
? 'The transmission separates into two layers. Beneath your future voice is a maintenance handshake signed by Moth—dated eighty-seven years ago. Rook-7 turns toward the sealed archive as its door unlocks one deliberate centimeter. “That,” the machine says, “was not me.”'
|
||||
: 'The signal fractures when you isolate it. For one breath every screen shows a different version of the archive—open, burning, empty. Rook-7 catches one surviving packet before the rest vanish: a map leading below the station, marked in your own handwriting.',
|
||||
}
|
||||
})
|
||||
36
apps/web/server/api/worlds/generate.post.ts
Normal file
36
apps/web/server/api/worlds/generate.post.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '../../utils/ai-provider'
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
const raw = await readBody(event)
|
||||
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
|
||||
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
|
||||
const moderation = moderate13Plus(`${prompt} ${Object.values(answers).join(' ')}`)
|
||||
if (!moderation.allowed) throw createError({ statusCode: 422, statusMessage: 'This concept falls outside the alpha’s 13+ content boundary.' })
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const request = CreateWorldRequestSchema.parse({ messages: [{ role: 'user', content: `${prompt}\nPreferences: ${JSON.stringify(answers)}` }] })
|
||||
let completion
|
||||
try {
|
||||
completion = buildJsonCompletion(config, {
|
||||
system: 'Create an original 13+ TTRPG starting world. Return only valid JSON matching the supplied WorldStarter structure, with exactly 3 NPCs and 2 factions. Do not use protected franchises.',
|
||||
messages: request.messages,
|
||||
schemaName: 'world_starter',
|
||||
jsonSchema: WorldStarterSchema.toJSONSchema(),
|
||||
})
|
||||
} catch (error) {
|
||||
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Invalid AI provider configuration.' })
|
||||
}
|
||||
|
||||
const response = await fetch(completion.endpoint, {
|
||||
method: 'POST',
|
||||
headers: completion.headers,
|
||||
body: JSON.stringify(completion.body),
|
||||
})
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
try {
|
||||
return WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
|
||||
}
|
||||
})
|
||||
7
apps/web/server/plugins/validate-runtime-config.ts
Normal file
7
apps/web/server/plugins/validate-runtime-config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineNitroPlugin(() => {
|
||||
// Keep builds and unit tests credential-free, but fail before serving traffic
|
||||
// when a production Nitro process starts with an incomplete Supabase setup.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
assertRequiredSupabaseConfig(useRuntimeConfig())
|
||||
}
|
||||
})
|
||||
25
apps/web/server/utils/ai-provider.test.ts
Normal file
25
apps/web/server/utils/ai-provider.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildJsonCompletion, parseJsonCompletion, resolveAiProvider } from './ai-provider'
|
||||
|
||||
const request = { system: 'Return JSON.', messages: [{ role: 'user' as const, content: 'Create a world' }], schemaName: 'world', jsonSchema: { type: 'object' } }
|
||||
|
||||
describe('AI provider adapter', () => {
|
||||
it('keeps OpenRouter structured outputs', () => {
|
||||
const completion = buildJsonCompletion({ aiProvider: 'openrouter', openrouterApiKey: 'key', openrouterModel: 'model' }, request)
|
||||
expect(completion.endpoint).toBe('https://openrouter.ai/api/v1/chat/completions')
|
||||
expect(completion.body.response_format).toEqual({ type: 'json_schema', json_schema: { name: 'world', strict: true, schema: { type: 'object' } } })
|
||||
})
|
||||
|
||||
it('uses official DeepSeek JSON mode', () => {
|
||||
const completion = buildJsonCompletion({ aiProvider: 'deepseek', deepseekApiKey: 'key', deepseekModel: 'deepseek-v4-flash' }, request)
|
||||
expect(completion.endpoint).toBe('https://api.deepseek.com/chat/completions')
|
||||
expect(completion.body.response_format).toEqual({ type: 'json_object' })
|
||||
expect(completion.body['thinking']).toEqual({ type: 'disabled' })
|
||||
})
|
||||
|
||||
it('rejects missing credentials and malformed responses', () => {
|
||||
expect(() => resolveAiProvider({ aiProvider: 'deepseek' })).toThrow('DEEPSEEK_API_KEY')
|
||||
expect(() => parseJsonCompletion({ choices: [] })).toThrow()
|
||||
expect(parseJsonCompletion({ choices: [{ message: { content: '{"ok":true}' } }] })).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
103
apps/web/server/utils/ai-provider.ts
Normal file
103
apps/web/server/utils/ai-provider.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const ProviderSchema = z.enum(['openrouter', 'deepseek'])
|
||||
const CompletionResponseSchema = z.object({
|
||||
choices: z.array(z.object({
|
||||
message: z.object({ content: z.string().min(1) }),
|
||||
})).min(1),
|
||||
})
|
||||
|
||||
export type AiProvider = z.infer<typeof ProviderSchema>
|
||||
|
||||
export interface AiRuntimeConfig {
|
||||
aiProvider?: unknown
|
||||
openrouterApiKey?: unknown
|
||||
openrouterModel?: unknown
|
||||
openrouterApiEndpoint?: unknown
|
||||
deepseekApiKey?: unknown
|
||||
deepseekModel?: unknown
|
||||
deepseekApiEndpoint?: unknown
|
||||
}
|
||||
|
||||
export interface JsonCompletionRequest {
|
||||
system: string
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
schemaName: string
|
||||
jsonSchema: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface JsonCompletion {
|
||||
endpoint: string
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
const nonEmptyString = (value: unknown, name: string) => {
|
||||
const result = z.string().trim().min(1).safeParse(value)
|
||||
if (!result.success) throw new Error(`${name} is required for the selected AI provider`)
|
||||
return result.data
|
||||
}
|
||||
|
||||
const endpoint = (value: unknown, fallback: string, name: string) => {
|
||||
const result = z.string().url().safeParse(value || fallback)
|
||||
if (!result.success) throw new Error(`${name} must be a valid URL`)
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function resolveAiProvider(config: AiRuntimeConfig) {
|
||||
const providerResult = ProviderSchema.safeParse(config.aiProvider || 'openrouter')
|
||||
if (!providerResult.success) throw new Error('AI_PROVIDER must be either "openrouter" or "deepseek"')
|
||||
|
||||
if (providerResult.data === 'deepseek') {
|
||||
return {
|
||||
provider: 'deepseek' as const,
|
||||
apiKey: nonEmptyString(config.deepseekApiKey, 'DEEPSEEK_API_KEY'),
|
||||
model: nonEmptyString(config.deepseekModel || 'deepseek-v4-flash', 'DEEPSEEK_MODEL'),
|
||||
endpoint: endpoint(config.deepseekApiEndpoint, 'https://api.deepseek.com/chat/completions', 'DEEPSEEK_API_ENDPOINT'),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'openrouter' as const,
|
||||
apiKey: nonEmptyString(config.openrouterApiKey, 'OPENROUTER_API_KEY'),
|
||||
model: nonEmptyString(config.openrouterModel || 'deepseek/deepseek-v4-flash', 'OPENROUTER_MODEL'),
|
||||
endpoint: endpoint(config.openrouterApiEndpoint, 'https://openrouter.ai/api/v1/chat/completions', 'OPENROUTER_API_ENDPOINT'),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonCompletionRequest): JsonCompletion {
|
||||
const provider = resolveAiProvider(config)
|
||||
const messages = [{ role: 'system' as const, content: request.system }, ...request.messages]
|
||||
const common = { model: provider.model, messages, stream: false }
|
||||
|
||||
if (provider.provider === 'deepseek') {
|
||||
return {
|
||||
endpoint: provider.endpoint,
|
||||
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: {
|
||||
...common,
|
||||
messages: [
|
||||
{ role: 'system' as const, content: `${request.system}\nReturn only valid JSON matching this JSON Schema: ${JSON.stringify(request.jsonSchema)}` },
|
||||
...request.messages,
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
thinking: { type: 'disabled' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: provider.endpoint,
|
||||
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json', 'X-Title': 'Dungeons & Ground' },
|
||||
body: {
|
||||
...common,
|
||||
response_format: { type: 'json_schema', json_schema: { name: request.schemaName, strict: true, schema: request.jsonSchema } },
|
||||
provider: { require_parameters: true, data_collection: 'deny' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function parseJsonCompletion(payload: unknown): unknown {
|
||||
const completion = CompletionResponseSchema.parse(payload)
|
||||
return JSON.parse(completion.choices[0]!.message.content)
|
||||
}
|
||||
12
apps/web/server/utils/runtime-config.test.ts
Normal file
12
apps/web/server/utils/runtime-config.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertRequiredSupabaseConfig } from './runtime-config'
|
||||
|
||||
describe('required Supabase runtime config', () => {
|
||||
it('accepts complete credentials', () => {
|
||||
expect(() => assertRequiredSupabaseConfig({ supabaseUrl: 'https://example.supabase.co', supabaseServiceRoleKey: 'service', public: { supabaseUrl: 'https://example.supabase.co', supabaseAnonKey: 'anon' } })).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports missing credentials clearly', () => {
|
||||
expect(() => assertRequiredSupabaseConfig({ public: {} })).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
|
||||
})
|
||||
})
|
||||
18
apps/web/server/utils/runtime-config.ts
Normal file
18
apps/web/server/utils/runtime-config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const RequiredSupabaseConfigSchema = z.object({
|
||||
supabaseUrl: z.string({ error: 'SUPABASE_URL is required' }).trim().url('SUPABASE_URL must be a valid URL'),
|
||||
supabaseServiceRoleKey: z.string({ error: 'SUPABASE_SERVICE_ROLE_KEY is required' }).trim().min(1, 'SUPABASE_SERVICE_ROLE_KEY is required'),
|
||||
public: z.object({
|
||||
supabaseUrl: z.string({ error: 'NUXT_PUBLIC_SUPABASE_URL is required' }).trim().url('NUXT_PUBLIC_SUPABASE_URL must be a valid URL'),
|
||||
supabaseAnonKey: z.string({ error: 'NUXT_PUBLIC_SUPABASE_ANON_KEY is required' }).trim().min(1, 'NUXT_PUBLIC_SUPABASE_ANON_KEY is required'),
|
||||
}),
|
||||
})
|
||||
|
||||
export function assertRequiredSupabaseConfig(config: unknown): void {
|
||||
const result = RequiredSupabaseConfigSchema.safeParse(config)
|
||||
if (!result.success) {
|
||||
const details = result.error.issues.map(issue => issue.message).join('; ')
|
||||
throw new Error(`Invalid Supabase runtime configuration: ${details}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user