// 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 { builtIpaEntitlements, 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[]; } /** Literal paths (the API guard test reads them from the source). */ const locsPath = (type: 'gameCenterLeaderboards' | 'gameCenterAchievements', id: string): string => type === 'gameCenterLeaderboards' ? `/v1/gameCenterLeaderboards/${id}/localizations?limit=200` : `/v1/gameCenterAchievements/${id}/localizations?limit=200`; const itemsPath = (type: 'gameCenterLeaderboards' | 'gameCenterAchievements', detail: string): string => type === 'gameCenterLeaderboards' ? `/v1/gameCenterDetails/${detail}/gameCenterLeaderboards?limit=200` : `/v1/gameCenterDetails/${detail}/gameCenterAchievements?limit=200`; 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(locsPath(type, x.id))).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)); 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; const sw = await ensureGcSwitch(c, { appId, detail: s.detail, version: s.version }); const detail = sw.detail; for (const p of plan) { const type = p.kind === 'leaderboard' ? 'gameCenterLeaderboards' as const : 'gameCenterAchievements' as const; 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(itemsPath(type, detail!))).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(locsPath(type, pid))).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')})`); } // ------------------------------------------------------------------ the Game Center switch (submit, signing ensure) // Seen live on a first submission: a build with com.apple.developer.game-center and an app version without Game // Center answer 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED ("appStoreVersion is missing // gameCenterConfigurations relationship"). What passes: POST /v1/gameCenterDetails {app} when the app has none, then // POST /v1/gameCenterAppVersions {appStoreVersion} (both verified in specs/app-store-connect.oas.json). export interface GcSwitch { appId: string; detail: string | null; version: { id: string; versionString: string; enabled: boolean } | null } export async function readGcSwitch(c: Asc, appId: string, version: J | null): Promise { const detail = (await c.maybe(`/v1/apps/${appId}/gameCenterDetail`))?.data?.id ?? null; return { appId, detail, version: version ? { id: version.id, versionString: String(version.attributes?.versionString ?? ''), enabled: !!(await c.maybe(`/v1/appStoreVersions/${version.id}/gameCenterAppVersion`))?.data } : null }; } /** The writes the switch still needs, as request lines (empty: on). */ export function gcSwitchPlan(s: GcSwitch): string[] { return [...(s.detail ? [] : [`POST /v1/gameCenterDetails {app=${s.appId}}`]), ...(s.version && !s.version.enabled ? [`POST /v1/gameCenterAppVersions {appStoreVersion=${s.version.id}} # version ${s.version.versionString}`] : [])]; } /** Creates what gcSwitchPlan names (find-then-create, never twice). */ export async function ensureGcSwitch(c: Asc, s: GcSwitch, tag = TAG): Promise { let detail = s.detail; if (!detail) { detail = (await createOnce({ what: 'Game Center detail', tag, unique: true, create: async () => (await c.asc('POST', '/v1/gameCenterDetails', { data: { type: 'gameCenterDetails', relationships: { app: { data: { type: 'apps', id: s.appId } } } } })).data.id as string, find: async () => (await c.maybe(`/v1/apps/${s.appId}/gameCenterDetail`))?.data?.id ?? null })).value; log(tag, `Game Center switched on for the app (${detail})`); } const v = s.version; if (v && !v.enabled) { await createOnce({ what: `Game Center for version ${v.versionString}`, tag, unique: true, create: async () => (await c.asc('POST', '/v1/gameCenterAppVersions', { data: { type: 'gameCenterAppVersions', relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: v.id } } } } })).data.id as string, find: async () => (await c.maybe(`/v1/appStoreVersions/${v.id}/gameCenterAppVersion`))?.data?.id ?? null }); log(tag, `Game Center switched on for version ${v.versionString}`); } return { ...s, detail, version: v ? { ...v, enabled: true } : null }; } /** Game Center is in use when the config says so, the iOS project has the entitlement, or the newest local IPA does. */ export function gameCenterInUse(config: Ctx['loaded']['config'], root: string): { inUse: boolean; why: string | null } { if (usesGameCenter(config)) return { inUse: true, why: 'games.gameCenter' }; try { const p = readProjectEntitlements(root)?.find(e => e.caps.some(x => x.cap === 'GAME_CENTER')); if (p) return { inUse: true, why: `${p.file} has com.apple.developer.game-center` }; } catch { /* unreadable */ } try { const b = builtIpaEntitlements(root, config); if (b?.caps.some(x => x.cap === 'GAME_CENTER')) return { inUse: true, why: `build ${b.number} (${b.file}) has com.apple.developer.game-center` }; } catch { /* no IPA */ } return { inUse: false, why: null }; }