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

@ -5,9 +5,10 @@
// The scripted engine (`console fill`) uses the same answers and click rules and stays an optional fast path. Pure.
// UI facts marked "verified" were read on a real console (App Privacy, 2026-10-12, /apps/<id>/distribution/privacy);
// everything else is modeled on Apple's and Google's help pages (sources in console-answers.ts): the agent adapts.
import type { ShipConfig } from './config';
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, type Store, valueText } from './console-answers';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppDashboard, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { playCategoryOf, type ShipConfig } from './config';
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, isPlanOnly, type PlanFormId, type Store, valueText } from './console-answers';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { PLAY_CREATE_CAPTURE } from './console-verify';
import { APPLE_PURPOSES } from './console-answers';
@ -49,7 +50,7 @@ export interface PlanStep {
}
export interface ConsolePlan {
form: FormId;
form: PlanFormId;
store: Store;
title: string;
/** Where to start: a deep link when the ids are known, else the console and how to reach the form. */
@ -72,6 +73,9 @@ export interface ConsolePlan {
uiFacts: string[];
/** How to drive long flows from a browser tool. */
driving: string[];
/** A ready-made in-page helper (JavaScript as text): paste it once per page with one evaluate call, start a run, then
* poll window.__log in short calls (one evaluate call times out around 45 s on big console pages). */
inPageLoop: string;
verify: { command: string; source: 'page-text' | 'api' | 'api-or-page-text' | 'none'; capture: string; expect: string[] };
/** False: guide only (the API sets it; `storebound apple appinfo`). */
fillable: boolean;
@ -79,7 +83,11 @@ export interface ConsolePlan {
}
/** Ids known outside the config (an API lookup or the account cache): they make deep links. */
export interface PlanIds { appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null }
export interface PlanIds {
appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null;
/** reviewDemoAccount exists in .storebound/credentials.json (only whether it exists: the values are never read here). */
demoAccount?: boolean;
}
const ASC = 'https://appstoreconnect.apple.com';
const PLAY = 'https://play.google.com/console';
@ -153,13 +161,76 @@ const ids = (answers: PlanAnswer[], pred: (a: PlanAnswer) => boolean) => answers
const DRIVING = [
'Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.',
'For a long flow, start an in-page async loop and poll it: one Runtime.evaluate / javascript call times out around 45 s. Pattern: window.__log = []; window.__run = (async () => { ... window.__log.push("step ..."); ... })(); then read window.__log every few seconds until it says done or stopped.',
'Long flows run INSIDE the page: one Runtime.evaluate / javascript call times out at about 45 s on big Play pages (the IARC questionnaire, App content), so never loop over many controls in one call. Paste plan.inPageLoop once per page (one short call; it defines window.__sb), start the job with window.__sb.run([{ role: "radio", label: "No", within: "<question text>" }, { role: "checkbox", label: "<box label>", want: true }, ...]) (it returns at once), then poll window.__log.slice(-20) and window.__done in short calls every 2-5 s until the log says done. The helper only picks radios and ticks boxes (never agreements); press Next / Save yourself afterwards, by their visible text.',
'Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.',
'An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.',
'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.',
'Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.',
];
/**
* The in-page helper of every plan (plan.inPageLoop), as text: defines window.__sb with labelOf (aria-label, else the
* nearest non-empty innerText: Play's labels sit in sibling nodes), scopeOf (the smallest block holding a question text
* and its controls), set (a JS click on the inner [role=radio] / [role=checkbox] of a material control, read back) and
* run (a logged async job over a list of steps). It never clicks buttons and refuses agreements. No template literals,
* so it reads the same in JSON and in a terminal.
*/
export const IN_PAGE_LOOP = [
'(() => {',
' const norm = (t) => (t || "").replace(/\\s+/g, " ").trim();',
' const log = (m) => { window.__log.push(new Date().toISOString().slice(11, 19) + " " + m); };',
' const sleep = (ms) => new Promise((r) => setTimeout(r, ms));',
' const labelOf = (el) => {',
' const own = norm(el.getAttribute("aria-label"));',
' if (own) return own;',
' for (let a = el; a && a !== document.body; a = a.parentElement) { const t = norm(a.innerText); if (t) return t; }',
' return "";',
' };',
' const NEVER = /terms of use|terms of service|i agree|agree to|declaration|developer program polic|export laws/i;',
' const CONTROLS = "[role=radio], [role=checkbox], input[type=radio], input[type=checkbox]";',
' const scopeOf = (text) => {',
' const want = norm(text).toLowerCase(); let best = null;',
' for (const el of document.querySelectorAll("section, fieldset, div, [role=radiogroup], [role=group]"))',
' if (norm(el.innerText).toLowerCase().includes(want) && el.querySelector(CONTROLS) && (!best || best.contains(el))) best = el;',
' return best;',
' };',
' const find = (role, label, scope) => {',
' const want = norm(label).toLowerCase();',
' const hits = [...(scope || document).querySelectorAll("[role=" + role + "], input[type=" + role + "]")]',
' .filter((el) => el.getClientRects().length && labelOf(el).toLowerCase().startsWith(want));',
' return hits.length === 1 ? hits[0] : null;',
' };',
' const set = async (s) => {',
' const scope = s.within ? scopeOf(s.within) : null;',
' if (s.within && !scope) return "no block with \\"" + s.within + "\\"";',
' const el = find(s.role, s.label, scope);',
' if (!el) return "not found or ambiguous";',
' if (NEVER.test(labelOf(el))) return "refused: an agreement (the owner ticks it)";',
' const host = el.closest("material-radio, material-checkbox");',
' const inner = el.matches("[role=radio], [role=checkbox]") ? el : (host && host.querySelector("[role=radio], [role=checkbox]")) || el;',
' const state = () => (inner.hasAttribute("aria-checked") ? inner.getAttribute("aria-checked") === "true" : !!inner.checked);',
' const want = s.want !== false;',
' if (state() !== want) inner.click();',
' await sleep(150);',
' return state() === want ? "ok" : "did not change (read the control, try the <input> itself)";',
' };',
' window.__log = []; window.__done = false;',
' window.__sb = { log, sleep, labelOf, scopeOf, find, set };',
' window.__sb.run = (steps) => {',
' window.__done = false;',
' window.__run = (async () => {',
' for (let i = 0; i < steps.length; i++) {',
' const s = steps[i], tag = (i + 1) + "/" + steps.length + " " + s.role + " \\"" + s.label + "\\"" + (s.within ? " in \\"" + s.within + "\\"" : "");',
' try { log(tag + ": " + await set(s)); } catch (e) { log(tag + ": error " + e.message); }',
' }',
' log("done"); window.__done = true;',
' })();',
' return "started " + steps.length + " steps: poll window.__log";',
' };',
' return "storebound helper ready: window.__sb.run([...]), then poll window.__log.slice(-20) and window.__done";',
'})()',
].join('\n');
function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined): Pick<ConsolePlan, 'steps' | 'uiFacts' | 'verify' | 'start'> {
const types = answers.filter(a => a.id.startsWith('type:'));
const collected = types.filter(a => a.value === true).map(a => a.id.slice(5));
@ -176,7 +247,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
add({ type: t, text: `"${t}" steps 3 and 4: two information pages ("Let’s define two important terms…", then "To help put tracking into context, here are a few examples:"), no inputs: press Next on each.`, click: ['Next', 'Next'] });
add({ type: t, text: `"${t}" step 5, tracking: pick the radio, then Save (a draft). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary.`, click: ['Save'], answers: [`tracking:${t}`] });
}
add({ text: 'Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish (it puts the answers on the App Store product page).' });
add({ text: 'Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish, then Publish again in the "Publish Your App Privacy Responses?" dialog (it puts the answers on the App Store product page). Afterwards the page shows "Published … ago by …".' });
add({ text: 'Verify: read the Data Types section as text (document.querySelector("main").innerText) and pipe it to `storebound console verify apple-app-privacy --from-text -`.' });
return {
start: appId ? { url: `${ASC}/apps/${appId}/distribution/privacy`, deepLink: true, reach: [] }
@ -192,7 +263,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
'Step 5, tracking: 2 radios, "Yes, we use <type> data for tracking purposes" / "No, we do not …". Buttons: "Definitions and Examples" (help: never press), Back, Cancel, Save.',
'Save closes the dialog. The type then shows "Edit" and a summary: "Used for <purposes>", "Linked to the user’s identity" (or not), "Used for tracking purposes" (or not). That summary is what `console verify` reads.',
'Labels use typographic quotes (’): match straight and curly quotes alike.',
'The "?" buttons ("More information") are help only. Publish (top right) is the owner\'s.',
'The "?" buttons ("More information") are help only. Publish (top right) is the owner\'s: it opens a <dialog> "Publish Your App Privacy Responses?" with Cancel / Publish (the owner\'s final button); after it the page shows "Published a few seconds ago by …" (verified 2026-10-12), which `console verify` reads as the published signal.',
],
verify: {
command: 'storebound console verify apple-app-privacy --from-text -',
@ -202,6 +273,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
`Every collected type (${collected.join(', ') || 'none'}) shows Edit and a summary, no "Set Up <type>" left.`,
'Each summary lists the purposes of the answer sheet ("Used for …"), linked and tracking as answered.',
'No other data type is listed. The App Store Connect API has no App Privacy endpoint, so the page text is the only source.',
'After the owner\'s Publish: a "Published … ago by …" line (the published signal).',
],
},
};
@ -215,7 +287,8 @@ function playStart(form: FormId, config: ShipConfig, p: PlanIds): ConsolePlan['s
return dev ? { url: playCreateApp(PLAY, dev), deepLink: true, reach: [] }
: { url: PLAY, deepLink: false, reach: ['Pick the developer account on the chooser (set play.developerId for a deep link)', 'then /console/u/0/developers/<developer id>/create-new-app, or Home (All apps) > Create app'] };
}
if (dev && app) return { url: playAppDashboard(PLAY, dev, app), deepLink: true, reach: ['Policy and programs > App content', `${entry} > Start (or Manage)`] };
// verified 2026-10-12: each App content card has its own page
if (dev && app) return { url: playAppPage(dev, app, form === 'play-content-rating' ? 'app-content/content-rating-overview' : 'app-content/target-audience-content'), deepLink: true, reach: [] };
return {
url: dev ? playAppList(PLAY, dev) : PLAY, deepLink: !!dev,
reach: [...(dev ? [] : ['Pick the developer account if asked (set play.developerId for a deep link)']),
@ -273,55 +346,131 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
'After Create app the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified): the app id deep-links the other Play forms (play.appId; storebound also remembers it).',
`A service account's app-level permissions do NOT cover apps created later: the owner adds the new app (${PLAY_SA_PATH}). Agents never do this (a permission grant; auto mode blocks it too).`,
'The package name is fixed for good once the app is created. Free can never become Paid.',
PLAY_MATERIAL_FACT,
],
verify: { command: 'storebound console verify play-app-record --from-text -', source: 'page-text', capture: PLAY_CREATE_CAPTURE,
expect: ['App name and App package name equal the answer sheet.', 'Game / App and Free / Paid are aria-checked="true" as answered.', 'No "Select …" hint text is left on the form.', '"Package name available" after Check availability.'] },
};
case 'play-content-rating':
add({ text: 'Open the app > Policy and programs > App content > Content rating > Start (or Start new questionnaire).', click: ['Start'] });
add({ text: 'Page 1: type the email address and pick the category. Save draft, then Next.', click: ['Save draft', 'Next'], answers: ['email', 'category'] });
add({ text: 'Questionnaire: answer each question Yes or No as the sheet says; leave ask-owner questions to the owner. Save draft, then Next.', click: ['Save draft', 'Next'], answers: all(a => a.id.startsWith('q:')) });
add({ text: 'Summary: stop. Do NOT press Submit: tell the owner to review the summary and press Submit (it sends the questionnaire to IARC).' });
case 'play-content-rating': {
const section = (sec: string) => all(a => a.id.startsWith(`d:${sec}:`));
add({ text: 'Open App content > Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire.', click: ['Start questionnaire'] });
add({ text: 'Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it; the owner ticks it (it is in the batch). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
add({ text: 'The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section\'s Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.', answers: all(a => a.id.startsWith('q:')) });
add({ text: 'Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.', answers: section('violence') });
add({ text: 'Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor follow-ups (after Yes): answer the questions shown as the sheet says (details.<section>.other).', answers: ['fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor'].flatMap(section) });
add({ text: 'Digital Purchases follow-ups (after "purchases of digital goods" Yes): the random items (loot box) question, then trading between players.', answers: section('digitalPurchases') });
add({ text: 'Miscellaneous: user interaction (after Yes: block, report, moderation, friends only), location sharing, Nazi symbols, Korea history, terrorism, crimes.', answers: [...section('userInteraction'), ...section('miscellaneous')] });
add({ text: 'Press Save (stores the draft; nothing goes to IARC yet), then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...).', click: ['Save', 'Next'] });
add({ text: 'Summary: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).' });
add({ text: 'Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.' });
return {
start: playStart(form, config, p), steps,
uiFacts: ['Modeled on Google\'s "Content ratings" help page, not recorded: the IARC question wording differs per category; match questions by their topic (violence, fear, gambling …) and adapt.', '"Save draft" stores the one draft of the app: nothing goes to IARC before Submit.', 'The email address field gets the full app.contactEmail from the config (printed masked here).', PLAY_DASHBOARD_FACT],
verify: { command: 'storebound console verify play-content-rating --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The Summary shows each question with the Yes / No of the answer sheet.'] },
uiFacts: [
'Verified 2026-10-12 (an agent with a browser tool on a real game):',
'App content > Content ratings is /app-content/content-rating-overview; "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.',
'Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.',
'The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound\'s: match by topic and the console\'s own labels.',
'Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary\'s final button is "Save" (not "Submit"): the owner\'s.',
'Example result (the game of that run): PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items alone made it 18), Google Play 7+.',
'The email address field gets the full app.contactEmail from the config (printed masked here).',
PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT,
],
verify: { command: 'storebound console verify play-content-rating --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The Summary shows each question with the Yes / No of the answer sheet, and a rating per authority.'] },
};
case 'play-target-audience':
add({ text: 'Open the app > Policy and programs > App content > Target audience and content > Start (or Manage).', click: ['Start'] });
add({ text: 'Target age: tick exactly the age groups of the sheet. Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
if (answers.some(a => a.id === 'appeal')) add({ text: 'Appeal to children: pick the answer. Next.', click: ['Next'], answers: ['appeal'] });
add({ text: 'Ads (also App content > Ads): pick whether the app contains ads.', answers: ['ads'] });
add({ text: 'Stop. Do NOT press the last Save: tell the owner to review and press Save (Google reviews the declaration).' });
}
case 'play-target-audience': {
const teens = answers.filter(a => a.id.startsWith('age:')).every(a => a.status === 'certain' && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
add({ text: 'Open App content > Target audience and content (/app-content/target-audience-content) > Start (or Manage).', click: ['Start'] });
add({ text: 'Target age: tick exactly the age groups of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back). Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
if (answers.some(a => a.id === 'appeal')) add({ text: `Appeal to children: pick the answer, then Next.${teens ? ' With 13+ age groups only the page skipped this question and went straight to the Summary (verified 2026-10-12): answer it only if it shows.' : ''}`, click: ['Next'], answers: ['appeal'] });
add({ text: 'Ads (also App content > Ads, /app-content/ads-declaration): pick whether the app contains ads, if this section shows it.', answers: ['ads'] });
add({ text: 'Summary: stop. Do NOT press Save: tell the owner to review and press Save (Google reviews the declaration).' });
add({ text: 'Verify: read the section\'s summary as text and pipe it to `storebound console verify play-target-audience --from-text -`.' });
return {
start: playStart(form, config, p), steps,
uiFacts: ['Modeled on Google\'s "Target audience and content" help page, not recorded: adapt to the page.', 'Any age group under 13 brings the Families policy.', 'Save on the last page is final (Google reviews it).', PLAY_DASHBOARD_FACT],
uiFacts: ['Verified 2026-10-12: /app-content/target-audience-content; age groups are checkboxes; with 13+ age groups only it went straight to the Summary (no appeal-to-children question); Save there is the final button.', 'Any age group under 13 brings the Families policy.', PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT],
verify: { command: 'storebound console verify play-target-audience --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The target age groups and the ads answer equal the answer sheet.'] },
};
}
case 'apple-app-privacy': throw new Error('unreachable');
}
}
export function consolePlan(form: FormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
/** The owner's batch: every answer a plan cannot fill from the config, the owner checks and the agreements. */
function askOwnerOf(answers: PlanAnswer[], prefix = ''): string[] {
return answers.filter(a => a.status === 'ask-owner' || a.status === 'owner-check' || a.status === 'agreement')
.map(a => `${prefix}${a.question}: ${a.status === 'agreement' ? 'an agreement, the owner ticks it' : a.status === 'owner-check' ? `${a.text} (owner check: ${a.note ?? ''})` : `ask the owner${a.note ? ` (${a.note})` : ''}`}`);
}
const ruleText = (draftSaves: string[], final: string) => `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${draftSaves.join(', ') || 'none in this form'}). Never press ${final} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`;
/** The App content overview: every card in the page's order, its page, its plan, and one batch of owner questions. */
function appContentPlan(cfg: ShipConfig, p: PlanIds): ConsolePlan {
const cards = APP_CONTENT_CARDS.map(card => ({ card, plan: card.form ? consolePlan(card.form, cfg, p) : null }));
const steps: PlanStep[] = [];
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
const answers: PlanAnswer[] = cards.flatMap(({ card, plan }) => (plan ? plan.answers.map(a => ({ ...a, id: `${plan.form}/${a.id}`, where: `${card.name}${a.where ? `: ${a.where}` : ''}` })) : []));
add({ text: 'Before the run: ask the owner every question of askOwner in ONE batch and write the answers into storebound.config.ts (rerun this plan: they turn certain).' });
add({ text: 'Open App content (/app-content/overview). Each card has a button aria-label="Start <Name> declaration"; a card already done shows its answers and a Manage / Edit link instead.' });
for (const { card, plan } of cards) {
if (!plan) { add({ text: `${card.name}: ${card.via}. Dry run first, then the real run; then read the card back.` }); continue; }
add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}") and follow \`storebound console plan ${plan.form}\`: ${plan.steps.length} steps, final button ${plan.buttons.final}.`, click: [startLabel(card.name)], answers: answers.filter(a => a.id.startsWith(`${plan.form}/`)).map(a => a.id) });
}
add({ text: 'Stop before each card\'s final Save (the content rating Summary\'s Save too): list the filled cards for the owner, who presses them (or says yes in chat for the listed cards; then the agent presses them).' });
add({ text: 'Verify: run the capture snippet (verify.capture) on the overview and pipe its JSON to `storebound console verify play-app-content --from-text -`.' });
const neverPress = [...new Set(cards.flatMap(c => c.plan?.buttons.neverPress ?? []))];
return {
form: 'play-app-content', store: 'play', title: 'Play Console: App content (every declaration)',
start: appContentStart(cfg, p), steps, answers,
askOwner: cards.flatMap(({ card, plan }) => (plan ? plan.askOwner.map(l => `${card.name}: ${l}`) : [])),
buttons: {
neverPress, final: 'Save (each card; the content rating Summary\'s Save)',
allowed: [{ button: 'Start <Name> declaration', class: 'navigation', where: 'App content overview: opens the card' },
...cards.flatMap(c => c.plan?.buttons.allowed ?? []).filter((b, i, l) => l.findIndex(x => x.button === b.button) === i)],
rule: ruleText(['Save draft / Save on the IARC questionnaire page', 'Save in Store settings'], 'a card\'s final Save'),
},
uiFacts: [
'Verified 2026-10-12 on /console/u/0/developers/<developerId>/app/<appId>/app-content/overview: each card has a button aria-label="Start <Name> declaration". The cards, in order:',
...APP_CONTENT_CARDS.map((c, i) => `${i + 1}. ${c.name}: /app-content/${c.slug}${c.verified ? '' : ' (slug modeled, not recorded)'}; ${c.via}.`),
'The App category is not an App content card: Store settings (`storebound console plan play-store-settings`).',
PLAY_MATERIAL_FACT,
],
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
verify: { command: 'storebound console verify play-app-content --from-text -', source: 'page-text', capture: APP_CONTENT_CAPTURE, expect: ['No card still shows its "Start <Name> declaration" button.'] },
fillable: true,
guidance: ['Open: Policy and programs > App content', ...APP_CONTENT_CARDS.map(c => `${c.name}: ${c.via}`)],
};
}
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config;
if (form === 'play-app-content') return appContentPlan(cfg, p);
if (isPlanOnly(form)) {
const part = playOnlyPart(form, cfg, p);
return {
form, store: 'play', title: part.title, start: part.start, steps: part.steps, answers: part.answers, askOwner: askOwnerOf(part.answers),
buttons: { neverPress: part.neverPress, final: part.final, allowed: part.allowed, rule: ruleText(part.allowed.filter(b => b.class === 'draft-save').map(b => b.button), part.final) },
uiFacts: part.uiFacts, driving: DRIVING, inPageLoop: IN_PAGE_LOOP, verify: part.verify, fillable: true, guidance: part.guidance,
};
}
const meta = formMeta(form, cfg);
const raw = consoleAnswers(form, cfg);
const answers = sheet(form, raw);
const part = form === 'apple-app-privacy' ? appPrivacyPlan(answers, cfg.apple?.appId) : otherPlan(form, cfg, answers, p);
const rules = FORM_BUTTONS[form];
const neverPress = [...new Set([...rules.filter(r => !r.auto && r.class === 'final-submit').flatMap(r => r.button.replace(/ \((?:form|and its confirmation dialog)\)$/, '').split(' / ')), ...NEVER_PRESS])];
const askOwner = answers.filter(a => a.status === 'ask-owner' || a.status === 'owner-check' || a.status === 'agreement')
.map(a => `${a.question}: ${a.status === 'agreement' ? 'an agreement, the owner ticks it' : a.status === 'owner-check' ? `${a.text} (owner check: ${a.note ?? ''})` : `ask the owner${a.note ? ` (${a.note})` : ''}`}`);
// the content rating's questionnaire Save is a draft save; only the Summary's Save is final (verified 2026-10-12)
const final = form === 'play-content-rating' ? 'Save (Summary page)' : meta.stopBefore;
const teens = form === 'play-target-audience' && raw.filter(a => a.id.startsWith('age:')).every(a => a.certain && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
const askOwner = askOwnerOf(answers).map(l => (teens && l.startsWith('Could your app unintentionally appeal') ? `${l} (only if the page asks it: with 13+ age groups only it did not, 2026-10-12)` : l));
return {
form, store: meta.store, title: meta.title, ...part, answers, askOwner,
buttons: {
neverPress, final: meta.stopBefore,
neverPress, final,
allowed: rules.filter(r => r.auto).map(r => ({ button: r.button, class: r.class, where: r.where })),
rule: `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${rules.filter(r => r.class === 'draft-save').map(r => r.button).join(', ') || 'none in this form'}). Never press ${meta.stopBefore} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`,
rule: ruleText(rules.filter(r => r.class === 'draft-save').map(r => r.button), final),
},
driving: DRIVING,
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
fillable: meta.fillable,
guidance: [...meta.open.map(o => `Open: ${o}`), ...(meta.notes ?? []).map(n => `Note: ${n}`)],
};
@ -337,7 +486,26 @@ export function planText(pl: ConsolePlan): string {
L.push(` ${tag} ${a.question}: ${a.text}${a.label ? ` -> ${a.action ?? ''} "${a.label}"${a.curlyQuotes ? ' (curly quotes)' : ''}` : ''}${a.where ? ` @ ${a.where}` : ''}`);
if (a.status !== 'certain' && a.note) L.push(` ${a.note}`);
}
L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`), '', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`));
L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`),
'', 'In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):', ...pl.inPageLoop.split('\n').map(l => ` ${l}`),
'', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`));
if (pl.askOwner.length) L.push('', 'Ask the owner first:', ...pl.askOwner.map(a => ` - ${a}`));
return L.join('\n');
}
/**
* What `storebound check` says about the Play console steps that have no API (pure): the App category (a warning when
* the config has none: Play shows "Not selected" until an agent sets it in Store settings; a hint when it has one,
* since it is still a console step) and how many App content answers the owner has to give in the one batch.
*/
export function playConsoleReminders(config: ShipConfig): { warnings: string[]; hints: string[] } {
const warnings: string[] = [], hints: string[] = [];
if (!config.play) return { warnings, hints };
const cat = playCategoryOf(config);
if (!cat) warnings.push('play.category is not set: Play\'s App category stays "Not selected" (the Play API cannot set it, so `play listing` does not). Set play.category (a Play category name, e.g. "Strategy"), then set it in Store settings with `storebound console plan play-store-settings`');
else hints.push(`Play App category "${cat.category}" (${cat.source}) is a console step, not an API one: \`storebound console plan play-store-settings\``);
const ask = consolePlan('play-app-content', config).askOwner;
if (ask.length) warnings.push(`${ask.length} Play App content answer(s) are not in the config: ask the owner in one batch at the start (\`storebound console plan play-app-content\` lists them), then write them into the config`);
if (config.products?.some(p => p.randomRewards)) hints.push('products with randomRewards (loot boxes): ClassInd (Brazil) rated such a game 18 on a real IARC run; other authorities barely moved');
return { warnings, hints };
}