// Production seed: registers an operator, creates a project, configures staging // content (items, remote configs, leaderboard, store with free+paid offers, a // global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves. // Excludes realtime & analytics. // // The scenario flow uses the SDK runtime's exact node `data` keys and handle names — // the game's plan builder copies node.data verbatim into the execution plan // (see liveops-game/shared/fsm/plan_builder.go), so what we store is what the SDK runs. import { createAdminClient, type AdminClient } from './adminClient.js'; import type { SeedArtifact } from './types.js'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); interface ReleaseResponse { id: string; status: string; version: number; } interface QuestResponse { id: string; name: string; status: string; objectives?: Array<{ id: string; metric: string; target: number }>; rewards?: Array<{ itemId?: string; currency?: string; amount: number }>; } interface StoreResponse { id: string; name: string; data?: { slug?: string }; offers?: Array<{ id: string; name: string; contents?: Array<{ itemId: string; amount: number }>; }>; } function buildFlow(scenarioSlug: string) { return { nodes: [ { id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } }, { id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } }, { id: 'rc_1', type: 'remote_config_override', data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] }, }, { id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } }, { id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } }, { id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } }, ], edges: [ { id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' }, { id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' }, { id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' }, { id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' }, { id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' }, ], }; } async function pollRelease( api: AdminClient, projPath: string, env: string, releaseId: string, log: (m: string) => void, ): Promise { const deadline = Date.now() + 60_000; while (Date.now() < deadline) { const releases = await api.get( `${projPath}/releases?environment=${env}&limit=20&offset=0`, ); const rel = releases.find((r) => r.id === releaseId); if (rel) { if (rel.status === 'completed') { log(`release ${releaseId} completed (v${rel.version})`); return; } if (rel.status === 'failed') { throw new Error(`release ${releaseId} failed`); } } await sleep(1500); } throw new Error(`release ${releaseId} did not complete within 60s`); } export async function runSeed( baseUrl: string, log: (m: string) => void = () => {}, ): Promise { const api = createAdminClient(baseUrl); const ts = Date.now(); const authoringEnv = 'staging'; const runtimeEnv = 'prod'; const q = `?environment=${authoringEnv}`; const prodQ = `?environment=${runtimeEnv}`; const adminEmail = `e2e+${ts}@rudder.build`; const password = `Passw0rd!e2e-${ts}`; // 1. Register operator (open registration; no approval gate at the API layer). const auth = await api.post<{ accessToken: string; refreshToken: string }>( '/platform/v1/authorization/register', { email: adminEmail, password, fullName: 'E2E Bot' }, { auth: false }, ); api.setToken(auth.accessToken); log(`registered ${adminEmail}`); // 2. Project. const project = await api.post<{ id: string; key: string; name: string }>( '/platform/v1/projects', { name: `e2e-${ts}` }, ); const projPath = `/platform/v1/projects/${project.id}`; log(`project ${project.id} key=${project.key}`); // 3. Staging environment (idempotent — may already exist). try { await api.post(`${projPath}/environments`, { projectId: project.id, name: authoringEnv }); } catch { // already exists / not required — content POSTs below are the real test } // 4. Item (granted as a paid offer's contents). const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, { projectId: project.id, environment: authoringEnv, name: 'Gold Coin', tags: ['currency'], }); // 5. Remote configs — one per value type. const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [ { key: 'max_energy', value: '100', valueType: 'int' }, { key: 'spawn_rate', value: '1.5', valueType: 'float' }, { key: 'feature_x', value: 'true', valueType: 'bool' }, { key: 'welcome_text', value: 'hi', valueType: 'string' }, { key: 'shop_layout', value: '{"cols":3}', valueType: 'json' }, ]; for (const rc of remoteConfigs) { await api.post(`${projPath}/remote-configs${q}`, { projectId: project.id, environment: authoringEnv, ...rc, }); } log(`created ${remoteConfigs.length} remote configs`); // 6. Leaderboard. await api.post(`${projPath}/leaderboards${q}`, { projectId: project.id, environment: authoringEnv, name: 'Weekly Score', slug: 'weekly-score', metric: 'score', resetPeriod: 'weekly', sortingOrder: 'desc', maxEntries: 100, }); // 7. Store with a free offer and a paid (currency) offer with a purchase limit. // The runtime resolves a store's slug from data.slug (liveops-game shop cache), and // the CreateStore API has no slug field — so we set it via data. const storeSlug = 'starter-shop'; const store = await api.post<{ id: string; offers: Array<{ id: string; name: string }> }>( `${projPath}/stores${q}`, { projectId: project.id, environment: authoringEnv, name: 'Starter Shop', description: 'E2E shop', data: { slug: storeSlug }, offers: [ { name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency: 'soft', amount: 0 } }, { name: 'Gold Pack', contents: [{ itemId: item.id, amount: 50 }], price: { currency: 'soft', amount: 100 }, maxPurchases: 2, }, ], }, ); log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`); const paidOffer = store.offers?.find((o) => o.name === 'Gold Pack'); if (!paidOffer?.id) { throw new Error('seeded paid offer did not return an id'); } // 8. Global quest: buying the paid offer completes it; claim grants extra item reward. const questName = 'Buy Gold Pack Quest'; const questObjectiveId = 'buy-gold-pack'; const questTarget = 1; const questMetric = `purchase.offer:${paidOffer.id}`; const questRewardAmount = 7; const quest = await api.post(`${projPath}/quests${q}`, { projectId: project.id, environment: authoringEnv, name: questName, objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }], rewards: [{ itemId: item.id, amount: questRewardAmount }], }); log(`quest ${quest.id} staged for metric ${questMetric}`); // 9. Scenario (must be live now: startAt <= now <= endAt). const startAt = new Date(ts - 3600_000).toISOString(); const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString(); const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, { projectId: project.id, environment: authoringEnv, name: 'Onboarding', description: 'e2e onboarding', startAt, endAt, }); // 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification). await api.put(`${projPath}/scenarios/${scenario.id}`, { projectId: project.id, scenarioId: scenario.id, flow: buildFlow(storeSlug), }); log(`scenario ${scenario.id} flow set`); // 11. Promote staging content to prod (the runtime serves prod/latest.json). const release = await api.post(`${projPath}/releases/promote`); await pollRelease(api, projPath, runtimeEnv, release.id, log); const prodStores = await api.get(`${projPath}/stores${prodQ}`); const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop'); if (!prodStore) { throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`); } const prodPaidOffer = prodStore.offers?.find((o) => o.name === 'Gold Pack'); if (!prodPaidOffer?.id) { throw new Error('promoted paid offer did not return an id'); } const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId; if (!prodRewardItemId) { throw new Error('promoted paid offer did not return a reward item id'); } const prodQuests = await api.get(`${projPath}/quests${prodQ}`); const prodQuest = prodQuests.find((q) => q.name === questName); if (!prodQuest?.id) { throw new Error(`promoted quest ${questName} was not found in ${runtimeEnv}`); } const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric; if (!prodQuestMetric) { throw new Error(`promoted quest ${questName} did not return metric ${questObjectiveId}`); } const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId; if (!prodQuestRewardItemId) { throw new Error(`promoted quest ${questName} did not return reward item id`); } // 12. Settle: let the game service hot-reload caches from the new snapshot. await sleep(3000); return { baseUrl, projectId: project.id, projectKey: project.key, adminToken: auth.accessToken, adminEmail, environment: runtimeEnv, seededAt: new Date(ts).toISOString(), releaseId: release.id, store: { name: 'Starter Shop', slug: storeSlug, freeOfferName: 'Welcome Gift', paidOfferName: 'Gold Pack', paidOfferId: prodPaidOffer.id, paidPrice: { currency: 'soft', amount: 100 }, paidMaxPurchases: 2, grantedItemName: 'Gold Coin', paidGrantAmount: 50, }, quest: { id: prodQuest.id, name: questName, objectiveId: questObjectiveId, metric: prodQuestMetric, target: questTarget, rewardItemId: prodQuestRewardItemId, rewardAmount: questRewardAmount, }, leaderboard: { slug: 'weekly-score', metric: 'score' }, item: { id: prodRewardItemId, name: item.name }, remoteConfigs: { maxEnergy: 100, spawnRate: 1.5, featureX: true, welcomeText: 'hi', shopLayout: { cols: 3 }, spawnRateOverride: 3.0, }, scenario: { id: scenario.id, event: 'session_start' }, }; } export async function deleteProject(artifact: SeedArtifact): Promise { const api = createAdminClient(artifact.baseUrl); api.setToken(artifact.adminToken); await api.del(`/platform/v1/projects/${artifact.projectId}`); }