openvibers/openvibe.games Commits

Games: campaign numbers for the casual games (players, first battles, D1/D7 by ad source and campaign; staff only) (#59)

OP OpenVibers committed 30 days ago · d3aee2d2fc · 4 files, +130 −10

Plan T22: "a game that does not pay back its ads within its first test budget is cut". That needs the numbers per campaign, so the owner can judge the first X test (O36) as soon as it runs. Builds on Games#58.

**`GET /api/v1/casual/:game/numbers?days=30` (staff only)**
- Groups by ad source and campaign (`src`/`c`, kept on the save at the first visit). Counts the players who first opened the game in the window, and how many of them:
  - fought a battle;
  - reached stage 5;
  - signed in;
  - came back the next UTC day (D1) and on the seventh day (D7).
- Each return count comes with how many players are old enough to have had that day.
- Returns are read from the ledger (any currency or item change that day), which every played session writes. No new table and no tracking beyond the game's own records.
- Access requires `staff.games.manage` (owner or admin), checked against the Network:
  - with a bearer token, or with the site's own `ovg_sso` session cookie, so the URL opens in a signed-in browser;
  - 401 without a session, 403 for anyone else.
- Limited per staff member by `games.public.read`.

**Docs:** `docs/casual.md` (the API table and "Getting players").

**Tests:** `casual` (12) covers two guests from `x/launch`, one battle, a next-day return, the eligibility counts, an untagged visit, 401, 403 and the cookie path.

Changes

ddiff --git a/apps/server/src/casual/casual.test.ts b/apps/server/src/casual/casual.test.ts
iindex a3f39fb..3628c56 100644
---- a/apps/server/src/casual/casual.test.ts
++++ b/apps/server/src/casual/casual.test.ts
@@@ -29,6 +29,10 @@ let dir = ''
let clock = Date.UTC(2026, 9, 14, 12)
const users = new Map<string, NetworkUser>([
['ana-token', { id: '1', name: 'ana', rank: null, subjectId: ANA }],
++ [
++ 'boss-token',
++ { id: '2', name: 'boss', rank: 'owner', subjectId: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2' },
++ ],
])
beforeAll(async () => {
@@@ -224,6 +228,43 @@ describe('signing in', () => {
})
})
++describe('campaign numbers', () => {
++ it('counts players, first battles and next-day returns per source and campaign, for staff only', async () => {
++ const GUEST2 = 'g0000000000000000000000000000002'
++ await call(`${S}/session`, { body: { src: 'x', c: 'launch' }, guest: GUEST })
++ await call(`${S}/session`, { body: { src: 'x', c: 'launch' }, guest: GUEST2 })
++ await call(`${S}/session`, { body: {}, guest: 'g0000000000000000000000000000003' })
++ await act({ t: 'battle', stage: 1 }, 'key-0000030')
++ clock += 86_400_000
++ await act({ t: 'login' }, 'key-0000031')
++ clock += 2 * 86_400_000
++ expect((await call(`${S}/numbers`)).status).toBe(401)
++ expect((await call(`${S}/numbers`, { bearer: 'ana-token' })).status).toBe(403)
++ // The signed-in browser's own session cookie opens it too.
++ const viaCookie = await fetch(`${base}${S}/numbers`, {
++ headers: { cookie: 'ovg_sso=boss-token' },
++ })
++ expect(viaCookie.status).toBe(200)
++ const r = await call(`${S}/numbers?days=7`, { bearer: 'boss-token' })
++ expect(r.status).toBe(200)
++ expect(r.body.rows).toEqual([
++ {
++ source: 'x',
++ campaign: 'launch',
++ players: 2,
++ battled: 1,
++ stage5: 0,
++ signedIn: 0,
++ d1: 1,
++ d1Eligible: 2,
++ d7: 0,
++ d7Eligible: 0,
++ },
++ expect.objectContaining({ source: '', campaign: '', players: 1, battled: 0 }),
++ ])
++ })
++})
++
describe('the casual page', () => {
it('serves /play/<game> with the game’s share card and keeps the campaign through redirects', async () => {
const page = await fetch(`${base}/play/vibe-heroes?src=x&c=t1`)
ddiff --git a/apps/server/src/casual/routes.ts b/apps/server/src/casual/routes.ts
iindex a6bf9cd..c332daf 100644
---- a/apps/server/src/casual/routes.ts
++++ b/apps/server/src/casual/routes.ts
@@@ -6,6 +6,7 @@
* POST /api/v1/casual/:game/actions { key, action } → the new state, under the idempotency key
* GET /api/v1/casual/:game/leaderboard the furthest signed-in players
* GET /api/v1/casual/:game/ledger the player's own currency and item history
++ * GET /api/v1/casual/:game/numbers staff: players, first battles and returns by ad source and campaign
*
* Identity: a Network session (Authorization: Bearer) or the browser's Games guest token (X-Guest-Token), the
* same two the 3D games use. A signed-in request that still carries the guest token takes over that guest's save.
@@@ -17,9 +18,10 @@ import type { Logger } from '@openvibe/shared'
import { CASUAL_GAMES, packDigest, parseAction, type CasualPack } from '@openvibe/casual'
import { isGuestToken } from '../platform/accounts.js'
import { hashGuestKey } from '../net/guestIdentity.js'
--import { resolveNetworkUser, type NetworkUser } from '../net/networkAuth.js'
++import { canEditMap, resolveNetworkUser, type NetworkUser } from '../net/networkAuth.js'
++import { SESSION_COOKIE, parseCookies } from '../net/sso.js'
import type { GamesActorLimits } from '../net/actorLimits.js'
--import { applyAction, casualLeaderboard, casualLedger, openSave } from './store.js'
++import { applyAction, casualLeaderboard, casualLedger, casualNumbers, openSave } from './store.js'
export interface CasualRouteOptions {
db: Db
@@@ -39,7 +41,8 @@ interface Who {
guestKey: string | null
}
--const PATH = /^\/api\/v1\/casual\/([a-z0-9-]{1,40})\/(session|actions|leaderboard|ledger)$/
++const PATH = /^\/api\/v1\/casual\/([a-z0-9-]{1,40})\/(session|actions|leaderboard|ledger|numbers)$/
++const READS = new Set(['leaderboard', 'ledger', 'numbers'])
const ACTION_KEY = /^[A-Za-z0-9_-]{8,64}$/
const TAG = /^[A-Za-z0-9_-]{1,32}$/
const MAX_BODY = 4096
@@@ -121,7 +124,7 @@ export function createCasualRoutes(
}
const pack: CasualPack | undefined = CASUAL_GAMES[m[1] ?? '']
const route = m[2]
-- const method = route === 'leaderboard' || route === 'ledger' ? 'GET' : 'POST'
++ const method = READS.has(route ?? '') ? 'GET' : 'POST'
if (req.method !== method) {
send(405, { error: 'method_not_allowed' }, { allow: method })
return true
@@@ -136,6 +139,23 @@ export function createCasualRoutes(
send(200, { entries: await casualLeaderboard(opts.db, pack.id) })
return
}
++ if (route === 'numbers') {
++ // Campaign numbers are for the owner and staff (staff.games.manage), read with their Network session: the
++ // bearer, or the site's own session cookie so the URL opens in a signed-in browser.
++ const token =
++ /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? '')?.[1] ??
++ parseCookies(req.headers.cookie)[SESSION_COOKIE]
++ const user = token ? await resolveUser(opts.networkAuthUrl, token) : null
++ if (!user || !canEditMap(user.rank)) {
++ send(user ? 403 : 401, { error: user ? 'forbidden' : 'unauthorized' })
++ return
++ }
++ if (!(await opts.limits.publicRead(req, res, `staff:${user.subjectId ?? user.id}`))) return
++ const days = Number(url.searchParams.get('days') ?? '30')
++ const span = Number.isSafeInteger(days) && days >= 1 && days <= 365 ? days : 30
++ send(200, await casualNumbers(opts.db, pack.id, now() - span * 86_400_000, now()))
++ return
++ }
if (method === 'POST') {
const type = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
if (type !== 'application/json') {
ddiff --git a/apps/server/src/casual/store.ts b/apps/server/src/casual/store.ts
iindex 7cb076e..bded8fa 100644
---- a/apps/server/src/casual/store.ts
++++ b/apps/server/src/casual/store.ts
@@@ -200,3 +200,59 @@ export async function casualLedger(
export async function pruneCasualActions(db: Db, now: number): Promise<number> {
return db.exec(sql`DELETE FROM casual_actions WHERE at < ${now - 86_400_000}`)
}
++
++export interface CampaignRow {
++ source: string
++ campaign: string
++ /** Saves first opened in the window. */
++ players: number
++ /** Of those: fought at least one battle. */
++ battled: number
++ /** Reached stage 5. */
++ stage5: number
++ signedIn: number
++ /** Played again the next UTC day (a ledger line that day), of the players old enough to have had one. */
++ d1: number
++ d1Eligible: number
++ /** Played again seven days later. */
++ d7: number
++ d7Eligible: number
++}
++
++/**
++ * The numbers an ad campaign is judged by (plan T22), per ad source and campaign, for saves first opened since
++ * `since`: how many came, fought, got going and came back. Returning is read from the ledger (any currency or item
++ * change that day), which every session that plays writes.
++ */
++export async function casualNumbers(
++ db: Db,
++ game: string,
++ since: number,
++ now: number,
++): Promise<{ game: string; since: number; rows: CampaignRow[] }> {
++ const DAY = 86_400_000
++ const today = Math.floor(now / DAY)
++ const rows = await db.many<CampaignRow>(sql`
++ SELECT coalesce(s.source, '') AS source, coalesce(s.campaign, '') AS campaign,
++ count(*)::int AS players,
++ count(*) FILTER (WHERE coalesce((s.state->'stats'->>'battles')::int, 0) > 0)::int AS battled,
++ count(*) FILTER (WHERE s.stage >= 5)::int AS stage5,
++ count(*) FILTER (WHERE s.account_key LIKE 'usr_%')::int AS "signedIn",
++ count(*) FILTER (WHERE EXISTS (
++ SELECT 1 FROM casual_ledger l WHERE l.game_id = s.game_id AND l.account_key = s.account_key
++ AND l.at >= (s.created_at / ${DAY} + 1) * ${DAY} AND l.at < (s.created_at / ${DAY} + 2) * ${DAY}
++ ))::int AS d1,
++ count(*) FILTER (WHERE s.created_at / ${DAY} + 1 < ${today})::int AS "d1Eligible",
++ count(*) FILTER (WHERE EXISTS (
++ SELECT 1 FROM casual_ledger l WHERE l.game_id = s.game_id AND l.account_key = s.account_key
++ AND l.at >= (s.created_at / ${DAY} + 7) * ${DAY} AND l.at < (s.created_at / ${DAY} + 8) * ${DAY}
++ ))::int AS d7,
++ count(*) FILTER (WHERE s.created_at / ${DAY} + 7 < ${today})::int AS "d7Eligible"
++ FROM casual_saves s
++ WHERE s.game_id = ${game} AND s.created_at >= ${since}
++ GROUP BY 1, 2
++ ORDER BY players DESC, source, campaign
++ LIMIT 200
++ `)
++ return { game, since, rows }
++}
ddiff --git a/docs/casual.md b/docs/casual.md
iindex a730b6f..23f9d3d 100644
---- a/docs/casual.md
++++ b/docs/casual.md
@@@ -32,12 +32,13 @@ All under `/api/v1/casual/:game`. Identity is a Network session (`Authorization:
guest token (`X-Guest-Token`), the same two the 3D games use. A signed-in request that still carries the guest
token takes over that guest's save when the account has none for that game.
--| Method and path | Answer |
--| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
--| `POST …/session` `{ src?, c? }` | `{ state, version, created, now, digest, player }`. The save is created on the first visit, and the ad link's `src` and `c` are kept then only. |
--| `POST …/actions` `{ key, action }` + `X-Casual-Digest` | `{ state, version, result, now }`, `replay: true` for a repeated key; 409 `content_changed` when the page is older than the server's pack; 422 with the refusal's code. |
--| `GET …/leaderboard` | The 50 furthest signed-in players (highest stage, then team power). Guests are never listed. |
--| `GET …/ledger?before=` | The player's own ledger, newest first, 100 lines a page. |
++| Method and path | Answer |
++| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
++| `POST …/session` `{ src?, c? }` | `{ state, version, created, now, digest, player }`. The save is created on the first visit, and the ad link's `src` and `c` are kept then only. |
++| `POST …/actions` `{ key, action }` + `X-Casual-Digest` | `{ state, version, result, now }`, `replay: true` for a repeated key; 409 `content_changed` when the page is older than the server's pack; 422 with the refusal's code. |
++| `GET …/leaderboard` | The 50 furthest signed-in players (highest stage, then team power). Guests are never listed. |
++| `GET …/ledger?before=` | The player's own ledger, newest first, 100 lines a page. |
++| `GET …/numbers?days=30` | Staff only (`staff.games.manage`, by bearer or the site's session cookie): per ad source and campaign, the players who first opened the game in the window, how many fought, reached stage 5 and signed in, and next-day (D1) and seventh-day (D7) returns with how many players were old enough for each. |
Actions: `collect`, `battle {stage}`, `sweep {stage, times}`, `team {heroes}`, `level {hero, times}`,
`star {hero}`, `fuse {hero}`, `equip {hero, slot, gear}`, `summon {count: 1|10}`, `quest {quest}`, `login`,
@@@ -58,6 +59,8 @@ Actions: `collect`, `battle {stage}`, `sweep {stage, times}`, `team {heroes}`, `
- The landing route is `/play/<game>?src=<source>&c=<campaign>`. The page sends `src` and `c` with the first visit
and removes them from the address bar; they are kept on the save (first-party only) for campaign numbers.
++- `…/numbers` is how a campaign is judged: landings, first battles, stage 5, sign-ins, D1 and D7. Returns are read
++ from the ledger (any currency or item change that day).
- The page's share card (Open Graph and X `summary_large_image`) and its JSON-LD are filled in by the server.
- No third-party pixel runs. X's pixel waits for the owner's ads account and pixel id (O36) and a consent prompt.