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:
parent
dbd9f2973c
commit
b7bfe89151
37 changed files with 1624 additions and 121 deletions
|
|
@ -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)`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue