openvibers/openvibe.games Commits
AI vs AI matches scored by Bench: Vibe Heroes drafts, replays, games.fun votes (off by default) (#199)
Changes
@@@ -37,6 +37,15 @@ HOST=0.0.0.0TRUST_PROXY=MAP_PATH=data/map.json++# ── AI vs AI Vibe Heroes draft matches (off by default; billable when enabled) ──++GAMES_AI_MATCHES_ENABLED=0++GAMES_AI_MATCHES_DAILY_CAP=0++GAMES_AI_MATCHES_GAMES=2++# JSON array of two pinned OpenVibe.AI offers: [{"provider":"...","model":"..."},...]++GAMES_AI_MATCHES_OFFERS=++GAMES_AI_URL=++GAMES_BENCH_URL=https://bench.openvibe.services++# ── IndexNow (optional) ──# The IndexNow protocol key (8-128 hex or alphanumeric), as IndexNow's own key generator produces it.# When set, GET /<key>.txt serves the key file the protocol requires engines to fetch; unset means@@@ -8,6 +8,7 @@ recovery bird earns haul points, with its own all-time/this-week leaderboard andThe same Games process also hosts Salvage Rush, an on-foot timed collection game with its ownplace, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.See [docs/games.md](docs/games.md) for both games and local progression.++Server-run Vibe Heroes AI draft matches and Bench scoring are described in [docs/ai-matches.md](docs/ai-matches.md).Players build their own **creator worlds** in the map editor and publish them at `/worlds` for anyone to join(sandboxed: characters are not saved there); see [docs/games.md](docs/games.md#creator-worlds).It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with@@@ -23,7 +23,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0","pg": "^8.23.0","quickjs-emscripten": "0.32.0",nnew file mode 100644@@@ -0,0 +1,77 @@++import { describe, expect, it } from 'vitest'++import type { BenchClient } from 'openvibe-sdk/bench'++import { openTestDb } from '@openvibe/persistence/testing'++import { parseMove, playMatch, type Offer } from './game.js'++import { createAiGenerate, submitMatch } from './services.js'++import type { PlatformClient } from '../platform/serviceClient.js'++import { reserveMatch, runMatchJob } from './job.js'++++const offers: [Offer, Offer] = [{ provider: 'a', model: 'alpha' }, { provider: 'b', model: 'beta' }]++++describe('AI match', () => {++ it('sends a pinned offer and reads the AI run usage shape', async () => {++ const platform = { tokens: { getToken: async () => 'test-token' } } as unknown as PlatformClient++ const calls: Array<{ url: string; body: Record<string, unknown> }> = []++ const fetchImpl = async (input: string | URL | Request, init?: RequestInit) => {++ calls.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record<string, unknown> })++ return new Response(JSON.stringify({ run: {++ id: 'run-1', status: 'succeeded', output: { text: 'hero_a' },++ provenance: { provider: 'a', model: 'alpha' },++ usage: { tokens_in: 4, tokens_out: 2, cost_usd: 0.01 },++ } }), { status: 201 })++ }++ const result = await createAiGenerate(platform, 'https://ai.example', fetchImpl as typeof fetch)(offers[0], 'Pick a hero')++ expect(calls[0]).toMatchObject({ url: 'https://ai.example/api/v1/generate?wait=60000', body: { route: offers[0], prompt: 'Pick a hero' } })++ expect(result).toMatchObject({ text: 'hero_a', tokens: 6, costUsd: 0.01, runId: 'run-1' })++ })++++ it('accepts only an exact legal move', () => {++ expect(parseMove('hero_a', ['hero_a'])).toBe('hero_a')++ expect(parseMove('{"move":"hero_a"}', ['hero_a'])).toBe('hero_a')++ expect(parseMove('I choose hero_a', ['hero_a'])).toBeNull()++ expect(parseMove('hero_b', ['hero_a'])).toBeNull()++ })++++ it('finishes with fake AI, alternates sides and records fallbacks', async () => {++ const match = await playMatch('match-1', offers, 2, async (offer, prompt) => {++ const legal = prompt.split('Legal heroes:\n')[1]!.split('\nReply')[0]!.split('\n').map((line) => line.split(':')[0]!)++ return { text: offer.model === 'beta' ? 'invalid' : legal[0]!, costUsd: 0.01, tokens: 3, ms: 10 }++ }, 44)++ expect(match.games).toHaveLength(2)++ expect(match.games.map((g) => g.first)).toEqual([0, 1])++ expect(match.games.every((g) => g.moves.length === 6 && g.battle.events.length > 0)).toBe(true)++ expect(match.usage[1].illegalMoves).toBe(6)++ expect(match.usage[0].illegalMoves).toBe(0)++ expect(match.wins[0] + match.wins[1]).toBe(2)++ expect(match.games.every((g) => new Set(g.moves.map((m) => m.hero)).size === 6)).toBe(true)++ })++++ it('submits two entries and a game_outcome judgement per game plus a fun pair', async () => {++ const calls: Array<{ method: string; args: unknown[] }> = []++ let next = 0++ const fake = {++ tasks: { create: async (...args: unknown[]) => { calls.push({ method: 'task', args }); return { task: { id: `task-${++next}` } } } },++ rounds: { create: async (...args: unknown[]) => { calls.push({ method: 'round', args }); return { round: { id: `round-${++next}` } } } },++ entries: { submit: async (...args: unknown[]) => { calls.push({ method: 'entry', args }); return { entry: { id: `entry-${++next}` } } } },++ judgements: { cast: async (...args: unknown[]) => { calls.push({ method: 'judge', args }); return {} } },++ } as unknown as BenchClient++ const match = await playMatch('match-2', offers, 2, async (_offer, prompt) => ({ text: prompt.split('Legal heroes:\n')[1]!.split(':')[0]! }), 12)++ const receipt = await submitMatch(fake, match, 'https://openvibe.games')++ expect(receipt.rounds).toHaveLength(2)++ expect(receipt.funEntries).toHaveLength(2)++ expect(calls.filter((c) => c.method === 'entry')).toHaveLength(6)++ const entries = calls.filter((c) => c.method === 'entry')++ expect(entries[0]!.args[1]).toMatchObject({ provider: 'a', model: 'alpha', payload: { result: { replay_url: 'https://openvibe.games/ai-matches/match-2/games/1?side=0' } } })++ expect(calls.filter((c) => c.method === 'judge')).toHaveLength(2)++ expect(calls.find((c) => c.method === 'judge')!.args[1]).toMatchObject({ kind_id: 'games.match', judge: 'game_outcome', pair: { left: expect.any(String), right: expect.any(String) } })++ })++++ it('keeps the job off and enforces the UTC daily match cap', async () => {++ const db = await openTestDb()++ const fake = {} as BenchClient++ const noRun = await runMatchJob(db, fake, async () => { throw new Error('must not call AI') }, { enabled: false, dailyCap: 1, gamesPerMatch: 1, offers, publicBase: 'https://openvibe.games' })++ expect(noRun).toBeNull()++ expect(await reserveMatch(db, 1, 'cap-test-1')).toBe('cap-test-1')++ expect(await reserveMatch(db, 1, 'cap-test-2')).toBeNull()++ })++})nnew file mode 100644@@@ -0,0 +1,79 @@++import { createRng } from '@openvibe/shared'++import { heroStats, simulate, vibeHeroes, type BattleLog } from '@openvibe/casual'++++export interface Offer { provider: string; model: string }++export interface Usage { costUsd: number; tokens: number; ms: number; illegalMoves: number }++export interface AiAnswer { text: string; costUsd?: number; tokens?: number; ms?: number; runId?: string }++export type Generate = (offer: Offer, prompt: string) => Promise<AiAnswer>++export interface Move { side: 0 | 1; hero: string; legal: string[]; error: boolean; runId?: string }++export interface GameResult { seed: number; first: 0 | 1; moves: Move[]; teams: [string[], string[]]; winner: 0 | 1; battle: BattleLog; usage: [Usage, Usage] }++export interface MatchResult { id: string; game: 'vibe-heroes-draft'; offers: [Offer, Offer]; games: GameResult[]; usage: [Usage, Usage]; wins: [number, number] }++++const pack = vibeHeroes++const heroes = pack.heroes++const names = new Map(heroes.map((h) => [h.id, h]))++const empty = (): Usage => ({ costUsd: 0, tokens: 0, ms: 0, illegalMoves: 0 })++++/** A bare id or a JSON object with one move field; prose is not a move. */++export function parseMove(answer: string, legal: readonly string[]): string | null {++ const trimmed = answer.trim()++ let candidate: unknown = trimmed++ if (trimmed.startsWith('{')) {++ try { candidate = (JSON.parse(trimmed) as { move?: unknown }).move } catch { return null }++ }++ return typeof candidate === 'string' && legal.includes(candidate) ? candidate : null++}++++export function promptForPick(teams: [string[], string[]], side: 0 | 1, legal: string[]): string {++ const roster = legal.map((id) => {++ const h = names.get(id)!++ return `${id}: ${h.class}, ${h.element}, ${h.rarity}, skill ${h.skill.name}`++ })++ return `Vibe Heroes draft duel. Pick one hero for your team of three. The server auto-battles after six picks.\nYour team: ${teams[side].join(', ') || '(none)'}\nOpponent: ${teams[(1 - side) as 0 | 1].join(', ') || '(none)'}\nLegal heroes:\n${roster.join('\n')}\nReply with exactly one legal hero id.`++}++++export async function playMatch(id: string, offers: [Offer, Offer], count: number, generate: Generate, seed = 1): Promise<MatchResult> {++ if (!Number.isSafeInteger(count) || count < 1 || count > 100) throw new Error('games must be 1..100')++ if (offers.some((o) => !o.provider || !o.model)) throw new Error('two pinned offers required')++ const usage: [Usage, Usage] = [empty(), empty()]++ const games: GameResult[] = []++ const wins: [number, number] = [0, 0]++ for (let g = 0; g < count; g++) {++ const gameUsage: [Usage, Usage] = [empty(), empty()]++ const first = (g % 2) as 0 | 1++ const teams: [string[], string[]] = [[], []]++ const moves: Move[] = []++ const available = new Set(heroes.map((h) => h.id))++ const rng = createRng(seed + g)++ for (let turn = 0; turn < 6; turn++) {++ const side = ((first + turn) % 2) as 0 | 1++ const legal = [...available]++ let answer: AiAnswer | null = null++ try { answer = await generate(offers[side], promptForPick(teams, side, legal)) } catch { /* fallback */ }++ const parsed = answer ? parseMove(answer.text, legal) : null++ const error = parsed === null++ const hero = parsed ?? legal[rng.int(0, legal.length - 1)]!++ if (error) { usage[side].illegalMoves++; gameUsage[side].illegalMoves++ }++ if (answer) {++ usage[side].costUsd += answer.costUsd ?? 0++ usage[side].tokens += answer.tokens ?? 0++ usage[side].ms += answer.ms ?? 0++ gameUsage[side].costUsd += answer.costUsd ?? 0++ gameUsage[side].tokens += answer.tokens ?? 0++ gameUsage[side].ms += answer.ms ?? 0++ }++ teams[side].push(hero)++ available.delete(hero)++ moves.push({ side, hero, legal, error, ...(answer?.runId ? { runId: answer.runId } : {}) })++ }++ const units = (side: 0 | 1) => teams[side].map((id) => {++ const h = names.get(id)!++ return { id, cls: h.class, element: h.element, power: h.skill.power, stats: heroStats(pack, h, { level: 20, stars: 3, gear: {} }) }++ })++ const battle = simulate(units(first), units((1 - first) as 0 | 1), createRng(seed + g + 100_000))++ const winner = (battle.won ? first : 1 - first) as 0 | 1++ wins[winner]++++ games.push({ seed: seed + g, first, moves, teams, winner, battle, usage: gameUsage })++ }++ return { id, game: 'vibe-heroes-draft', offers, games, usage, wins }++}nnew file mode 100644@@@ -0,0 +1,43 @@++import { randomUUID } from 'node:crypto'++import { sql, type Db } from 'openvibe-sdk/db'++import type { BenchClient } from 'openvibe-sdk/bench'++import { playMatch, type Generate, type MatchResult, type Offer } from './game.js'++import { submitMatch } from './services.js'++++export interface MatchJobConfig { enabled: boolean; dailyCap: number; gamesPerMatch: number; offers: [Offer, Offer]; publicBase: string }++++/** Reserve a daily slot in SQL before making any billable AI calls. */++export async function reserveMatch(db: Db, cap: number, id: string = randomUUID()): Promise<string | null> {++ if (!Number.isSafeInteger(cap) || cap < 1) return null++ return db.tx(async (tx) => {++ await tx.exec(sql`SELECT pg_advisory_xact_lock(102030405)`)++ const row = await tx.maybe<{ id: string }>(sql`++ INSERT INTO ai_matches (id, status)++ SELECT ${id}, 'running'++ WHERE (SELECT COUNT(*) FROM ai_matches WHERE created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') < ${cap}++ RETURNING id++ `)++ return row?.id ?? null++ })++}++++export async function runMatchJob(db: Db, bench: BenchClient, generate: Generate, config: MatchJobConfig): Promise<MatchResult | null> {++ if (!config.enabled) return null++ const id = await reserveMatch(db, config.dailyCap)++ if (!id) return null++ try {++ const match = await playMatch(id, config.offers, config.gamesPerMatch, generate, Math.floor(Math.random() * 2 ** 31))++ await db.exec(sql`UPDATE ai_matches SET status = 'submitting', result = ${JSON.stringify(match)}::jsonb WHERE id = ${id}`)++ const receipt = await submitMatch(bench, match, config.publicBase)++ await db.exec(sql`UPDATE ai_matches SET status = 'complete', bench = ${JSON.stringify(receipt)}::jsonb WHERE id = ${id}`)++ return match++ } catch (error) {++ await db.exec(sql`UPDATE ai_matches SET status = 'failed', error = ${String(error).slice(0, 500)} WHERE id = ${id}`)++ throw error++ }++}++++export async function loadMatch(db: Db, id: string): Promise<{ result: MatchResult; bench: { rounds: string[]; funRound: string; funEntries: [string, string] } } | null> {++ const row = await db.maybe<{ result: unknown; bench: unknown }>(sql`SELECT result, bench FROM ai_matches WHERE id = ${id} AND status = 'complete'`)++ return row ? { result: row.result as MatchResult, bench: row.bench as { rounds: string[]; funRound: string; funEntries: [string, string] } } : null++}nnew file mode 100644@@@ -0,0 +1,62 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import type { Db } from 'openvibe-sdk/db'++import { createClient } from 'openvibe-sdk/core'++import { createBenchClient } from 'openvibe-sdk/bench'++import { parseCookies, SESSION_COOKIE } from '../net/sso.js'++import { loadMatch } from './job.js'++++const escape = (s: unknown): string => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)++const page = (title: string, body: string): string => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} · OpenVibe Games</title><link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/room.css"></head><body class="ovk-app"><main class="ovk-page"><h1>${escape(title)}</h1>${body}</main></body></html>`++const send = (res: ServerResponse, status: number, html: string) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(html) }++++export function createAiMatchRoutes(db: Db, benchUrl: string) {++ return (req: IncomingMessage, res: ServerResponse): boolean => {++ const path = new URL(req.url ?? '/', 'http://games.local').pathname++ const match = /^\/ai-matches\/([0-9a-f-]{36})(?:\/games\/(\d+)|\/fun|\/vote)?$/.exec(path)++ if (!match) return false++ void (async () => {++ const stored = await loadMatch(db, match[1]!)++ if (!stored) return send(res, 404, page('Match not found', '<p>No replay is available.</p>'))++ const { result, bench } = stored++ const link = `/ai-matches/${result.id}`++ if (path.endsWith('/vote')) {++ if (req.method !== 'POST') return send(res, 405, page('Method not allowed', ''))++ const origin = req.headers.origin++ const host = req.headers.host++ if (!origin || !host || new URL(origin).host !== host) return send(res, 403, page('Vote refused', '<p>Open this page on Games to vote.</p>'))++ const token = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? parseCookies(req.headers.cookie)[SESSION_COOKIE]++ if (!token) return send(res, 401, page('Sign in required', '<p>Sign in to vote.</p>'))++ const chunks: Buffer[] = []++ for await (const chunk of req) { chunks.push(Buffer.from(chunk)); if (Buffer.concat(chunks).length > 1024) return send(res, 413, page('Vote too large', '')) }++ const vote = new URLSearchParams(Buffer.concat(chunks).toString()).get('vote')++ if (vote !== 'left' && vote !== 'right' && vote !== 'tie') return send(res, 400, page('Invalid vote', ''))++ try {++ const client = createBenchClient(createClient({ network: benchUrl, baseUrls: { bench: benchUrl }, token, autoDiscover: false, retries: 0 }), { baseUrl: benchUrl })++ await client.judgements.cast(bench.funRound, { kind_id: 'games.fun', judge: 'human_pairwise', pair: { left: bench.funEntries[0], right: bench.funEntries[1] }, outcome: vote })++ return send(res, 200, page('Vote recorded', `<p>Thanks for voting.</p><p><a href="${link}/fun">Back to replays</a></p>`))++ } catch { return send(res, 400, page('Vote refused', '<p>Bench could not record this vote. You may have voted already.</p>')) }++ }++ if (path.endsWith('/fun')) {++ const columns = ([0, 1] as const).map((side) => {++ const game = result.games[0]!++ const actor = side === game.first ? 'a' : 'd'++ return `<td><section class="ovk-card"><h2>Replay ${side + 1}</h2><p>Team: ${game.teams[side].map(escape).join(', ')}</p><ol>${game.moves.filter((m) => m.side === side).map((m) => `<li>${escape(m.hero)}</li>`).join('')}</ol><h3>Battle</h3><ol>${game.battle.events.filter((e) => e.actor.startsWith(actor)).map((e) => `<li>Round ${e.round}: ${escape(e.kind)}</li>`).join('')}</ol><a href="${link}/games/1?side=${side}">Full replay</a></section></td>`++ }).join('')++ return send(res, 200, page('Which replay is more fun?', `<table><tr>${columns}</tr></table><form method="post" action="${link}/vote"><button name="vote" value="left">Vote replay 1</button> <button name="vote" value="right">Vote replay 2</button> <button name="vote" value="tie">Tie</button></form>`))++ }++ if (match[2]) {++ const number = Number(match[2])++ const game = result.games[number - 1]++ if (!game) return send(res, 404, page('Game not found', ''))++ const selected = new URL(req.url ?? '/', 'http://games.local').searchParams.get('side')++ const side = selected === '0' || selected === '1' ? Number(selected) as 0 | 1 : null++ const moves = game.moves.filter((m) => side === null || m.side === side).map((m, i) => `<li>Pick ${i + 1}: ${escape(result.offers[m.side].model)} chose ${escape(m.hero)}${m.error ? ' (fallback after illegal answer)' : ''}</li>`).join('')++ const events = game.battle.events.filter((e) => side === null || e.actor.startsWith(side === game.first ? 'a' : 'd')).map((e) => `<li>Round ${e.round}: ${escape(e.actor)} ${escape(e.kind)} ${e.hits.map((h) => `${escape(h.r)} ${h.dmg ?? h.heal ?? h.shield ?? 0}`).join(', ')}</li>`).join('')++ return send(res, 200, page(`Game ${number} replay`, `<p><a href="${link}">Match summary</a></p><p>Winner: ${escape(result.offers[game.winner].model)}</p><h2>Draft</h2><ol>${moves}</ol><h2>Battle</h2><ol>${events}</ol>`))++ }++ const games = result.games.map((g, i) => `<li><a href="${link}/games/${i + 1}">Game ${i + 1}</a>: ${escape(result.offers[g.winner].model)} won</li>`).join('')++ return send(res, 200, page('AI match replay', `<p>${escape(result.offers[0].model)} ${result.wins[0]}–${result.wins[1]} ${escape(result.offers[1].model)}</p><ol>${games}</ol><p><a href="${link}/fun">Vote for the more fun replay</a></p>`))++ })().catch(() => send(res, 500, page('Replay unavailable', '')))++ return true++ }++}nnew file mode 100644@@@ -0,0 +1,97 @@++import { createClient } from 'openvibe-sdk/core'++import { createBenchClient, type BenchClient } from 'openvibe-sdk/bench'++import type { PlatformClient } from '../platform/serviceClient.js'++import type { AiAnswer, Generate, MatchResult, Offer } from './game.js'++++export function createBench(platform: PlatformClient, url: string): BenchClient {++ return createBenchClient(createClient({++ network: url,++ baseUrls: { bench: url },++ tokenProvider: platform.tokens,++ autoDiscover: false,++ retries: 0,++ }), { baseUrl: url })++}++++const textOf = (output: unknown): string => {++ if (typeof output === 'string') return output++ if (!output || typeof output !== 'object') return ''++ const o = output as Record<string, unknown>++ for (const key of ['text', 'content', 'body']) if (typeof o[key] === 'string') return o[key]++ return ''++}++++/** OpenVibe.AI generate, pinned to the offered route for every move. */++export function createAiGenerate(platform: PlatformClient, url: string, fetchImpl: typeof fetch = fetch): Generate {++ return async (offer: Offer, prompt: string): Promise<AiAnswer> => {++ const started = Date.now()++ const token = await platform.tokens.getToken({ audience: 'openvibe.ai', scope: 'ai.run.create' })++ const response = await fetchImpl(`${url.replace(/\/$/, '')}/api/v1/generate?wait=60000`, {++ method: 'POST',++ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },++ body: JSON.stringify({ prompt, system: 'Choose one legal Vibe Heroes draft move. Reply with its id only.', route: offer, max_tokens: 30, temperature: 0, options: { cache: false } }),++ signal: AbortSignal.timeout(70_000),++ })++ if (!response.ok) throw new Error(`AI status ${response.status}`)++ const body = await response.json() as { run?: Record<string, unknown> }++ let run = body.run++ const deadline = Date.now() + 70_000++ while (run && (run.status === 'queued' || run.status === 'running') && Date.now() < deadline) {++ if (typeof run.id !== 'string') break++ await new Promise((resolve) => setTimeout(resolve, 500))++ const poll = await fetchImpl(`${url.replace(/\/$/, '')}/api/v1/runs/${encodeURIComponent(run.id)}`, {++ headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(10_000),++ })++ if (!poll.ok) throw new Error(`AI poll status ${poll.status}`)++ run = ((await poll.json()) as { run?: Record<string, unknown> }).run++ }++ if (!run || !['succeeded', 'cached', 'completed', 'ok'].includes(String(run.status))) throw new Error('AI run unfinished')++ const provenance = (run.provenance ?? {}) as Record<string, unknown>++ if (provenance.provider !== offer.provider || provenance.model !== offer.model) throw new Error('AI route changed')++ const usage = (run.usage ?? {}) as Record<string, unknown>++ return {++ text: textOf(run.output),++ costUsd: Number(usage.cost_usd ?? 0),++ tokens: Number(usage.tokens_in ?? 0) + Number(usage.tokens_out ?? 0),++ ms: Date.now() - started,++ ...(typeof run.id === 'string' ? { runId: run.id } : {}),++ }++ }++}++++/** Two model entries share one pooled games.match judgement per individual game. */++export async function submitMatch(bench: BenchClient, match: MatchResult, publicBase: string): Promise<{ rounds: string[]; funRound: string; funEntries: [string, string] }> {++ const task = (await bench.tasks.create('games.match', {++ prompt: 'Vibe Heroes draft duel: alternate three hero picks, then resolve the auto-battle.',++ task_class: match.game,++ public: true,++ })).task++ const rounds: string[] = []++ for (let index = 0; index < match.games.length; index++) {++ const game = match.games[index]!++ const round = (await bench.rounds.create(task.id, { offers: match.offers, seed: game.seed })).round++ const entries = []++ for (const side of [0, 1] as const) {++ const offer = match.offers[side]++ const u = game.usage[side]++ entries.push((await bench.entries.submit(round.id, {++ ...offer,++ route: { provider: offer.provider, model: offer.model },++ payload: { result: { game: match.game, winner: game.winner, side, replay_url: `${publicBase}/ai-matches/${match.id}/games/${index + 1}?side=${side}`, moves: game.moves.filter((m) => m.side === side), illegal_moves: u.illegalMoves } },++ cost_usd: u.costUsd,++ tokens: u.tokens,++ ms: u.ms,++ })).entry)++ }++ await bench.judgements.cast(round.id, { kind_id: 'games.match', judge: 'game_outcome', pair: { left: entries[0]!.id, right: entries[1]!.id }, outcome: game.winner === 0 ? 'left' : 'right' })++ rounds.push(round.id)++ }++ const funTask = (await bench.tasks.create('games.fun', { prompt: 'Which Vibe Heroes draft replay is more fun to watch?', task_class: match.game, public: true })).task++ const funRound = (await bench.rounds.create(funTask.id, { offers: match.offers, seed: match.games[0]!.seed })).round++ const funEntries: string[] = []++ for (const side of [0, 1] as const) funEntries.push((await bench.entries.submit(funRound.id, {++ ...match.offers[side], route: { provider: match.offers[side].provider, model: match.offers[side].model },++ payload: { result: { replay_url: `${publicBase}/ai-matches/${match.id}/games/1?side=${side}`, side, game: match.game } },++ })).entry.id)++ return { rounds, funRound: funRound.id, funEntries: funEntries as [string, string] }++}@@@ -100,6 +100,10 @@ import { createAssetRoutes } from './worlds/assetRoutes.js'import { createWorldPage } from './worlds/worldPage.js'import { createSteamCatalog } from './steam/steamCatalog.js'import { createSteamRoutes } from './steam/steamPage.js'++import { createAiGenerate, createBench } from './aiMatches/services.js'++import { runMatchJob } from './aiMatches/job.js'++import { createAiMatchRoutes } from './aiMatches/routes.js'++import type { Offer } from './aiMatches/game.js'import { createWorldSearchIndex } from './worlds/searchIndex.js'/**@@@ -1017,6 +1021,33 @@ async function main(): Promise<void> {address: clientAddress,log: log.child({ system: 'casual' }),})++ const benchUrl = process.env.GAMES_BENCH_URL ?? 'https://bench.openvibe.services'++ const serveAiMatches = createAiMatchRoutes(db, benchUrl)++ let aiMatchTimer: ReturnType<typeof setInterval> | null = null++ if (process.env.GAMES_AI_MATCHES_ENABLED === '1' && platform) {++ const dailyCap = Number(process.env.GAMES_AI_MATCHES_DAILY_CAP ?? 0)++ const gamesPerMatch = Number(process.env.GAMES_AI_MATCHES_GAMES ?? 2)++ let offers: [Offer, Offer] | null = null++ try {++ const raw: unknown = JSON.parse(process.env.GAMES_AI_MATCHES_OFFERS ?? 'null')++ if (Array.isArray(raw) && raw.length === 2 && raw.every((o) => o && typeof o.provider === 'string' && typeof o.model === 'string' && o.provider && o.model)) offers = raw as [Offer, Offer]++ } catch { /* invalid configuration stays off */ }++ const aiUrl = process.env.GAMES_AI_URL++ if (offers && aiUrl && Number.isSafeInteger(dailyCap) && dailyCap > 0 && Number.isSafeInteger(gamesPerMatch) && gamesPerMatch > 0 && gamesPerMatch <= 100) {++ const bench = createBench(platform, benchUrl)++ const generate = createAiGenerate(platform, aiUrl)++ let busy = false++ const tick = () => {++ if (busy) return++ busy = true++ void runMatchJob(db, bench, generate, { enabled: true, dailyCap, gamesPerMatch, offers, publicBase: gamesSiteUrl })++ .catch((error: unknown) => log.warn('AI match failed', { error: String(error) }))++ .finally(() => { busy = false })++ }++ tick()++ aiMatchTimer = setInterval(tick, 60 * 60 * 1000)++ } else log.warn('AI matches enabled but configuration is incomplete')++ }const http = createHttpServer(config.staticDir,metrics,@@@ -1124,6 +1155,7 @@ async function main(): Promise<void> {return true}if (serveCasual(req, res)) return true++ if (serveAiMatches(req, res)) return trueif (serveWorlds(req, res)) return trueif (serveAssets(req, res)) return trueif (serveWorldPage(req, res)) return true@@@ -1401,6 +1433,7 @@ async function main(): Promise<void> {const httpDone = drainer.close(DRAIN_MS)running = falseclearInterval(metricsTimer)++ if (aiMatchTimer) clearInterval(aiMatchTimer)clearInterval(heartbeatTimer)clearInterval(worldSweepTimer)worldSearch?.stop()nnew file mode 100644@@@ -0,0 +1,26 @@++# AI matches++++The first headless game is a **Vibe Heroes draft duel**. The two offers alternate six picks from the existing hero roster, three heroes per side. Games runs the existing Vibe Heroes battle simulator on the two teams. Each game alternates who picks first and who attacks. A model must return one legal hero id (or `{ "move": "id" }`). Any other answer or a failed AI request becomes a random legal pick and increments that offer's illegal move count.++++Set `GAMES_AI_MATCHES_ENABLED=1`, `GAMES_AI_MATCHES_DAILY_CAP` to a positive integer, `GAMES_AI_MATCHES_OFFERS` to a JSON pair of pinned `{provider,model}` offers, and `GAMES_AI_URL` to the OpenVibe.AI base URL. `GAMES_AI_MATCHES_GAMES` sets games per match (1–100, default 2). `GAMES_BENCH_URL` defaults to `https://bench.openvibe.services`. Games also needs its existing `OV_OAUTH_CLIENT_SECRET`. The job runs at boot and hourly, with an atomic UTC daily reservation before AI calls. It is entirely off unless explicitly enabled and configured.++++Each game produces a `games.match` Bench round with two outside entries and one `game_outcome` judgement. A separate `games.fun` round holds two replay entries; `/ai-matches/<id>/fun` displays the two sides and sends a person's vote to Bench using their own Games session or bearer token. Public replay pages are `/ai-matches/<id>` and `/ai-matches/<id>/games/<number>`.++++Required Network service grants for principal `games`:++++```json++{++ "principal": "games",++ "grants": [++ { "audience": "openvibe.ai", "capability": "ai.run.create", "namespaces": ["ai.generate"] },++ { "audience": "openvibe.bench", "capability": "bench.task.create" },++ { "audience": "openvibe.bench", "capability": "bench.round.create" },++ { "audience": "openvibe.bench", "capability": "bench.entry.create" },++ { "audience": "openvibe.bench", "capability": "bench.judgement.cast" }++ ]++}++```++++Match records, moves, battle events, and Bench receipts are stored in `ai_matches`. A failed Bench submission leaves the local match with `failed` status for operator inspection; failed submissions are not automatically retried because Bench's create endpoints have no idempotency key for this workflow. Bench also applies its own round quotas (currently three rounds per creator per day by default); one match uses `GAMES_AI_MATCHES_GAMES + 1` Bench rounds including `games.fun`, so its quota must be raised for longer or multiple daily matches.++++Bench currently rejects a `game_outcome` judgement when its subject is also the round creator. This job creates rounds and casts outcomes with the same `games` service principal, so its first judgement receives `bench.creator_cannot_vote` and the local match is marked failed. Bench needs to permit service-submitted `game_outcome` judgements from the round creator, or provide a separate authorized judge identity, before enabling this job in production.@@@ -2,6 +2,7 @@ export * from './types.js'export * from './time.js'export * from './stats.js'export * from './battle.js'++export { heroStats } from './stats.js'export * from './kit.js'export * from './actions.js'export * from './validate.js'nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++CREATE TABLE ai_matches (++ id text PRIMARY KEY,++ created_at timestamptz NOT NULL DEFAULT now(),++ status text NOT NULL CHECK (status IN ('running', 'submitting', 'complete', 'failed')),++ result jsonb,++ bench jsonb,++ error text++);++CREATE INDEX ai_matches_daily ON ai_matches (created_at);@@@ -15,6 +15,6 @@"@openvibe/gameplay": "workspace:*","@openvibe/protocol": "workspace:*","@openvibe/shared": "workspace:*",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0"}}@@@ -128,10 +128,10 @@ importers:version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0openvibe-publishing:specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0-- version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0)++ version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0)openvibe-sdk:-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0-- version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected])++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0++ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])openvibe-shared:specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0@@@ -198,8 +198,8 @@ importers:specifier: workspace:*version: link:../sharedopenvibe-sdk:-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0-- version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected])++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0++ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])packages/physics:dependencies:@@@ -1361,9 +1361,9 @@ packages:openvibe-shared:optional: true-- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0:-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0}-- version: 0.67.0++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0:++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0}++ version: 0.71.0engines: {node: '>=20'}hasBin: truepeerDependencies:@@@ -2786,13 +2786,13 @@ snapshots:ajv: 8.20.0ajv-formats: 3.0.1([email protected])-- openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0):++ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0):dependencies:-- openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected])++ openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])optionalDependencies:openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0-- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected]):++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]):optionalDependencies:'@electric-sql/pglite': 0.5.8iovalkey: 0.4.0nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/aiMatches/aiMatches.test.ts'++// covers: 'apps/server/src/aiMatches'++// Generated by node test/run.js --index; do not edit.
@@ -37,6 +37,15 @@ HOST=0.0.0.0
@@ -37,6 +37,15 @@ HOST=0.0.0.0
TRUST_PROXY=
TRUST_PROXY=
MAP_PATH=data/map.json
MAP_PATH=data/map.json
+# ── AI vs AI Vibe Heroes draft matches (off by default; billable when enabled) ──
+GAMES_AI_MATCHES_ENABLED=0
+GAMES_AI_MATCHES_DAILY_CAP=0
+GAMES_AI_MATCHES_GAMES=2
+# JSON array of two pinned OpenVibe.AI offers: [{"provider":"...","model":"..."},...]
+GAMES_AI_MATCHES_OFFERS=
+GAMES_AI_URL=
+GAMES_BENCH_URL=https://bench.openvibe.services
+
# ── IndexNow (optional) ──
# ── IndexNow (optional) ──
# The IndexNow protocol key (8-128 hex or alphanumeric), as IndexNow's own key generator produces it.
# The IndexNow protocol key (8-128 hex or alphanumeric), as IndexNow's own key generator produces it.
# When set, GET /<key>.txt serves the key file the protocol requires engines to fetch; unset means
# When set, GET /<key>.txt serves the key file the protocol requires engines to fetch; unset means
@@ -8,6 +8,7 @@ recovery bird earns haul points, with its own all-time/this-week leaderboard and
@@ -8,6 +8,7 @@ recovery bird earns haul points, with its own all-time/this-week leaderboard and
The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own
The same Games process also hosts Salvage Rush, an on-foot timed collection game with its own
place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.
place, world, rooms and round system. Play it at `/play?game=salvage-rush` on the same host.
See [docs/games.md](docs/games.md) for both games and local progression.
See [docs/games.md](docs/games.md) for both games and local progression.
+Server-run Vibe Heroes AI draft matches and Bench scoring are described in [docs/ai-matches.md](docs/ai-matches.md).
Players build their own **creator worlds** in the map editor and publish them at `/worlds` for anyone to join
Players build their own **creator worlds** in the map editor and publish them at `/worlds` for anyone to join
(sandboxed: characters are not saved there); see [docs/games.md](docs/games.md#creator-worlds).
(sandboxed: characters are not saved there); see [docs/games.md](docs/games.md#creator-worlds).
It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with
It also hosts the casual line (plan T22): small phone-first browser games built from one kit, starting with
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0",
"quickjs-emscripten": "0.32.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest'
+import type { BenchClient } from 'openvibe-sdk/bench'
+import { openTestDb } from '@openvibe/persistence/testing'
+import { parseMove, playMatch, type Offer } from './game.js'
+import { createAiGenerate, submitMatch } from './services.js'
+import type { PlatformClient } from '../platform/serviceClient.js'
+import { reserveMatch, runMatchJob } from './job.js'
+
+const offers: [Offer, Offer] = [{ provider: 'a', model: 'alpha' }, { provider: 'b', model: 'beta' }]
+
+describe('AI match', () => {
+ it('sends a pinned offer and reads the AI run usage shape', async () => {
+ const platform = { tokens: { getToken: async () => 'test-token' } } as unknown as PlatformClient
+ const calls: Array<{ url: string; body: Record<string, unknown> }> = []
+ const fetchImpl = async (input: string | URL | Request, init?: RequestInit) => {
+ calls.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record<string, unknown> })
+ return new Response(JSON.stringify({ run: {
+ id: 'run-1', status: 'succeeded', output: { text: 'hero_a' },
+ provenance: { provider: 'a', model: 'alpha' },
+ usage: { tokens_in: 4, tokens_out: 2, cost_usd: 0.01 },
+ } }), { status: 201 })
+ }
+ const result = await createAiGenerate(platform, 'https://ai.example', fetchImpl as typeof fetch)(offers[0], 'Pick a hero')
+ expect(calls[0]).toMatchObject({ url: 'https://ai.example/api/v1/generate?wait=60000', body: { route: offers[0], prompt: 'Pick a hero' } })
+ expect(result).toMatchObject({ text: 'hero_a', tokens: 6, costUsd: 0.01, runId: 'run-1' })
+ })
+
+ it('accepts only an exact legal move', () => {
+ expect(parseMove('hero_a', ['hero_a'])).toBe('hero_a')
+ expect(parseMove('{"move":"hero_a"}', ['hero_a'])).toBe('hero_a')
+ expect(parseMove('I choose hero_a', ['hero_a'])).toBeNull()
+ expect(parseMove('hero_b', ['hero_a'])).toBeNull()
+ })
+
+ it('finishes with fake AI, alternates sides and records fallbacks', async () => {
+ const match = await playMatch('match-1', offers, 2, async (offer, prompt) => {
+ const legal = prompt.split('Legal heroes:\n')[1]!.split('\nReply')[0]!.split('\n').map((line) => line.split(':')[0]!)
+ return { text: offer.model === 'beta' ? 'invalid' : legal[0]!, costUsd: 0.01, tokens: 3, ms: 10 }
+ }, 44)
+ expect(match.games).toHaveLength(2)
+ expect(match.games.map((g) => g.first)).toEqual([0, 1])
+ expect(match.games.every((g) => g.moves.length === 6 && g.battle.events.length > 0)).toBe(true)
+ expect(match.usage[1].illegalMoves).toBe(6)
+ expect(match.usage[0].illegalMoves).toBe(0)
+ expect(match.wins[0] + match.wins[1]).toBe(2)
+ expect(match.games.every((g) => new Set(g.moves.map((m) => m.hero)).size === 6)).toBe(true)
+ })
+
+ it('submits two entries and a game_outcome judgement per game plus a fun pair', async () => {
+ const calls: Array<{ method: string; args: unknown[] }> = []
+ let next = 0
+ const fake = {
+ tasks: { create: async (...args: unknown[]) => { calls.push({ method: 'task', args }); return { task: { id: `task-${++next}` } } } },
+ rounds: { create: async (...args: unknown[]) => { calls.push({ method: 'round', args }); return { round: { id: `round-${++next}` } } } },
+ entries: { submit: async (...args: unknown[]) => { calls.push({ method: 'entry', args }); return { entry: { id: `entry-${++next}` } } } },
+ judgements: { cast: async (...args: unknown[]) => { calls.push({ method: 'judge', args }); return {} } },
+ } as unknown as BenchClient
+ const match = await playMatch('match-2', offers, 2, async (_offer, prompt) => ({ text: prompt.split('Legal heroes:\n')[1]!.split(':')[0]! }), 12)
+ const receipt = await submitMatch(fake, match, 'https://openvibe.games')
+ expect(receipt.rounds).toHaveLength(2)
+ expect(receipt.funEntries).toHaveLength(2)
+ expect(calls.filter((c) => c.method === 'entry')).toHaveLength(6)
+ const entries = calls.filter((c) => c.method === 'entry')
+ expect(entries[0]!.args[1]).toMatchObject({ provider: 'a', model: 'alpha', payload: { result: { replay_url: 'https://openvibe.games/ai-matches/match-2/games/1?side=0' } } })
+ expect(calls.filter((c) => c.method === 'judge')).toHaveLength(2)
+ expect(calls.find((c) => c.method === 'judge')!.args[1]).toMatchObject({ kind_id: 'games.match', judge: 'game_outcome', pair: { left: expect.any(String), right: expect.any(String) } })
+ })
+
+ it('keeps the job off and enforces the UTC daily match cap', async () => {
+ const db = await openTestDb()
+ const fake = {} as BenchClient
+ const noRun = await runMatchJob(db, fake, async () => { throw new Error('must not call AI') }, { enabled: false, dailyCap: 1, gamesPerMatch: 1, offers, publicBase: 'https://openvibe.games' })
+ expect(noRun).toBeNull()
+ expect(await reserveMatch(db, 1, 'cap-test-1')).toBe('cap-test-1')
+ expect(await reserveMatch(db, 1, 'cap-test-2')).toBeNull()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+import { createRng } from '@openvibe/shared'
+import { heroStats, simulate, vibeHeroes, type BattleLog } from '@openvibe/casual'
+
+export interface Offer { provider: string; model: string }
+export interface Usage { costUsd: number; tokens: number; ms: number; illegalMoves: number }
+export interface AiAnswer { text: string; costUsd?: number; tokens?: number; ms?: number; runId?: string }
+export type Generate = (offer: Offer, prompt: string) => Promise<AiAnswer>
+export interface Move { side: 0 | 1; hero: string; legal: string[]; error: boolean; runId?: string }
+export interface GameResult { seed: number; first: 0 | 1; moves: Move[]; teams: [string[], string[]]; winner: 0 | 1; battle: BattleLog; usage: [Usage, Usage] }
+export interface MatchResult { id: string; game: 'vibe-heroes-draft'; offers: [Offer, Offer]; games: GameResult[]; usage: [Usage, Usage]; wins: [number, number] }
+
+const pack = vibeHeroes
+const heroes = pack.heroes
+const names = new Map(heroes.map((h) => [h.id, h]))
+const empty = (): Usage => ({ costUsd: 0, tokens: 0, ms: 0, illegalMoves: 0 })
+
+/** A bare id or a JSON object with one move field; prose is not a move. */
+export function parseMove(answer: string, legal: readonly string[]): string | null {
+ const trimmed = answer.trim()
+ let candidate: unknown = trimmed
+ if (trimmed.startsWith('{')) {
+ try { candidate = (JSON.parse(trimmed) as { move?: unknown }).move } catch { return null }
+ }
+ return typeof candidate === 'string' && legal.includes(candidate) ? candidate : null
+}
+
+export function promptForPick(teams: [string[], string[]], side: 0 | 1, legal: string[]): string {
+ const roster = legal.map((id) => {
+ const h = names.get(id)!
+ return `${id}: ${h.class}, ${h.element}, ${h.rarity}, skill ${h.skill.name}`
+ })
+ return `Vibe Heroes draft duel. Pick one hero for your team of three. The server auto-battles after six picks.\nYour team: ${teams[side].join(', ') || '(none)'}\nOpponent: ${teams[(1 - side) as 0 | 1].join(', ') || '(none)'}\nLegal heroes:\n${roster.join('\n')}\nReply with exactly one legal hero id.`
+}
+
+export async function playMatch(id: string, offers: [Offer, Offer], count: number, generate: Generate, seed = 1): Promise<MatchResult> {
+ if (!Number.isSafeInteger(count) || count < 1 || count > 100) throw new Error('games must be 1..100')
+ if (offers.some((o) => !o.provider || !o.model)) throw new Error('two pinned offers required')
+ const usage: [Usage, Usage] = [empty(), empty()]
+ const games: GameResult[] = []
+ const wins: [number, number] = [0, 0]
+ for (let g = 0; g < count; g++) {
+ const gameUsage: [Usage, Usage] = [empty(), empty()]
+ const first = (g % 2) as 0 | 1
+ const teams: [string[], string[]] = [[], []]
+ const moves: Move[] = []
+ const available = new Set(heroes.map((h) => h.id))
+ const rng = createRng(seed + g)
+ for (let turn = 0; turn < 6; turn++) {
+ const side = ((first + turn) % 2) as 0 | 1
+ const legal = [...available]
+ let answer: AiAnswer | null = null
+ try { answer = await generate(offers[side], promptForPick(teams, side, legal)) } catch { /* fallback */ }
+ const parsed = answer ? parseMove(answer.text, legal) : null
+ const error = parsed === null
+ const hero = parsed ?? legal[rng.int(0, legal.length - 1)]!
+ if (error) { usage[side].illegalMoves++; gameUsage[side].illegalMoves++ }
+ if (answer) {
+ usage[side].costUsd += answer.costUsd ?? 0
+ usage[side].tokens += answer.tokens ?? 0
+ usage[side].ms += answer.ms ?? 0
+ gameUsage[side].costUsd += answer.costUsd ?? 0
+ gameUsage[side].tokens += answer.tokens ?? 0
+ gameUsage[side].ms += answer.ms ?? 0
+ }
+ teams[side].push(hero)
+ available.delete(hero)
+ moves.push({ side, hero, legal, error, ...(answer?.runId ? { runId: answer.runId } : {}) })
+ }
+ const units = (side: 0 | 1) => teams[side].map((id) => {
+ const h = names.get(id)!
+ return { id, cls: h.class, element: h.element, power: h.skill.power, stats: heroStats(pack, h, { level: 20, stars: 3, gear: {} }) }
+ })
+ const battle = simulate(units(first), units((1 - first) as 0 | 1), createRng(seed + g + 100_000))
+ const winner = (battle.won ? first : 1 - first) as 0 | 1
+ wins[winner]++
+ games.push({ seed: seed + g, first, moves, teams, winner, battle, usage: gameUsage })
+ }
+ return { id, game: 'vibe-heroes-draft', offers, games, usage, wins }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+import { randomUUID } from 'node:crypto'
+import { sql, type Db } from 'openvibe-sdk/db'
+import type { BenchClient } from 'openvibe-sdk/bench'
+import { playMatch, type Generate, type MatchResult, type Offer } from './game.js'
+import { submitMatch } from './services.js'
+
+export interface MatchJobConfig { enabled: boolean; dailyCap: number; gamesPerMatch: number; offers: [Offer, Offer]; publicBase: string }
+
+/** Reserve a daily slot in SQL before making any billable AI calls. */
+export async function reserveMatch(db: Db, cap: number, id: string = randomUUID()): Promise<string | null> {
+ if (!Number.isSafeInteger(cap) || cap < 1) return null
+ return db.tx(async (tx) => {
+ await tx.exec(sql`SELECT pg_advisory_xact_lock(102030405)`)
+ const row = await tx.maybe<{ id: string }>(sql`
+ INSERT INTO ai_matches (id, status)
+ SELECT ${id}, 'running'
+ WHERE (SELECT COUNT(*) FROM ai_matches WHERE created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') < ${cap}
+ RETURNING id
+ `)
+ return row?.id ?? null
+ })
+}
+
+export async function runMatchJob(db: Db, bench: BenchClient, generate: Generate, config: MatchJobConfig): Promise<MatchResult | null> {
+ if (!config.enabled) return null
+ const id = await reserveMatch(db, config.dailyCap)
+ if (!id) return null
+ try {
+ const match = await playMatch(id, config.offers, config.gamesPerMatch, generate, Math.floor(Math.random() * 2 ** 31))
+ await db.exec(sql`UPDATE ai_matches SET status = 'submitting', result = ${JSON.stringify(match)}::jsonb WHERE id = ${id}`)
+ const receipt = await submitMatch(bench, match, config.publicBase)
+ await db.exec(sql`UPDATE ai_matches SET status = 'complete', bench = ${JSON.stringify(receipt)}::jsonb WHERE id = ${id}`)
+ return match
+ } catch (error) {
+ await db.exec(sql`UPDATE ai_matches SET status = 'failed', error = ${String(error).slice(0, 500)} WHERE id = ${id}`)
+ throw error
+ }
+}
+
+export async function loadMatch(db: Db, id: string): Promise<{ result: MatchResult; bench: { rounds: string[]; funRound: string; funEntries: [string, string] } } | null> {
+ const row = await db.maybe<{ result: unknown; bench: unknown }>(sql`SELECT result, bench FROM ai_matches WHERE id = ${id} AND status = 'complete'`)
+ return row ? { result: row.result as MatchResult, bench: row.bench as { rounds: string[]; funRound: string; funEntries: [string, string] } } : null
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import type { Db } from 'openvibe-sdk/db'
+import { createClient } from 'openvibe-sdk/core'
+import { createBenchClient } from 'openvibe-sdk/bench'
+import { parseCookies, SESSION_COOKIE } from '../net/sso.js'
+import { loadMatch } from './job.js'
+
+const escape = (s: unknown): string => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
+const page = (title: string, body: string): string => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} · OpenVibe Games</title><link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/room.css"></head><body class="ovk-app"><main class="ovk-page"><h1>${escape(title)}</h1>${body}</main></body></html>`
+const send = (res: ServerResponse, status: number, html: string) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(html) }
+
+export function createAiMatchRoutes(db: Db, benchUrl: string) {
+ return (req: IncomingMessage, res: ServerResponse): boolean => {
+ const path = new URL(req.url ?? '/', 'http://games.local').pathname
+ const match = /^\/ai-matches\/([0-9a-f-]{36})(?:\/games\/(\d+)|\/fun|\/vote)?$/.exec(path)
+ if (!match) return false
+ void (async () => {
+ const stored = await loadMatch(db, match[1]!)
+ if (!stored) return send(res, 404, page('Match not found', '<p>No replay is available.</p>'))
+ const { result, bench } = stored
+ const link = `/ai-matches/${result.id}`
+ if (path.endsWith('/vote')) {
+ if (req.method !== 'POST') return send(res, 405, page('Method not allowed', ''))
+ const origin = req.headers.origin
+ const host = req.headers.host
+ if (!origin || !host || new URL(origin).host !== host) return send(res, 403, page('Vote refused', '<p>Open this page on Games to vote.</p>'))
+ const token = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? parseCookies(req.headers.cookie)[SESSION_COOKIE]
+ if (!token) return send(res, 401, page('Sign in required', '<p>Sign in to vote.</p>'))
+ const chunks: Buffer[] = []
+ for await (const chunk of req) { chunks.push(Buffer.from(chunk)); if (Buffer.concat(chunks).length > 1024) return send(res, 413, page('Vote too large', '')) }
+ const vote = new URLSearchParams(Buffer.concat(chunks).toString()).get('vote')
+ if (vote !== 'left' && vote !== 'right' && vote !== 'tie') return send(res, 400, page('Invalid vote', ''))
+ try {
+ const client = createBenchClient(createClient({ network: benchUrl, baseUrls: { bench: benchUrl }, token, autoDiscover: false, retries: 0 }), { baseUrl: benchUrl })
+ await client.judgements.cast(bench.funRound, { kind_id: 'games.fun', judge: 'human_pairwise', pair: { left: bench.funEntries[0], right: bench.funEntries[1] }, outcome: vote })
+ return send(res, 200, page('Vote recorded', `<p>Thanks for voting.</p><p><a href="${link}/fun">Back to replays</a></p>`))
+ } catch { return send(res, 400, page('Vote refused', '<p>Bench could not record this vote. You may have voted already.</p>')) }
+ }
+ if (path.endsWith('/fun')) {
+ const columns = ([0, 1] as const).map((side) => {
+ const game = result.games[0]!
+ const actor = side === game.first ? 'a' : 'd'
+ return `<td><section class="ovk-card"><h2>Replay ${side + 1}</h2><p>Team: ${game.teams[side].map(escape).join(', ')}</p><ol>${game.moves.filter((m) => m.side === side).map((m) => `<li>${escape(m.hero)}</li>`).join('')}</ol><h3>Battle</h3><ol>${game.battle.events.filter((e) => e.actor.startsWith(actor)).map((e) => `<li>Round ${e.round}: ${escape(e.kind)}</li>`).join('')}</ol><a href="${link}/games/1?side=${side}">Full replay</a></section></td>`
+ }).join('')
+ return send(res, 200, page('Which replay is more fun?', `<table><tr>${columns}</tr></table><form method="post" action="${link}/vote"><button name="vote" value="left">Vote replay 1</button> <button name="vote" value="right">Vote replay 2</button> <button name="vote" value="tie">Tie</button></form>`))
+ }
+ if (match[2]) {
+ const number = Number(match[2])
+ const game = result.games[number - 1]
+ if (!game) return send(res, 404, page('Game not found', ''))
+ const selected = new URL(req.url ?? '/', 'http://games.local').searchParams.get('side')
+ const side = selected === '0' || selected === '1' ? Number(selected) as 0 | 1 : null
+ const moves = game.moves.filter((m) => side === null || m.side === side).map((m, i) => `<li>Pick ${i + 1}: ${escape(result.offers[m.side].model)} chose ${escape(m.hero)}${m.error ? ' (fallback after illegal answer)' : ''}</li>`).join('')
+ const events = game.battle.events.filter((e) => side === null || e.actor.startsWith(side === game.first ? 'a' : 'd')).map((e) => `<li>Round ${e.round}: ${escape(e.actor)} ${escape(e.kind)} ${e.hits.map((h) => `${escape(h.r)} ${h.dmg ?? h.heal ?? h.shield ?? 0}`).join(', ')}</li>`).join('')
+ return send(res, 200, page(`Game ${number} replay`, `<p><a href="${link}">Match summary</a></p><p>Winner: ${escape(result.offers[game.winner].model)}</p><h2>Draft</h2><ol>${moves}</ol><h2>Battle</h2><ol>${events}</ol>`))
+ }
+ const games = result.games.map((g, i) => `<li><a href="${link}/games/${i + 1}">Game ${i + 1}</a>: ${escape(result.offers[g.winner].model)} won</li>`).join('')
+ return send(res, 200, page('AI match replay', `<p>${escape(result.offers[0].model)} ${result.wins[0]}–${result.wins[1]} ${escape(result.offers[1].model)}</p><ol>${games}</ol><p><a href="${link}/fun">Vote for the more fun replay</a></p>`))
+ })().catch(() => send(res, 500, page('Replay unavailable', '')))
+ return true
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+import { createClient } from 'openvibe-sdk/core'
+import { createBenchClient, type BenchClient } from 'openvibe-sdk/bench'
+import type { PlatformClient } from '../platform/serviceClient.js'
+import type { AiAnswer, Generate, MatchResult, Offer } from './game.js'
+
+export function createBench(platform: PlatformClient, url: string): BenchClient {
+ return createBenchClient(createClient({
+ network: url,
+ baseUrls: { bench: url },
+ tokenProvider: platform.tokens,
+ autoDiscover: false,
+ retries: 0,
+ }), { baseUrl: url })
+}
+
+const textOf = (output: unknown): string => {
+ if (typeof output === 'string') return output
+ if (!output || typeof output !== 'object') return ''
+ const o = output as Record<string, unknown>
+ for (const key of ['text', 'content', 'body']) if (typeof o[key] === 'string') return o[key]
+ return ''
+}
+
+/** OpenVibe.AI generate, pinned to the offered route for every move. */
+export function createAiGenerate(platform: PlatformClient, url: string, fetchImpl: typeof fetch = fetch): Generate {
+ return async (offer: Offer, prompt: string): Promise<AiAnswer> => {
+ const started = Date.now()
+ const token = await platform.tokens.getToken({ audience: 'openvibe.ai', scope: 'ai.run.create' })
+ const response = await fetchImpl(`${url.replace(/\/$/, '')}/api/v1/generate?wait=60000`, {
+ method: 'POST',
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
+ body: JSON.stringify({ prompt, system: 'Choose one legal Vibe Heroes draft move. Reply with its id only.', route: offer, max_tokens: 30, temperature: 0, options: { cache: false } }),
+ signal: AbortSignal.timeout(70_000),
+ })
+ if (!response.ok) throw new Error(`AI status ${response.status}`)
+ const body = await response.json() as { run?: Record<string, unknown> }
+ let run = body.run
+ const deadline = Date.now() + 70_000
+ while (run && (run.status === 'queued' || run.status === 'running') && Date.now() < deadline) {
+ if (typeof run.id !== 'string') break
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ const poll = await fetchImpl(`${url.replace(/\/$/, '')}/api/v1/runs/${encodeURIComponent(run.id)}`, {
+ headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(10_000),
+ })
+ if (!poll.ok) throw new Error(`AI poll status ${poll.status}`)
+ run = ((await poll.json()) as { run?: Record<string, unknown> }).run
+ }
+ if (!run || !['succeeded', 'cached', 'completed', 'ok'].includes(String(run.status))) throw new Error('AI run unfinished')
+ const provenance = (run.provenance ?? {}) as Record<string, unknown>
+ if (provenance.provider !== offer.provider || provenance.model !== offer.model) throw new Error('AI route changed')
+ const usage = (run.usage ?? {}) as Record<string, unknown>
+ return {
+ text: textOf(run.output),
+ costUsd: Number(usage.cost_usd ?? 0),
+ tokens: Number(usage.tokens_in ?? 0) + Number(usage.tokens_out ?? 0),
+ ms: Date.now() - started,
+ ...(typeof run.id === 'string' ? { runId: run.id } : {}),
+ }
+ }
+}
+
+/** Two model entries share one pooled games.match judgement per individual game. */
+export async function submitMatch(bench: BenchClient, match: MatchResult, publicBase: string): Promise<{ rounds: string[]; funRound: string; funEntries: [string, string] }> {
+ const task = (await bench.tasks.create('games.match', {
+ prompt: 'Vibe Heroes draft duel: alternate three hero picks, then resolve the auto-battle.',
+ task_class: match.game,
+ public: true,
+ })).task
+ const rounds: string[] = []
+ for (let index = 0; index < match.games.length; index++) {
+ const game = match.games[index]!
+ const round = (await bench.rounds.create(task.id, { offers: match.offers, seed: game.seed })).round
+ const entries = []
+ for (const side of [0, 1] as const) {
+ const offer = match.offers[side]
+ const u = game.usage[side]
+ entries.push((await bench.entries.submit(round.id, {
+ ...offer,
+ route: { provider: offer.provider, model: offer.model },
+ payload: { result: { game: match.game, winner: game.winner, side, replay_url: `${publicBase}/ai-matches/${match.id}/games/${index + 1}?side=${side}`, moves: game.moves.filter((m) => m.side === side), illegal_moves: u.illegalMoves } },
+ cost_usd: u.costUsd,
+ tokens: u.tokens,
+ ms: u.ms,
+ })).entry)
+ }
+ await bench.judgements.cast(round.id, { kind_id: 'games.match', judge: 'game_outcome', pair: { left: entries[0]!.id, right: entries[1]!.id }, outcome: game.winner === 0 ? 'left' : 'right' })
+ rounds.push(round.id)
+ }
+ const funTask = (await bench.tasks.create('games.fun', { prompt: 'Which Vibe Heroes draft replay is more fun to watch?', task_class: match.game, public: true })).task
+ const funRound = (await bench.rounds.create(funTask.id, { offers: match.offers, seed: match.games[0]!.seed })).round
+ const funEntries: string[] = []
+ for (const side of [0, 1] as const) funEntries.push((await bench.entries.submit(funRound.id, {
+ ...match.offers[side], route: { provider: match.offers[side].provider, model: match.offers[side].model },
+ payload: { result: { replay_url: `${publicBase}/ai-matches/${match.id}/games/1?side=${side}`, side, game: match.game } },
+ })).entry.id)
+ return { rounds, funRound: funRound.id, funEntries: funEntries as [string, string] }
+}
@@ -100,6 +100,10 @@ import { createAssetRoutes } from './worlds/assetRoutes.js'
@@ -100,6 +100,10 @@ import { createAssetRoutes } from './worlds/assetRoutes.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createSteamCatalog } from './steam/steamCatalog.js'
import { createSteamCatalog } from './steam/steamCatalog.js'
import { createSteamRoutes } from './steam/steamPage.js'
import { createSteamRoutes } from './steam/steamPage.js'
+import { createAiGenerate, createBench } from './aiMatches/services.js'
+import { runMatchJob } from './aiMatches/job.js'
+import { createAiMatchRoutes } from './aiMatches/routes.js'
+import type { Offer } from './aiMatches/game.js'
import { createWorldSearchIndex } from './worlds/searchIndex.js'
import { createWorldSearchIndex } from './worlds/searchIndex.js'
/**
/**
@@ -1017,6 +1021,33 @@ async function main(): Promise<void> {
@@ -1017,6 +1021,33 @@ async function main(): Promise<void> {
address: clientAddress,
address: clientAddress,
log: log.child({ system: 'casual' }),
log: log.child({ system: 'casual' }),
})
})
+ const benchUrl = process.env.GAMES_BENCH_URL ?? 'https://bench.openvibe.services'
+ const serveAiMatches = createAiMatchRoutes(db, benchUrl)
+ let aiMatchTimer: ReturnType<typeof setInterval> | null = null
+ if (process.env.GAMES_AI_MATCHES_ENABLED === '1' && platform) {
+ const dailyCap = Number(process.env.GAMES_AI_MATCHES_DAILY_CAP ?? 0)
+ const gamesPerMatch = Number(process.env.GAMES_AI_MATCHES_GAMES ?? 2)
+ let offers: [Offer, Offer] | null = null
+ try {
+ const raw: unknown = JSON.parse(process.env.GAMES_AI_MATCHES_OFFERS ?? 'null')
+ if (Array.isArray(raw) && raw.length === 2 && raw.every((o) => o && typeof o.provider === 'string' && typeof o.model === 'string' && o.provider && o.model)) offers = raw as [Offer, Offer]
+ } catch { /* invalid configuration stays off */ }
+ const aiUrl = process.env.GAMES_AI_URL
+ if (offers && aiUrl && Number.isSafeInteger(dailyCap) && dailyCap > 0 && Number.isSafeInteger(gamesPerMatch) && gamesPerMatch > 0 && gamesPerMatch <= 100) {
+ const bench = createBench(platform, benchUrl)
+ const generate = createAiGenerate(platform, aiUrl)
+ let busy = false
+ const tick = () => {
+ if (busy) return
+ busy = true
+ void runMatchJob(db, bench, generate, { enabled: true, dailyCap, gamesPerMatch, offers, publicBase: gamesSiteUrl })
+ .catch((error: unknown) => log.warn('AI match failed', { error: String(error) }))
+ .finally(() => { busy = false })
+ }
+ tick()
+ aiMatchTimer = setInterval(tick, 60 * 60 * 1000)
+ } else log.warn('AI matches enabled but configuration is incomplete')
+ }
const http = createHttpServer(
const http = createHttpServer(
config.staticDir,
config.staticDir,
metrics,
metrics,
@@ -1124,6 +1155,7 @@ async function main(): Promise<void> {
@@ -1124,6 +1155,7 @@ async function main(): Promise<void> {
return true
return true
}
}
if (serveCasual(req, res)) return true
if (serveCasual(req, res)) return true
+ if (serveAiMatches(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveAssets(req, res)) return true
if (serveAssets(req, res)) return true
if (serveWorldPage(req, res)) return true
if (serveWorldPage(req, res)) return true
@@ -1401,6 +1433,7 @@ async function main(): Promise<void> {
@@ -1401,6 +1433,7 @@ async function main(): Promise<void> {
const httpDone = drainer.close(DRAIN_MS)
const httpDone = drainer.close(DRAIN_MS)
running = false
running = false
clearInterval(metricsTimer)
clearInterval(metricsTimer)
+ if (aiMatchTimer) clearInterval(aiMatchTimer)
clearInterval(heartbeatTimer)
clearInterval(heartbeatTimer)
clearInterval(worldSweepTimer)
clearInterval(worldSweepTimer)
worldSearch?.stop()
worldSearch?.stop()
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+# AI matches
+
+The first headless game is a **Vibe Heroes draft duel**. The two offers alternate six picks from the existing hero roster, three heroes per side. Games runs the existing Vibe Heroes battle simulator on the two teams. Each game alternates who picks first and who attacks. A model must return one legal hero id (or `{ "move": "id" }`). Any other answer or a failed AI request becomes a random legal pick and increments that offer's illegal move count.
+
+Set `GAMES_AI_MATCHES_ENABLED=1`, `GAMES_AI_MATCHES_DAILY_CAP` to a positive integer, `GAMES_AI_MATCHES_OFFERS` to a JSON pair of pinned `{provider,model}` offers, and `GAMES_AI_URL` to the OpenVibe.AI base URL. `GAMES_AI_MATCHES_GAMES` sets games per match (1–100, default 2). `GAMES_BENCH_URL` defaults to `https://bench.openvibe.services`. Games also needs its existing `OV_OAUTH_CLIENT_SECRET`. The job runs at boot and hourly, with an atomic UTC daily reservation before AI calls. It is entirely off unless explicitly enabled and configured.
+
+Each game produces a `games.match` Bench round with two outside entries and one `game_outcome` judgement. A separate `games.fun` round holds two replay entries; `/ai-matches/<id>/fun` displays the two sides and sends a person's vote to Bench using their own Games session or bearer token. Public replay pages are `/ai-matches/<id>` and `/ai-matches/<id>/games/<number>`.
+
+Required Network service grants for principal `games`:
+
+```json
+{
+ "principal": "games",
+ "grants": [
+ { "audience": "openvibe.ai", "capability": "ai.run.create", "namespaces": ["ai.generate"] },
+ { "audience": "openvibe.bench", "capability": "bench.task.create" },
+ { "audience": "openvibe.bench", "capability": "bench.round.create" },
+ { "audience": "openvibe.bench", "capability": "bench.entry.create" },
+ { "audience": "openvibe.bench", "capability": "bench.judgement.cast" }
+ ]
+}
+```
+
+Match records, moves, battle events, and Bench receipts are stored in `ai_matches`. A failed Bench submission leaves the local match with `failed` status for operator inspection; failed submissions are not automatically retried because Bench's create endpoints have no idempotency key for this workflow. Bench also applies its own round quotas (currently three rounds per creator per day by default); one match uses `GAMES_AI_MATCHES_GAMES + 1` Bench rounds including `games.fun`, so its quota must be raised for longer or multiple daily matches.
+
+Bench currently rejects a `game_outcome` judgement when its subject is also the round creator. This job creates rounds and casts outcomes with the same `games` service principal, so its first judgement receives `bench.creator_cannot_vote` and the local match is marked failed. Bench needs to permit service-submitted `game_outcome` judgements from the round creator, or provide a separate authorized judge identity, before enabling this job in production.
@@ -2,6 +2,7 @@ export * from './types.js'
@@ -2,6 +2,7 @@ export * from './types.js'
export * from './time.js'
export * from './time.js'
export * from './stats.js'
export * from './stats.js'
export * from './battle.js'
export * from './battle.js'
+export { heroStats } from './stats.js'
export * from './kit.js'
export * from './kit.js'
export * from './actions.js'
export * from './actions.js'
export * from './validate.js'
export * from './validate.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+CREATE TABLE ai_matches (
+ id text PRIMARY KEY,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ status text NOT NULL CHECK (status IN ('running', 'submitting', 'complete', 'failed')),
+ result jsonb,
+ bench jsonb,
+ error text
+);
+CREATE INDEX ai_matches_daily ON ai_matches (created_at);
@@ -15,6 +15,6 @@
@@ -15,6 +15,6 @@
"@openvibe/gameplay": "workspace:*",
"@openvibe/gameplay": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/shared": "workspace:*",
"@openvibe/shared": "workspace:*",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0"
}
}
}
}
@@ -128,10 +128,10 @@ importers:
@@ -128,10 +128,10 @@ importers:
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0
openvibe-publishing:
openvibe-publishing:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0
- version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0)
+ version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0)
openvibe-sdk:
openvibe-sdk:
- specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0
- version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected])
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0
+ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
openvibe-shared:
openvibe-shared:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
@@ -198,8 +198,8 @@ importers:
@@ -198,8 +198,8 @@ importers:
specifier: workspace:*
specifier: workspace:*
version: link:../shared
version: link:../shared
openvibe-sdk:
openvibe-sdk:
- specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0
- version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected])
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0
+ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
packages/physics:
packages/physics:
dependencies:
dependencies:
@@ -1361,9 +1361,9 @@ packages:
@@ -1361,9 +1361,9 @@ packages:
openvibe-shared:
openvibe-shared:
optional: true
optional: true
- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0:
- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0}
- version: 0.67.0
+ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0:
+ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0}
+ version: 0.71.0
engines: {node: '>=20'}
engines: {node: '>=20'}
hasBin: true
hasBin: true
peerDependencies:
peerDependencies:
@@ -2786,13 +2786,13 @@ snapshots:
@@ -2786,13 +2786,13 @@ snapshots:
ajv: 8.20.0
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
ajv-formats: 3.0.1([email protected])
- openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0):
+ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0):
dependencies:
dependencies:
- openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected])
+ openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected])
optionalDependencies:
optionalDependencies:
openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0
- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0(@electric-sql/[email protected])([email protected])([email protected]):
+ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0(@electric-sql/[email protected])([email protected])([email protected]):
optionalDependencies:
optionalDependencies:
'@electric-sql/pglite': 0.5.8
'@electric-sql/pglite': 0.5.8
iovalkey: 0.4.0
iovalkey: 0.4.0
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/aiMatches/aiMatches.test.ts'
+// covers: 'apps/server/src/aiMatches'
+// Generated by node test/run.js --index; do not edit.