Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

> **Update (2026-11-06)**: Compliance adds three read tools under `compliance:view`: `rg.player_state` (one player's responsible-gambling standing as `RG_STANDING_READER` reports it - exclusions in force, open flags, limits with a raise still waiting, and priced money windows - with no reason and no admin), `rg.flags_queue` (the open monitoring flags across all players, counted per type, the oldest by player id, without the flag's detail) and `kyc.queue` (each player's latest verification per tier waiting for a decision or a resubmission, the oldest pending by player id, without a decision reason, document or risk signal). The two queues join the player profile through its read-only schema, so they name a player by player id with no identity lookup per row. `rg.player_state` resolves the standing reader per call, so a kernel built without play eligibility still serves compliance's other tools.

> **Update (2026-11-06)**: Three more modules register operator-level read tools, each reporting per currency and never summing across currencies: `gaming.top_games` (gaming, `report:view`: the games ranked by completed rounds - volume, revenue, rounds and players - as the back office game performance report counts them), `sportsbook.turnover_exposure` (betting, `sportsbook:view`: stakes and payouts of a range as the per-player sportsbook summary counts them, the open bets' stakes and potential payout now, and the largest open bets by player id, without a selection, event or market) and `bonus.grants_summary` (bonus, `bonus:view`: grants funded in a range, how grants ended in it - converted, expired, forfeited with its reason, cancelled - by source, and the bonus balance and wagering live grants hold now, with no player). Each builds its reader from `DRIZZLE` alone, because the kernel builds every handler when it starts. The whole-UTC-day range the analytics tools take moved to the shared reporting contract, so every report tool resolves and bounds its days the same way. Reading the operation rather than one player adds partial indexes on `sportsbook_bet.settled_at` (settled bets) and on `promo_grant.activated_at` and `closed_at`.

## Context

An agent hub (a premium package outside this repo) runs models that read platform data and
Expand Down
1 change: 1 addition & 0 deletions docs/modules/gaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The game catalog, category ordering, and round management module. `docs/catalog.
- **Rule-based category membership** - a category can be populated by a rule instead of by hand: a pipeline of clauses over an operator-extensible catalog of rule kinds (built-ins: providers, tags, most played); see below.
- **Player game favorites** - a player's hearted games, capped at 200; see below.
- **Game review** - a newly imported game waits in a review queue, off, until an admin approves it or its provider auto-approves; see below.
- **Its agent surface** - `gaming.top_games`, a read tool under `report:view`, ranks the games per currency by the completed rounds of a range, with the figures the admin game performance report shows. It is served only through the MCP kernel (ADR-0042).
- **Read ports** - `GAME_CATALOG_READER` for cross-module access (lobby sections, promotions) and `GAMING_COMMANDS` for wallet integration (`accumulateExternalRound`, `setGameAvailability`, `setGameAvailabilityInTransaction`) and catalogue imports (`notifyGamesCreated`, `notifyGamesChanged`).

## Game thumbnails
Expand Down
4 changes: 4 additions & 0 deletions docs/modules/sportsbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ fields. This file does not repeat it.
its payout leaves the period it first settled in, even when the reversal was collected. The
ledger books a stake at reserve, not acceptance, and is cash-basis for a reopened bet, so a
reversal left as debt or written off makes the ledger GGR lower than the summary's (ADR-0047).
- **Its agent surface.** `sportsbook.turnover_exposure`, a read tool under the `sportsbook` view
grant, totals every player's bets per currency by the player summary's rules, adds the open
bets' potential payout, and lists the largest open bets by player id, without their selections.
It is served only through the MCP kernel (ADR-0042).
- **Sportsbook debt.** What a reversed settlement could not take back, per player and currency,
with the withdrawal hold that lives exactly as long as the debt.
- **Reconciliation findings.** Everything the ledger noticed but could not decide on its own, for
Expand Down
47 changes: 3 additions & 44 deletions packages/core/src/analytics/contract/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import {
WALLET_TRANSACTION_STATUSES,
WalletTransactionStatusSchema,
WalletTransactionTypeSchema,
checkDayRange,
defineMcpTool,
resolveDayRange,
type DayRangeInput,
} from '@openora/core/contracts';
import { GgrSeriesSchema } from './financial.js';

const DAY_MS = 24 * 60 * 60 * 1000;
const GGR_SUMMARY_DEFAULT_DAYS = 30;
export const GGR_SUMMARY_MAX_DAYS = 366;
const PAYMENTS_FLOW_DEFAULT_DAYS = 14;
Expand All @@ -23,49 +25,6 @@ export const FINANCIAL_SUMMARY_MAX_DAYS = 366;
export const FINANCIAL_SUMMARY_MAX_CURRENCIES = 100;
export const FINANCIAL_SUMMARY_MAX_PRODUCT_ROWS = 300;

type DayRangeInput = { dateFrom?: string; dateTo?: string };
type DayRange = { dateFrom: string; dateTo: string };

const isoDay = (epochMs: number) => new Date(epochMs).toISOString().slice(0, 10);

/**
* The whole UTC days a report covers, both ends inclusive: `dateTo` defaults to the day of
* `now`, and `dateFrom` to the `defaultDays` days ending on `dateTo`.
*/
function resolveDayRange(
{ dateFrom, dateTo }: DayRangeInput,
defaultDays: number,
now: Date,
): DayRange {
const to = dateTo ?? isoDay(now.getTime());
return {
dateFrom: dateFrom ?? isoDay(Date.parse(to) - (defaultDays - 1) * DAY_MS),
dateTo: to,
};
}

function dayRangeDays({ dateFrom, dateTo }: DayRange) {
return (Date.parse(dateTo) - Date.parse(dateFrom)) / DAY_MS + 1;
}

function checkDayRange(range: DayRange, maxDays: number, ctx: z.core.$RefinementCtx) {
if (range.dateFrom > range.dateTo) {
ctx.addIssue({
code: 'custom',
path: ['dateFrom'],
message: 'dateFrom must be on or before dateTo',
});
return;
}
if (dayRangeDays(range) > maxDays) {
ctx.addIssue({
code: 'custom',
path: ['dateFrom'],
message: `the range spans at most ${maxDays} days`,
});
}
}

/** ggr.summary's days: `dateTo` defaults to the day of `now`, `dateFrom` to 30 days ending on it. */
export function resolveGgrSummaryRange(input: DayRangeInput, now = new Date()) {
return resolveDayRange(input, GGR_SUMMARY_DEFAULT_DAYS, now);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import * as z from 'zod';
import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/core/server';
import { TopGamesInputSchema } from '../contract/index.js';
import gamingPlugin from '../plugin.js';

describe('gaming.top_games input', () => {
const parse = (input: unknown) => z.safeParse(TopGamesInputSchema, input);

it('defaults to the 10 games with the most volume', () => {
expect(parse({})).toEqual({
success: true,
data: { sortBy: 'volume', sortDir: 'desc', limit: 10 },
});
});

it('takes a limit from 1 to 50 and sorts only by the figures it returns', () => {
expect(parse({ limit: '50', sortBy: 'revenue', sortDir: 'asc' }).success).toBe(true);
expect(parse({ limit: 0 }).success).toBe(false);
expect(parse({ limit: 51 }).success).toBe(false);
expect(parse({ sortBy: 'name' }).success).toBe(false);
expect(parse({ gameType: 'lottery' }).success).toBe(false);
});

it('spans at most 92 days', () => {
expect(parse({ dateFrom: '2026-01-01', dateTo: '2026-04-02' }).success).toBe(true);
expect(parse({ dateFrom: '2026-01-01', dateTo: '2026-04-03' }).success).toBe(false);
});
});

describe('gaming agent surface registration', () => {
it('registers gaming.top_games within the kernel contract rules', async () => {
const registry = new ModuleRegistryImpl<CoreTokenCatalog>(new Container());
registry.setOwner('gaming');

await gamingPlugin.register(registry);

expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['gaming.top_games']);
});
});
218 changes: 218 additions & 0 deletions packages/core/src/casino/gaming/__tests__/top-games-tool.int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { sql } from 'drizzle-orm';
import {
Container,
DRIZZLE,
ModuleRegistryImpl,
findOneOrThrow,
type CoreTokenCatalog,
} from '@openora/core/server';
import type { GameType, RunContext } from '@openora/core/contracts';
import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing';
import { makeAuditWriter } from '../../../testing/mock.js';
import { migrate } from '../migrate.js';
import { game, gameProvider, gameRound } from '../schema/index.js';
import gamingPlugin from '../plugin.js';

let db: TestDb;

const RANGE = { dateFrom: '2026-01-01', dateTo: '2026-01-31' };
const IN_RANGE = new Date('2026-01-10T12:00:00.000Z');

function bootKernel() {
const container = new Container<CoreTokenCatalog>();
container.register(DRIZZLE, () => db.drizzle);
const registry = new ModuleRegistryImpl<CoreTokenCatalog>(container);
registry.setOwner('gaming');
gamingPlugin.register(registry);
return createMcpKernel({
tools: registry.mcp.getTools(),
actions: registry.actions.getAll(),
container,
authorize: async () => 'allowed',
audit: makeAuditWriter(),
});
}

const runContext = (): RunContext => ({
runId: randomUUID(),
actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: randomUUID() },
catalogVersion: 'test',
correlationId: 'top-games-tool',
});

async function seedGame(name: string, gameType: GameType = 'casino') {
const provider = findOneOrThrow(
await db.drizzle.db
.insert(gameProvider)
.values({ slug: `studio-${randomUUID()}`, name: `${name} Studio` })
.returning(),
new Error('seedGame: no provider'),
);
return findOneOrThrow(
await db.drizzle.db
.insert(game)
.values({
name,
slug: `game-${randomUUID()}`,
providerId: provider.id,
aggregator: 'direct',
gameType,
reviewStatus: 'approved',
})
.returning(),
new Error('seedGame: no game'),
);
}

async function seedRound(
gameId: string,
values: Partial<typeof gameRound.$inferInsert> & { betAmount: string },
) {
await db.drizzle.db.insert(gameRound).values({
gameId,
userId: randomUUID(),
status: 'completed',
winAmount: '0',
currency: 'USD',
startedAt: IN_RANGE,
...values,
});
}

beforeAll(async () => {
db = await createTestDb([migrate]);
});

afterAll(async () => {
await db.drop();
});

beforeEach(async () => {
await db.drizzle.db.execute(
sql`TRUNCATE ${gameRound}, ${game}, ${gameProvider} RESTART IDENTITY CASCADE`,
);
});

describe('gaming.top_games through the MCP kernel (real PG)', () => {
it('ranks the games per currency over the completed rounds of whole UTC days', async () => {
const kernel = bootKernel();
const [aces, blaze, comet] = [
await seedGame('Aces'),
await seedGame('Blaze'),
await seedGame('Comet'),
];
const regular = randomUUID();
await seedRound(aces.id, { betAmount: '100', winAmount: '30', userId: regular });
await seedRound(aces.id, { betAmount: '50', winAmount: '0', userId: regular });
// The last millisecond of dateTo counts; the first of the next day does not.
await seedRound(blaze.id, {
betAmount: '300',
winAmount: '400',
startedAt: new Date('2026-01-31T23:59:59.999Z'),
});
await seedRound(blaze.id, {
betAmount: '999',
startedAt: new Date('2026-02-01T00:00:00.000Z'),
});
await seedRound(comet.id, { betAmount: '10' });
await seedRound(comet.id, { betAmount: '999', status: 'active' });
await seedRound(aces.id, { betAmount: '7', winAmount: '2', currency: 'EUR' });

const result = await kernel.invokeTool(
'gaming.top_games',
{ ...RANGE, limit: 2 },
runContext(),
);

expect(result).toEqual({
ok: true,
output: {
...RANGE,
sortBy: 'volume',
sortDir: 'desc',
currencies: [
{
currency: 'EUR',
totals: expect.objectContaining({ roundsPlayed: 1, uniquePlayers: 1, gamesPlayed: 1 }),
games: [expect.objectContaining({ gameId: aces.id, roundsPlayed: 1 })],
},
{
currency: 'USD',
totals: {
volume: expect.stringMatching(/^460(\.0+)?$/),
revenue: expect.stringMatching(/^30(\.0+)?$/),
roundsPlayed: 4,
uniquePlayers: 3,
gamesPlayed: 3,
},
games: [
{
gameId: blaze.id,
name: 'Blaze',
provider: 'Blaze Studio',
gameType: 'casino',
volume: expect.any(String),
revenue: expect.any(String),
roundsPlayed: 1,
uniquePlayers: 1,
},
expect.objectContaining({ gameId: aces.id, roundsPlayed: 2, uniquePlayers: 1 }),
],
},
],
truncated: false,
},
});
});

it('lists the biggest losers first by revenue ascending, narrowed by game type', async () => {
const kernel = bootKernel();
const [winner, loser, sportsbook] = [
await seedGame('Winner'),
await seedGame('Loser'),
await seedGame('Book', 'sportsbook'),
];
await seedRound(winner.id, { betAmount: '100', winAmount: '10' });
await seedRound(loser.id, { betAmount: '100', winAmount: '500' });
await seedRound(sportsbook.id, { betAmount: '100', winAmount: '900' });

const result = await kernel.invokeTool(
'gaming.top_games',
{ ...RANGE, sortBy: 'revenue', sortDir: 'asc', gameType: 'casino', currency: 'usd' },
runContext(),
);

expect(result).toMatchObject({
ok: true,
output: {
currencies: [
{
currency: 'USD',
totals: { gamesPlayed: 2 },
games: [
{ gameId: loser.id, revenue: expect.stringMatching(/^-400(\.0+)?$/) },
{ gameId: winner.id },
],
},
],
},
});
});

it('refuses a range over 92 days and a limit over 50', async () => {
const kernel = bootKernel();

for (const input of [
{ dateFrom: '2026-01-01', dateTo: '2026-04-03' },
{ dateFrom: '2026-02-01', dateTo: '2026-01-31' },
{ limit: 51 },
]) {
expect(await kernel.invokeTool('gaming.top_games', input, runContext())).toMatchObject({
ok: false,
error: 'invalid_input',
});
}
});
});
Loading
Loading