diff --git a/cli/check.ts b/cli/check.ts index d85776e..8b9368e 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -4,6 +4,7 @@ // storebound check everything above // storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors // --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems +import { localGameFindings } from './games-status'; import { templateBlockers } from './lib/template-answers'; import { COPYRIGHT_MISSING, templatePlaceholders } from './lib/validate-rules'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; @@ -239,6 +240,11 @@ export async function run(ctx: Ctx): Promise { if (r.severity !== 'low' && !(config.scan?.ignore ?? []).some(i => i.rule === r.rule && (!i.store || i.store === r.store))) note(`${r.store}: ${r.title} (${r.guideline}; ${r.evidence.map(where).join(', ')}). ${r.fix}`); } catch { /* no project sources here */ } } + // Game Center entitlement, the PGS APP_ID resource, player-id declarations (offline) + if (config.games || config.apple || config.play) { + try { for (const f of localGameFindings(ctx.loaded)) { if (f.level === 'error') problems.push(`${f.store}: ${f.msg} (${f.fix ?? ''})`); else if (f.level === 'warn') note(`${f.store}: ${f.msg}${f.fix ? ` (${f.fix})` : ''}`); } } + catch { /* no project sources here */ } + } // Play steps without an API: the App category (Store settings) and the App content answers the owner still owes const pc = playConsoleReminders(config); for (const w of pc.warnings) note(w); diff --git a/cli/games-status.ts b/cli/games-status.ts new file mode 100644 index 0000000..2362309 --- /dev/null +++ b/cli/games-status.ts @@ -0,0 +1,88 @@ +// 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 { 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 Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, planGameCenter, planPlayGames, pgsFindings, readPgsProject, usesGameCenter, usesPlayGames } from './lib/game-services'; +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 }; + 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 }[]; +} + +/** 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 { + const { config, root } = loaded; + const g = config.games ?? {}; + const findings = localGameFindings(loaded); + const out: GameServicesStatus = { apple: null, play: null, findings, links: [] }; + 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 }); + if (config.apple && usesGameCenter(config) && 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); + 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(planGameCenter(g, config.languages, s)) }; + } 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); + out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(planPlayGames(g, config.languages, playDefaultLocale(config), have)) }; + } catch (e) { out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } + } + 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' }); + if (!a.enabled) out.push({ store: 'apple', level: 'blocker', code: 'game-center.detail', msg: 'Game Center is not switched on for the app in App Store Connect', fix: 'storebound apple game-center --dry, then without --dry' }); + if (a.versionEnabled === false) out.push({ store: 'apple', level: 'warn', code: 'game-center.version', msg: `version ${a.version} does not have Game Center switched on`, fix: 'storebound apple game-center' }); + 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; +} diff --git a/cli/validate.ts b/cli/validate.ts index b6acedf..73dbead 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -14,6 +14,7 @@ // Live apps: crash / ANR vitals (Play Reporting API) and App Store diagnostics, as in `storebound health`: over a Play // bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips. import { templateBlockers } from './lib/template-answers'; +import { gameServicesFindings, gameServicesStatus } from './games-status'; import { ascClient, EDITABLE as APPLE_EDITABLE, type J } from './apple/asc'; import { allVersions, type AscPlatform, parsePlatform, platformOf, stateOf, versionsPath } from './apple/platform'; import { capabilityReport } from './apple/signing'; @@ -226,6 +227,11 @@ export async function run(ctx: Ctx): Promise { try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); } catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); } } else if (a.has('play')) warn('validate', 'no play section in the config'); + // Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config + if (config.games && (config.games.gameCenter || config.games.playGames)) { + try { findings.push(...gameServicesFindings(await gameServicesStatus(ctx.loaded, { apple: both || a.has('apple'), play: both || a.has('play') }))); } + catch (e) { warn('validate', `game services: ${(e as Error).message}`); } + } // other platforms named in a store's listing text (App Store 2.3.10, Play Metadata): blockers (pitfall 77) try { for (const h of listingFileMentions(ctx.loaded, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play }))