storebound/templates/storebound.config.ts

184 lines
11 KiB
TypeScript

// storebound config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or
// .storebound/credentials.json, which must stay gitignored). Paths are relative to this file.
// The type import is optional: it only works when storebound is installed in the project (bun add -d <repo url>).
import type { ShipConfig } from 'storebound';
const config: ShipConfig = {
// Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block
// and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is
// already live: storebound onboard --declarations), answer the rest, then remove the area from this list.
unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'],
app: {
name: 'Example Game',
profile: 'game',
copyright: '2026 Example Studio',
privacyPolicyUrl: 'https://example.com/privacy',
supportUrl: 'https://example.com',
marketingUrl: 'https://example.com',
// Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads
website: 'https://example.com',
contactEmail: 'support@example.com',
},
// id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale
languages: [
{ id: 'en', play: 'en-US', apple: 'en-US' },
{ id: 'tr', play: 'tr-TR', apple: 'tr' },
{ id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' },
{ id: 'ja', play: 'ja-JP', apple: 'ja' },
{ id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true },
],
play: {
packageName: 'com.example.game',
defaultLanguage: 'en-US',
listing: 'store/listing.md',
icon: 'store/icons/play-512.png',
// read by `storebound scan` (fill in what is true for your account and app):
// accountType: 'personal', accountCreated: 'YYYY-MM-DD', // new personal accounts: 12 testers x 14 days closed test
// targetAges: ['13-15', '16-17', '18+'], // App content > Target audience; under 13 = Families policy
},
apple: {
bundleId: 'com.example.game',
// name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there ("already being used"); default app.name
// appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out
listing: 'store/appstore.md',
reviewShots: 'store/iap-review',
// Export compliance (export-compliance skill). false ONLY if the game uses no encryption beyond the OS (HTTPS through
// the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.
// usesNonExemptEncryption: false,
// ipad: true, // the app runs on iPad: 13" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded
// iphoneMedium: true, // optional 6.3" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5" set down
// iphoneDuo: true, // optional iPhone Duo (foldable) sets, outer 1398 x 2034 + inner 2007 x 2853; rendered and checked, not uploaded until the API takes them
// screenshotTypes: ['APP_IPHONE_61'], // what `storebound validate` requires per locale (default: medium display, met by a 6.9" or 6.5" set; + APP_IPAD_PRO_3GEN_129 with ipad)
// TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.
testflight: {
groups: [
{ name: 'Team', internal: true, allBuilds: true },
{ name: 'Friends' },
],
description: { en: 'Example Game beta: try the new levels and tell us what feels too hard.' },
},
info: {
categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' },
ageRating: { advertising: true }, // everything else NONE / false
contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',
free: true,
reviewNotes: [
'Single-player game. No account or login is needed; there is no user-generated content or chat.',
'In-app purchases: Home > Shop. Restore Purchases is in Settings.',
'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',
'Privacy policy: Settings > Privacy.',
// With remote config, feature flags or a remotely changed paywall (storebound scan warns, 2.3.1) you may add:
// 'Shop offers and prices shown come from <SDK> and change remotely; no feature is switched on or off after review.',
// Google Play has no review-notes field: put the same line in reviewAccess.instructions (Play App content >
// App access, 'Any other information') when the app has a sign-in, and say in the full description what needs
// a paid subscription (Play: Deceptive behavior, Payments).
].join('\n\n'),
},
},
// Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry.
// prices: { csv: 'store/prices.csv', baseTerritory: 'USA' },
// Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a
// region by hand (Apple needs an existing price point with exactly that value).
products: [
{
id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99,
overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],
listings: {
en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' },
tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' },
},
reviewScreenshot: 'store/iap-review/shop.png',
},
{
id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99,
listings: {
en: { title: '500 Coins', description: 'A small bag of coins.' },
tr: { title: '500 Altın', description: 'Küçük bir kese altın.' },
},
reviewScreenshot: 'store/iap-review/shop.png',
},
],
// Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications,
// fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation:
dataSafety: {
collectsData: true,
encryptedInTransit: true,
// No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no
// email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's
// "Username and other authentication"; `storebound scan` proposes it). With accounts Play also requires the
// delete-account URL (from accounts.deletionUrl). That page also answers "can users request that their data is
// deleted?" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true +
// partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's
// current form (cli/data/play-data-safety-form.csv).
accountCreation: ['PSL_ACM_NONE'],
data: {
PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },
PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },
PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] },
},
},
// Ads: `storebound validate --play` checks <app.website>/app-ads.txt for these lines (copy them from your mediation
// dashboard's app-ads.txt page). Placeholder line below; replace it.
// ads: { appAdsTxt: ['example-network.com, pub-0000000000000000, DIRECT, 0000000000000000'] },
renders: 'store/renders',
shots: {
copy: 'store/shots/copy.json',
frames: 'store/shots/frames', // <lang>/phone/<name>.png, <lang>/tablet/<name>.png
out: 'store/renders',
logo: 'store/shots/logo.png', // optional: hero scene + feature graphic
// Google Fonts; both cover Latin Extended (tr, pl, cs, ro ...) and Vietnamese. `shots fonts` checks every glyph of the
// copy; a character the font lacks needs a font that has it or fallback: ['Noto Sans Display'] (else shots check fails).
fonts: { title: 'Paytone One', body: 'Montserrat' },
// phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar)
// tablet: { w: 1032, h: 1352, dpr: 2 }, // default with apple.ipad (13" iPad below its 24 px bar), else 800 x 1240
scenes: [
{ id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }],
badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] },
{ id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }],
badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] },
{ id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }],
badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] },
{ id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }],
badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] },
],
feature: { frame: 'hero.png', palette: 'fire' },
capture: {
baseUrl: 'http://127.0.0.1:5173',
query: 'lang={lang}&noads=1&seed=1',
shots: {
'hero': { path: '/?level=12', waitFor: "window.game && game.state === 'playing'", delayMs: 1500, device: 'both' },
'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' },
'level-a': { path: '/?level=20', delayMs: 2000 },
'level-b': { path: '/?level=40', delayMs: 2000 },
'level-c': { path: '/?level=60', delayMs: 2000 },
'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' },
},
},
},
icons: {
composite: 'store/icon/icon.png', // 1024 opaque
foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent
background: 'store/icon/background.png', // 1024 opaque
out: 'store/icons',
web: 'public/icons',
// capacitorAssets: 'native/assets',
// capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets',
// androidRes: 'native/android/app/src/main/res',
},
};
export default config;