From ca7289b1730ece28c10cf085e90dc46c1e400271 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 21 Oct 2026 04:59:51 +0700 Subject: [PATCH] games: one config section for Game Center and Play Games Services (shared leaderboards and achievements); apple game-center (gameCenterDetails, gameCenterAppVersions, leaderboards, achievements and localizations, upsert by vendor id, read back); play games (Games Configuration API upsert, no POST retry); signing ensure adds GAME_CENTER when games.gameCenter --- cli/apple/game-center.ts | 135 ++++++++++++++ cli/apple/signing.ts | 8 +- cli/commands.ts | 2 + cli/data/flags.json | 16 ++ cli/lib/config.ts | 5 + cli/lib/game-services.ts | 380 +++++++++++++++++++++++++++++++++++++++ cli/lib/signing.ts | 8 +- cli/play/games.ts | 105 +++++++++++ 8 files changed, 653 insertions(+), 6 deletions(-) create mode 100644 cli/apple/game-center.ts create mode 100644 cli/lib/game-services.ts create mode 100644 cli/play/games.ts diff --git a/cli/apple/game-center.ts b/cli/apple/game-center.ts new file mode 100644 index 0000000..fa5649e --- /dev/null +++ b/cli/apple/game-center.ts @@ -0,0 +1,135 @@ +// Game Center through the App Store Connect API, from the `games` config section (cli/lib/game-services.ts). +// storebound apple game-center [status] [--dry] [--yes] [--json] +// status: read-only (App ID capability, Game Center detail, the editable version's switch, leaderboards and +// achievements on the store vs the config, the project's entitlement). Default: the upsert. Every write is a +// find-then-create (createOnce: a conflict or an unclear answer reads the store again, never a second create) or a +// PATCH of the fields that differ, so a re-run writes nothing. Nothing is deleted or archived; nothing is submitted +// (leaderboards and achievements go to review with the next app version, the owner's Submit for review). +import { findBundleId, readProjectEntitlements } from './signing'; +import { consoleLink } from '../lib/console-links'; +import type { Ctx } from '../lib/ctx'; +import { confirm, need } from '../lib/ctx'; +import { dryPlan } from '../lib/dry'; +import { type GcExisting, gcFindings, type ItemPlan, planGameCenter, planRows, usesGameCenter } from '../lib/game-services'; +import { done, log, warn } from '../lib/log'; +import { createOnce } from '../lib/upsert'; +import { type Asc, ascClient, type J } from './asc'; + +const TAG = 'game-center'; + +export interface GcState { + capability: boolean | null; + detail: string | null; + version: { id: string; versionString: string; enabled: boolean } | null; + leaderboards: GcExisting[]; + achievements: GcExisting[]; +} + +async function withLocs(c: Asc, list: J[], type: 'gameCenterLeaderboards' | 'gameCenterAchievements'): Promise { + const out: GcExisting[] = []; + for (const x of list) out.push({ id: x.id, attributes: x.attributes ?? {}, localizations: (await c.all(`/v1/${type}/${x.id}/localizations?limit=200`)).map((l: J) => ({ id: l.id, attributes: l.attributes ?? {} })) }); + return out; +} + +/** Everything Game Center has for the app (read-only). */ +export async function readGameCenter(c: Asc, bundleId: string): Promise { + const appId = await c.app(); + const b = await findBundleId(c, bundleId).catch(() => null); + const detail = (await c.maybe(`/v1/apps/${appId}/gameCenterDetail`))?.data?.id ?? null; + const v = await c.editableVersion(); + const version = v ? { id: v.id as string, versionString: String(v.attributes?.versionString ?? ''), enabled: !!(await c.maybe(`/v1/appStoreVersions/${v.id}/gameCenterAppVersion`))?.data } : null; + const leaderboards = detail ? await withLocs(c, await c.all(`/v1/gameCenterDetails/${detail}/gameCenterLeaderboards?limit=200`), 'gameCenterLeaderboards') : []; + const achievements = detail ? await withLocs(c, await c.all(`/v1/gameCenterDetails/${detail}/gameCenterAchievements?limit=200`), 'gameCenterAchievements') : []; + return { capability: b ? (b.capabilities as string[]).includes('GAME_CENTER') : null, detail, version, leaderboards, achievements }; +} + +export function stateSummary(s: GcState, plan: ItemPlan[]): Record { + return { + capability: s.capability, enabled: !!s.detail, detailId: s.detail, version: s.version, + leaderboards: s.leaderboards.length, achievements: s.achievements.length, + changes: plan.filter(p => p.action !== 'keep' || p.locales.some(l => l.action !== 'keep')).map(p => `${p.kind} ${p.id}: ${p.action}`), + }; +} + +export async function run(ctx: Ctx): Promise { + const { config, root } = ctx.loaded; + const a = ctx.args; + const sub = a._[0] ?? 'sync'; + if (!['sync', 'status'].includes(sub)) throw new Error('usage: storebound apple game-center [status] [--dry] [--yes] [--json]'); + if (!usesGameCenter(config)) throw new Error('games.gameCenter is not true in storebound.config.ts (game-services skill): nothing to do'); + const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); + const c = await ascClient(root, bundleId, config.apple?.appId); + const appId = await c.app(); + const s = await readGameCenter(c, bundleId); + const g = config.games!; + const plan = planGameCenter(g, config.languages, s); + const project = gcFindings(config, readProjectEntitlements(root)); + + if (a.has('json')) { console.log(JSON.stringify({ ...stateSummary(s, plan), project, plan }, null, 2)); if (sub === 'status') return; } + if (s.capability === false) warn(TAG, `the App ID ${bundleId} has no GAME_CENTER capability: storebound apple signing ensure --dry, then without --dry (games.gameCenter adds it)`); + if (s.capability === null) warn(TAG, `bundle id ${bundleId} not found in Certificates, Identifiers & Profiles: storebound apple bundle`); + for (const f of project) (f.level === 'ok' ? log : warn)(TAG, `${f.msg}${f.fix ? ` (${f.fix})` : ''}`); + + const rows = [ + { item: 'Game Center (app)', action: s.detail ? 'keep' : 'create', detail: 'POST /v1/gameCenterDetails' }, + ...(s.version ? [{ item: `Game Center (version ${s.version.versionString})`, action: s.version.enabled ? 'keep' : 'create', detail: 'POST /v1/gameCenterAppVersions' }] : []), + ...planRows(plan), + ]; + if (!s.version) log(TAG, 'no editable App Store version: the version switch is set with the next one (storebound apple listing creates it)'); + if (sub === 'status' || a.has('dry')) { + if (!a.has('json')) console.log(dryPlan('apple game-center', rows)); + return; + } + if (rows.every(r => r.action === 'keep')) { done(TAG, `Game Center in step: ${s.leaderboards.length} leaderboard(s), ${s.achievements.length} achievement(s)`); return; } + console.log(dryPlan('apple game-center (to write)', rows).replace(/^dry run: /, '').replace(/; nothing written/, '')); + if (!(await confirm(ctx, 'Write these Game Center changes to App Store Connect? (nothing is submitted; they go to review with the next version)'))) return; + + let detail = s.detail; + if (!detail) { + const r = await createOnce({ what: 'Game Center detail', tag: TAG, unique: true, + create: async () => (await c.asc('POST', '/v1/gameCenterDetails', { data: { type: 'gameCenterDetails', relationships: { app: { data: { type: 'apps', id: appId } } } } })).data.id as string, + find: async () => (await c.maybe(`/v1/apps/${appId}/gameCenterDetail`))?.data?.id ?? null }); + detail = r.value; + log(TAG, `Game Center switched on for the app (${detail})`); + } + if (s.version && !s.version.enabled) { + const vid = s.version.id; + await createOnce({ what: `Game Center for version ${s.version.versionString}`, tag: TAG, unique: true, + create: async () => (await c.asc('POST', '/v1/gameCenterAppVersions', { data: { type: 'gameCenterAppVersions', relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: vid } } } } })).data.id as string, + find: async () => (await c.maybe(`/v1/appStoreVersions/${vid}/gameCenterAppVersion`))?.data?.id ?? null }); + log(TAG, `Game Center switched on for version ${s.version.versionString}`); + } + for (const p of plan) { + const type = p.kind === 'leaderboard' ? 'gameCenterLeaderboards' : 'gameCenterAchievements'; + const locType = p.kind === 'leaderboard' ? 'gameCenterLeaderboardLocalizations' : 'gameCenterAchievementLocalizations'; + const parentRel = p.kind === 'leaderboard' ? 'gameCenterLeaderboard' : 'gameCenterAchievement'; + let id = p.storeId; + if (p.action === 'create') { + const find = async () => (await c.all(`/v1/gameCenterDetails/${detail}/${type}?limit=200`)).find((x: J) => x.attributes?.vendorIdentifier === p.id)?.id ?? null; + id = (await createOnce({ what: `${p.kind} ${p.id}`, tag: TAG, unique: true, find, + create: async () => (await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { ...p.attributes, vendorIdentifier: p.id }, relationships: { gameCenterDetail: { data: { type: 'gameCenterDetails', id: detail } } } } })).data.id as string })).value; + log(TAG, `${p.kind} ${p.id}: created`); + } else if (p.action === 'update') { + const attrs = Object.fromEntries(p.changed.map(k => [k, p.attributes[k]])); + await c.asc('PATCH', `/v1/${type}/${id}`, { data: { type, id, attributes: attrs } }); + log(TAG, `${p.kind} ${p.id}: updated ${p.changed.join(', ')}`); + } + for (const l of p.locales) { + if (l.action === 'create') { + const pid = id!; + await createOnce({ what: `${p.kind} ${p.id} ${l.locale}`, tag: TAG, unique: true, + find: async () => (await c.all(`/v1/${type}/${pid}/localizations?limit=200`)).find((x: J) => x.attributes?.locale === l.locale)?.id ?? null, + create: async () => (await c.asc('POST', `/v1/${locType}`, { data: { type: locType, attributes: { locale: l.locale, ...l.attributes }, relationships: { [parentRel]: { data: { type, id: pid } } } } })).data.id as string }); + log(TAG, `${p.kind} ${p.id} ${l.locale}: created`); + } else if (l.action === 'update') { + await c.asc('PATCH', `/v1/${locType}/${l.id}`, { data: { type: locType, id: l.id, attributes: Object.fromEntries(l.changed.map(k => [k, l.attributes[k]])) } }); + log(TAG, `${p.kind} ${p.id} ${l.locale}: updated ${l.changed.join(', ')}`); + } + } + } + // read back: the store must now match the config (lists can lag right after a create: say so, do not fail) + const after = await readGameCenter(c, bundleId); + const left = planGameCenter(g, config.languages, after).filter(p => p.action !== 'keep' || p.locales.some(l => l.action !== 'keep')); + if (!after.detail || left.length) warn(TAG, `read back: ${!after.detail ? 'no Game Center detail yet; ' : ''}${left.map(p => `${p.kind} ${p.id}`).join(', ') || ''} not listed as written yet (App Store Connect lists lag after a create): run storebound apple game-center status in a minute`); + done(TAG, `Game Center: ${after.leaderboards.length} leaderboard(s), ${after.achievements.length} achievement(s); images and review go with the next version (${consoleLink('asc.version')})`); +} diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index 63ca35d..90f569c 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -167,7 +167,7 @@ export async function capabilityReport(c: Asc, config: ShipConfig, root: string) const bundleId = config.apple!.bundleId; const b = await findBundleId(c, bundleId); const appId = b ? (b.capabilities as string[]) : null; - const cfg = capabilitySpecs(config.apple?.signing).map(x => x.type); + const cfg = capabilitySpecs(config.apple?.signing, config.games?.gameCenter === true).map(x => x.type); const project = readProjectEntitlements(root); let build: BuildEntitlements | null = null; try { build = builtIpaEntitlements(root, config); } catch { /* unreadable IPA: compared without it */ } @@ -238,7 +238,7 @@ export async function run(ctx: Ctx): Promise { const bundles = new Map(); for (const id of ids) bundles.set(id, await findBundleId(c, id)); const main = bundles.get(bundleId); - const capPlan = main ? planCapabilities(main.id, main.capabilities ?? [], capabilitySpecs(s)) : { create: [], present: [], extra: [] }; + const capPlan = main ? planCapabilities(main.id, main.capabilities ?? [], capabilitySpecs(s, config.games?.gameCenter === true)) : { create: [], present: [], extra: [] }; const certs: ApiCert[] = await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`); const keys = localKeys(root); const identities = keychainIdentities(); @@ -258,7 +258,7 @@ export async function run(ctx: Ctx): Promise { } const state: SigningState = { bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main, - capabilities: { present: capPlan.present, missing: capabilitySpecs(s).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra }, + capabilities: { present: capPlan.present, missing: capabilitySpecs(s, config.games?.gameCenter === true).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra }, certificate: { type: certType, chosen: choice.cert?.attributes.name ?? choice.cert?.attributes.displayName ?? null, why: choice.why, onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null }, profiles: ids.map(id => { @@ -330,7 +330,7 @@ export async function run(ctx: Ctx): Promise { if (plan.length && !(await confirm(ctx, `Create ${plan.length} item(s) on the Apple Developer team (${[capPlan.create.length && `${capPlan.create.length} capabilities`, needCert && `a ${certType} certificate`, plan.length - capPlan.create.length - (needCert ? 1 : 0) && 'provisioning profiles'].filter(Boolean).join(', ')})?`))) return; const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG }); - if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s).map(w => w.type), responses); + if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type), responses); if (!scope.profiles) { printCapabilityReport(await capabilityReport(c, config, root), TAG); cleanup(); diff --git a/cli/commands.ts b/cli/commands.ts index 96e27e3..b7a8f6a 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -61,6 +61,7 @@ export const COMMANDS: Record Promise, string]> = { 'play rollout': [() => import('./play/rollout'), 'staged rollout: --fraction x | --halt | --resume | --complete (asks first)'], 'play share': [() => import('./play/share'), 'internal app sharing: upload an AAB / APK, print the share link (asks first; --dry)'], 'play app-id': [() => import('./play/app-id'), '[--from-text ] [--write]: the Play Console app id (play.appId) by package name: known ids, else the agent steps (app list search / pages, capture) and the dry diff; --write saves it; read-only on Play'], + 'play games': [() => import('./play/games'), '[status] Play Games Services leaderboards + achievements from games (Games Configuration API upsert, matched by playId / name; drafts only, the owner publishes); --dry, asks first'], 'play next-version-code': [() => import('./play/next-version-code'), 'next free versionCode (prints the number)'], 'apple bundle': [() => import('./apple/bundle'), 'register the bundle id; finds the app record'], 'apple iap': [() => import('./apple/iap'), 'in-app purchases, localizations, price schedules, availability'], @@ -88,6 +89,7 @@ export const COMMANDS: Record Promise, string]> = { 'apple upload': [() => import('./apple/upload'), 'upload an IPA (App Store Connect API build upload, or --via altool), build number checked, waits for processing; --dry, asks first'], 'apple adhoc': [() => import('./apple/adhoc'), 'ad-hoc / release-testing: devices (list) | register (dry run, asks first) | options (ExportOptions.plist) | export --archive '], 'apple next-build': [() => import('./apple/next-build'), 'next safe build number (prints the number)'], + 'apple game-center': [() => import('./apple/game-center'), '[status] Game Center from games: the app\'s Game Center detail, the version switch, leaderboards + achievements with localizations (upsert by vendor id); App ID capability via signing ensure; --dry, asks first'], 'apple encryption': [() => import('./apple/encryption'), 'export compliance answer for builds, from the config'], 'apple ppo': [() => import('./apple/ppo'), 'Product Page Optimization: status | create --variant b | upload --variant b | start | stop (asks first)'], 'apple testflight': [() => import('./apple/testflight'), 'TestFlight: status | groups | testers (CSV in .storebound/) | what-to-test --build N | info | distribute --build N --group g [--submit-review] | wait --build N | expire --keep N|--older-than D (asks)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 16a092e..18751ba 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -98,6 +98,10 @@ "apple events": [ "name" ], + "apple game-center": [ + "keychain", + "no-install" + ], "apple iap": [], "apple iap-review": [], "apple iap-submit": [ @@ -584,6 +588,10 @@ "csv", "force" ], + "play games": [ + "achievements", + "leaderboards" + ], "play images": [ "include-drafts", "not-sent-for-review", @@ -906,6 +914,7 @@ "ws": [ "aab", "accounts", + "achievements", "add-for-review", "adgroup", "against", @@ -1007,6 +1016,7 @@ "landing", "lang", "languages", + "leaderboards", "limit", "listing-text", "listings", @@ -1209,6 +1219,9 @@ "storebound apple events upload --name \"...\" [--dry] event card (16:9) + details page (9:16) images per locale", "storebound apple events submit --name \"...\" [--dry] [--yes] into a review submission (the owner's step; asks)" ], + "apple game-center": [ + "storebound apple game-center [status] [--dry] [--yes] [--json]" + ], "apple iap": [ "storebound apple iap [--dry] [--only id1,id2]" ], @@ -1395,6 +1408,9 @@ "play data-safety": [ "storebound play data-safety [--csv out.csv] [--dry] [--force]" ], + "play games": [ + "storebound play games [status] [--dry] [--yes] [--json]" + ], "play images": [ "storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts] [--not-sent-for-review]" ], diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 147ca9d..0a02d45 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -1,5 +1,6 @@ // Per-project config (storebound.config.ts at the project root). Everything app-specific lives here; credentials never // do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional. +import { type GamesConfig, validateGames } from './game-services'; import { consoleIdsFromConfig, consoleLink, setConsoleIds } from './console-links'; import { existsSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; @@ -555,6 +556,9 @@ export interface ShipConfig { /** What players / users can do with each other (chat, sharing, friends). Answers the Play IARC questionnaire's * Miscellaneous > user interaction questions (`console plan play-content-rating`). */ social?: SocialConfig; + /** Game Center and Play Games Services: the switches per store and ONE list of leaderboards and achievements mapped + * to both (`storebound apple game-center`, `storebound play games`; game-services skill). */ + games?: GamesConfig; languages: Language[]; play?: { packageName: string; defaultLanguage?: string; listing?: string; icon?: string; @@ -1102,6 +1106,7 @@ export function validateConfig(c: unknown): string[] { e.push(`accounts.signUp: a list of ${ACCOUNT_SIGN_UPS.map(x => `'${x}'`).join(' | ')}`); } if (cfg.reviewAccess && typeof cfg.reviewAccess.signInRequired !== 'boolean') e.push('reviewAccess.signInRequired: true | false'); + e.push(...validateGames(cfg.games, cfg.languages ?? [])); if (cfg.social !== undefined) { if (!cfg.social || typeof cfg.social.interaction !== 'boolean') e.push('social.interaction: true | false'); else for (const k of ['block', 'report', 'moderation', 'friendsOnly', 'sharesLocation'] as const) if (cfg.social[k] !== undefined && typeof cfg.social[k] !== 'boolean') e.push(`social.${k}: true | false`); diff --git a/cli/lib/game-services.ts b/cli/lib/game-services.ts new file mode 100644 index 0000000..82e63a7 --- /dev/null +++ b/cli/lib/game-services.ts @@ -0,0 +1,380 @@ +// Game Center (App Store) and Play Games Services (Google Play) from ONE config section, `games`: the switches per +// store plus one shared list of leaderboards and achievements, each mapped to both stores. Pure: config types and +// validation, the request bodies, the create / update / keep plans against what the store returned, and the project +// checks (Game Center entitlement, the PGS APP_ID resource and Gradle dependency). +// +// Why one shared list: a game ships the same leaderboards and achievements on both stores, the texts are per config +// language like products' listings, and only the ids differ (Apple: our vendor id; Play: an id Google generates). So +// `id` is the vendorIdentifier on the App Store, and `playId` is the generated Play id once known (until then the +// Play side is matched by its default-language name). +// +// Names verified in specs/app-store-connect.oas.json: gameCenterDetails (POST, app relationship; GET +// /v1/apps/{id}/gameCenterDetail), gameCenterAppVersions (POST with appStoreVersion; GET +// /v1/appStoreVersions/{id}/gameCenterAppVersion; the older /v1/apps/{id}/gameCenterEnabledVersions is deprecated), +// gameCenterLeaderboards / gameCenterAchievements v1 (POST, PATCH; GET /v1/gameCenterDetails/{id}/gameCenterLeaderboards +// and .../gameCenterAchievements) and their v1 localizations (GET /v1/gameCenterLeaderboards/{id}/localizations). +// Play: the Google Play Games Services Configuration API (gamesconfiguration.googleapis.com/games/v1configuration, +// achievementConfigurations / leaderboardConfigurations: list, insert, update; scope androidpublisher). Its discovery +// document is not pinned in specs/ (only androidpublisher and the reporting API are): shapes follow Google's reference. +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import type { Language, Platform, ShipConfig } from './config'; + +export const GC_FORMATS = ['INTEGER', 'DECIMAL_POINT_1_PLACE', 'DECIMAL_POINT_2_PLACE', 'DECIMAL_POINT_3_PLACE', 'ELAPSED_TIME_CENTISECOND', + 'ELAPSED_TIME_MINUTE', 'ELAPSED_TIME_SECOND', 'MONEY_POUND_DECIMAL', 'MONEY_POUND', 'MONEY_DOLLAR_DECIMAL', 'MONEY_DOLLAR', 'MONEY_EURO_DECIMAL', + 'MONEY_EURO', 'MONEY_FRANC_DECIMAL', 'MONEY_FRANC', 'MONEY_KRONER_DECIMAL', 'MONEY_KRONER', 'MONEY_YEN'] as const; +export type GcFormat = typeof GC_FORMATS[number]; + +export interface GameLeaderboard { + /** Your id: the App Store vendorIdentifier (letters, digits, . _ -; e.g. "com.example.game.highscore"). */ + id: string; + /** high (default): a higher score is better (Apple DESC, Play LARGER_IS_BETTER); low: lower is better (a race time). */ + sort?: 'high' | 'low'; + /** Score format, Apple's formatter names (default INTEGER); Play gets the matching NUMERIC / TIME_DURATION / CURRENCY. */ + format?: GcFormat; + /** best (default): the best score counts (Apple BEST_SCORE); recent: the latest (Apple only; Play keeps the best). */ + submission?: 'best' | 'recent'; + min?: number; + max?: number; + /** Texts per config language id: name (Apple: 32 characters; Play: 100) and an optional score suffix (" points"). */ + locales: Record; + /** The Play leaderboard id Google generated (CgkI...), once it exists. */ + playId?: string; + platforms?: Platform[]; +} + +export interface GameAchievement { + id: string; + /** Apple: 0-100 each, 1000 in total; Play: a multiple of 5, 1000 in total. */ + points: number; + /** Hidden until earned (Apple showBeforeEarned false; Play initialState HIDDEN). Default false. */ + hidden?: boolean; + /** Apple only: can be earned again. Default false. */ + repeatable?: boolean; + /** Play only: an incremental achievement with this many steps (Apple reports progress in percent from the game). */ + steps?: number; + /** name, description (before earned) and earned (after; default the description). */ + locales: Record; + playId?: string; + platforms?: Platform[]; +} + +export interface GamesConfig { + /** Game Center: the App ID capability, the app's Game Center detail and the version's Game Center switch. */ + gameCenter?: boolean; + /** Play Games Services: the games project id (the digits of games_APP_ID / com.google.android.gms.games.APP_ID). */ + playGames?: { appId?: string }; + leaderboards?: GameLeaderboard[]; + achievements?: GameAchievement[]; +} + +const ID_RE = /^[A-Za-z0-9._-]{1,100}$/; + +export function validateGames(g: GamesConfig | undefined, languages: Language[] = []): string[] { + if (g === undefined) return []; + const e: string[] = []; + if (!g || typeof g !== 'object') return ['games: an object { gameCenter, playGames, leaderboards, achievements }']; + if (g.gameCenter !== undefined && typeof g.gameCenter !== 'boolean') e.push('games.gameCenter: true | false'); + if (g.playGames !== undefined && (typeof g.playGames !== 'object' || (g.playGames.appId !== undefined && !/^\d{6,20}$/.test(String(g.playGames.appId))))) + e.push('games.playGames.appId: the digits of the Play Games Services project id (games_APP_ID)'); + const langs = new Set(languages.map(l => l.id)); + const seen = new Set(); + const checkId = (kind: string, id: unknown) => { + if (typeof id !== 'string' || !ID_RE.test(id)) e.push(`games.${kind}: id "${String(id)}" (letters, digits, . _ -; at most 100)`); + else if (seen.has(id)) e.push(`games.${kind}: id "${id}" is used twice (ids are unique across leaderboards and achievements)`); + else seen.add(id); + }; + const checkLocales = (kind: string, id: string, loc: Record | undefined, keys: string[]) => { + if (!loc || typeof loc !== 'object' || !Object.keys(loc).length) { e.push(`games.${kind} ${id}: locales needs at least one language`); return; } + for (const [k, v] of Object.entries(loc)) { + if (langs.size && !langs.has(k)) e.push(`games.${kind} ${id}: locales.${k} is not a language id of the config (${[...langs].join(', ')})`); + for (const f of keys) if (typeof (v as Record)?.[f] !== 'string' || !(v as Record)[f]!.trim()) e.push(`games.${kind} ${id}: locales.${k}.${f} is missing`); + const name = (v as { name?: string })?.name ?? ''; + if (name.length > 32 && g.gameCenter) e.push(`games.${kind} ${id}: locales.${k}.name is ${name.length} characters (Game Center: at most 32)`); + } + }; + for (const l of g.leaderboards ?? []) { + checkId('leaderboards', l?.id); + if (l.sort !== undefined && !['high', 'low'].includes(l.sort)) e.push(`games.leaderboards ${l.id}: sort high | low`); + if (l.format !== undefined && !GC_FORMATS.includes(l.format)) e.push(`games.leaderboards ${l.id}: format one of ${GC_FORMATS.slice(0, 4).join(', ')}, ...`); + if (l.submission !== undefined && !['best', 'recent'].includes(l.submission)) e.push(`games.leaderboards ${l.id}: submission best | recent`); + if (l.min !== undefined && l.max !== undefined && l.min >= l.max) e.push(`games.leaderboards ${l.id}: min must be below max`); + checkLocales('leaderboards', l.id, l.locales, ['name']); + } + let apple = 0, play = 0; + for (const a of g.achievements ?? []) { + checkId('achievements', a?.id); + if (!Number.isInteger(a.points) || a.points < 0 || a.points > 100) e.push(`games.achievements ${a.id}: points 0-100 (Game Center's limit per achievement)`); + else if (g.playGames && a.points % 5) e.push(`games.achievements ${a.id}: points ${a.points} is not a multiple of 5 (Play Games Services refuses it)`); + if (a.steps !== undefined && (!Number.isInteger(a.steps) || a.steps < 2 || a.steps > 10000)) e.push(`games.achievements ${a.id}: steps 2-10000 (an incremental Play achievement)`); + checkLocales('achievements', a.id, a.locales, ['name', 'description']); + if (!a.platforms || a.platforms.includes('ios')) apple += a.points || 0; + if (!a.platforms || a.platforms.includes('android')) play += a.points || 0; + } + if (g.gameCenter && apple > 1000) e.push(`games.achievements: ${apple} points in total (Game Center: at most 1000)`); + if (g.playGames && play > 1000) e.push(`games.achievements: ${play} points in total (Play Games Services: at most 1000)`); + if ((g.achievements?.length ?? 0) > 200 && g.playGames) e.push('games.achievements: more than 200 (Play Games Services: at most 200)'); + return e; +} + +export const onStore = (item: { platforms?: Platform[] }, store: 'apple' | 'play'): boolean => !item.platforms || item.platforms.includes(store === 'apple' ? 'ios' : 'android'); +export const usesGameCenter = (c: Pick): boolean => c.games?.gameCenter === true; +export const usesPlayGames = (c: Pick): boolean => !!c.games?.playGames; + +// ------------------------------------------------------------------------------------------- plans (both stores) + +export type Action = 'create' | 'update' | 'keep'; +export interface LocPlan { locale: string; action: Action; id?: string; attributes: Record; changed: string[] } +export interface ItemPlan { kind: 'leaderboard' | 'achievement'; id: string; action: Action; storeId?: string; attributes: Record; changed: string[]; locales: LocPlan[] } + +/** Fields of `want` that differ from `have` (JSON compare per field; a missing field on the store counts as different). */ +export function changedFields(have: Record | undefined, want: Record): string[] { + return Object.keys(want).filter(k => JSON.stringify(have?.[k] ?? null) !== JSON.stringify(want[k] ?? null)); +} + +const appleLocale = (languages: Language[], id: string): string | undefined => languages.find(l => l.id === id)?.apple; + +export function gcLeaderboardAttrs(l: GameLeaderboard): Record { + return { + referenceName: l.id, defaultFormatter: l.format ?? 'INTEGER', submissionType: l.submission === 'recent' ? 'MOST_RECENT_SCORE' : 'BEST_SCORE', + scoreSortType: l.sort === 'low' ? 'ASC' : 'DESC', + ...(l.min !== undefined ? { scoreRangeStart: String(l.min) } : {}), ...(l.max !== undefined ? { scoreRangeEnd: String(l.max) } : {}), + }; +} + +export function gcAchievementAttrs(a: GameAchievement): Record { + return { referenceName: a.id, points: a.points, showBeforeEarned: !a.hidden, repeatable: !!a.repeatable }; +} + +export interface GcExisting { id: string; attributes: Record; localizations: { id: string; attributes: Record }[] } + +/** Game Center plan: matched by vendorIdentifier; create what is missing, PATCH what differs, keep the rest. Archived + * items on the store are left alone (an archived vendor id cannot be reused; the plan names it). */ +export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): ItemPlan[] { + const out: ItemPlan[] = []; + const one = (kind: ItemPlan['kind'], id: string, attrs: Record, locs: Record>, list: GcExisting[]) => { + const cur = list.find(x => x.attributes.vendorIdentifier === id); + const locales: LocPlan[] = Object.entries(locs).map(([locale, want]) => { + const l = cur?.localizations.find(x => x.attributes.locale === locale); + const changed = changedFields(l?.attributes, want); + return { locale, action: !l ? 'create' : changed.length ? 'update' : 'keep', id: l?.id, attributes: want, changed: l ? changed : Object.keys(want) }; + }); + const changed = changedFields(cur?.attributes, attrs); + out.push({ kind, id, action: !cur ? 'create' : changed.length ? 'update' : 'keep', storeId: cur?.id, attributes: attrs, changed: cur ? changed : Object.keys(attrs), locales }); + }; + for (const l of (g.leaderboards ?? []).filter(x => onStore(x, 'apple'))) { + const locs: Record> = {}; + for (const [k, v] of Object.entries(l.locales)) { + const loc = appleLocale(languages, k); + if (loc) locs[loc] = { name: v.name, ...(v.suffix ? { formatterSuffix: v.suffix } : {}) }; + } + one('leaderboard', l.id, gcLeaderboardAttrs(l), locs, have.leaderboards); + } + for (const a of (g.achievements ?? []).filter(x => onStore(x, 'apple'))) { + const locs: Record> = {}; + for (const [k, v] of Object.entries(a.locales)) { + const loc = appleLocale(languages, k); + if (loc) locs[loc] = { name: v.name, beforeEarnedDescription: v.description, afterEarnedDescription: v.earned ?? v.description }; + } + one('achievement', a.id, gcAchievementAttrs(a), locs, have.achievements); + } + return out; +} + +// Play Games Services Configuration API bodies + +type Bundle = { kind: 'gamesConfiguration#localizedStringBundle'; translations: { kind: 'gamesConfiguration#localizedString'; locale: string; value: string }[] }; + +function bundle(languages: Language[], locales: Record, pick: (v: never) => string | undefined): Bundle { + const translations: Bundle['translations'] = []; + for (const [k, v] of Object.entries(locales)) { + const l = languages.find(x => x.id === k); + const codes = l?.play === undefined ? [] : Array.isArray(l.play) ? l.play : [l.play]; + const value = pick(v as never); + if (value) for (const locale of codes) translations.push({ kind: 'gamesConfiguration#localizedString', locale, value }); + } + return { kind: 'gamesConfiguration#localizedStringBundle', translations }; +} + +export function playAchievementBody(a: GameAchievement, languages: Language[]): Record { + return { + kind: 'gamesConfiguration#achievementConfiguration', + achievementType: a.steps ? 'INCREMENTAL' : 'STANDARD', initialState: a.hidden ? 'HIDDEN' : 'REVEALED', ...(a.steps ? { stepsToUnlock: a.steps } : {}), + draft: { + kind: 'gamesConfiguration#achievementConfigurationDetail', + name: bundle(languages, a.locales, (v: { name: string }) => v.name), + description: bundle(languages, a.locales, (v: { description: string }) => v.description), + pointValue: a.points, + }, + }; +} + +function playScoreFormat(f: GcFormat | undefined): Record { + const fmt = f ?? 'INTEGER'; + if (fmt.startsWith('ELAPSED_TIME')) return { numberFormatType: 'TIME_DURATION' }; + if (fmt.startsWith('MONEY_')) { + const cur = { POUND: 'GBP', DOLLAR: 'USD', EURO: 'EUR', FRANC: 'CHF', KRONER: 'DKK', YEN: 'JPY' }[fmt.replace(/^MONEY_|_DECIMAL$/g, '')] ?? 'USD'; + return { numberFormatType: 'CURRENCY', currencyCode: cur }; + } + const m = /DECIMAL_POINT_(\d)_PLACE/.exec(fmt); + return { numberFormatType: 'NUMERIC', numDecimalPlaces: m ? Number(m[1]) : 0 }; +} + +export function playLeaderboardBody(l: GameLeaderboard, languages: Language[]): Record { + const suffix = Object.values(l.locales).some(v => v.suffix) ? bundle(languages, l.locales, (v: { suffix?: string }) => v.suffix) : undefined; + return { + kind: 'gamesConfiguration#leaderboardConfiguration', + scoreOrder: l.sort === 'low' ? 'SMALLER_IS_BETTER' : 'LARGER_IS_BETTER', + ...(l.min !== undefined ? { scoreMin: String(l.min) } : {}), ...(l.max !== undefined ? { scoreMax: String(l.max) } : {}), + draft: { + kind: 'gamesConfiguration#leaderboardConfigurationDetail', + name: bundle(languages, l.locales, (v: { name: string }) => v.name), + scoreFormat: { ...playScoreFormat(l.format), ...(suffix ? { suffix: { other: suffix } } : {}) }, + }, + }; +} + +export interface PlayExisting { id: string; draft?: { name?: Bundle }; [k: string]: unknown } + +const defaultName = (x: PlayExisting, locale: string): string | undefined => x.draft?.name?.translations.find(t => t.locale === locale)?.value; + +/** Comparable part of a Play configuration (what we send, without ids / tokens / published / icon / sortRank). */ +function comparable(body: Record, have: PlayExisting | undefined): Record { + if (!have) return {}; + const draft = (have.draft ?? {}) as Record; + const want = body.draft as Record; + const out: Record = {}; + for (const k of Object.keys(body)) if (k !== 'draft') out[k] = have[k]; + out.draft = Object.fromEntries(Object.keys(want).map(k => [k, draft[k]])); + return out; +} + +/** Play plan: matched by playId, else by the name in the default language (Play ids are generated). Update = PUT of + * the whole configuration (Google's update replaces it); the existing icon / sort rank are kept in the body. */ +export function planPlayGames(g: GamesConfig, languages: Language[], defaultLocale: string, have: { leaderboards: PlayExisting[]; achievements: PlayExisting[] }): ItemPlan[] { + const out: ItemPlan[] = []; + const one = (kind: ItemPlan['kind'], item: GameLeaderboard | GameAchievement, body: Record, list: PlayExisting[]) => { + const name = (body.draft as { name: Bundle }).name.translations.find(t => t.locale === defaultLocale)?.value; + const cur = item.playId ? list.find(x => x.id === item.playId) : list.find(x => name !== undefined && defaultName(x, defaultLocale) === name); + const changed = cur ? changedFields(comparable(body, cur), body).flatMap(k => (k === 'draft' ? changedFields(comparable(body, cur).draft as Record, body.draft as Record).map(d => `draft.${d}`) : [k])) : Object.keys(body); + if (cur) { + // keep what Play has that we do not manage (icon, sort rank) + const d = cur.draft as Record | undefined; + body = { ...body, id: cur.id, draft: { ...(d?.iconUrl ? { iconUrl: d.iconUrl } : {}), ...(d?.sortRank !== undefined ? { sortRank: d.sortRank } : {}), ...(body.draft as object) } }; + } + out.push({ kind, id: item.id, action: !cur ? 'create' : changed.length ? 'update' : 'keep', storeId: cur?.id, attributes: body, changed, locales: [] }); + }; + for (const l of (g.leaderboards ?? []).filter(x => onStore(x, 'play'))) one('leaderboard', l, playLeaderboardBody(l, languages), have.leaderboards); + for (const a of (g.achievements ?? []).filter(x => onStore(x, 'play'))) one('achievement', a, playAchievementBody(a, languages), have.achievements); + return out; +} + +/** Dry-plan rows (cli/lib/dry.ts) of a plan. */ +export function planRows(plan: ItemPlan[]): { item: string; action: string; detail?: string }[] { + const rows: { item: string; action: string; detail?: string }[] = []; + for (const p of plan) { + rows.push({ item: `${p.kind} ${p.id}`, action: p.action, detail: p.action === 'keep' ? undefined : p.changed.join(', ') }); + for (const l of p.locales) rows.push({ item: `${p.kind} ${p.id} ${l.locale}`, action: l.action, detail: l.action === 'keep' ? undefined : l.changed.map(k => `${k}=${JSON.stringify(l.attributes[k])}`).join(', ') }); + } + return rows; +} + +// ------------------------------------------------------------------------------------------------ project checks + +export interface Finding { level: 'error' | 'warn' | 'ok'; id: string; msg: string; fix?: string } + +const SKIP = new Set(['node_modules', '.git', 'build', 'Pods', 'DerivedData', '.gradle', 'intermediates', 'generated', '.storebound']); + +function walk(dir: string, depth: number, pick: (f: string) => boolean, out: string[] = []): string[] { + let names: string[] = []; + try { names = readdirSync(dir); } catch { return out; } + for (const n of names) { + if (SKIP.has(n) || n.startsWith('.')) continue; + const f = join(dir, n); + let dirp = false; + try { dirp = statSync(f).isDirectory(); } catch { continue; } + if (dirp) { if (depth > 0) walk(f, depth - 1, pick, out); } else if (pick(f)) out.push(f); + } + return out; +} + +export interface PgsProject { + /** Gradle files that use play-services-games(-v2). */ + gradle: string[]; + /** Manifest meta-data com.google.android.gms.games.APP_ID: the raw value (literal or @string/name) and where. */ + manifest: { file: string; value: string } | null; + /** The resolved project id (digits) and the resource file it came from. */ + appId: { value: string; file: string } | null; +} + +/** Reads the Android project for Play Games Services: the Gradle dependency, the manifest meta-data and the string + * resource it points at (res/values/games-ids.xml or strings.xml). */ +export function readPgsProject(root: string): PgsProject { + const files = walk(root, 7, f => /\.gradle(\.kts)?$|AndroidManifest\.xml$|[\\/]res[\\/]values[^\\/]*[\\/][^\\/]+\.xml$/.test(f)); + const rel = (f: string) => relative(root, f); + const gradle = files.filter(f => /\.gradle(\.kts)?$/.test(f) && /play-services-games(-v2)?\b|games-v2/.test(readFileSync(f, 'utf8'))).map(rel); + let manifest: PgsProject['manifest'] = null; + for (const f of files.filter(x => x.endsWith('AndroidManifest.xml'))) { + const t = readFileSync(f, 'utf8'); + const m = /]*android:name\s*=\s*"com\.google\.android\.gms\.games\.APP_ID"[^>]*android:value\s*=\s*"([^"]+)"|]*android:value\s*=\s*"([^"]+)"[^>]*android:name\s*=\s*"com\.google\.android\.gms\.games\.APP_ID"/.exec(t); + if (m) { manifest = { file: rel(f), value: (m[1] ?? m[2])!.trim() }; break; } + } + let appId: PgsProject['appId'] = null; + const literal = manifest?.value.replace(/^\\ ?/, '').trim(); + if (literal && /^\d+$/.test(literal)) appId = { value: literal, file: manifest!.file }; + else { + const names = manifest && /^@string\//.test(manifest.value) ? [manifest.value.slice(8)] : ['game_services_project_id', 'app_id', 'games_APP_ID']; + for (const f of files.filter(x => /[\\/]res[\\/]values[\\/][^\\/]+\.xml$/.test(x))) { + const t = readFileSync(f, 'utf8'); + for (const n of names) { + const m = new RegExp(`]*name\\s*=\\s*"${n}"[^>]*>\\s*([^<]*?)\\s*`).exec(t); + if (m && /^\d+$/.test(m[1]!)) { appId = { value: m[1]!, file: rel(f) }; break; } + } + if (appId) break; + } + } + return { gradle, manifest, appId }; +} + +/** PGS project checks: the dependency, the manifest meta-data, the resource and the config's id. */ +export function pgsFindings(config: Pick, p: PgsProject, hasAndroid = true): Finding[] { + const out: Finding[] = []; + const want = config.games?.playGames?.appId; + if (!hasAndroid) return out; + if (!p.gradle.length && !usesPlayGames(config)) return out; + if (p.gradle.length && !usesPlayGames(config)) + out.push({ level: 'warn', id: 'pgs.config', msg: `the Android code uses Play Games Services (${p.gradle[0]}) but the config has no games.playGames`, fix: 'add games: { playGames: { appId: "" } } so storebound checks and syncs it' }); + if (usesPlayGames(config) && !p.gradle.length) + out.push({ level: 'warn', id: 'pgs.gradle', msg: 'games.playGames is set but no Gradle file uses com.google.android.gms:play-services-games-v2', fix: 'add implementation "com.google.android.gms:play-services-games-v2:" to the app module' }); + if (!p.manifest) out.push({ level: 'error', id: 'pgs.manifest', msg: 'no in AndroidManifest.xml: sign-in fails at runtime', fix: 'add inside ' }); + else if (!p.appId) out.push({ level: 'error', id: 'pgs.app-id', msg: `the manifest's APP_ID (${p.manifest.value}) points at no string resource with the project id`, fix: 'add res/values/games-ids.xml from Play Console > Play Games Services > Setup and management > Configuration > Get resources' }); + else if (want && p.appId.value !== String(want)) out.push({ level: 'error', id: 'pgs.app-id', msg: `games_APP_ID ${p.appId.value} (${p.appId.file}) differs from games.playGames.appId ${want}`, fix: 'use the resources of the games project linked to this app (Play Console > Play Games Services > Configuration)' }); + else out.push({ level: 'ok', id: 'pgs.app-id', msg: `games_APP_ID ${p.appId.value} (${p.appId.file})${want ? ' matches the config' : ''}` }); + if (p.appId && !want) out.push({ level: 'warn', id: 'pgs.config-id', msg: `games.playGames.appId is not set (the project has ${p.appId.value})`, fix: `set games.playGames.appId: "${p.appId.value}"` }); + return out; +} + +/** Game Center project checks: the entitlement in the iOS project (and that CODE_SIGN_ENTITLEMENTS references it). */ +export function gcFindings(config: Pick, ents: { file: string; caps: { cap: string }[]; referenced: boolean | null }[] | null): Finding[] { + if (!ents) return []; + const has = ents.filter(e => e.caps.some(c => c.cap === 'GAME_CENTER')); + if (!usesGameCenter(config)) return has.length ? [{ level: 'warn', id: 'gc.config', msg: `${has[0]!.file} has com.apple.developer.game-center but games.gameCenter is not true`, fix: 'set games: { gameCenter: true } so signing ensure and apple game-center keep the store in step' }] : []; + if (!has.length) return [{ level: 'error', id: 'gc.entitlement', msg: 'games.gameCenter is true but no .entitlements file has com.apple.developer.game-center', fix: 'Xcode > the app target > Signing & Capabilities > + Game Center (adds the key and CODE_SIGN_ENTITLEMENTS)' }]; + if (has.every(e => e.referenced === false)) return [{ level: 'error', id: 'gc.entitlement', msg: `${has[0]!.file} has the Game Center key but no CODE_SIGN_ENTITLEMENTS points at it`, fix: 'set CODE_SIGN_ENTITLEMENTS of the app target to that file' }]; + return [{ level: 'ok', id: 'gc.entitlement', msg: `com.apple.developer.game-center in ${has[0]!.file}` }]; +} + +/** Declarations reminders: a platform player id is a User ID on both stores. */ +export function gamesPrivacyFindings(config: Pick): Finding[] { + const out: Finding[] = []; + if (!usesGameCenter(config) && !usesPlayGames(config)) return out; + const ds = (config.dataSafety as { data?: Record } | undefined)?.data; + if (usesPlayGames(config) && ds && !ds.PSL_USER_ACCOUNT) + out.push({ level: 'warn', id: 'games.data-safety', msg: 'Play Games Services gives the game a player id: Data safety > Personal info > User IDs (PSL_USER_ACCOUNT) is not declared', fix: 'add PSL_USER_ACCOUNT to dataSafety.data (collected, app functionality), store-declarations skill' }); + const types = (config.appPrivacy as { types?: Record } | undefined)?.types; + if (usesGameCenter(config) && types && !types.USER_ID) + out.push({ level: 'warn', id: 'games.app-privacy', msg: 'Game Center gives the game a player id: App Privacy > Identifiers > User ID (USER_ID) is not declared', fix: 'add USER_ID (App Functionality, linked to the user) to appPrivacy.types, store-declarations skill' }); + return out; +} + +export const hasAndroidProject = (root: string): boolean => existsSync(join(root, 'android')) || walk(root, 3, f => f.endsWith('AndroidManifest.xml')).length > 0; diff --git a/cli/lib/signing.ts b/cli/lib/signing.ts index 6e598a1..8abefab 100644 --- a/cli/lib/signing.ts +++ b/cli/lib/signing.ts @@ -60,8 +60,12 @@ export function validateSigning(s: AppleSigning | undefined): string[] { return e; } -export const capabilitySpecs = (s: AppleSigning | undefined): CapabilitySpec[] => - (s?.capabilities ?? []).map(c => (typeof c === 'string' ? { type: c } : c)); +/** The capabilities the App ID must have: apple.signing.capabilities, plus GAME_CENTER when games.gameCenter is true. */ +export const capabilitySpecs = (s: AppleSigning | undefined, gameCenter = false): CapabilitySpec[] => { + const out = (s?.capabilities ?? []).map((c): CapabilitySpec => (typeof c === 'string' ? { type: c } : c)); + if (gameCenter && !out.some(c => c.type === 'GAME_CENTER')) out.push({ type: 'GAME_CENTER' }); + return out; +}; /** Capabilities to add (never removes one: removing can break a shipped feature; extra ones are reported). */ export function planCapabilities(bundleIdId: string, existing: string[], want: CapabilitySpec[]): { create: Req[]; present: string[]; extra: string[] } { diff --git a/cli/play/games.ts b/cli/play/games.ts new file mode 100644 index 0000000..83239a7 --- /dev/null +++ b/cli/play/games.ts @@ -0,0 +1,105 @@ +// Play Games Services leaderboards and achievements from the `games` config section (cli/lib/game-services.ts), +// through the Google Play Games Services Configuration API (games/v1configuration: achievementConfigurations, +// leaderboardConfigurations; list / insert / update; the Play service account, scope androidpublisher). +// storebound play games [status] [--dry] [--yes] [--json] +// The games project itself, its Android credential (app-signing SHA-1), the web OAuth client and publishing the +// configuration have no API: `storebound console plan play-games-services` (agent-first) and `console verify`. +// Matching: games.*[].playId, else the name in the default Play language (Play generates the ids). An insert is never +// sent twice (no 5xx retry on POST; an unclear answer reads the list again); update is a PUT of the whole +// configuration with the fields Play has and we do not manage (icon, sort rank) kept. Nothing is published: the +// owner's Publish in Play Console makes the draft live. +import { playServiceAccount } from '../lib/credentials'; +import type { Ctx } from '../lib/ctx'; +import { confirm, need } from '../lib/ctx'; +import { dryPlan } from '../lib/dry'; +import { hasAndroidProject, type ItemPlan, pgsFindings, type PlayExisting, planPlayGames, planRows, readPgsProject, usesPlayGames } from '../lib/game-services'; +import { PLAY_SCOPE, tokenSource } from '../lib/google-auth'; +import { errorBody } from '../lib/http'; +import { done, log, warn } from '../lib/log'; +import * as rt from '../lib/rt'; +import { AmbiguousWriteError, createOnce, retrySafe } from '../lib/upsert'; + +const TAG = 'play-games'; +export const GAMES_CONFIG_API = 'https://gamesconfiguration.googleapis.com/games/v1configuration'; + +export function gamesConfigClient(root: string) { + const token = tokenSource(playServiceAccount(root), PLAY_SCOPE); + async function call(method: string, path: string, body?: unknown): Promise { + const url = GAMES_CONFIG_API + path; + const safe = method !== 'POST' && retrySafe(method, url); + for (let attempt = 0; ; attempt++) { + const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) }); + if (r.status === 429 && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; } + if (r.status >= 500) { + if (safe && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; } + const t = await r.text(); + if (!safe) throw new AmbiguousWriteError(`${method} ${path} -> ${r.status} ${errorBody(t, 300)}`, r.status); + throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t, 300)}`); + } + const t = await r.text(); + if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t, 800)}`); + return (t ? JSON.parse(t) : {}) as T; + } + } + async function list(kind: 'achievements' | 'leaderboards', appId: string): Promise { + const out: PlayExisting[] = []; + let page: string | undefined; + do { + const r = await call<{ items?: PlayExisting[]; nextPageToken?: string }>('GET', `/applications/${appId}/${kind}?maxResults=200${page ? `&pageToken=${encodeURIComponent(page)}` : ''}`); + out.push(...(r.items ?? [])); + page = r.nextPageToken; + } while (page); + return out; + } + return { call, list }; +} + +export const playDefaultLocale = (config: Ctx['loaded']['config']): string => config.play?.defaultLanguage ?? 'en-US'; + +export async function readPlayGames(c: ReturnType, appId: string): Promise<{ leaderboards: PlayExisting[]; achievements: PlayExisting[] }> { + return { leaderboards: await c.list('leaderboards', appId), achievements: await c.list('achievements', appId) }; +} + +const pending = (plan: ItemPlan[]) => plan.filter(p => p.action !== 'keep'); + +export async function run(ctx: Ctx): Promise { + const { config, root } = ctx.loaded; + const a = ctx.args; + const sub = a._[0] ?? 'sync'; + if (!['sync', 'status'].includes(sub)) throw new Error('usage: storebound play games [status] [--dry] [--yes] [--json]'); + if (!usesPlayGames(config)) throw new Error('games.playGames is not set in storebound.config.ts (game-services skill): nothing to do'); + need(config.play?.packageName, 'play.packageName'); + const pgs = hasAndroidProject(root) ? readPgsProject(root) : null; + const project = pgs ? pgsFindings(config, pgs) : []; + const appId = config.games!.playGames!.appId ?? pgs?.appId?.value; + for (const f of project) (f.level === 'ok' ? log : warn)(TAG, `${f.msg}${f.fix ? ` (${f.fix})` : ''}`); + if (!appId) throw new Error('games.playGames.appId is not set and the Android project has no games_APP_ID: create the games project first (storebound console plan play-games-services), then set the id'); + const c = gamesConfigClient(root); + const have = await readPlayGames(c, appId); + const locale = playDefaultLocale(config); + const plan = planPlayGames(config.games!, config.languages, locale, have); + if (a.has('json')) console.log(JSON.stringify({ appId, leaderboards: have.leaderboards.length, achievements: have.achievements.length, project, plan }, null, 2)); + if (sub === 'status' || a.has('dry')) { + if (!a.has('json')) console.log(dryPlan(`play games (project ${appId})`, planRows(plan))); + return; + } + if (!pending(plan).length) { done(TAG, `Play Games Services in step: ${have.leaderboards.length} leaderboard(s), ${have.achievements.length} achievement(s)`); return; } + console.log(dryPlan(`play games (project ${appId}, to write)`, planRows(plan)).replace(/^dry run: /, '').replace(/; nothing written/, '')); + if (!(await confirm(ctx, 'Write these Play Games Services drafts? (nothing is published; the owner publishes in Play Console)'))) return; + for (const p of pending(plan)) { + const kind = p.kind === 'leaderboard' ? 'leaderboards' : 'achievements'; + const name = (p.attributes.draft as { name: { translations: { locale: string; value: string }[] } }).name.translations.find(t => t.locale === locale)?.value; + if (p.action === 'create') { + const r = await createOnce({ what: `${p.kind} ${p.id}`, tag: TAG, + create: async () => (await c.call('POST', `/applications/${appId}/${kind}`, p.attributes)).id, + find: async () => (await c.list(kind, appId)).find(x => x.draft?.name?.translations.some(t => t.locale === locale && t.value === name))?.id ?? null }); + log(TAG, `${p.kind} ${p.id}: created as ${r.value} (set playId: "${r.value}" in the config to match it by id)`); + } else { + await c.call('PUT', `/${kind}/${encodeURIComponent(p.storeId!)}`, p.attributes); + log(TAG, `${p.kind} ${p.id}: updated ${p.changed.join(', ')}`); + } + } + const left = pending(planPlayGames(config.games!, config.languages, locale, await readPlayGames(c, appId))); + if (left.length) warn(TAG, `read back: ${left.map(p => `${p.kind} ${p.id}`).join(', ')} not shown as written yet; run storebound play games status in a minute`); + done(TAG, 'Play Games Services drafts written; publish them with the games project in Play Console (the owner\'s Publish button: storebound console plan play-games-services)'); +}