diff --git a/cli/lib/console-answers.ts b/cli/lib/console-answers.ts index 499c0cf..a585937 100644 --- a/cli/lib/console-answers.ts +++ b/cli/lib/console-answers.ts @@ -28,7 +28,8 @@ export type FormId = typeof FORM_IDS[number]; /** Forms with a console plan only (the agent drives them; no scripted fill): Play Store settings (the App category the * API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */ export const PLAN_ONLY_FORM_IDS = ['play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details', - 'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release'] as const; + 'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', + 'play-games-services'] as const; export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number]; /** Every form `console plan` / `console verify` know. */ export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const; diff --git a/cli/lib/console-games-plan.ts b/cli/lib/console-games-plan.ts new file mode 100644 index 0000000..48bb8cc --- /dev/null +++ b/cli/lib/console-games-plan.ts @@ -0,0 +1,90 @@ +// `storebound console plan play-games-services`: the Play Games Services setup has no API (the games project, its +// credentials, publishing). Only the leaderboards and achievements do (`storebound play games`). Agent-first like the +// other Play plans: the agent drives Play Console and Google Cloud console with its own browser tool, stops before +// Publish (the owner's button), then `console verify play-games-services --from-text -`. Pure. +// Modeled on Google's help ("Set up Play Games Services", "Create a credential", "Publish your game's changes"), +// not yet verified on a real console: the page paths and labels are where to start, the agent adapts. +// App signing SHA-1: the Play Developer API has no read of the app signing certificate SHA-1 (generatedapks.list only +// gives certificateSha256Hash), so the plan points at Play Console > App integrity > Play app signing. +import type { ShipConfig } from './config'; +import { consoleUrl, PLAY_CONSOLE } from './console-links'; +import { NEVER_PRESS, PLAY_NEVER_PRESS } from './console-forms'; +import type { PlanAnswer, PlanIds } from './console-plan'; +import { type Part, PLAY_MATERIAL_FACT } from './console-play-plans'; +import type { VerifyRow } from './console-verify'; +import { onStore } from './game-services'; + +export const GOOGLE_CLOUD_CREDENTIALS = 'https://console.cloud.google.com/apis/credentials'; +export const GOOGLE_CLOUD_CONSENT = 'https://console.cloud.google.com/apis/credentials/consent'; +export const PGS_PUBLISH = 'Publish'; + +const ids = (c: ShipConfig, p: PlanIds) => ({ playDeveloperId: p.playDeveloperId ?? c.play?.developerId, playAppId: p.playAppId ?? c.play?.appId }); + +export function playGamesPart(config: ShipConfig, p: PlanIds): Part { + const pkg = config.play?.packageName ?? '(play.packageName)'; + const i = ids(config, p); + const exact = !!(i.playDeveloperId && i.playAppId); + const setup = consoleUrl('play.games-services', i), signing = consoleUrl('play.app-signing', i); + const g = config.games; + const pid = g?.playGames?.appId; + const ach = (g?.achievements ?? []).filter(a => onStore(a, 'play')).length; + const lb = (g?.leaderboards ?? []).filter(l => onStore(l, 'play')).length; + const a = (id: string, question: string, value: string | null, extra: Partial = {}): PlanAnswer => + ({ id, question, value, text: value ?? '(you decide)', status: value === null ? 'ask-owner' : 'certain', source: 'storebound.config.ts', ...extra }); + const answers: PlanAnswer[] = [ + a('project', 'Games project', pid ? `the existing project ${pid}` : 'a new one ("No, my game doesn\'t use Google APIs")', { where: 'Setup: the first question', control: 'radio', action: 'pick' }), + a('displayName', 'Display name', config.play?.name ?? config.app.name, { where: 'Properties', control: 'text', action: 'type' }), + a('package', 'Android credential: package name', pkg, { where: 'Credentials > Add credential > Android', control: 'select', action: 'choose' }), + a('sha1', 'OAuth client SHA-1', 'the App signing key certificate SHA-1 from Play app signing (not the upload key)', { where: `Google Cloud > Credentials > OAuth client ID (Android): ${signing}`, control: 'text', action: 'type' }), + a('webClient', 'Web OAuth client (server auth codes)', null, { note: 'Only when a game server exchanges server auth codes (requestServerSideAccess). Ask the owner; with no server, skip it.', where: 'Credentials > Add credential > Game server' }), + ]; + return { + title: 'Play Console + Google Cloud: Play Games Services setup (games project, credentials, publish)', + start: { url: setup, deepLink: exact, reach: exact ? [] : ['Pick the app (set play.developerId and play.appId for a deep link)', 'Grow users > Play Games Services > Setup and management > Configuration'] }, + steps: [ + { n: 1, text: `Open ${setup}. No games project yet: answer "No, my game doesn't use Google APIs" and press Create (a draft project; nothing is public). With games.playGames.appId set, check the page shows that project id.`, click: ['Create'], answers: ['project'] }, + { n: 2, text: 'Properties: Edit properties > display name (and the description / graphics the owner gave) > Save as draft.', click: ['Edit properties', 'Save as draft'], answers: ['displayName'] }, + { n: 3, text: `Google Cloud OAuth consent screen (once per Cloud project): ${GOOGLE_CLOUD_CONSENT}. If it is not configured, set it up as External with the app name and the support email; never press "Publish app" there without the owner.` }, + { n: 4, text: `App signing SHA-1: open ${signing} and copy "SHA-1 certificate fingerprint" of the App signing key certificate (Play re-signs installs with it). The upload key's SHA-1 is only for builds you install yourself (an optional second credential).` }, + { n: 5, text: `Credentials > Add credential > Android: pick ${pkg}; it links to an OAuth client in Google Cloud. Create it at ${GOOGLE_CLOUD_CREDENTIALS} > Create credentials > OAuth client ID > Android (package ${pkg}, the SHA-1 of step 4) > Create, then back in Play Console pick that client > Save changes.`, click: ['Add credential', 'Save changes'], answers: ['package', 'sha1'] }, + { n: 6, text: `Only with a game server (ask-owner): Add credential > Game server: create a Web application OAuth client at ${GOOGLE_CLOUD_CREDENTIALS}, pick it, Save changes. Its client id goes into the game's requestServerSideAccess; the client secret stays on the server (never in chat or files here).`, answers: ['webClient'] }, + { n: 7, text: 'Copy the project id (Configuration page, "Project ID" / Get resources): write it to games.playGames.appId, and put the resources XML into android/app/src/main/res/values/games-ids.xml. `storebound check` then compares it with the manifest\'s com.google.android.gms.games.APP_ID.' }, + { n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). Play needs at least 5 achievements before it publishes the games project.` }, + { n: 9, text: 'Testers: Play Games Services > Testers lists who can sign in before publishing (the owner adds them; storebound never invites testers).' }, + { n: 10, text: `Stop before "${PGS_PUBLISH}" (Review and publish): the owner presses it. Then read the page text and pipe it to \`storebound console verify play-games-services --from-text -\`.` }, + ], + answers, + final: PGS_PUBLISH, + neverPress: [...new Set([PGS_PUBLISH, 'Publish changes', 'Publish app', 'Delete', 'Unlink', ...PLAY_NEVER_PRESS, ...NEVER_PRESS])], + allowed: [ + { button: 'Create', class: 'navigation', where: 'Setup: creates the draft games project (not public)' }, + { button: 'Edit properties / Add credential / Create credentials', class: 'navigation', where: 'Play Console and Google Cloud console' }, + { button: 'Save as draft / Save changes', class: 'draft-save', where: 'Play Games Services pages: drafts until the owner publishes' }, + { button: 'Create (OAuth client ID)', class: 'draft-save', where: `${GOOGLE_CLOUD_CREDENTIALS}: an OAuth client (private to the Cloud project)` }, + ], + uiFacts: [ + 'Modeled on Google\'s Play Games Services help (not verified on a real console yet): Grow users > Play Games Services > Setup and management > Configuration; Credentials and Properties are sections of that page; Review and publish has the Publish button.', + 'The Android credential needs the SHA-1 of the key that signs what players install: with Play app signing that is the App signing key certificate (Test and release > App integrity > Play app signing), not the upload key. The Play Developer API gives only SHA-256 hashes (generatedapks.list), so the SHA-1 is read from that page.', + 'A games project links one Google Cloud project; linking a different one later is not possible from the page: check the project id before Create.', + PLAY_MATERIAL_FACT, + `Absolute pages: ${PLAY_CONSOLE} (Play Console), ${GOOGLE_CLOUD_CREDENTIALS} (OAuth clients), ${GOOGLE_CLOUD_CONSENT} (consent screen).`, + ], + verify: { command: 'storebound console verify play-games-services --from-text -', source: 'page-text', capture: 'document.querySelector("main")?.innerText ?? document.body.innerText', + expect: ['The Configuration page shows the project id of games.playGames.appId', `An Android credential for ${pkg}`, 'The status is ready to publish or published'] }, + guidance: ['Open: Grow users > Play Games Services > Setup and management > Configuration', `Then: ${GOOGLE_CLOUD_CREDENTIALS} for the OAuth clients`], + }; +} + +/** Page text of the Configuration page vs the config. */ +export function verifyPlayGames(config: ShipConfig, text: string): VerifyRow[] { + const rows: VerifyRow[] = []; + const pid = config.games?.playGames?.appId; + const pkg = config.play?.packageName; + if (pid) rows.push({ item: 'project id', expected: pid, seen: text.includes(pid) ? pid : '(not on the page)', status: text.includes(pid) ? 'match' : 'mismatch' }); + else rows.push({ item: 'project id', expected: 'games.playGames.appId', seen: (/\b\d{10,14}\b/.exec(text) ?? ['?'])[0], status: 'ask-owner', note: 'set games.playGames.appId to the project id shown on the page' }); + if (pkg) rows.push({ item: 'Android credential', expected: pkg, seen: text.includes(pkg) ? pkg : '(no credential with the package)', status: text.includes(pkg) ? 'match' : 'mismatch' }); + const published = /\bPublished\b/i.test(text) && !/not published|unpublished/i.test(text); + const ready = /ready to publish|ready for publishing/i.test(text); + rows.push({ item: 'status', expected: 'ready to publish or published', seen: published ? 'published' : ready ? 'ready to publish' : '(neither)', status: published || ready ? 'match' : 'mismatch', ...(ready && !published ? { note: 'the owner presses Publish' } : {}) }); + return rows; +} diff --git a/cli/lib/console-links.ts b/cli/lib/console-links.ts index 748874c..b5bb7ae 100644 --- a/cli/lib/console-links.ts +++ b/cli/lib/console-links.ts @@ -64,6 +64,7 @@ export const CONSOLE_PAGES = { 'play.internal-testing': { store: 'play', label: 'Play Console > Test and release > Internal testing', scope: 'app', path: 'tracks/internal-testing' }, 'play.publishing': { store: 'play', label: 'Play Console > Publishing overview', scope: 'app', path: 'publishing' }, 'play.app-signing': { store: 'play', label: 'Play Console > Test and release > App integrity > Play app signing', scope: 'app', path: 'keymanagement' }, + 'play.games-services': { store: 'play', label: 'Play Console > Grow users > Play Games Services > Setup and management > Configuration', scope: 'app', path: 'play-games-services/setup' }, 'play.bundle-explorer': { store: 'play', label: 'Play Console > Test and release > App bundle explorer', scope: 'app', path: 'bundle-explorer-selector' }, 'play.products': { store: 'play', label: 'Play Console > Monetize with Play > Products > In-app products', scope: 'app', path: 'managed-products' }, 'play.subscriptions': { store: 'play', label: 'Play Console > Monetize with Play > Products > Subscriptions', scope: 'app', path: 'subscriptions' }, diff --git a/cli/lib/console-play-plans.ts b/cli/lib/console-play-plans.ts index 205c378..da3a979 100644 --- a/cli/lib/console-play-plans.ts +++ b/cli/lib/console-play-plans.ts @@ -19,6 +19,7 @@ import type { PlanFormId, PlanOnlyFormId } from './console-answers'; import { NEVER_PRESS, PLAY_NEVER_PRESS, playAppList } from './console-forms'; import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan'; import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals'; +import { playGamesPart } from './console-games-plan'; const PLAY = 'https://play.google.com/console'; @@ -73,7 +74,7 @@ function start(config: ShipConfig, p: PlanIds, path: string, reach: string[]): C const appContentReach = (card: string) => ['Policy and programs > App content (/app-content/overview)', `the "${card}" card: the button aria-label="${startLabel(card)}" (or its Manage / Edit link)`]; -interface Part { +export interface Part { title: string; start: ConsolePlan['start']; steps: PlanStep[]; @@ -514,6 +515,7 @@ export function playOnlyPart(form: Exclude, case 'play-government-apps': return government(config, p); case 'play-financial-features': return featureCard(config, p, 'finance'); case 'play-health-apps': return featureCard(config, p, 'health'); + case 'play-games-services': return playGamesPart(config, p); } } diff --git a/cli/lib/console-verify.ts b/cli/lib/console-verify.ts index 6134fb6..b23359a 100644 --- a/cli/lib/console-verify.ts +++ b/cli/lib/console-verify.ts @@ -11,6 +11,7 @@ // apple-age-rating the API (ageRatingDeclaration) // play-content-rating, play-target-audience page text (summary pages: a label line, its answer on the same or the // next line). Modeled, not recorded: rows that cannot be found are "not found", never a match. +import { verifyPlayGames } from './console-games-plan'; import type { ShipConfig } from './config'; import { type Answer, consoleAnswers, type FormId, type PlanFormId, type PlanOnlyFormId, valueText } from './console-answers'; import { APP_CONTENT_CARDS, playOnlyPart, startLabel } from './console-play-plans'; @@ -427,6 +428,7 @@ export function verifyText(form: PlanFormId, config: ShipConfig, text: string): if (form === 'play-app-content') return verifyAppContent(text); if (form === 'play-production-countries') return verifyProductionCountries(config, text); if (form === 'play-production-release') return verifyProductionRelease(text); + if (form === 'play-games-services') return result(form, 'page-text', verifyPlayGames(config, text)); if (form === 'play-store-settings' || form === 'play-privacy-policy' || form === 'play-ads' || form === 'play-sign-in-details' || form === 'play-advertising-id' || form === 'play-government-apps' || form === 'play-financial-features' || form === 'play-health-apps') return verifyPlayDeclaration(form, config, text); switch (form) { diff --git a/cli/lib/game-services.ts b/cli/lib/game-services.ts index 82e63a7..9de1f7f 100644 --- a/cli/lib/game-services.ts +++ b/cli/lib/game-services.ts @@ -19,6 +19,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import type { Language, Platform, ShipConfig } from './config'; +import { consoleLink } from './console-links'; export const GC_FORMATS = ['INTEGER', 'DECIMAL_POINT_1_PLACE', 'DECIMAL_POINT_2_PLACE', 'DECIMAL_POINT_3_PLACE', 'ELAPSED_TIME_CENTISECOND', 'ELAPSED_TIME_MINUTE', 'ELAPSED_TIME_SECOND', 'MONEY_POUND_DECIMAL', 'MONEY_POUND', 'MONEY_DOLLAR_DECIMAL', 'MONEY_DOLLAR', 'MONEY_EURO_DECIMAL', @@ -347,8 +348,8 @@ export function pgsFindings(config: Pick, p: PgsProject, ha if (usesPlayGames(config) && !p.gradle.length) out.push({ level: 'warn', id: 'pgs.gradle', msg: 'games.playGames is set but no Gradle file uses com.google.android.gms:play-services-games-v2', fix: 'add implementation "com.google.android.gms:play-services-games-v2:" to the app module' }); if (!p.manifest) out.push({ level: 'error', id: 'pgs.manifest', msg: 'no in AndroidManifest.xml: sign-in fails at runtime', fix: 'add inside ' }); - else if (!p.appId) out.push({ level: 'error', id: 'pgs.app-id', msg: `the manifest's APP_ID (${p.manifest.value}) points at no string resource with the project id`, fix: 'add res/values/games-ids.xml from Play Console > Play Games Services > Setup and management > Configuration > Get resources' }); - else if (want && p.appId.value !== String(want)) out.push({ level: 'error', id: 'pgs.app-id', msg: `games_APP_ID ${p.appId.value} (${p.appId.file}) differs from games.playGames.appId ${want}`, fix: 'use the resources of the games project linked to this app (Play Console > Play Games Services > Configuration)' }); + else if (!p.appId) out.push({ level: 'error', id: 'pgs.app-id', msg: `the manifest's APP_ID (${p.manifest.value}) points at no string resource with the project id`, fix: `add res/values/games-ids.xml from ${consoleLink('play.games-services', '> Get resources')}` }); + else if (want && p.appId.value !== String(want)) out.push({ level: 'error', id: 'pgs.app-id', msg: `games_APP_ID ${p.appId.value} (${p.appId.file}) differs from games.playGames.appId ${want}`, fix: `use the resources of the games project linked to this app: ${consoleLink('play.games-services')}` }); else out.push({ level: 'ok', id: 'pgs.app-id', msg: `games_APP_ID ${p.appId.value} (${p.appId.file})${want ? ' matches the config' : ''}` }); if (p.appId && !want) out.push({ level: 'warn', id: 'pgs.config-id', msg: `games.playGames.appId is not set (the project has ${p.appId.value})`, fix: `set games.playGames.appId: "${p.appId.value}"` }); return out; diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index d6d5726..77cc405 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -39,7 +39,7 @@ export const notSentProp = { type: 'boolean', default: false, description: 'Comm /** Every form `console plan` / `console verify` know (cli/lib/console-answers.ts PLAN_FORM_IDS; a test keeps them equal). */ export const CONSOLE_PLAN_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience', 'play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details', 'play-advertising-id', 'play-government-apps', - 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release']; + 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services']; export const TOOLS: ToolDef[] = [ { name: 'check', title: 'Offline config check', command: ['check'], write: false, network: false, diff --git a/tests/console-play-plans.test.ts b/tests/console-play-plans.test.ts index 87acf89..b7c6c65 100644 --- a/tests/console-play-plans.test.ts +++ b/tests/console-play-plans.test.ts @@ -26,7 +26,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch const slugs: Record = { 'play-store-settings': 'store-settings', 'play-app-content': 'app-content/overview', 'play-privacy-policy': 'app-content/privacy-policy', 'play-ads': 'app-content/ads-declaration', 'play-sign-in-details': 'app-content/testing-credentials', 'play-advertising-id': 'app-content/ad-id-declaration', - 'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health', + 'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health', 'play-games-services': 'play-games-services/setup', 'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', }; for (const form of PLAN_ONLY_FORM_IDS) { @@ -45,7 +45,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch for (const b of ['Publish', 'Submit', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b); for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit'); // an App content card's Save is the agent's (verified 2026-10-13); the owner's button is Send changes for review - if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release'].includes(form)) { + if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release', 'play-games-services'].includes(form)) { expect(p.buttons.final).toBe('Send changes for review'); expect(p.buttons.neverPress).not.toContain('Save'); expect(p.buttons.allowed).toContainEqual(expect.objectContaining({ button: 'Save', class: 'declaration-save' }));