storebound/cli/apple/game-center.ts

135 lines
9.6 KiB
TypeScript

// 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<GcExisting[]> {
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<GcState> {
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<string, unknown> {
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<void> {
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;
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')})`);
}