135 lines
9.3 KiB
TypeScript
135 lines
9.3 KiB
TypeScript
// 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 { PlayToken } from '../lib/google-auth';
|
|
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, playToken } from '../lib/google-auth';
|
|
import { enableUrl, findingText, parseGoogleError } from '../lib/google-errors';
|
|
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_SERVICE = 'gamesconfiguration.googleapis.com';
|
|
export const GAMES_API_TITLE = 'Google Play Game Services Publishing API';
|
|
export const GAMES_CONFIG_API = 'https://gamesconfiguration.googleapis.com/games/v1configuration';
|
|
|
|
/** The Games Configuration API is off in the service account's Cloud project (403 SERVICE_DISABLED / accessNotConfigured): the exact API name, where to turn it on and the console plan. Null for any other error. */
|
|
export function gamesApiDisabledText(errText: string, fallbackProject: string | null): string | null {
|
|
const f = parseGoogleError(errText);
|
|
if (f?.kind !== 'api-disabled') return null;
|
|
const projectId = f.projectId ?? fallbackProject;
|
|
const line = findingText({ ...f, service: GAMES_SERVICE, serviceTitle: GAMES_API_TITLE, projectId, activationUrl: enableUrl(GAMES_SERVICE, projectId) }, { withUrl: true });
|
|
return `${line}
|
|
API: ${GAMES_API_TITLE} (${GAMES_SERVICE}). Enable it, wait a minute, then run storebound play games again. Plan: storebound console plan play-games-services`;
|
|
}
|
|
|
|
export function gamesConfigClient(root: string) {
|
|
// reads: the credential broker's cloud-platform.read-only token (androidpublisher does not cover this API), else the
|
|
// local key; writes stay on the local key (the broker has no write scope for it): made only when a write is sent
|
|
let projectId: string | null = null;
|
|
try { projectId = playServiceAccount(root).project_id ?? null; } catch { /* broker only */ }
|
|
const readToken = playToken(root, PLAY_SCOPE, { brokerScope: 'cloud-platform.read-only' });
|
|
let writeToken: PlayToken | null = null;
|
|
const token = (method: string): Promise<string> => {
|
|
if (method === 'GET') return readToken();
|
|
try { writeToken ??= playToken(root, PLAY_SCOPE, { brokerScope: null }); } catch (e) {
|
|
throw new Error(`Play Games configuration writes need a local service account key (PLAY_SERVICE_ACCOUNT): the credential broker hands out read scopes only for this API. ${(e as Error).message}`);
|
|
}
|
|
return writeToken();
|
|
};
|
|
async function call<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
|
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(method)), ...(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) {
|
|
const msg = `${method} ${path} -> ${r.status} ${errorBody(t, 800)}`;
|
|
const off = r.status === 403 ? gamesApiDisabledText(msg, projectId) : null;
|
|
throw new Error(off ? `${msg.split(' -> ')[0]} -> 403: ${off}` : msg);
|
|
}
|
|
return (t ? JSON.parse(t) : {}) as T;
|
|
}
|
|
}
|
|
async function listAll(kind: 'achievements' | 'leaderboards', appId: string): Promise<PlayExisting[]> {
|
|
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, listAll };
|
|
}
|
|
|
|
export const playDefaultLocale = (config: Ctx['loaded']['config']): string => config.play?.defaultLanguage ?? 'en-US';
|
|
|
|
export async function readPlayGames(c: ReturnType<typeof gamesConfigClient>, appId: string): Promise<{ leaderboards: PlayExisting[]; achievements: PlayExisting[] }> {
|
|
return { leaderboards: await c.listAll('leaderboards', appId), achievements: await c.listAll('achievements', appId) };
|
|
}
|
|
|
|
const pending = (plan: ItemPlan[]) => plan.filter(p => p.action !== 'keep');
|
|
|
|
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 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;
|
|
if (!a.has('json')) 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)); return; }
|
|
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<PlayExisting>('POST', `/applications/${appId}/${kind}`, p.attributes)).id,
|
|
find: async () => (await c.listAll(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)');
|
|
}
|