Play console steps without an API as agent plans: play-store-settings (App category; play listing cannot set it), play-app-content (every card in order, one owner batch) and its cards (privacy policy, ads, sign in details, advertising ID, government, financial, health), play-production-countries and play-production-release; IARC plan with the Terms of Use agreement, follow-ups (console.contentRating.details, products[].randomRewards, social) and the Summary's final Save; config play.category / countries / managedPublishing, console.appContent; check, validate, play status and play release reminders; inPageLoop helper and material checkbox clicks; App Privacy published signal; first IAPs via apple iap-submit --with-version. Pitfalls 27, 28

This commit is contained in:
CRTkafa 2026-10-12 10:16:34 +08:00
parent dbd9f2973c
commit b7bfe89151
37 changed files with 1624 additions and 121 deletions

View file

@ -55,6 +55,10 @@ export interface Product {
platforms?: Platform[];
/** Apple offer codes for this one-time purchase (`storebound apple offer-codes`). */
offerCodes?: IapOfferCodeDef[];
/** The purchase gives a random reward (loot box, gacha pull, mystery pack). The Play IARC questionnaire asks it
* (Digital Purchases > random items): any true answers it Yes, every product false answers it No. Brazil's ClassInd
* rates such games 18 (seen on a real run, 2026-10-12). */
randomRewards?: boolean;
}
export interface Language {
@ -514,6 +518,9 @@ export interface ShipConfig {
accounts?: AccountsConfig;
/** Sign-in for store reviewers (demo account; credentials live in .storebound/ only). */
reviewAccess?: ReviewAccess;
/** What players / users can do with each other (chat, sharing, friends). Answers the Play IARC questionnaire's
* Miscellaneous > user interaction questions (`console plan play-content-rating`). */
social?: SocialConfig;
languages: Language[];
play?: {
packageName: string; defaultLanguage?: string; listing?: string; icon?: string;
@ -532,6 +539,19 @@ export interface ShipConfig {
name?: string;
/** Free (true) or paid (false) on Play. A free app can never become paid later; storebound flags it for the owner. */
free?: boolean;
/** Play Console > Store presence > Store settings > App category, by Play's English name ("Strategy", "Puzzle",
* "Tools"; PLAY_GAME_CATEGORIES / PLAY_APP_CATEGORIES). The Play Developer API has no category field (`play
* listing` cannot set it): an agent sets it with `storebound console plan play-store-settings`. Default: mapped
* from apple.info.categories when there is an exact match (GAMES_STRATEGY -> Strategy). */
category?: string;
/** Countries / regions of the production track (ISO 3166-1 alpha-2, e.g. ["US", "GB", "TR"]). Default: all. The API's
* edits.countryAvailability is read-only, so an agent sets it in Play Console with `storebound console plan
* play-production-countries` before the first production release; `validate` reports a track with 0 countries. */
countries?: string[];
/** Play Console > Publishing overview > Managed publishing, which only the console sets (no API). true: approved
* changes wait until the owner publishes them. Off by default on Play: a release goes live automatically as soon as
* Google approves it, and `play release` / the release plan say so. Record here what the console shows. */
managedPublishing?: boolean;
/** Play 7" and 10" tablet screenshots (renders tab7/ and tab10/). Optional on Play (required: icon, feature graphic,
* at least 2 phone screenshots), but without them the app is not eligible for tablet featuring. Default: true when
* tablet renders exist under the renders folder, else false. false = `shots render` / `shots check`, `validate` and
@ -707,13 +727,132 @@ export const RATING_CATEGORIES = ['game', 'social', 'other'] as const;
export const AGE_GROUPS = ['5-under', '6-8', '9-12', '13-15', '16-17', '18-over'] as const;
export type AgeGroup = typeof AGE_GROUPS[number];
/** Users interacting with each other (Play IARC Miscellaneous; see ShipConfig.social). */
export interface SocialConfig {
/** Users can chat, message, post or share content with each other (in-game chat, comments, shared levels). */
interaction: boolean;
/** Users can block other users. */
block?: boolean;
/** Users can report other users or their content. */
report?: boolean;
/** Chat or shared content is moderated (people or filters). */
moderation?: boolean;
/** Interaction is limited to friends the user accepted (no strangers). */
friendsOnly?: boolean;
/** The app shares the user's location with other users. */
sharesLocation?: boolean;
}
/** A follow-up the plan does not model, keyed by the question text as the console shows it: true / false (Yes / No
* or tick / untick), a radio option label, or the labels of the checkboxes to tick. */
export type IarcOther = Record<string, boolean | string | string[]>;
/**
* Follow-up questions of the Play IARC questionnaire (one long page: each Yes reveals its follow-ups; recorded on a
* real run 2026-10-12). Asked only when the section's answer is Yes (Miscellaneous always). Option labels are the
* console's own words (the agent matches them by "starts with"). Anything left out is asked of the owner in the one
* batch at the start (`storebound console plan play-content-rating`).
*/
export interface IarcDetails {
violence?: {
/** Violence against humans / human-like characters (checkbox). */
humans?: boolean;
/** Violence against non-humans: fantasy creatures, animals, robots (checkbox). */
nonHumans?: boolean;
/** The setting option as the console labels it (a fantasy or a realistic setting ...). */
setting?: string;
/** Cartoon / childlike art style. */
childlike?: boolean;
/** The reaction checkboxes to tick (how characters react to harm), console labels ([] = none). */
reactions?: string[];
/** The presentation checkboxes to tick (how violence is shown), console labels ([] = none). */
presentation?: string[];
/** The blood level option, console label. */
blood?: string;
/** Set in a historical war. */
historicalWar?: boolean;
/** Violence against innocent / defenseless characters. */
innocents?: boolean;
/** Fierce or realistic sounds of violence. */
fierceSounds?: boolean;
other?: IarcOther;
};
fear?: { other?: IarcOther };
sexuality?: { other?: IarcOther };
gambling?: { other?: IarcOther };
language?: { other?: IarcOther };
controlledSubstances?: { other?: IarcOther };
crudeHumor?: { other?: IarcOther };
/** Digital Purchases, after "purchases of digital goods" Yes: random items (default from products[].randomRewards),
* then trading between players (default from social). */
digitalPurchases?: { lootBoxes?: boolean; playerTrading?: boolean; other?: IarcOther };
/** Miscellaneous > user interaction follow-ups (default from `social`). */
userInteraction?: { block?: boolean; report?: boolean; moderation?: boolean; friendsOnly?: boolean; other?: IarcOther };
/** The other Miscellaneous questions (always asked). */
miscellaneous?: { naziSymbols?: boolean; koreaHistory?: boolean; terrorism?: boolean; crimes?: boolean; other?: IarcOther };
}
export const IARC_DETAIL_SECTIONS = ['violence', 'fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor', 'digitalPurchases', 'userInteraction', 'miscellaneous'] as const;
export type IarcDetailSection = typeof IARC_DETAIL_SECTIONS[number];
/** Every key a details section may have and its kind (validation and the answer sheet); `other` is allowed everywhere. */
export const IARC_DETAIL_KEYS: Record<IarcDetailSection, Record<string, 'boolean' | 'label' | 'labels'>> = {
violence: { humans: 'boolean', nonHumans: 'boolean', setting: 'label', childlike: 'boolean', reactions: 'labels', presentation: 'labels', blood: 'label', historicalWar: 'boolean', innocents: 'boolean', fierceSounds: 'boolean' },
fear: {}, sexuality: {}, gambling: {}, language: {}, controlledSubstances: {}, crudeHumor: {},
digitalPurchases: { lootBoxes: 'boolean', playerTrading: 'boolean' },
userInteraction: { block: 'boolean', report: 'boolean', moderation: 'boolean', friendsOnly: 'boolean' },
miscellaneous: { naziSymbols: 'boolean', koreaHistory: 'boolean', terrorism: 'boolean', crimes: 'boolean' },
};
/** Play Console > Policy and programs > App content declarations without a form of their own in ConsoleConfig
* (`storebound console plan play-app-content` lists every card). */
export interface AppContentConfig {
/** Government apps: developed by or on behalf of a government. */
government?: boolean;
/** Financial features: false = "My app doesn't provide any financial features"; else the console labels to tick. */
financialFeatures?: false | string[];
/** Health apps: false = no health features; else the console labels to tick. */
healthFeatures?: false | string[];
/** Advertising ID: the app (or an SDK in it) uses the Android advertising ID. Default: yes with ads.appAdsTxt or a
* dataSafety Device or other IDs entry. */
advertisingId?: boolean;
}
export interface ConsoleConfig {
/** App Store Connect > Apps > + > New App: the SKU (internal, never shown) and who in the team sees the app. */
appRecord?: { sku?: string; userAccess?: 'full' | 'limited' };
/** Play IARC questionnaire: the category and the answers you decided (true = Yes). */
contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial<Record<IarcQuestion, boolean>> };
/** Play IARC questionnaire: the category, the answers you decided (true = Yes) and their follow-ups (details). */
contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial<Record<IarcQuestion, boolean>>; details?: IarcDetails };
/** Play Target audience and content, and the Ads declaration. */
targetAudience?: { ages?: AgeGroup[]; appealsToChildren?: boolean; containsAds?: boolean };
/** Play App content declarations: government apps, financial features, health apps, advertising ID. */
appContent?: AppContentConfig;
}
/** Play Console's game categories (Store settings > App category, app type Game). */
export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing',
'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const;
/** Play Console's app categories (Store settings > App category, app type App). */
export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating',
'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle',
'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping',
'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const;
/** App Store category ids with an exact Play counterpart. */
const APPLE_TO_PLAY: Record<string, string> = {
GAMES_ACTION: 'Action', GAMES_ADVENTURE: 'Adventure', GAMES_BOARD: 'Board', GAMES_CARD: 'Card', GAMES_CASINO: 'Casino', GAMES_CASUAL: 'Casual',
GAMES_EDUCATIONAL: 'Educational', GAMES_MUSIC: 'Music', GAMES_PUZZLE: 'Puzzle', GAMES_RACING: 'Racing', GAMES_ROLE_PLAYING: 'Role Playing',
GAMES_SIMULATION: 'Simulation', GAMES_SPORTS: 'Sports', GAMES_STRATEGY: 'Strategy', GAMES_TRIVIA: 'Trivia', GAMES_WORD: 'Word',
BOOKS: 'Books & Reference', REFERENCE: 'Books & Reference', BUSINESS: 'Business', EDUCATION: 'Education', ENTERTAINMENT: 'Entertainment',
FINANCE: 'Finance', FOOD_AND_DRINK: 'Food & Drink', HEALTH_AND_FITNESS: 'Health & Fitness', LIFESTYLE: 'Lifestyle', MEDICAL: 'Medical',
MUSIC: 'Music & Audio', NAVIGATION: 'Maps & Navigation', NEWS: 'News & Magazines', PRODUCTIVITY: 'Productivity', SHOPPING: 'Shopping',
SOCIAL_NETWORKING: 'Social', SPORTS: 'Sports', TRAVEL: 'Travel & Local', UTILITIES: 'Tools', WEATHER: 'Weather', GRAPHICS_AND_DESIGN: 'Art & Design',
};
/** The Play App category and where it comes from: play.category, else an exact match of apple.info.categories (the
* first subcategory first, for games), else null. */
export function playCategoryOf(c: Pick<ShipConfig, 'play' | 'apple'>): { category: string; source: string } | null {
if (c.play?.category) return { category: c.play.category, source: 'play.category' };
const k = c.apple?.info?.categories;
for (const id of [k?.primarySubOne, k?.primary]) if (id && APPLE_TO_PLAY[id]) return { category: APPLE_TO_PLAY[id]!, source: `apple.info.categories (${id})` };
return null;
}
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
@ -771,6 +910,43 @@ function validateConsole(c: ConsoleConfig): string[] {
for (const [k, v] of Object.entries(cr?.answers ?? {})) if (!(IARC_QUESTIONS as readonly string[]).includes(k) || typeof v !== 'boolean') e.push(`console.contentRating.answers.${k}: one of ${IARC_QUESTIONS.join(', ')}, true | false`);
for (const a of ta?.ages ?? []) if (!AGE_GROUPS.includes(a)) e.push(`console.targetAudience.ages: "${a}" is not one of ${AGE_GROUPS.join(', ')}`);
for (const k of ['appealsToChildren', 'containsAds'] as const) if (ta?.[k] !== undefined && typeof ta[k] !== 'boolean') e.push(`console.targetAudience.${k}: true | false`);
e.push(...validateIarcDetails(cr?.details));
const ac = c.appContent;
if (ac !== undefined) {
if (!ac || typeof ac !== 'object') e.push('console.appContent: an object');
else {
for (const k of ['government', 'advertisingId'] as const) if (ac[k] !== undefined && typeof ac[k] !== 'boolean') e.push(`console.appContent.${k}: true | false`);
for (const k of ['financialFeatures', 'healthFeatures'] as const) {
const v = ac[k];
if (v !== undefined && v !== false && !(Array.isArray(v) && v.length && v.every(x => typeof x === 'string' && x.trim())))
e.push(`console.appContent.${k}: false (none) or a list of the console's labels to tick`);
}
}
}
return e;
}
function validateIarcDetails(d: IarcDetails | undefined): string[] {
if (d === undefined) return [];
if (!d || typeof d !== 'object' || Array.isArray(d)) return ['console.contentRating.details: an object'];
const e: string[] = [];
const text = (x: unknown) => typeof x === 'string' && !!x.trim();
const isOther = (o: unknown) => !!o && typeof o === 'object' && !Array.isArray(o)
&& Object.entries(o).every(([k, v]) => k.trim() && (typeof v === 'boolean' || text(v) || (Array.isArray(v) && v.every(text))));
for (const [sec, body] of Object.entries(d)) {
const at = `console.contentRating.details.${sec}`;
const keys = (IARC_DETAIL_KEYS as Record<string, Record<string, string>>)[sec];
if (!keys) { e.push(`${at}: unknown section (${IARC_DETAIL_SECTIONS.join(', ')})`); continue; }
if (!body || typeof body !== 'object' || Array.isArray(body)) { e.push(`${at}: an object`); continue; }
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
if (k === 'other') { if (!isOther(v)) e.push(`${at}.other: { '<question as the console shows it>': true | false | '<option label>' | ['<labels>'] }`); continue; }
const kind = keys[k];
if (!kind) e.push(`${at}.${k}: unknown (${[...Object.keys(keys), 'other'].join(', ')})`);
else if (kind === 'boolean' && typeof v !== 'boolean') e.push(`${at}.${k}: true | false`);
else if (kind === 'label' && !text(v)) e.push(`${at}.${k}: the option label as the console shows it`);
else if (kind === 'labels' && !(Array.isArray(v) && v.every(text))) e.push(`${at}.${k}: a list of the console's checkbox labels ([] = none)`);
}
}
return e;
}
@ -832,6 +1008,10 @@ export function validateConfig(c: unknown): string[] {
e.push(`accounts.signUp: a list of ${ACCOUNT_SIGN_UPS.map(x => `'${x}'`).join(' | ')}`);
}
if (cfg.reviewAccess && typeof cfg.reviewAccess.signInRequired !== 'boolean') e.push('reviewAccess.signInRequired: true | false');
if (cfg.social !== undefined) {
if (!cfg.social || typeof cfg.social.interaction !== 'boolean') e.push('social.interaction: true | false');
else for (const k of ['block', 'report', 'moderation', 'friendsOnly', 'sharesLocation'] as const) if (cfg.social[k] !== undefined && typeof cfg.social[k] !== 'boolean') e.push(`social.${k}: true | false`);
}
const langs = cfg.languages;
if (!Array.isArray(langs) || !langs.length) e.push('languages: at least one language');
const ids = new Set<string>();
@ -848,6 +1028,16 @@ export function validateConfig(c: unknown): string[] {
e.push('play.reportsBucket: the Play reports bucket, e.g. "pubsite_prod_rev_<number>" (Play Console > Download reports > Copy Cloud Storage URI)');
if (cfg.play?.targetAges !== undefined && !(Array.isArray(cfg.play.targetAges) && cfg.play.targetAges.length && cfg.play.targetAges.every(a => PLAY_AGE_GROUPS.includes(a))))
e.push(`play.targetAges: a list of ${PLAY_AGE_GROUPS.join(' | ')}`);
if (cfg.play?.category !== undefined) {
const games: readonly string[] = PLAY_GAME_CATEGORIES, apps: readonly string[] = PLAY_APP_CATEGORIES;
const prof = cfg.app?.profile;
const list = prof === 'game' ? games : prof ? apps : [...games, ...apps];
if (typeof cfg.play.category !== 'string' || !list.includes(cfg.play.category))
e.push(`play.category: "${String(cfg.play.category)}" is not a Play ${prof === 'game' ? 'game' : prof ? 'app' : 'game or app'} category (${list.join(', ')})`);
}
if (cfg.play?.countries !== undefined && !(Array.isArray(cfg.play.countries) && cfg.play.countries.length && cfg.play.countries.every(c => typeof c === 'string' && /^[A-Z]{2}$/.test(c))))
e.push('play.countries: a list of ISO 3166-1 alpha-2 codes ("US", "GB"), or leave it out for all countries / regions');
if (cfg.play?.managedPublishing !== undefined && typeof cfg.play.managedPublishing !== 'boolean') e.push('play.managedPublishing: true | false (what Publishing overview > Managed publishing shows)');
if (cfg.play?.accountType !== undefined && cfg.play.accountType !== 'personal' && cfg.play.accountType !== 'organization') e.push('play.accountType: personal | organization');
if (cfg.play?.accountCreated !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(cfg.play.accountCreated)) e.push('play.accountCreated: a date, YYYY-MM-DD');
if (cfg.play?.billingPrograms !== undefined && !(Array.isArray(cfg.play.billingPrograms) && cfg.play.billingPrograms.every(b => (PLAY_BILLING_PROGRAMS as readonly string[]).includes(b))))
@ -895,6 +1085,7 @@ export function validateConfig(c: unknown): string[] {
if ([...(l.title ?? '')].length > 30) e.push(`${at}.listings.${lang}.title: over 30 characters (App Store limit)`);
if (!l.description) e.push(`${at}.listings.${lang}.description: missing`);
}
if (p.randomRewards !== undefined && typeof p.randomRewards !== 'boolean') e.push(`${at}.randomRewards: true | false`);
e.push(...validateIapOfferCodes(at, p.offerCodes, p.type));
for (const o of p.overrides ?? []) {
if (!/^[A-Z]{2}$/.test(o.region)) e.push(`${at}.overrides: region "${o.region}" must be ISO alpha-2 (TR)`);