185 lines
11 KiB
TypeScript
185 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
|
|
// releaseType: "afterApproval", // afterApproval | manual | scheduled (+ earliestReleaseDate): how an approved version goes live; unset = new versions MANUAL
|
|
// 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;
|