|
|
|
@@ -1,11 +1,10 @@
|
|
|
|
|
// Production seed: registers an operator, creates a project, configures staging
|
|
|
|
|
// content (items, remote configs, leaderboard, store with free+paid offers, a
|
|
|
|
|
// content (counter, item, 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.
|
|
|
|
|
// The scenario flow uses the server engine's node types, `data` keys and handle names —
|
|
|
|
|
// the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
|
|
|
|
|
// and .../fsm), so what we store is what the server runs.
|
|
|
|
|
|
|
|
|
|
import { createAdminClient, type AdminClient } from './adminClient.js';
|
|
|
|
|
import type { SeedArtifact } from './types.js';
|
|
|
|
@@ -29,6 +28,7 @@ interface QuestResponse {
|
|
|
|
|
interface StoreResponse {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
slug?: string;
|
|
|
|
|
data?: { slug?: string };
|
|
|
|
|
offers?: Array<{
|
|
|
|
|
id: string;
|
|
|
|
@@ -110,31 +110,43 @@ export async function runSeed(
|
|
|
|
|
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}` },
|
|
|
|
|
);
|
|
|
|
|
// 2. Project. Creating one provisions the staging + prod environments, each with
|
|
|
|
|
// its own SDK key — that key is what the runtime SDK sends as projectKey.
|
|
|
|
|
const project = await api.post<{ id: 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
|
|
|
|
|
const environments = await api.get<Array<{ name: string; sdkKey: string }>>(
|
|
|
|
|
`${projPath}/environments`,
|
|
|
|
|
);
|
|
|
|
|
const projectKey = environments.find((e) => e.name === runtimeEnv)?.sdkKey;
|
|
|
|
|
if (!projectKey) {
|
|
|
|
|
throw new Error(`project ${project.id} has no ${runtimeEnv} environment sdk key`);
|
|
|
|
|
}
|
|
|
|
|
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
|
|
|
|
|
|
|
|
|
|
// 3. Soft currency counter — offer prices are validated against counter slugs, and
|
|
|
|
|
// the purchase debit resolves the currency through the catalog counter cache.
|
|
|
|
|
const currency = 'soft';
|
|
|
|
|
await api.post(`${projPath}/counters${q}`, {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
environment: authoringEnv,
|
|
|
|
|
name: 'Soft Currency',
|
|
|
|
|
slug: currency,
|
|
|
|
|
kind: 'currency',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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'],
|
|
|
|
|
slug: 'gold-coin',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 5. Remote configs — one per value type. RCs are live per-env flags served
|
|
|
|
|
// straight from the outbox/cache, so they go directly into the runtime env.
|
|
|
|
|
// straight from the cache and are not copied by a promote, so they go directly
|
|
|
|
|
// into the runtime env.
|
|
|
|
|
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
|
|
|
|
|
{ key: 'max_energy', value: '100', valueType: 'int' },
|
|
|
|
|
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
|
|
|
|
@@ -164,28 +176,25 @@ export async function runSeed(
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
// The runtime resolves a store's slug from data.slug, 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,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
const store = await api.post<StoreResponse>(`${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, amount: 0 } },
|
|
|
|
|
{
|
|
|
|
|
name: 'Gold Pack',
|
|
|
|
|
contents: [{ itemId: item.id, amount: 50 }],
|
|
|
|
|
price: { currency, 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) {
|
|
|
|
@@ -210,6 +219,7 @@ export async function runSeed(
|
|
|
|
|
// 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 scenarioEvent = 'session_start';
|
|
|
|
|
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
environment: authoringEnv,
|
|
|
|
@@ -219,7 +229,7 @@ export async function runSeed(
|
|
|
|
|
endAt,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 10. Scenario flow (trigger -> notification -> rc override -> store -> boundary -> notification).
|
|
|
|
|
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
|
|
|
|
|
await api.put(`${projPath}/scenarios/${scenario.id}`, {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
scenarioId: scenario.id,
|
|
|
|
@@ -227,7 +237,7 @@ export async function runSeed(
|
|
|
|
|
});
|
|
|
|
|
log(`scenario ${scenario.id} flow set`);
|
|
|
|
|
|
|
|
|
|
// 11. Promote staging content to prod (the runtime serves prod/latest.json).
|
|
|
|
|
// 11. Promote staging content to prod (the runtime serves the prod snapshot).
|
|
|
|
|
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
fromEnvironment: authoringEnv,
|
|
|
|
@@ -235,6 +245,7 @@ export async function runSeed(
|
|
|
|
|
});
|
|
|
|
|
await pollRelease(api, projPath, runtimeEnv, release.id, log);
|
|
|
|
|
|
|
|
|
|
// Promotion re-creates every entity with fresh ids — read the prod ones back.
|
|
|
|
|
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
|
|
|
|
|
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
|
|
|
|
|
if (!prodStore) {
|
|
|
|
@@ -263,13 +274,18 @@ export async function runSeed(
|
|
|
|
|
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);
|
|
|
|
|
const prodScenarios = await api.get<Array<{ id: string; name: string }>>(
|
|
|
|
|
`${projPath}/scenarios${prodQ}`,
|
|
|
|
|
);
|
|
|
|
|
const prodScenario = prodScenarios.find((s) => s.name === 'Onboarding');
|
|
|
|
|
if (!prodScenario?.id) {
|
|
|
|
|
throw new Error(`promoted scenario Onboarding was not found in ${runtimeEnv}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
baseUrl,
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
projectKey: project.key,
|
|
|
|
|
projectKey,
|
|
|
|
|
adminToken: auth.accessToken,
|
|
|
|
|
adminEmail,
|
|
|
|
|
environment: runtimeEnv,
|
|
|
|
@@ -281,7 +297,7 @@ export async function runSeed(
|
|
|
|
|
freeOfferName: 'Welcome Gift',
|
|
|
|
|
paidOfferName: 'Gold Pack',
|
|
|
|
|
paidOfferId: prodPaidOffer.id,
|
|
|
|
|
paidPrice: { currency: 'soft', amount: 100 },
|
|
|
|
|
paidPrice: { currency, amount: 100 },
|
|
|
|
|
paidMaxPurchases: 2,
|
|
|
|
|
grantedItemName: 'Gold Coin',
|
|
|
|
|
paidGrantAmount: 50,
|
|
|
|
@@ -305,7 +321,7 @@ export async function runSeed(
|
|
|
|
|
shopLayout: { cols: 3 },
|
|
|
|
|
spawnRateOverride: 3.0,
|
|
|
|
|
},
|
|
|
|
|
scenario: { id: scenario.id, event: 'session_start' },
|
|
|
|
|
scenario: { id: prodScenario.id, event: scenarioEvent },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|