storebound/cli/games-status.ts
CRT 0ef27cf2e4 desktop: five sections by job (Release, Store page, Revenue, Players, Health), Game services view, sidebar by attention
- app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state
  already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the
  context menu list sections and sub views; old tab ids, section and section/sub links all resolve
- Players > Game services (games only): both stores, one row per leaderboard / achievement with its
  languages and each store's state, Game Center locale map, Play API-disabled state with the console plan
- games status --json: items, localeMap, playApiDisabled; inspect summary: games
- sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio
  filter, workspace as a footer icon row
- boot check opens places like a person (section, then sub view)
2026-10-24 13:06:42 +08:00

136 lines
12 KiB
TypeScript

// Game services status for both stores, read-only: what `validate`, `check`, the MCP tool and the desktop card show.
// Local part (no network): the Game Center entitlement, the PGS APP_ID resource and Gradle dependency, the player-id
// declaration reminders. Store part: Game Center (App ID capability, detail, version switch, counts, pending changes)
// and Play Games Services (counts, pending changes).
import { gameCenterInUse, readGameCenter } from './apple/game-center';
import { ascClient } from './apple/asc';
import { readProjectEntitlements } from './apple/signing';
import type { Loaded } from './lib/config';
import { consoleUrl } from './lib/console-links';
import { type Action, type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, type ItemPlan, liveGcLocales, onStore, planGameCenter, planPlayGames, pgsFindings, readPgsProject, suggestLocaleMap, usesPlayGames } from './lib/game-services';
import { parseGoogleError } from './lib/google-errors';
import type { Finding as VFinding } from './lib/validate-rules';
import { gamesConfigClient, playDefaultLocale, readPlayGames } from './play/games';
export interface GameServicesStatus {
apple: null | { capability: boolean | null; enabled: boolean; version: string | null; versionEnabled: boolean | null; leaderboards: number; achievements: number;
config: { leaderboards: number; achievements: number }; changes: string[]; error?: string; why?: string };
play: null | { appId: string | null; resource: string | null; leaderboards: number; achievements: number;
config: { leaderboards: number; achievements: number }; changes: string[]; error?: string };
findings: (Finding & { store: 'apple' | 'play' })[];
links: { label: string; url: string }[];
/** One row per configured leaderboard / achievement: its texts and where each store stands against the config
* (create / update / keep; null = not on that store or that store was not read). */
items?: GameItemRow[];
/** Game Center locale map: the configured entries and the suggestions from the live locales. */
localeMap?: { configured: Record<string, string>; suggested: { id: string; from: string; to: string }[] };
/** Play: the Games Configuration API is off in the service account's Cloud project. */
playApiDisabled?: { service: string; title: string; url: string } | null;
}
export interface GameItemRow {
kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean;
ios: boolean; android: boolean;
apple: { action: Action; changed: string[]; locales: { locale: string; action: Action }[] } | null;
play: { action: Action; changed: string[] } | null;
}
/** The per-item rows of the status (pure; plans are null when that store was not read). */
export function gameItemRows(g: NonNullable<Loaded['config']['games']>, applePlan: ItemPlan[] | null, playPlan: ItemPlan[] | null): GameItemRow[] {
const find = (plan: ItemPlan[] | null, kind: ItemPlan['kind'], id: string) => plan?.find(p => p.kind === kind && p.id === id) ?? null;
const row = (kind: ItemPlan['kind'], x: { id: string; locales: Record<string, { name: string }>; platforms?: ('ios' | 'android')[] }, extra: Partial<GameItemRow>): GameItemRow => {
const a = find(applePlan, kind, x.id), p = find(playPlan, kind, x.id);
return { kind, id: x.id, name: Object.values(x.locales)[0]?.name ?? x.id, locales: Object.keys(x.locales), ...extra,
ios: onStore(x, 'apple'), android: onStore(x, 'play'),
apple: a ? { action: a.action, changed: a.changed, locales: a.locales.map(l => ({ locale: l.locale, action: l.action })) } : null,
play: p ? { action: p.action, changed: p.changed } : null };
};
return [...(g.leaderboards ?? []).map(l => row('leaderboard', l, {})), ...(g.achievements ?? []).map(a => row('achievement', a, { points: a.points, hidden: !!a.hidden }))];
}
/** Offline findings (check, scan-like): entitlement, APP_ID resource, declarations reminders. */
export function localGameFindings(loaded: Loaded): (Finding & { store: 'apple' | 'play' })[] {
const { config, root } = loaded;
const out: (Finding & { store: 'apple' | 'play' })[] = [];
if (config.apple) { let ents = null; try { ents = readProjectEntitlements(root); } catch { /* unreadable */ } out.push(...gcFindings(config, ents).map(f => ({ ...f, store: 'apple' as const }))); }
if (config.play && hasAndroidProject(root)) out.push(...pgsFindings(config, readPgsProject(root)).map(f => ({ ...f, store: 'play' as const })));
for (const f of gamesPrivacyFindings(config)) out.push({ ...f, store: f.id === 'games.app-privacy' ? 'apple' : 'play' });
return out;
}
const changesOf = (plan: { kind: string; id: string; action: string; locales: { action: string; locale: string }[] }[]) =>
plan.flatMap(p => [...(p.action !== 'keep' ? [`${p.kind} ${p.id}: ${p.action}`] : []), ...p.locales.filter(l => l.action !== 'keep').map(l => `${p.kind} ${p.id} ${l.locale}: ${l.action}`)]);
export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; play?: boolean } = {}): Promise<GameServicesStatus> {
const { config, root } = loaded;
const g = config.games ?? {};
const findings = localGameFindings(loaded);
const out: GameServicesStatus = { apple: null, play: null, findings, links: [], localeMap: { configured: { ...(g.localeMap ?? {}) }, suggested: [] }, playApiDisabled: null };
let applePlan: ItemPlan[] | null = null, playPlan: ItemPlan[] | null = null;
const count = (store: 'apple' | 'play') => ({ leaderboards: (g.leaderboards ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length,
achievements: (g.achievements ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length });
const gcUse = config.apple ? gameCenterInUse(config, root) : { inUse: false, why: null };
if (config.apple && gcUse.inUse && o.apple !== false) {
out.links.push({ label: 'App Store Connect: the version page (Game Center section)', url: consoleUrl('asc.version') });
try {
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
const s = await readGameCenter(c, config.apple.bundleId);
applePlan = planGameCenter(g, config.languages, s, g.localeMap);
out.localeMap!.suggested = suggestLocaleMap(config.languages, liveGcLocales(s), g.localeMap ?? {});
out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null,
leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(applePlan), why: gcUse.why ?? undefined };
} catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; }
}
if (config.play && usesPlayGames(config) && o.play !== false) {
out.links.push({ label: 'Play Console: Play Games Services configuration', url: consoleUrl('play.games-services') }, { label: 'Play Console: Play app signing (SHA-1)', url: consoleUrl('play.app-signing') });
const res = hasAndroidProject(root) ? readPgsProject(root).appId : null;
const appId = g.playGames?.appId ?? res?.value ?? null;
const base = { appId, resource: res ? `${res.value} (${res.file})` : null, config: count('play') };
if (!appId) out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: 'no games project id yet (storebound console plan play-games-services)' };
else try {
const have = await readPlayGames(gamesConfigClient(root), appId);
playPlan = planPlayGames(g, config.languages, playDefaultLocale(config), have);
out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(playPlan) };
} catch (e) {
const f = parseGoogleError((e as Error).message);
if (f?.kind === 'api-disabled') out.playApiDisabled = { service: f.service, title: f.serviceTitle, url: f.activationUrl };
out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; }
}
out.items = gameItemRows(g, applePlan, playPlan);
return out;
}
/** The status as validate findings (pure). */
export function gameServicesFindings(s: GameServicesStatus): VFinding[] {
const out: VFinding[] = s.findings.map(f => ({ store: f.store, level: f.level === 'error' ? 'blocker' : f.level, code: f.id, msg: f.msg, ...(f.fix ? { fix: f.fix } : {}) }) as VFinding);
const a = s.apple;
if (a) {
if (a.error) out.push({ store: 'apple', level: 'warn', code: 'game-center', msg: `Game Center not read: ${a.error}` });
else {
if (a.capability === false) out.push({ store: 'apple', level: 'blocker', code: 'game-center.capability', msg: 'games.gameCenter is true but the App ID has no GAME_CENTER capability: the build\'s profile will not allow Game Center', fix: 'storebound apple signing ensure --dry, then without --dry' });
const why = a.why ? ` (${a.why})` : '';
const bounce = 'the submission would answer 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED';
if (!a.enabled) out.push({ store: 'apple', level: 'blocker', code: 'game-center.detail', msg: `Game Center is in use${why} but the app has no Game Center detail in App Store Connect: ${bounce}`, fix: 'storebound apple game-center --dry, then without --dry (apple submit runs the same switch)' });
if (a.versionEnabled === false) out.push({ store: 'apple', level: 'blocker', code: 'game-center.version', msg: `Game Center is in use${why} but version ${a.version} has no Game Center switch (gameCenterAppVersion): ${bounce}`, fix: 'storebound apple game-center --dry, then without --dry (apple submit runs the same switch)' });
if (a.changes.length) out.push({ store: 'apple', level: 'warn', code: 'game-center.sync', msg: `Game Center differs from the config: ${a.changes.slice(0, 6).join('; ')}${a.changes.length > 6 ? ` (+${a.changes.length - 6})` : ''}`, fix: 'storebound apple game-center --dry' });
if (a.enabled && !a.changes.length) out.push({ store: 'apple', level: 'ok', code: 'game-center', msg: `Game Center on: ${a.leaderboards} leaderboard(s), ${a.achievements} achievement(s), in step with the config` });
}
}
const p = s.play;
if (p) {
if (p.error) out.push({ store: 'play', level: 'warn', code: 'play-games', msg: `Play Games Services not read: ${p.error}`, fix: 'storebound console plan play-games-services' });
else if (p.changes.length) out.push({ store: 'play', level: 'warn', code: 'play-games.sync', msg: `Play Games Services differs from the config: ${p.changes.slice(0, 6).join('; ')}`, fix: 'storebound play games --dry' });
else out.push({ store: 'play', level: 'ok', code: 'play-games', msg: `Play Games Services project ${p.appId}: ${p.leaderboards} leaderboard(s), ${p.achievements} achievement(s), in step with the config` });
if (!p.error && p.achievements < 5) out.push({ store: 'play', level: 'warn', code: 'play-games.achievements', msg: `${p.achievements} achievement(s) on Play: a games project needs at least 5 before it can be published`, fix: 'add achievements to games.achievements, then storebound play games' });
}
return out;
}
/** `storebound games status [--json]`: both stores' game services in one read-only report (desktop card, MCP). */
export async function run(ctx: import('./lib/ctx').Ctx): Promise<void> {
const s = await gameServicesStatus(ctx.loaded);
if (ctx.args.has('json')) { console.log(JSON.stringify(s, null, 2)); return; }
const { formatFindings } = await import('./lib/validate-rules');
console.log(formatFindings(gameServicesFindings(s)));
for (const l of s.links) console.log(`${l.label}: ${l.url}`);
}