// Game Center (App Store) and Play Games Services (Google Play) from ONE config section, `games`: the switches per // store plus one shared list of leaderboards and achievements, each mapped to both stores. Pure: config types and // validation, the request bodies, the create / update / keep plans against what the store returned, and the project // checks (Game Center entitlement, the PGS APP_ID resource and Gradle dependency). // // Why one shared list: a game ships the same leaderboards and achievements on both stores, the texts are per config // language like products' listings, and only the ids differ (Apple: our vendor id; Play: an id Google generates). So // `id` is the vendorIdentifier on the App Store, and `playId` is the generated Play id once known (until then the // Play side is matched by its default-language name). // // Names verified in specs/app-store-connect.oas.json: gameCenterDetails (POST, app relationship; GET // /v1/apps/{id}/gameCenterDetail), gameCenterAppVersions (POST with appStoreVersion; GET // /v1/appStoreVersions/{id}/gameCenterAppVersion; the older /v1/apps/{id}/gameCenterEnabledVersions is deprecated), // gameCenterLeaderboards / gameCenterAchievements v1 (POST, PATCH; GET /v1/gameCenterDetails/{id}/gameCenterLeaderboards // and .../gameCenterAchievements) and their v1 localizations (GET /v1/gameCenterLeaderboards/{id}/localizations). // Play: the Google Play Games Services Configuration API (gamesconfiguration.googleapis.com/games/v1configuration, // achievementConfigurations / leaderboardConfigurations: list, insert, update; scope androidpublisher). Its discovery // document is not pinned in specs/ (only androidpublisher and the reporting API are): shapes follow Google's reference. 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', 'MONEY_EURO', 'MONEY_FRANC_DECIMAL', 'MONEY_FRANC', 'MONEY_KRONER_DECIMAL', 'MONEY_KRONER', 'MONEY_YEN'] as const; export type GcFormat = typeof GC_FORMATS[number]; export interface GameLeaderboard { /** Your id: the App Store vendorIdentifier (letters, digits, . _ -; e.g. "com.example.game.highscore"). */ id: string; /** high (default): a higher score is better (Apple DESC, Play LARGER_IS_BETTER); low: lower is better (a race time). */ sort?: 'high' | 'low'; /** Score format, Apple's formatter names (default INTEGER); Play gets the matching NUMERIC / TIME_DURATION / CURRENCY. */ format?: GcFormat; /** best (default): the best score counts (Apple BEST_SCORE); recent: the latest (Apple only; Play keeps the best). */ submission?: 'best' | 'recent'; min?: number; max?: number; /** Texts per config language id: name (Apple: 32 characters; Play: 100) and an optional score suffix (" points"). */ locales: Record; /** The Play leaderboard id Google generated (CgkI...), once it exists. */ playId?: string; platforms?: Platform[]; } export interface GameAchievement { id: string; /** Apple: 0-100 each, 1000 in total; Play: a multiple of 5, 1000 in total. */ points: number; /** Hidden until earned (Apple showBeforeEarned false; Play initialState HIDDEN). Default false. */ hidden?: boolean; /** Apple only: can be earned again. Default false. */ repeatable?: boolean; /** Play only: an incremental achievement with this many steps (Apple reports progress in percent from the game). */ steps?: number; /** name, description (before earned) and earned (after; default the description). */ locales: Record; playId?: string; platforms?: Platform[]; } export interface GamesConfig { /** Game Center: the App ID capability, the app's Game Center detail and the version's Game Center switch. */ gameCenter?: boolean; /** Play Games Services: the games project id (the digits of games_APP_ID / com.google.android.gms.games.APP_ID). */ playGames?: { appId?: string }; leaderboards?: GameLeaderboard[]; achievements?: GameAchievement[]; } const ID_RE = /^[A-Za-z0-9._-]{1,100}$/; export function validateGames(g: GamesConfig | undefined, languages: Language[] = []): string[] { if (g === undefined) return []; const e: string[] = []; if (!g || typeof g !== 'object') return ['games: an object { gameCenter, playGames, leaderboards, achievements }']; if (g.gameCenter !== undefined && typeof g.gameCenter !== 'boolean') e.push('games.gameCenter: true | false'); if (g.playGames !== undefined && (typeof g.playGames !== 'object' || (g.playGames.appId !== undefined && !/^\d{6,20}$/.test(String(g.playGames.appId))))) e.push('games.playGames.appId: the digits of the Play Games Services project id (games_APP_ID)'); const langs = new Set(languages.map(l => l.id)); const seen = new Set(); const checkId = (kind: string, id: unknown) => { if (typeof id !== 'string' || !ID_RE.test(id)) e.push(`games.${kind}: id "${String(id)}" (letters, digits, . _ -; at most 100)`); else if (seen.has(id)) e.push(`games.${kind}: id "${id}" is used twice (ids are unique across leaderboards and achievements)`); else seen.add(id); }; const checkLocales = (kind: string, id: string, loc: Record | undefined, keys: string[]) => { if (!loc || typeof loc !== 'object' || !Object.keys(loc).length) { e.push(`games.${kind} ${id}: locales needs at least one language`); return; } for (const [k, v] of Object.entries(loc)) { if (langs.size && !langs.has(k)) e.push(`games.${kind} ${id}: locales.${k} is not a language id of the config (${[...langs].join(', ')})`); for (const f of keys) if (typeof (v as Record)?.[f] !== 'string' || !(v as Record)[f]!.trim()) e.push(`games.${kind} ${id}: locales.${k}.${f} is missing`); const name = (v as { name?: string })?.name ?? ''; if (name.length > 32 && g.gameCenter) e.push(`games.${kind} ${id}: locales.${k}.name is ${name.length} characters (Game Center: at most 32)`); } }; for (const l of g.leaderboards ?? []) { checkId('leaderboards', l?.id); if (l.sort !== undefined && !['high', 'low'].includes(l.sort)) e.push(`games.leaderboards ${l.id}: sort high | low`); if (l.format !== undefined && !GC_FORMATS.includes(l.format)) e.push(`games.leaderboards ${l.id}: format one of ${GC_FORMATS.slice(0, 4).join(', ')}, ...`); if (l.submission !== undefined && !['best', 'recent'].includes(l.submission)) e.push(`games.leaderboards ${l.id}: submission best | recent`); if (l.min !== undefined && l.max !== undefined && l.min >= l.max) e.push(`games.leaderboards ${l.id}: min must be below max`); checkLocales('leaderboards', l.id, l.locales, ['name']); } let apple = 0, play = 0; for (const a of g.achievements ?? []) { checkId('achievements', a?.id); if (!Number.isInteger(a.points) || a.points < 0 || a.points > 100) e.push(`games.achievements ${a.id}: points 0-100 (Game Center's limit per achievement)`); else if (g.playGames && a.points % 5) e.push(`games.achievements ${a.id}: points ${a.points} is not a multiple of 5 (Play Games Services refuses it)`); if (a.steps !== undefined && (!Number.isInteger(a.steps) || a.steps < 2 || a.steps > 10000)) e.push(`games.achievements ${a.id}: steps 2-10000 (an incremental Play achievement)`); checkLocales('achievements', a.id, a.locales, ['name', 'description']); if (!a.platforms || a.platforms.includes('ios')) apple += a.points || 0; if (!a.platforms || a.platforms.includes('android')) play += a.points || 0; } if (g.gameCenter && apple > 1000) e.push(`games.achievements: ${apple} points in total (Game Center: at most 1000)`); if (g.playGames && play > 1000) e.push(`games.achievements: ${play} points in total (Play Games Services: at most 1000)`); if ((g.achievements?.length ?? 0) > 200 && g.playGames) e.push('games.achievements: more than 200 (Play Games Services: at most 200)'); return e; } export const onStore = (item: { platforms?: Platform[] }, store: 'apple' | 'play'): boolean => !item.platforms || item.platforms.includes(store === 'apple' ? 'ios' : 'android'); export const usesGameCenter = (c: Pick): boolean => c.games?.gameCenter === true; export const usesPlayGames = (c: Pick): boolean => !!c.games?.playGames; // ------------------------------------------------------------------------------------------- plans (both stores) export type Action = 'create' | 'update' | 'keep'; export interface LocPlan { locale: string; action: Action; id?: string; attributes: Record; changed: string[] } export interface ItemPlan { kind: 'leaderboard' | 'achievement'; id: string; action: Action; storeId?: string; attributes: Record; changed: string[]; locales: LocPlan[] } /** Fields of `want` that differ from `have` (JSON compare per field; a missing field on the store counts as different). */ export function changedFields(have: Record | undefined, want: Record): string[] { return Object.keys(want).filter(k => JSON.stringify(have?.[k] ?? null) !== JSON.stringify(want[k] ?? null)); } const appleLocale = (languages: Language[], id: string): string | undefined => languages.find(l => l.id === id)?.apple; export function gcLeaderboardAttrs(l: GameLeaderboard): Record { return { referenceName: l.id, defaultFormatter: l.format ?? 'INTEGER', submissionType: l.submission === 'recent' ? 'MOST_RECENT_SCORE' : 'BEST_SCORE', scoreSortType: l.sort === 'low' ? 'ASC' : 'DESC', ...(l.min !== undefined ? { scoreRangeStart: String(l.min) } : {}), ...(l.max !== undefined ? { scoreRangeEnd: String(l.max) } : {}), }; } export function gcAchievementAttrs(a: GameAchievement): Record { return { referenceName: a.id, points: a.points, showBeforeEarned: !a.hidden, repeatable: !!a.repeatable }; } export interface GcExisting { id: string; attributes: Record; localizations: { id: string; attributes: Record }[] } /** Game Center plan: matched by vendorIdentifier; create what is missing, PATCH what differs, keep the rest. Archived * items on the store are left alone (an archived vendor id cannot be reused; the plan names it). */ export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): ItemPlan[] { const out: ItemPlan[] = []; const one = (kind: ItemPlan['kind'], id: string, attrs: Record, locs: Record>, list: GcExisting[]) => { const cur = list.find(x => x.attributes.vendorIdentifier === id); const locales: LocPlan[] = Object.entries(locs).map(([locale, want]) => { const l = cur?.localizations.find(x => x.attributes.locale === locale); const changed = changedFields(l?.attributes, want); return { locale, action: !l ? 'create' : changed.length ? 'update' : 'keep', id: l?.id, attributes: want, changed: l ? changed : Object.keys(want) }; }); const changed = changedFields(cur?.attributes, attrs); out.push({ kind, id, action: !cur ? 'create' : changed.length ? 'update' : 'keep', storeId: cur?.id, attributes: attrs, changed: cur ? changed : Object.keys(attrs), locales }); }; for (const l of (g.leaderboards ?? []).filter(x => onStore(x, 'apple'))) { const locs: Record> = {}; for (const [k, v] of Object.entries(l.locales)) { const loc = appleLocale(languages, k); if (loc) locs[loc] = { name: v.name, ...(v.suffix ? { formatterSuffix: v.suffix } : {}) }; } one('leaderboard', l.id, gcLeaderboardAttrs(l), locs, have.leaderboards); } for (const a of (g.achievements ?? []).filter(x => onStore(x, 'apple'))) { const locs: Record> = {}; for (const [k, v] of Object.entries(a.locales)) { const loc = appleLocale(languages, k); if (loc) locs[loc] = { name: v.name, beforeEarnedDescription: v.description, afterEarnedDescription: v.earned ?? v.description }; } one('achievement', a.id, gcAchievementAttrs(a), locs, have.achievements); } return out; } // Play Games Services Configuration API bodies type Bundle = { kind: 'gamesConfiguration#localizedStringBundle'; translations: { kind: 'gamesConfiguration#localizedString'; locale: string; value: string }[] }; function bundle(languages: Language[], locales: Record, pick: (v: never) => string | undefined): Bundle { const translations: Bundle['translations'] = []; for (const [k, v] of Object.entries(locales)) { const l = languages.find(x => x.id === k); const codes = l?.play === undefined ? [] : Array.isArray(l.play) ? l.play : [l.play]; const value = pick(v as never); if (value) for (const locale of codes) translations.push({ kind: 'gamesConfiguration#localizedString', locale, value }); } return { kind: 'gamesConfiguration#localizedStringBundle', translations }; } export function playAchievementBody(a: GameAchievement, languages: Language[]): Record { return { kind: 'gamesConfiguration#achievementConfiguration', achievementType: a.steps ? 'INCREMENTAL' : 'STANDARD', initialState: a.hidden ? 'HIDDEN' : 'REVEALED', ...(a.steps ? { stepsToUnlock: a.steps } : {}), draft: { kind: 'gamesConfiguration#achievementConfigurationDetail', name: bundle(languages, a.locales, (v: { name: string }) => v.name), description: bundle(languages, a.locales, (v: { description: string }) => v.description), pointValue: a.points, }, }; } function playScoreFormat(f: GcFormat | undefined): Record { const fmt = f ?? 'INTEGER'; if (fmt.startsWith('ELAPSED_TIME')) return { numberFormatType: 'TIME_DURATION' }; if (fmt.startsWith('MONEY_')) { const cur = { POUND: 'GBP', DOLLAR: 'USD', EURO: 'EUR', FRANC: 'CHF', KRONER: 'DKK', YEN: 'JPY' }[fmt.replace(/^MONEY_|_DECIMAL$/g, '')] ?? 'USD'; return { numberFormatType: 'CURRENCY', currencyCode: cur }; } const m = /DECIMAL_POINT_(\d)_PLACE/.exec(fmt); return { numberFormatType: 'NUMERIC', numDecimalPlaces: m ? Number(m[1]) : 0 }; } export function playLeaderboardBody(l: GameLeaderboard, languages: Language[]): Record { const suffix = Object.values(l.locales).some(v => v.suffix) ? bundle(languages, l.locales, (v: { suffix?: string }) => v.suffix) : undefined; return { kind: 'gamesConfiguration#leaderboardConfiguration', scoreOrder: l.sort === 'low' ? 'SMALLER_IS_BETTER' : 'LARGER_IS_BETTER', ...(l.min !== undefined ? { scoreMin: String(l.min) } : {}), ...(l.max !== undefined ? { scoreMax: String(l.max) } : {}), draft: { kind: 'gamesConfiguration#leaderboardConfigurationDetail', name: bundle(languages, l.locales, (v: { name: string }) => v.name), scoreFormat: { ...playScoreFormat(l.format), ...(suffix ? { suffix: { other: suffix } } : {}) }, }, }; } export interface PlayExisting { id: string; draft?: { name?: Bundle }; [k: string]: unknown } const defaultName = (x: PlayExisting, locale: string): string | undefined => x.draft?.name?.translations.find(t => t.locale === locale)?.value; /** Comparable part of a Play configuration (what we send, without ids / tokens / published / icon / sortRank). */ function comparable(body: Record, have: PlayExisting | undefined): Record { if (!have) return {}; const draft = (have.draft ?? {}) as Record; const want = body.draft as Record; const out: Record = {}; for (const k of Object.keys(body)) if (k !== 'draft') out[k] = have[k]; out.draft = Object.fromEntries(Object.keys(want).map(k => [k, draft[k]])); return out; } /** Play plan: matched by playId, else by the name in the default language (Play ids are generated). Update = PUT of * the whole configuration (Google's update replaces it); the existing icon / sort rank are kept in the body. */ export function planPlayGames(g: GamesConfig, languages: Language[], defaultLocale: string, have: { leaderboards: PlayExisting[]; achievements: PlayExisting[] }): ItemPlan[] { const out: ItemPlan[] = []; const one = (kind: ItemPlan['kind'], item: GameLeaderboard | GameAchievement, body: Record, list: PlayExisting[]) => { const name = (body.draft as { name: Bundle }).name.translations.find(t => t.locale === defaultLocale)?.value; const cur = item.playId ? list.find(x => x.id === item.playId) : list.find(x => name !== undefined && defaultName(x, defaultLocale) === name); const changed = cur ? changedFields(comparable(body, cur), body).flatMap(k => (k === 'draft' ? changedFields(comparable(body, cur).draft as Record, body.draft as Record).map(d => `draft.${d}`) : [k])) : Object.keys(body); if (cur) { // keep what Play has that we do not manage (icon, sort rank) const d = cur.draft as Record | undefined; body = { ...body, id: cur.id, draft: { ...(d?.iconUrl ? { iconUrl: d.iconUrl } : {}), ...(d?.sortRank !== undefined ? { sortRank: d.sortRank } : {}), ...(body.draft as object) } }; } out.push({ kind, id: item.id, action: !cur ? 'create' : changed.length ? 'update' : 'keep', storeId: cur?.id, attributes: body, changed, locales: [] }); }; for (const l of (g.leaderboards ?? []).filter(x => onStore(x, 'play'))) one('leaderboard', l, playLeaderboardBody(l, languages), have.leaderboards); for (const a of (g.achievements ?? []).filter(x => onStore(x, 'play'))) one('achievement', a, playAchievementBody(a, languages), have.achievements); return out; } /** Dry-plan rows (cli/lib/dry.ts) of a plan. */ export function planRows(plan: ItemPlan[]): { item: string; action: string; detail?: string }[] { const rows: { item: string; action: string; detail?: string }[] = []; for (const p of plan) { rows.push({ item: `${p.kind} ${p.id}`, action: p.action, detail: p.action === 'keep' ? undefined : p.changed.join(', ') }); for (const l of p.locales) rows.push({ item: `${p.kind} ${p.id} ${l.locale}`, action: l.action, detail: l.action === 'keep' ? undefined : l.changed.map(k => `${k}=${JSON.stringify(l.attributes[k])}`).join(', ') }); } return rows; } // ------------------------------------------------------------------------------------------------ project checks export interface Finding { level: 'error' | 'warn' | 'ok'; id: string; msg: string; fix?: string } const SKIP = new Set(['node_modules', '.git', 'build', 'Pods', 'DerivedData', '.gradle', 'intermediates', 'generated', '.storebound']); function walk(dir: string, depth: number, pick: (f: string) => boolean, out: string[] = []): string[] { let names: string[] = []; try { names = readdirSync(dir); } catch { return out; } for (const n of names) { if (SKIP.has(n) || n.startsWith('.')) continue; const f = join(dir, n); let dirp = false; try { dirp = statSync(f).isDirectory(); } catch { continue; } if (dirp) { if (depth > 0) walk(f, depth - 1, pick, out); } else if (pick(f)) out.push(f); } return out; } export interface PgsProject { /** Gradle files that use play-services-games(-v2). */ gradle: string[]; /** Manifest meta-data com.google.android.gms.games.APP_ID: the raw value (literal or @string/name) and where. */ manifest: { file: string; value: string } | null; /** The resolved project id (digits) and the resource file it came from. */ appId: { value: string; file: string } | null; } /** Reads the Android project for Play Games Services: the Gradle dependency, the manifest meta-data and the string * resource it points at (res/values/games-ids.xml or strings.xml). */ export function readPgsProject(root: string): PgsProject { const files = walk(root, 7, f => /\.gradle(\.kts)?$|AndroidManifest\.xml$|[\\/]res[\\/]values[^\\/]*[\\/][^\\/]+\.xml$/.test(f)); const rel = (f: string) => relative(root, f); const gradle = files.filter(f => /\.gradle(\.kts)?$/.test(f) && /play-services-games(-v2)?\b|games-v2/.test(readFileSync(f, 'utf8'))).map(rel); let manifest: PgsProject['manifest'] = null; for (const f of files.filter(x => x.endsWith('AndroidManifest.xml'))) { const t = readFileSync(f, 'utf8'); const m = /]*android:name\s*=\s*"com\.google\.android\.gms\.games\.APP_ID"[^>]*android:value\s*=\s*"([^"]+)"|]*android:value\s*=\s*"([^"]+)"[^>]*android:name\s*=\s*"com\.google\.android\.gms\.games\.APP_ID"/.exec(t); if (m) { manifest = { file: rel(f), value: (m[1] ?? m[2])!.trim() }; break; } } let appId: PgsProject['appId'] = null; const literal = manifest?.value.replace(/^\\ ?/, '').trim(); if (literal && /^\d+$/.test(literal)) appId = { value: literal, file: manifest!.file }; else { const names = manifest && /^@string\//.test(manifest.value) ? [manifest.value.slice(8)] : ['game_services_project_id', 'app_id', 'games_APP_ID']; for (const f of files.filter(x => /[\\/]res[\\/]values[\\/][^\\/]+\.xml$/.test(x))) { const t = readFileSync(f, 'utf8'); for (const n of names) { const m = new RegExp(`]*name\\s*=\\s*"${n}"[^>]*>\\s*([^<]*?)\\s*`).exec(t); if (m && /^\d+$/.test(m[1]!)) { appId = { value: m[1]!, file: rel(f) }; break; } } if (appId) break; } } return { gradle, manifest, appId }; } /** PGS project checks: the dependency, the manifest meta-data, the resource and the config's id. */ export function pgsFindings(config: Pick, p: PgsProject, hasAndroid = true): Finding[] { const out: Finding[] = []; const want = config.games?.playGames?.appId; if (!hasAndroid) return out; if (!p.gradle.length && !usesPlayGames(config)) return out; if (p.gradle.length && !usesPlayGames(config)) out.push({ level: 'warn', id: 'pgs.config', msg: `the Android code uses Play Games Services (${p.gradle[0]}) but the config has no games.playGames`, fix: 'add games: { playGames: { appId: "" } } so storebound checks and syncs it' }); 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 ${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; } /** Game Center project checks: the entitlement in the iOS project (and that CODE_SIGN_ENTITLEMENTS references it). */ export function gcFindings(config: Pick, ents: { file: string; caps: { cap: string }[]; referenced: boolean | null }[] | null): Finding[] { if (!ents) return []; const has = ents.filter(e => e.caps.some(c => c.cap === 'GAME_CENTER')); if (!usesGameCenter(config)) return has.length ? [{ level: 'warn', id: 'gc.config', msg: `${has[0]!.file} has com.apple.developer.game-center but games.gameCenter is not true`, fix: 'set games: { gameCenter: true } so signing ensure and apple game-center keep the store in step' }] : []; if (!has.length) return [{ level: 'error', id: 'gc.entitlement', msg: 'games.gameCenter is true but no .entitlements file has com.apple.developer.game-center', fix: 'Xcode > the app target > Signing & Capabilities > + Game Center (adds the key and CODE_SIGN_ENTITLEMENTS)' }]; if (has.every(e => e.referenced === false)) return [{ level: 'error', id: 'gc.entitlement', msg: `${has[0]!.file} has the Game Center key but no CODE_SIGN_ENTITLEMENTS points at it`, fix: 'set CODE_SIGN_ENTITLEMENTS of the app target to that file' }]; return [{ level: 'ok', id: 'gc.entitlement', msg: `com.apple.developer.game-center in ${has[0]!.file}` }]; } /** Declarations reminders: a platform player id is a User ID on both stores. */ export function gamesPrivacyFindings(config: Pick): Finding[] { const out: Finding[] = []; if (!usesGameCenter(config) && !usesPlayGames(config)) return out; const ds = (config.dataSafety as { data?: Record } | undefined)?.data; if (usesPlayGames(config) && ds && !ds.PSL_USER_ACCOUNT) out.push({ level: 'warn', id: 'games.data-safety', msg: 'Play Games Services gives the game a player id: Data safety > Personal info > User IDs (PSL_USER_ACCOUNT) is not declared', fix: 'add PSL_USER_ACCOUNT to dataSafety.data (collected, app functionality), store-declarations skill' }); const types = (config.appPrivacy as { types?: Record } | undefined)?.types; if (usesGameCenter(config) && types && !types.USER_ID) out.push({ level: 'warn', id: 'games.app-privacy', msg: 'Game Center gives the game a player id: App Privacy > Identifiers > User ID (USER_ID) is not declared', fix: 'add USER_ID (App Functionality, linked to the user) to appPrivacy.types, store-declarations skill' }); return out; } export const hasAndroidProject = (root: string): boolean => existsSync(join(root, 'android')) || walk(root, 3, f => f.endsWith('AndroidManifest.xml')).length > 0;