openvibers/openvibe.games Commits
Games: campaign numbers for the casual games (players, first battles, D1/D7 by ad source and campaign; staff only) (#59)
Changes
@@@ -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`)@@@ -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') {@@@ -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 }++}@@@ -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 guesttoken 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 visitand 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.
@@ -29,6 +29,10 @@ let dir = ''
@@ -29,6 +29,10 @@ let dir = ''
let clock = Date.UTC(2026, 9, 14, 12)
let clock = Date.UTC(2026, 9, 14, 12)
const users = new Map<string, NetworkUser>([
const users = new Map<string, NetworkUser>([
['ana-token', { id: '1', name: 'ana', rank: null, subjectId: ANA }],
['ana-token', { id: '1', name: 'ana', rank: null, subjectId: ANA }],
+ [
+ 'boss-token',
+ { id: '2', name: 'boss', rank: 'owner', subjectId: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2' },
+ ],
])
])
beforeAll(async () => {
beforeAll(async () => {
@@ -224,6 +228,43 @@ describe('signing in', () => {
@@ -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', () => {
describe('the casual page', () => {
it('serves /play/<game> with the game’s share card and keeps the campaign through redirects', async () => {
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`)
const page = await fetch(`${base}/play/vibe-heroes?src=x&c=t1`)
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
* POST /api/v1/casual/:game/actions { key, action } → the new state, under the idempotency key
* 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/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/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
* 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.
* 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'
@@ -17,9 +18,10 @@ import type { Logger } from '@openvibe/shared'
import { CASUAL_GAMES, packDigest, parseAction, type CasualPack } from '@openvibe/casual'
import { CASUAL_GAMES, packDigest, parseAction, type CasualPack } from '@openvibe/casual'
import { isGuestToken } from '../platform/accounts.js'
import { isGuestToken } from '../platform/accounts.js'
import { hashGuestKey } from '../net/guestIdentity.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 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 {
export interface CasualRouteOptions {
db: Db
db: Db
@@ -39,7 +41,8 @@ interface Who {
@@ -39,7 +41,8 @@ interface Who {
guestKey: string | null
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 ACTION_KEY = /^[A-Za-z0-9_-]{8,64}$/
const TAG = /^[A-Za-z0-9_-]{1,32}$/
const TAG = /^[A-Za-z0-9_-]{1,32}$/
const MAX_BODY = 4096
const MAX_BODY = 4096
@@ -121,7 +124,7 @@ export function createCasualRoutes(
@@ -121,7 +124,7 @@ export function createCasualRoutes(
}
}
const pack: CasualPack | undefined = CASUAL_GAMES[m[1] ?? '']
const pack: CasualPack | undefined = CASUAL_GAMES[m[1] ?? '']
const route = m[2]
const route = m[2]
- const method = route === 'leaderboard' || route === 'ledger' ? 'GET' : 'POST'
+ const method = READS.has(route ?? '') ? 'GET' : 'POST'
if (req.method !== method) {
if (req.method !== method) {
send(405, { error: 'method_not_allowed' }, { allow: method })
send(405, { error: 'method_not_allowed' }, { allow: method })
return true
return true
@@ -136,6 +139,23 @@ export function createCasualRoutes(
@@ -136,6 +139,23 @@ export function createCasualRoutes(
send(200, { entries: await casualLeaderboard(opts.db, pack.id) })
send(200, { entries: await casualLeaderboard(opts.db, pack.id) })
return
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') {
if (method === 'POST') {
const type = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
const type = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
if (type !== 'application/json') {
if (type !== 'application/json') {
@@ -200,3 +200,59 @@ export async function casualLedger(
@@ -200,3 +200,59 @@ export async function casualLedger(
export async function pruneCasualActions(db: Db, now: number): Promise<number> {
export async function pruneCasualActions(db: Db, now: number): Promise<number> {
return db.exec(sql`DELETE FROM casual_actions WHERE at < ${now - 86_400_000}`)
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 }
+}
@@ -32,12 +32,13 @@ All under `/api/v1/casual/:game`. Identity is a Network session (`Authorization:
@@ -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
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.
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}`,
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`,
`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}`, `
@@ -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
- 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.
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.
- 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.
- No third-party pixel runs. X's pixel waits for the owner's ads account and pixel id (O36) and a consent prompt.