Slug-based scenarios, quests and offers per the environments contract; regenerated models; e2e seed passes environment to admin mirrors

Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
This commit is contained in:
edmand46
2026-09-06 21:44:54 +03:00
parent e863cc3bf7
commit a7401b534a
23 changed files with 127 additions and 115 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ export async function grantCurrency(
amount: number,
): Promise<void> {
await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/wallet/adjust`,
`/platform/v1/projects/${artifact.projectId}/players/wallet/adjust?environment=${artifact.environment}`,
{ items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] },
);
}
+36 -30
View File
@@ -19,6 +19,7 @@ interface ReleaseResponse {
interface QuestResponse {
id: string;
slug: string;
name: string;
status: string;
objectives?: Array<{ id: string; metric: string; target: number }>;
@@ -28,10 +29,10 @@ interface QuestResponse {
interface StoreResponse {
id: string;
name: string;
slug?: string;
data?: { slug?: string };
slug: string;
offers?: Array<{
id: string;
slug: string;
name: string;
contents?: Array<{ itemId: string; amount: number }>;
}>;
@@ -176,18 +177,24 @@ 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, and the CreateStore API has
// no slug field — so we set it via data.
// Stores and offers carry an authored slug that survives promotes.
const storeSlug = 'starter-shop';
const paidOfferSlug = 'gold-pack';
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: storeSlug,
name: 'Starter Shop',
description: 'E2E shop',
data: { slug: storeSlug },
offers: [
{ name: 'Welcome Gift', contents: [{ itemId: item.id, amount: 10 }], price: { currency, amount: 0 } },
{
slug: 'welcome-gift',
name: 'Welcome Gift',
contents: [{ itemId: item.id, amount: 10 }],
price: { currency, amount: 0 },
},
{
slug: paidOfferSlug,
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency, amount: 100 },
@@ -196,20 +203,18 @@ export async function runSeed(
],
});
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 questSlug = 'buy-gold-pack-quest';
const questMetric = `purchase.offer:${paidOfferSlug}`;
const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: questSlug,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
@@ -220,9 +225,11 @@ export async function runSeed(
const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenarioEvent = 'session_start';
const scenarioSlug = 'onboarding';
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: scenarioSlug,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
@@ -230,9 +237,8 @@ export async function runSeed(
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}`, {
await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
projectId: project.id,
scenarioId: scenario.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
@@ -245,15 +251,15 @@ 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.
// Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.data?.slug === storeSlug || s.name === 'Starter Shop');
const prodStore = prodStores.find((s) => s.slug === storeSlug);
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 prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
if (!prodPaidOffer?.slug) {
throw new Error('promoted paid offer did not return a slug');
}
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
@@ -261,25 +267,25 @@ export async function runSeed(
}
const prodQuests = await api.get<QuestResponse[]>(`${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 prodQuest = prodQuests.find((qq) => qq.slug === questSlug);
if (!prodQuest?.slug) {
throw new Error(`promoted quest ${questSlug} 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}`);
throw new Error(`promoted quest ${questSlug} 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`);
throw new Error(`promoted quest ${questSlug} did not return reward item id`);
}
const prodScenarios = await api.get<Array<{ id: string; name: string }>>(
const prodScenarios = await api.get<Array<{ id: string; slug: 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}`);
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
if (!prodScenario?.slug) {
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
}
return {
@@ -296,14 +302,14 @@ export async function runSeed(
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferId: prodPaidOffer.id,
paidOfferSlug: prodPaidOffer.slug,
paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
id: prodQuest.id,
slug: prodQuest.slug,
name: questName,
objectiveId: questObjectiveId,
metric: prodQuestMetric,
@@ -321,7 +327,7 @@ export async function runSeed(
shopLayout: { cols: 3 },
spawnRateOverride: 3.0,
},
scenario: { id: prodScenario.id, event: scenarioEvent },
scenario: { slug: prodScenario.slug, event: scenarioEvent },
};
}
+3 -3
View File
@@ -16,14 +16,14 @@ export interface SeedArtifact {
slug: string;
freeOfferName: string;
paidOfferName: string;
paidOfferId: string;
paidOfferSlug: string;
paidPrice: { currency: string; amount: number };
paidMaxPurchases: number;
grantedItemName: string;
paidGrantAmount: number;
};
quest: {
id: string;
slug: string;
name: string;
objectiveId: string;
metric: string;
@@ -42,5 +42,5 @@ export interface SeedArtifact {
/** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number;
};
scenario: { id: string; event: string };
scenario: { slug: string; event: string };
}