174 lines
13 KiB
TypeScript
174 lines
13 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 { 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<GcExisting[]> {
|
|
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<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;
|
|
|
|
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<GcSwitch> {
|
|
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<GcSwitch> {
|
|
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 };
|
|
}
|