@idosgames/mcp 0.1.44 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +119 -119
  5. package/registry/modules/ai.json +5 -5
  6. package/registry/modules/board-game.json +6 -6
  7. package/registry/modules/boosts.json +5 -5
  8. package/registry/modules/character.json +9 -9
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +5 -5
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +5 -5
  13. package/registry/modules/deals.json +6 -6
  14. package/registry/modules/events.json +3 -3
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/hero-run.json +5 -5
  17. package/registry/modules/idle-rpg.json +7 -7
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/leaderboard.json +3 -3
  21. package/registry/modules/lootboxes.json +5 -5
  22. package/registry/modules/mailbox.json +3 -3
  23. package/registry/modules/marketplace.json +3 -3
  24. package/registry/modules/mini-game.json +3 -3
  25. package/registry/modules/multiplayer.json +3 -3
  26. package/registry/modules/premium.json +5 -5
  27. package/registry/modules/profile.json +3 -3
  28. package/registry/modules/pvp.json +3 -3
  29. package/registry/modules/quests.json +3 -3
  30. package/registry/modules/rewards.json +3 -3
  31. package/registry/modules/season.json +3 -3
  32. package/registry/modules/store.json +6 -6
  33. package/registry/modules/tutorials.json +3 -3
  34. package/registry/modules/video-studio.json +8 -8
  35. package/registry/modules/voxelcraft.json +5 -5
  36. package/registry/modules/workshop.json +6 -6
  37. package/registry/skills/idosgames-ui-kit.json +1 -1
  38. package/registry/skills/store-system.json +1 -1
@@ -32,16 +32,16 @@
32
32
  "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.25.0",
36
- "@idosgames/module-sdk": "0.13.2",
37
- "@idosgames/react": "0.6.3",
35
+ "@idosgames/core": "0.26.0",
36
+ "@idosgames/module-sdk": "0.13.3",
37
+ "@idosgames/react": "0.7.0",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "a2fa5522c2b27ba330c9edf5610df2029a7f6a50843bbdbc955dc73b60f5eb07",
40
+ "contentHash": "6af34c48b5056a655586dd2351d4b370756b9244b1aeda980c667b1c962428e5",
41
41
  "files": [
42
42
  {
43
43
  "path": "CoopEventsScreen.tsx",
44
- "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { ActiveCoopEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n Center,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n bulkSpins,\n chainIDs,\n coopBadge,\n eventOf,\n groupRows,\n spinPrices,\n type CoopEventView,\n type GroupView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Team events (engine CoopEvent): the player joins a group (the server matches partners, bots fill\n// the gaps), each member spins the wheel to build their own object, and the group shares a grand\n// prize when every object is done. The group lives in ONE shared document on the server, so the\n// screen always re-reads it after an action instead of patching it locally.\n\n/** The player's group and the active events, shared by the screen and the badge (one read). */\nexport function createCoopStore(features: FeatureRegistry) {\n let group: GroupView | null | undefined = undefined;\n const listeners = new Set<() => void>();\n // One read at a time; a load asked for while one is in flight runs again right after it. Overlapping\n // reads (the screen, the badge, the re-read after a spin) used to be skipped by the transport as\n // duplicates, and a skipped read turned the player's group into \"no group\" — the join screen.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.coopEvent.getUserState();\n // A failed read keeps what is on screen; only the very first one ends the spinner.\n if (res.ok) group = (res.data.ActiveGroup as GroupView | null) ?? null;\n else if (group === undefined) group = null;\n const rows = groupRows(\n group,\n eventOf(configSection(client, \"CoopEvent\"), group),\n client.auth.context?.userID,\n );\n features.setBadge(\"coop-events\", group ? coopBadge(rows) : 0);\n for (const l of listeners) l();\n };\n return {\n get: () => group,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n };\n}\nexport type CoopStore = ReturnType<typeof createCoopStore>;\n\nexport function makeCoopEventsScreen(store: CoopStore) {\n return function CoopEventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const group = useSyncExternalStore(store.subscribe, store.get, store.get);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (group === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n return group ? (\n <Group group={group} store={store} />\n ) : (\n <Join store={store} />\n );\n };\n}\n\n// ── Not in a group: the running events to join ───────────────────────────────────────────────\n\nfunction Join({ store }: { store: CoopStore }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const stagger = useStagger();\n const [active, setActive] = useState<ActiveCoopEventInfo[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n const chains = chainIDs(configSection(client, \"CoopEvent\"));\n void Promise.all(\n chains.map((c) => client.coopEvent.getActiveEvent(c)),\n ).then((all) => setActive(all.flatMap((r) => (r.ok ? [r.data] : []))));\n }, [client]);\n\n const join = async (chainID: string) => {\n setBusy(chainID);\n const res = await client.coopEvent.joinOrCreateGroup(chainID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await store.load(client);\n };\n\n if (!active)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const running = active.filter((a) => a.EventDef);\n if (running.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noEvent\")} />;\n return (\n <Columns gap={12} min={360}>\n {running.map((a, i) => {\n const def = a.EventDef as CoopEventView;\n return (\n <Card\n key={a.CoopChainID}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"green\"),\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"friends\" size={48} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(def.DisplayName ?? a.CoopChainID)}\n </div>\n <Timer\n at={a.ComputedEndUtc as string | undefined}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n {def.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {catalog.localize(def.Description)}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList\n lines={groupRows(null, def, null).grandPrize}\n size={20}\n />\n </div>\n <Button\n tone=\"green\"\n size=\"lg\"\n attract\n busy={busy === a.CoopChainID}\n onClick={() => void join(a.CoopChainID)}\n data-tutorial-anchor={`coop:join:${a.CoopChainID}`}\n >\n {t(\"join\")} · {Number(def.PartnerCount ?? 0) + 1} {t(\"players\")}\n </Button>\n </Card>\n );\n })}\n </Columns>\n );\n}\n\n// ── In a group ───────────────────────────────────────────────────────────────────────────────\n\nfunction Group({\n group,\n store,\n}: {\n group: GroupView;\n store: CoopStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [last, setLast] = useState<number | null>(null);\n const [leaving, setLeaving] = useState(false);\n const event = eventOf(configSection(client, \"CoopEvent\"), group);\n const rows = groupRows(group, event, client.auth.context?.userID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const reload = useCallback(() => store.load(client), [client]);\n\n const spin = async (optionID: string | null, count: number) => {\n setBusy(`${optionID ?? \"\"}:${count}`);\n const res = await client.coopEvent.spin(\n group.CoopChainID ?? \"\",\n group.GroupID ?? \"\",\n count,\n optionID ?? undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n setLast(Number(res.data.ProgressDelta ?? 0));\n await reload();\n };\n const claim = async (kind: \"object\" | \"grand\") => {\n setBusy(kind);\n const res =\n kind === \"object\"\n ? await client.coopEvent.claimObjectReward(group.GroupID ?? \"\")\n : await client.coopEvent.claimGrandPrize(group.GroupID ?? \"\");\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n grantedBy(res.data),\n kind === \"grand\" ? { sound: \"levelUp\" } : undefined,\n );\n await reload();\n };\n const leave = async () => {\n setLeaving(false);\n const res = await client.coopEvent.leaveGroup(group.GroupID ?? undefined);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await reload();\n };\n\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(event?.DisplayName ?? group.CoopEventID ?? \"\")}\n </div>\n {group.ExpiresAtUtc && !rows.finished ? (\n <Timer at={group.ExpiresAtUtc} now={now} label={t(\"endsIn\")} />\n ) : null}\n {rows.forming ? (\n <div style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"forming\")}\n </div>\n ) : null}\n </div>\n\n <SectionTitle>{t(\"team\")}</SectionTitle>\n {rows.members.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{ display: \"grid\", gap: 6, opacity: m.left ? 0.5 : 1 }}\n >\n <div\n style={{ display: \"flex\", justifyContent: \"space-between\", gap: 8 }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {m.me ? t(\"you\") : m.name}\n {m.bot ? ` · ${t(\"bot\")}` : \"\"}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(m.objectName)}\n </span>\n </div>\n <ProgressBar\n value={m.progress}\n max={m.max}\n tone={m.done ? \"gold\" : \"green\"}\n />\n </Card>\n ))}\n\n {rows.mine && !rows.mine.done && !rows.finished ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n {last !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n color: v.green,\n textAlign: \"center\",\n }}\n >\n +{last} {t(\"progress\")}\n </div>\n ) : null}\n {spinPrices(event).map((o) => {\n const affordable = canAfford(o.price, balances);\n const bulk = bulkSpins(event);\n return (\n <div key={o.id ?? \"free\"} style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === `${o.id ?? \"\"}:1`}\n onClick={() => void spin(o.id, 1)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"coop:spin\"\n >\n {t(\"spin\")}{\" \"}\n {o.price.length > 0 ? (\n <ResourceList lines={o.price} size={18} />\n ) : null}\n </Button>\n {bulk > 0 ? (\n <Button\n tone=\"blue\"\n disabled={\n !canAfford(\n o.price.map((l) => ({ ...l, amount: l.amount * bulk })),\n balances,\n )\n }\n busy={busy === `${o.id ?? \"\"}:${bulk}`}\n onClick={() => void spin(o.id, bulk)}\n >\n ×{bulk}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n\n {rows.objectRewardReady ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"object\"}\n onClick={() => void claim(\"object\")}\n >\n {t(\"claimObject\")}{\" \"}\n <ResourceList lines={rows.objectReward} size={18} />\n </Button>\n ) : null}\n {rows.grandPrizeReady ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claim(\"grand\")}\n >\n {t(\"claimGrand\")} <ResourceList lines={rows.grandPrize} size={20} />\n </Button>\n ) : null}\n {!rows.grandPrizeReady ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => (rows.finished ? void leave() : setLeaving(true))}\n >\n {rows.finished ? t(\"close\") : t(\"leave\")}\n </Button>\n ) : null}\n {leaving ? (\n <Popup title={t(\"leave\")} onClose={() => setLeaving(false)} width={360}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"leaveConfirm\")}\n </div>\n <Button tone=\"red\" onClick={() => void leave()}>\n {t(\"leave\")}\n </Button>\n </Popup>\n ) : null}\n </div>\n );\n}\n\n/** Keeps the badge (object reward and grand prize waiting) — only on a title with team events. */\nexport function watchCoop(ctx: ModuleContext, store: CoopStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n void store.load(client);\n });\n}\n"
44
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { ActiveCoopEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n Center,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport {\n bulkSpins,\n chainIDs,\n coopBadge,\n eventOf,\n groupRows,\n spinPrices,\n type CoopEventView,\n type GroupView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Team events (engine CoopEvent): the player joins a group (the server matches partners, bots fill\n// the gaps), each member spins the wheel to build their own object, and the group shares a grand\n// prize when every object is done. The group lives in ONE shared document on the server, so the\n// screen always re-reads it after an action instead of patching it locally.\n\n/** The player's group and the active events, shared by the screen and the badge (one read). */\nexport function createCoopStore(features: FeatureRegistry) {\n let group: GroupView | null | undefined = undefined;\n const listeners = new Set<() => void>();\n // One read at a time; a load asked for while one is in flight runs again right after it. Overlapping\n // reads (the screen, the badge, the re-read after a spin) used to be skipped by the transport as\n // duplicates, and a skipped read turned the player's group into \"no group\" — the join screen.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.coopEvent.getUserState();\n // A failed read keeps what is on screen; only the very first one ends the spinner.\n if (res.ok) group = (res.data.ActiveGroup as GroupView | null) ?? null;\n else if (group === undefined) group = null;\n const rows = groupRows(\n group,\n eventOf(configSection(client, \"CoopEvent\"), group),\n client.auth.context?.userID,\n );\n features.setBadge(\"coop-events\", group ? coopBadge(rows) : 0);\n for (const l of listeners) l();\n };\n return {\n get: () => group,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n };\n}\nexport type CoopStore = ReturnType<typeof createCoopStore>;\n\nexport function makeCoopEventsScreen(store: CoopStore) {\n return function CoopEventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const group = useSyncExternalStore(store.subscribe, store.get, store.get);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (group === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n return group ? (\n <Group group={group} store={store} />\n ) : (\n <Join store={store} />\n );\n };\n}\n\n// ── Not in a group: the running events to join ───────────────────────────────────────────────\n\nfunction Join({ store }: { store: CoopStore }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const stagger = useStagger();\n const [active, setActive] = useState<ActiveCoopEventInfo[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n const chains = chainIDs(configSection(client, \"CoopEvent\"));\n void Promise.all(\n chains.map((c) => client.coopEvent.getActiveEvent(c)),\n ).then((all) => setActive(all.flatMap((r) => (r.ok ? [r.data] : []))));\n }, [client]);\n\n const join = async (chainID: string) => {\n setBusy(chainID);\n const res = await client.coopEvent.joinOrCreateGroup(chainID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await store.load(client);\n };\n\n if (!active)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const running = active.filter((a) => a.EventDef);\n if (running.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noEvent\")} />;\n return (\n <Columns gap={12} min={360}>\n {running.map((a, i) => {\n const def = a.EventDef as CoopEventView;\n return (\n <Card\n key={a.CoopChainID}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"green\"),\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"friends\" size={48} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(def.DisplayName ?? a.CoopChainID)}\n </div>\n <Timer\n at={a.ComputedEndUtc as string | undefined}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n {def.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {catalog.localize(def.Description)}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList\n lines={groupRows(null, def, null).grandPrize}\n size={20}\n />\n </div>\n <Button\n tone=\"green\"\n size=\"lg\"\n attract\n busy={busy === a.CoopChainID}\n onClick={() => void join(a.CoopChainID)}\n data-tutorial-anchor={`coop:join:${a.CoopChainID}`}\n >\n {t(\"join\")} · {Number(def.PartnerCount ?? 0) + 1} {t(\"players\")}\n </Button>\n </Card>\n );\n })}\n </Columns>\n );\n}\n\n// ── In a group ───────────────────────────────────────────────────────────────────────────────\n\nfunction Group({\n group,\n store,\n}: {\n group: GroupView;\n store: CoopStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [last, setLast] = useState<number | null>(null);\n const [leaving, setLeaving] = useState(false);\n const event = eventOf(configSection(client, \"CoopEvent\"), group);\n const rows = groupRows(group, event, client.auth.context?.userID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const reload = useCallback(() => store.load(client), [client]);\n\n const spin = async (optionID: string | null, count: number) => {\n setBusy(`${optionID ?? \"\"}:${count}`);\n // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.coopEvent.spin(\n group.CoopChainID ?? \"\",\n group.GroupID ?? \"\",\n count,\n optionID ?? undefined,\n creditsConfirmationID,\n ),\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n setLast(Number(res.data.ProgressDelta ?? 0));\n await reload();\n };\n const claim = async (kind: \"object\" | \"grand\") => {\n setBusy(kind);\n const res =\n kind === \"object\"\n ? await client.coopEvent.claimObjectReward(group.GroupID ?? \"\")\n : await client.coopEvent.claimGrandPrize(group.GroupID ?? \"\");\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n grantedBy(res.data),\n kind === \"grand\" ? { sound: \"levelUp\" } : undefined,\n );\n await reload();\n };\n const leave = async () => {\n setLeaving(false);\n const res = await client.coopEvent.leaveGroup(group.GroupID ?? undefined);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await reload();\n };\n\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(event?.DisplayName ?? group.CoopEventID ?? \"\")}\n </div>\n {group.ExpiresAtUtc && !rows.finished ? (\n <Timer at={group.ExpiresAtUtc} now={now} label={t(\"endsIn\")} />\n ) : null}\n {rows.forming ? (\n <div style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"forming\")}\n </div>\n ) : null}\n </div>\n\n <SectionTitle>{t(\"team\")}</SectionTitle>\n {rows.members.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{ display: \"grid\", gap: 6, opacity: m.left ? 0.5 : 1 }}\n >\n <div\n style={{ display: \"flex\", justifyContent: \"space-between\", gap: 8 }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {m.me ? t(\"you\") : m.name}\n {m.bot ? ` · ${t(\"bot\")}` : \"\"}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(m.objectName)}\n </span>\n </div>\n <ProgressBar\n value={m.progress}\n max={m.max}\n tone={m.done ? \"gold\" : \"green\"}\n />\n </Card>\n ))}\n\n {rows.mine && !rows.mine.done && !rows.finished ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n {last !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n color: v.green,\n textAlign: \"center\",\n }}\n >\n +{last} {t(\"progress\")}\n </div>\n ) : null}\n {spinPrices(event).map((o) => {\n const affordable = canAfford(o.price, balances);\n // An Aspis spin is one spin per call (the player confirms its exact price): no bulk button.\n const bulk = o.price.some((l) => l.kind === \"credits\")\n ? 0\n : bulkSpins(event);\n return (\n <div key={o.id ?? \"free\"} style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === `${o.id ?? \"\"}:1`}\n onClick={() => void spin(o.id, 1)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"coop:spin\"\n >\n {t(\"spin\")}{\" \"}\n {o.price.length > 0 ? (\n <ResourceList lines={o.price} size={18} />\n ) : null}\n </Button>\n {bulk > 0 ? (\n <Button\n tone=\"blue\"\n disabled={\n !canAfford(\n o.price.map((l) => ({ ...l, amount: l.amount * bulk })),\n balances,\n )\n }\n busy={busy === `${o.id ?? \"\"}:${bulk}`}\n onClick={() => void spin(o.id, bulk)}\n >\n ×{bulk}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n\n {rows.objectRewardReady ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"object\"}\n onClick={() => void claim(\"object\")}\n >\n {t(\"claimObject\")}{\" \"}\n <ResourceList lines={rows.objectReward} size={18} />\n </Button>\n ) : null}\n {rows.grandPrizeReady ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claim(\"grand\")}\n >\n {t(\"claimGrand\")} <ResourceList lines={rows.grandPrize} size={20} />\n </Button>\n ) : null}\n {!rows.grandPrizeReady ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => (rows.finished ? void leave() : setLeaving(true))}\n >\n {rows.finished ? t(\"close\") : t(\"leave\")}\n </Button>\n ) : null}\n {leaving ? (\n <Popup title={t(\"leave\")} onClose={() => setLeaving(false)} width={360}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"leaveConfirm\")}\n </div>\n <Button tone=\"red\" onClick={() => void leave()}>\n {t(\"leave\")}\n </Button>\n </Popup>\n ) : null}\n </div>\n );\n}\n\n/** Keeps the badge (object reward and grand prize waiting) — only on a title with team events. */\nexport function watchCoop(ctx: ModuleContext, store: CoopStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n void store.load(client);\n });\n}\n"
45
45
  },
46
46
  {
47
47
  "path": "i18n.ts",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  {
55
55
  "path": "model.ts",
56
- "content": "// Pure helpers of the team events screen — covered by model.test.ts. The group lives on the server\n// (one shared document per group); these only decide what the screen draws.\n\nimport type { ResourceGrant, CoopEventDefinitions } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface CoopObjectView {\n Index?: number | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n MaxProgress?: number | null;\n CompletionReward?: ResourceGrant | null;\n}\n\nexport interface CoopEventView {\n CoopEventID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n PartnerCount?: number | null;\n BuildObjects?: {\n Objects?: CoopObjectView[] | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n MaxSpinsPerCall?: number | null;\n } | null;\n GrandPrize?: ResourceGrant | null;\n}\n\n/** The group's shared document (as the server returns it). */\nexport interface GroupView {\n GroupID?: string | null;\n CoopChainID?: string | null;\n CoopEventID?: string | null;\n Status?: string | null;\n ExpiresAtUtc?: string | null;\n Members?: Array<{\n UserID?: string | null;\n PublicData?: { Username?: string | null } | null;\n IsBot?: boolean | null;\n MemberStatus?: string | null;\n GrandPrizeClaimed?: boolean | null;\n BuildObjectsProgress?: {\n ObjectIndex?: number | null;\n ObjectCompletionRewardClaimed?: boolean | null;\n } | null;\n } | null> | null;\n BuildObjectsState?: {\n Objects?: Array<{\n Index?: number | null;\n OwnerUserID?: string | null;\n CurrentProgress?: number | null;\n MaxProgress?: number | null;\n IsCompleted?: boolean | null;\n } | null> | null;\n } | null;\n}\n\n// ── Chains ───────────────────────────────────────────────────────────────────────────────────\n\n/** Chain ids of the title (each runs its own schedule of team events). */\nexport function chainIDs(\n section: CoopEventDefinitions | null | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(([, c]) => c)\n .map(([id, c]) => c!.CoopChainID || id)\n .sort();\n}\n\n/** The event definition the group plays (by its CoopEventID across the chains). */\nexport function eventOf(\n section: CoopEventDefinitions | null | undefined,\n group: GroupView | null | undefined,\n): CoopEventView | null {\n for (const c of Object.values(section?.Chains ?? {}))\n for (const e of c?.Events ?? [])\n if (e?.CoopEventID && e.CoopEventID === group?.CoopEventID) return e;\n return null;\n}\n\n// ── The group ────────────────────────────────────────────────────────────────────────────────\n\nexport interface MemberRow {\n userID: string;\n name: string;\n me: boolean;\n bot: boolean;\n left: boolean;\n objectName: string;\n progress: number;\n max: number;\n done: boolean;\n}\n\nexport interface GroupRows {\n members: MemberRow[];\n /** The player's own object (null — not in this group). */\n mine: MemberRow | null;\n /** The player's object is complete and its reward is not taken yet. */\n objectRewardReady: boolean;\n objectReward: ResourceLine[];\n /** The whole group is done and the player has not taken the grand prize. */\n grandPrizeReady: boolean;\n grandPrize: ResourceLine[];\n /** Waiting for partners — the server matches the group. */\n forming: boolean;\n /** The event is over for the group (done, failed or expired). */\n finished: boolean;\n}\n\n/** The group as the screen draws it: every member with their object's progress. */\nexport function groupRows(\n group: GroupView | null | undefined,\n event: CoopEventView | null | undefined,\n myUserID: string | null | undefined,\n): GroupRows {\n const objects = group?.BuildObjectsState?.Objects ?? [];\n const defs = event?.BuildObjects?.Objects ?? [];\n const members: MemberRow[] = (group?.Members ?? [])\n .filter((m): m is NonNullable<typeof m> => !!m?.UserID)\n .map((m) => {\n const index = Number(m.BuildObjectsProgress?.ObjectIndex ?? -1);\n const obj =\n objects.find((o) => o?.OwnerUserID === m.UserID) ??\n objects.find((o) => Number(o?.Index) === index);\n const def = defs.find((d) => Number(d?.Index) === Number(obj?.Index));\n const max = Math.max(\n 1,\n Number(obj?.MaxProgress ?? def?.MaxProgress ?? 1),\n );\n return {\n userID: m.UserID!,\n name: m.PublicData?.Username || m.UserID!,\n me: m.UserID === myUserID,\n bot: m.IsBot === true,\n left: !!m.MemberStatus && m.MemberStatus !== \"Active\",\n objectName: def?.DisplayName || `#${Number(obj?.Index ?? index) + 1}`,\n progress: Math.min(max, Number(obj?.CurrentProgress ?? 0)),\n max,\n done: obj?.IsCompleted === true,\n };\n });\n const mine = members.find((m) => m.me) ?? null;\n const meRaw = (group?.Members ?? []).find((m) => m?.UserID === myUserID);\n const myObj = objects.find((o) => o?.OwnerUserID === myUserID);\n const myDef = defs.find((d) => Number(d?.Index) === Number(myObj?.Index));\n const status = group?.Status ?? \"\";\n return {\n members,\n mine,\n objectRewardReady:\n !!mine?.done &&\n meRaw?.BuildObjectsProgress?.ObjectCompletionRewardClaimed !== true &&\n grantLines(myDef?.CompletionReward).length > 0,\n objectReward: grantLines(myDef?.CompletionReward),\n grandPrizeReady:\n status === \"Completed\" &&\n !!meRaw &&\n meRaw.GrandPrizeClaimed !== true &&\n grantLines(event?.GrandPrize).length > 0,\n grandPrize: grantLines(event?.GrandPrize),\n forming: status === \"Forming\",\n finished: [\"Completed\", \"Failed\", \"Expired\"].includes(status),\n };\n}\n\n/** Ways to pay for a spin (currencies/items); empty — spins are free. */\nexport function spinPrices(\n event: CoopEventView | null | undefined,\n): Array<{ id: string | null; price: ResourceLine[] }> {\n const options = Object.entries(event?.BuildObjects?.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }));\n return options.length > 0 ? options : [{ id: null, price: [] }];\n}\n\n/** The bulk spin offered next to a single one: five, when the ceiling allows (platform default 25). */\nexport function bulkSpins(event: CoopEventView | null | undefined): number {\n const max = Number(event?.BuildObjects?.MaxSpinsPerCall ?? 25);\n return max >= 5 ? 5 : 0;\n}\n\n/** Badge: the object reward and the grand prize waiting. */\nexport function coopBadge(rows: GroupRows): number {\n return (rows.objectRewardReady ? 1 : 0) + (rows.grandPrizeReady ? 1 : 0);\n}\n\n/** Whether the title has team events — until it does, the lobby hides the tab. */\nexport function hasTitleData(\n section: CoopEventDefinitions | undefined,\n): boolean {\n return chainIDs(section).length > 0;\n}\n"
56
+ "content": "// Pure helpers of the team events screen — covered by model.test.ts. The group lives on the server\n// (one shared document per group); these only decide what the screen draws.\n\nimport type { ResourceGrant, CoopEventDefinitions } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface CoopObjectView {\n Index?: number | null;\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n MaxProgress?: number | null;\n CompletionReward?: ResourceGrant | null;\n}\n\nexport interface CoopEventView {\n CoopEventID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n PartnerCount?: number | null;\n BuildObjects?: {\n Objects?: CoopObjectView[] | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n MaxSpinsPerCall?: number | null;\n } | null;\n GrandPrize?: ResourceGrant | null;\n}\n\n/** The group's shared document (as the server returns it). */\nexport interface GroupView {\n GroupID?: string | null;\n CoopChainID?: string | null;\n CoopEventID?: string | null;\n Status?: string | null;\n ExpiresAtUtc?: string | null;\n Members?: Array<{\n UserID?: string | null;\n PublicData?: { Username?: string | null } | null;\n IsBot?: boolean | null;\n MemberStatus?: string | null;\n GrandPrizeClaimed?: boolean | null;\n BuildObjectsProgress?: {\n ObjectIndex?: number | null;\n ObjectCompletionRewardClaimed?: boolean | null;\n } | null;\n } | null> | null;\n BuildObjectsState?: {\n Objects?: Array<{\n Index?: number | null;\n OwnerUserID?: string | null;\n CurrentProgress?: number | null;\n MaxProgress?: number | null;\n IsCompleted?: boolean | null;\n } | null> | null;\n } | null;\n}\n\n// ── Chains ───────────────────────────────────────────────────────────────────────────────────\n\n/** Chain ids of the title (each runs its own schedule of team events). */\nexport function chainIDs(\n section: CoopEventDefinitions | null | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(([, c]) => c)\n .map(([id, c]) => c!.CoopChainID || id)\n .sort();\n}\n\n/** The event definition the group plays (by its CoopEventID across the chains). */\nexport function eventOf(\n section: CoopEventDefinitions | null | undefined,\n group: GroupView | null | undefined,\n): CoopEventView | null {\n for (const c of Object.values(section?.Chains ?? {}))\n for (const e of c?.Events ?? [])\n if (e?.CoopEventID && e.CoopEventID === group?.CoopEventID) return e;\n return null;\n}\n\n// ── The group ────────────────────────────────────────────────────────────────────────────────\n\nexport interface MemberRow {\n userID: string;\n name: string;\n me: boolean;\n bot: boolean;\n left: boolean;\n objectName: string;\n progress: number;\n max: number;\n done: boolean;\n}\n\nexport interface GroupRows {\n members: MemberRow[];\n /** The player's own object (null — not in this group). */\n mine: MemberRow | null;\n /** The player's object is complete and its reward is not taken yet. */\n objectRewardReady: boolean;\n objectReward: ResourceLine[];\n /** The whole group is done and the player has not taken the grand prize. */\n grandPrizeReady: boolean;\n grandPrize: ResourceLine[];\n /** Waiting for partners — the server matches the group. */\n forming: boolean;\n /** The event is over for the group (done, failed or expired). */\n finished: boolean;\n}\n\n/** The group as the screen draws it: every member with their object's progress. */\nexport function groupRows(\n group: GroupView | null | undefined,\n event: CoopEventView | null | undefined,\n myUserID: string | null | undefined,\n): GroupRows {\n const objects = group?.BuildObjectsState?.Objects ?? [];\n const defs = event?.BuildObjects?.Objects ?? [];\n const members: MemberRow[] = (group?.Members ?? [])\n .filter((m): m is NonNullable<typeof m> => !!m?.UserID)\n .map((m) => {\n const index = Number(m.BuildObjectsProgress?.ObjectIndex ?? -1);\n const obj =\n objects.find((o) => o?.OwnerUserID === m.UserID) ??\n objects.find((o) => Number(o?.Index) === index);\n const def = defs.find((d) => Number(d?.Index) === Number(obj?.Index));\n const max = Math.max(\n 1,\n Number(obj?.MaxProgress ?? def?.MaxProgress ?? 1),\n );\n return {\n userID: m.UserID!,\n name: m.PublicData?.Username || m.UserID!,\n me: m.UserID === myUserID,\n bot: m.IsBot === true,\n left: !!m.MemberStatus && m.MemberStatus !== \"Active\",\n objectName: def?.DisplayName || `#${Number(obj?.Index ?? index) + 1}`,\n progress: Math.min(max, Number(obj?.CurrentProgress ?? 0)),\n max,\n done: obj?.IsCompleted === true,\n };\n });\n const mine = members.find((m) => m.me) ?? null;\n const meRaw = (group?.Members ?? []).find((m) => m?.UserID === myUserID);\n const myObj = objects.find((o) => o?.OwnerUserID === myUserID);\n const myDef = defs.find((d) => Number(d?.Index) === Number(myObj?.Index));\n const status = group?.Status ?? \"\";\n return {\n members,\n mine,\n objectRewardReady:\n !!mine?.done &&\n meRaw?.BuildObjectsProgress?.ObjectCompletionRewardClaimed !== true &&\n grantLines(myDef?.CompletionReward).length > 0,\n objectReward: grantLines(myDef?.CompletionReward),\n grandPrizeReady:\n status === \"Completed\" &&\n !!meRaw &&\n meRaw.GrandPrizeClaimed !== true &&\n grantLines(event?.GrandPrize).length > 0,\n grandPrize: grantLines(event?.GrandPrize),\n forming: status === \"Forming\",\n finished: [\"Completed\", \"Failed\", \"Expired\"].includes(status),\n };\n}\n\n/** Ways to pay for a spin (currencies/items/Aspis); empty — spins are free. */\nexport function spinPrices(\n event: CoopEventView | null | undefined,\n): Array<{ id: string | null; price: ResourceLine[] }> {\n const options = Object.entries(event?.BuildObjects?.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }));\n return options.length > 0 ? options : [{ id: null, price: [] }];\n}\n\n/** The bulk spin offered next to a single one: five, when the ceiling allows (platform default 25). */\nexport function bulkSpins(event: CoopEventView | null | undefined): number {\n const max = Number(event?.BuildObjects?.MaxSpinsPerCall ?? 25);\n return max >= 5 ? 5 : 0;\n}\n\n/** Badge: the object reward and the grand prize waiting. */\nexport function coopBadge(rows: GroupRows): number {\n return (rows.objectRewardReady ? 1 : 0) + (rows.grandPrizeReady ? 1 : 0);\n}\n\n/** Whether the title has team events — until it does, the lobby hides the tab. */\nexport function hasTitleData(\n section: CoopEventDefinitions | undefined,\n): boolean {\n return chainIDs(section).length > 0;\n}\n"
57
57
  },
58
58
  {
59
59
  "path": "module.ts",
@@ -35,16 +35,16 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.25.0",
39
- "@idosgames/module-sdk": "0.13.2",
40
- "@idosgames/react": "0.6.3",
38
+ "@idosgames/core": "0.26.0",
39
+ "@idosgames/module-sdk": "0.13.3",
40
+ "@idosgames/react": "0.7.0",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "f4d962e257dc5647a013afc7899b43134964f6505431f9ed1147611c67c4af90",
43
+ "contentHash": "f59536188295910902674239c662a7ed6eca971ba4820ade716a25e56f21ccf3",
44
44
  "files": [
45
45
  {
46
46
  "path": "CraftScreen.tsx",
47
- "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n addToContract,\n autoFill,\n eligibleInputs,\n ownedCounts,\n possibleOutputs,\n recipes,\n type Recipe,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Craft (Unity Alikhan/Craft: recipe cards → contract panel → info popup). The screen is the recipe\n// ladder — \"3 × Common → 1 × Rare\"; a recipe opens its CONTRACT as a popup: the slots to fill, the\n// player's fitting items to tap in (or fill automatically), the ways to pay, what can come out, and\n// \"Craft\". The server rolls the result; it pops out with the reward animation.\n\nexport function makeCraftScreen(features: FeatureRegistry) {\n return function CraftScreen(_: FeatureScreenProps): ReactNode {\n return <Craft features={features} />;\n };\n}\n\nfunction Craft({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const layout = useGameLayout();\n const [, loaded] = useState(0);\n const section = configSection(client, \"Craft\");\n useEffect(() => {\n if (!section)\n void client.craft.getDefinitions().then(() => loaded((n) => n + 1));\n void client.cache.ensureState([\"InventoryV2\"], { maxAgeMs: 60_000 });\n }, [client, section]);\n const list = recipes(section);\n const types = [...new Set(list.map((r) => r.type))];\n const [tab, setTab] = useState<Recipe[\"type\"]>(\"rarity\");\n const [open, setOpen] = useState<Recipe | null>(null);\n const state = useUserState();\n const owned = ownedCounts(state?.InventoryV2 as never);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string }\n >;\n\n if (list.length === 0)\n return <EmptyState glyph=\"hammer\" text={t(\"noRecipes\")} />;\n const current = types.includes(tab) ? tab : types[0]!;\n const shown = list.filter((r) => r.type === current);\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={types.map((x) => ({\n id: x,\n label: x === \"rarity\" ? t(\"byRarity\") : t(\"byCollection\"),\n }))}\n value={current}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns:\n layout === \"phone\"\n ? \"1fr\"\n : \"repeat(auto-fill, minmax(320px, 1fr))\",\n gap: 12,\n }}\n >\n {shown.map((r, i) => {\n const fit = eligibleInputs(r, items, owned).reduce(\n (s, e) => s + e.available,\n 0,\n );\n const ready = fit >= r.count;\n return (\n <Card\n key={r.id}\n highlight={ready}\n onClick={() => setOpen(r)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto 1fr\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <RarityChip rarity={r.input} label={`${r.count} × ${r.input}`} />\n <div className={ready ? \"idos-bounce\" : undefined}>\n <Icon glyph=\"hammer\" size={34} />\n </div>\n <RarityChip rarity={r.output} label={`1 × ${r.output}`} />\n <div\n style={{\n gridColumn: \"1 / -1\",\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"need\")}: {Math.min(fit, r.count)} / {r.count}\n </span>\n <ResourceList lines={r.options[0]?.price ?? []} size={16} />\n </div>\n </Card>\n );\n })}\n </div>\n {open ? (\n <ContractPopup\n recipe={open}\n owned={owned}\n items={items}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction RarityChip({\n rarity,\n label,\n}: {\n rarity: string;\n label: string;\n}): ReactNode {\n const catalog = useCatalog();\n const color = catalog.rarityColor(rarity);\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div\n style={{\n width: 58,\n height: 58,\n borderRadius: 14,\n border: `3px solid ${color}`,\n background: `radial-gradient(circle, ${color}55, ${v.panelDeep} 70%)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <Icon glyph=\"box\" size={34} />\n </div>\n <span style={{ ...outlined, fontSize: 12, color }}>{label}</span>\n </div>\n );\n}\n\nfunction ContractPopup({\n recipe,\n owned,\n items,\n onShop,\n onClose,\n}: {\n recipe: Recipe;\n owned: Record<string, number>;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n onShop?: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const anvil = useRef<HTMLDivElement>(null);\n const eligible = eligibleInputs(recipe, items, owned);\n const [contract, setContract] = useState<string[]>([]);\n const [optionID, setOptionID] = useState(recipe.options[0]?.id ?? \"\");\n const [busy, setBusy] = useState(false);\n const option = recipe.options.find((o) => o.id === optionID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(option?.price ?? [], balances);\n\n const craft = async () => {\n setBusy(true);\n const started = Date.now();\n const res = await client.craft.craft(recipe.id, contract, 1, option?.id);\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 600 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(anvil.current);\n setContract([]);\n celebrate(grantedBy(res.data), { title: t(\"crafted\"), sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={`${t(\"contract\")}: ${recipe.count} × ${recipe.input} → ${recipe.output}`}\n onClose={onClose}\n width={520}\n >\n <div\n ref={anvil}\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n animation: busy ? \"idos-shake .35s ease-in-out infinite\" : undefined,\n }}\n >\n {Array.from({ length: recipe.count }, (_, i) => {\n const itemID = contract[i];\n return itemID ? (\n <ItemTile\n key={i}\n itemID={itemID}\n size={56}\n onClick={() => setContract((c) => c.filter((_, j) => j !== i))}\n />\n ) : (\n <div\n key={i}\n style={{\n width: 56,\n height: 56,\n borderRadius: 14,\n border: `3px dashed ${v.panelEdge}`,\n background: v.wellSoft,\n }}\n />\n );\n })}\n </div>\n <SectionTitle\n right={\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setContract(autoFill(recipe, eligible))}\n disabled={eligible.length === 0}\n >\n {t(\"auto\")}\n </Button>\n {contract.length > 0 ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setContract([])}>\n {t(\"clear\")}\n </Button>\n ) : null}\n </span>\n }\n >\n <span style={{ fontSize: 15 }}>{t(\"yourItems\")}</span>\n </SectionTitle>\n {eligible.length === 0 ? (\n <EmptyState\n glyph=\"box\"\n text={t(\"noItems\")}\n action={\n onShop ? <Button onClick={onShop}>{t(\"toShop\")}</Button> : undefined\n }\n />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n {eligible.map((e) => {\n const used = contract.filter((x) => x === e.itemID).length;\n return (\n <ItemTile\n key={e.itemID}\n itemID={e.itemID}\n count={e.available - used}\n size={64}\n dim={used >= e.available}\n onClick={() =>\n setContract((c) =>\n addToContract(c, e.itemID, recipe, e.available),\n )\n }\n />\n );\n })}\n </div>\n )}\n <Outputs recipe={recipe} items={items} />\n {recipe.options.length > 1 ? (\n <Tabs\n tabs={recipe.options.map((o) => ({\n id: o.id,\n label: o.name ?? o.id,\n }))}\n value={optionID}\n onChange={setOptionID}\n />\n ) : null}\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={contract.length === recipe.count && affordable}\n disabled={contract.length < recipe.count || !affordable}\n busy={busy}\n onClick={() => void craft()}\n data-tutorial-anchor=\"craft:go\"\n >\n <Icon glyph=\"hammer\" size={22} /> {t(\"craft\")}{\" \"}\n {option && option.price.length > 0 ? (\n <ResourceList\n lines={option.price}\n size={18}\n dimIf={() => !affordable}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n </Popup>\n );\n}\n\nfunction Outputs({\n recipe,\n items,\n}: {\n recipe: Recipe;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n}): ReactNode {\n const outputs = possibleOutputs(recipe, items);\n if (outputs.length === 0) return null;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"canYield\")}\n </div>\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 6,\n justifyContent: \"center\",\n }}\n >\n {outputs.map((id) => (\n <ItemTile key={id} itemID={id} size={48} />\n ))}\n </div>\n </div>\n );\n}\n"
47
+ "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ItemDefinitionView,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport {\n addToContract,\n autoFill,\n eligibleInputs,\n ownedCounts,\n possibleOutputs,\n recipes,\n type Recipe,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Craft (Unity Alikhan/Craft: recipe cards → contract panel → info popup). The screen is the recipe\n// ladder — \"3 × Common → 1 × Rare\"; a recipe opens its CONTRACT as a popup: the slots to fill, the\n// player's fitting items to tap in (or fill automatically), the ways to pay, what can come out, and\n// \"Craft\". The server rolls the result; it pops out with the reward animation.\n\nexport function makeCraftScreen(features: FeatureRegistry) {\n return function CraftScreen(_: FeatureScreenProps): ReactNode {\n return <Craft features={features} />;\n };\n}\n\nfunction Craft({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const layout = useGameLayout();\n const [, loaded] = useState(0);\n const section = configSection(client, \"Craft\");\n useEffect(() => {\n if (!section)\n void client.craft.getDefinitions().then(() => loaded((n) => n + 1));\n void client.cache.ensureState([\"InventoryV2\"], { maxAgeMs: 60_000 });\n }, [client, section]);\n const list = recipes(section);\n const types = [...new Set(list.map((r) => r.type))];\n const [tab, setTab] = useState<Recipe[\"type\"]>(\"rarity\");\n const [open, setOpen] = useState<Recipe | null>(null);\n const state = useUserState();\n const owned = ownedCounts(state?.InventoryV2 as never);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string }\n >;\n\n if (list.length === 0)\n return <EmptyState glyph=\"hammer\" text={t(\"noRecipes\")} />;\n const current = types.includes(tab) ? tab : types[0]!;\n const shown = list.filter((r) => r.type === current);\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={types.map((x) => ({\n id: x,\n label: x === \"rarity\" ? t(\"byRarity\") : t(\"byCollection\"),\n }))}\n value={current}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns:\n layout === \"phone\"\n ? \"1fr\"\n : \"repeat(auto-fill, minmax(320px, 1fr))\",\n gap: 12,\n }}\n >\n {shown.map((r, i) => {\n const fit = eligibleInputs(r, items, owned).reduce(\n (s, e) => s + e.available,\n 0,\n );\n const ready = fit >= r.count;\n return (\n <Card\n key={r.id}\n highlight={ready}\n onClick={() => setOpen(r)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto 1fr\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <RarityChip rarity={r.input} label={`${r.count} × ${r.input}`} />\n <div className={ready ? \"idos-bounce\" : undefined}>\n <Icon glyph=\"hammer\" size={34} />\n </div>\n <RarityChip rarity={r.output} label={`1 × ${r.output}`} />\n <div\n style={{\n gridColumn: \"1 / -1\",\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"need\")}: {Math.min(fit, r.count)} / {r.count}\n </span>\n <ResourceList lines={r.options[0]?.price ?? []} size={16} />\n </div>\n </Card>\n );\n })}\n </div>\n {open ? (\n <ContractPopup\n recipe={open}\n owned={owned}\n items={items}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction RarityChip({\n rarity,\n label,\n}: {\n rarity: string;\n label: string;\n}): ReactNode {\n const catalog = useCatalog();\n const color = catalog.rarityColor(rarity);\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div\n style={{\n width: 58,\n height: 58,\n borderRadius: 14,\n border: `3px solid ${color}`,\n background: `radial-gradient(circle, ${color}55, ${v.panelDeep} 70%)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <Icon glyph=\"box\" size={34} />\n </div>\n <span style={{ ...outlined, fontSize: 12, color }}>{label}</span>\n </div>\n );\n}\n\nfunction ContractPopup({\n recipe,\n owned,\n items,\n onShop,\n onClose,\n}: {\n recipe: Recipe;\n owned: Record<string, number>;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n onShop?: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const anvil = useRef<HTMLDivElement>(null);\n const eligible = eligibleInputs(recipe, items, owned);\n const [contract, setContract] = useState<string[]>([]);\n const [optionID, setOptionID] = useState(recipe.options[0]?.id ?? \"\");\n const [busy, setBusy] = useState(false);\n const option = recipe.options.find((o) => o.id === optionID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(option?.price ?? [], balances);\n\n const craft = async () => {\n setBusy(true);\n const started = Date.now();\n // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.craft.craft(\n recipe.id,\n contract,\n 1,\n option?.id,\n undefined,\n creditsConfirmationID,\n ),\n );\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 600 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(anvil.current);\n setContract([]);\n celebrate(grantedBy(res.data), { title: t(\"crafted\"), sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={`${t(\"contract\")}: ${recipe.count} × ${recipe.input} → ${recipe.output}`}\n onClose={onClose}\n width={520}\n >\n <div\n ref={anvil}\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n animation: busy ? \"idos-shake .35s ease-in-out infinite\" : undefined,\n }}\n >\n {Array.from({ length: recipe.count }, (_, i) => {\n const itemID = contract[i];\n return itemID ? (\n <ItemTile\n key={i}\n itemID={itemID}\n size={56}\n onClick={() => setContract((c) => c.filter((_, j) => j !== i))}\n />\n ) : (\n <div\n key={i}\n style={{\n width: 56,\n height: 56,\n borderRadius: 14,\n border: `3px dashed ${v.panelEdge}`,\n background: v.wellSoft,\n }}\n />\n );\n })}\n </div>\n <SectionTitle\n right={\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setContract(autoFill(recipe, eligible))}\n disabled={eligible.length === 0}\n >\n {t(\"auto\")}\n </Button>\n {contract.length > 0 ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setContract([])}>\n {t(\"clear\")}\n </Button>\n ) : null}\n </span>\n }\n >\n <span style={{ fontSize: 15 }}>{t(\"yourItems\")}</span>\n </SectionTitle>\n {eligible.length === 0 ? (\n <EmptyState\n glyph=\"box\"\n text={t(\"noItems\")}\n action={\n onShop ? <Button onClick={onShop}>{t(\"toShop\")}</Button> : undefined\n }\n />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n {eligible.map((e) => {\n const used = contract.filter((x) => x === e.itemID).length;\n return (\n <ItemTile\n key={e.itemID}\n itemID={e.itemID}\n count={e.available - used}\n size={64}\n dim={used >= e.available}\n onClick={() =>\n setContract((c) =>\n addToContract(c, e.itemID, recipe, e.available),\n )\n }\n />\n );\n })}\n </div>\n )}\n <Outputs recipe={recipe} items={items} />\n {recipe.options.length > 1 ? (\n <Tabs\n tabs={recipe.options.map((o) => ({\n id: o.id,\n label: o.name ?? o.id,\n }))}\n value={optionID}\n onChange={setOptionID}\n />\n ) : null}\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={contract.length === recipe.count && affordable}\n disabled={contract.length < recipe.count || !affordable}\n busy={busy}\n onClick={() => void craft()}\n data-tutorial-anchor=\"craft:go\"\n >\n <Icon glyph=\"hammer\" size={22} /> {t(\"craft\")}{\" \"}\n {option && option.price.length > 0 ? (\n <ResourceList\n lines={option.price}\n size={18}\n dimIf={() => !affordable}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n </Popup>\n );\n}\n\nfunction Outputs({\n recipe,\n items,\n}: {\n recipe: Recipe;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n}): ReactNode {\n const outputs = possibleOutputs(recipe, items);\n if (outputs.length === 0) return null;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"canYield\")}\n </div>\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 6,\n justifyContent: \"center\",\n }}\n >\n {outputs.map((id) => (\n <ItemTile key={id} itemID={id} size={48} />\n ))}\n </div>\n </div>\n );\n}\n"
48
48
  },
49
49
  {
50
50
  "path": "i18n.ts",
@@ -34,16 +34,16 @@
34
34
  "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.25.0",
38
- "@idosgames/module-sdk": "0.13.2",
39
- "@idosgames/react": "0.6.3",
37
+ "@idosgames/core": "0.26.0",
38
+ "@idosgames/module-sdk": "0.13.3",
39
+ "@idosgames/react": "0.7.0",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "20e587ae8a91d2d1fff38e7b2c1d66a320b2a995d797629b890c6ca9b8e9bb5e",
42
+ "contentHash": "c4c7f012dbd1a629ebfd9276d872fad99f9b7254bf87279c6b2fcfa5fa7d0e53",
43
43
  "files": [
44
44
  {
45
45
  "path": "DealsScreen.tsx",
46
- "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type {\n ActiveDealSlotInfo,\n DealNodeDefinition,\n IDosGamesClient,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n Center,\n EmptyState,\n errorText,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n dealMilestones,\n dealShape,\n dealsBadge,\n milestonesReachedBy,\n needsStore,\n nodeKind,\n nodePrice,\n nodeReward,\n nodeStatus,\n offerRewards,\n orderedNodes,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Special offers (Unity Alikhan/DealOffer): a deal pops up by itself when a new one starts (the\n// impression is recorded), and the \"Offers\" screen lists every active one. A deal opens as a popup\n// shaped by its graph — one big offer, a chain of steps (each unlocks the next), or cards to choose\n// one from — with its milestone bar under it.\n\n/** Active deals, shared by the screen and the auto-popup (one request for both). */\nexport function createDealsStore(features: FeatureRegistry) {\n let slots: ActiveDealSlotInfo[] | null = null;\n const listeners = new Set<() => void>();\n const set = (next: ActiveDealSlotInfo[]) => {\n slots = [...next].sort(\n (a, b) => Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0),\n );\n features.setBadge(\"deals\", dealsBadge(slots));\n for (const l of listeners) l();\n };\n // One read at a time; a load asked for while one is in flight runs again right after it. The\n // screen, the auto-popup and the reload after a purchase used to overlap — the transport skipped\n // the second as a duplicate, which emptied the list and lost the fresh read after the action.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.dealOffer.getActiveDeals();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) set(res.data.Slots ?? []);\n else if (slots === null) set([]);\n };\n return {\n get: () => slots,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n };\n}\nexport type DealsStore = ReturnType<typeof createDealsStore>;\n\nexport function makeDealsScreen(store: DealsStore) {\n return function DealsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const [open, setOpen] = useState<string | null>(null);\n const stagger = useStagger();\n const catalog = useCatalog();\n const now = useNow();\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!slots)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (slots.length === 0)\n return <EmptyState glyph=\"offer\" text={t(\"noDeals\")} />;\n const current = slots.find((s) => s.SlotID === open);\n return (\n <Columns gap={12}>\n {slots.map((s, i) => (\n <Card\n key={s.SlotID!}\n onClick={() => setOpen(s.SlotID!)}\n style={{\n display: \"grid\",\n gap: 8,\n ...toneSurface(\"blue\"),\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={s.OfferDef?.Identity?.AssetPaths?.icon ?? \"offer\"}\n size={44}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(\n s.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n </div>\n <Timer\n at={s.ComputedExpiresAtUtc}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n <ResourceList lines={offerRewards(s)} size={22} />\n </Card>\n ))}\n {current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </Columns>\n );\n };\n}\n\n/** The deal that starts pops up by itself, once per session per slot; the impression is recorded. */\nexport function makeDealsWatcher(store: DealsStore) {\n return function DealsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const shown = useRef(new Set<string>());\n const [popup, setPopup] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => {\n if (popup || !slots) return;\n const next = slots.find(\n (s) => s.IsNewActivation && s.SlotID && !shown.current.has(s.SlotID),\n );\n if (!next?.SlotID) return;\n shown.current.add(next.SlotID);\n void client.dealOffer.recordShow(next.SlotID);\n setPopup(next.SlotID);\n }, [slots, popup, client]);\n const current = slots?.find((s) => s.SlotID === popup);\n return current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setPopup(null)}\n queued\n />\n ) : null;\n };\n}\n\n/** `queued` — popped up by itself (the watcher): waits for other auto-popups. */\nfunction DealPopup({\n slot,\n store,\n onClose,\n queued,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n onClose: () => void;\n queued?: boolean;\n}): ReactNode {\n const catalog = useCatalog();\n const now = useNow();\n const shape = dealShape(slot);\n const nodes = orderedNodes(slot).filter(\n (n) =>\n !(n.HideWhenCompleted && nodeStatus(slot, n) === \"Completed\") &&\n nodeStatus(slot, n) !== \"Hidden\",\n );\n return (\n <Popup\n title={catalog.localize(\n slot.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n onClose={onClose}\n width={shape === \"choice\" ? 600 : 460}\n variant=\"center\"\n queued={queued}\n >\n {slot.OfferDef?.Identity?.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 14,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(slot.OfferDef.Identity.Description)}\n </div>\n ) : null}\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <Timer at={slot.ComputedExpiresAtUtc} now={now} label={t(\"endsIn\")} />\n </div>\n {shape === \"choice\" ? (\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"choose\")}\n </div>\n ) : null}\n <div\n style={\n shape === \"choice\"\n ? {\n display: \"grid\",\n gridTemplateColumns: `repeat(${Math.min(3, nodes.length)}, minmax(0, 1fr))`,\n gap: 10,\n }\n : { display: \"grid\", gap: 10 }\n }\n >\n {nodes.map((node, i) => (\n <NodeCard\n key={node.NodeID ?? i}\n slot={slot}\n node={node}\n index={i}\n shape={shape}\n store={store}\n />\n ))}\n </div>\n <Milestones slot={slot} store={store} />\n </Popup>\n );\n}\n\nfunction NodeCard({\n slot,\n node,\n index,\n shape,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n node: DealNodeDefinition;\n index: number;\n shape: string;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const stagger = useStagger();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const status = nodeStatus(slot, node);\n const kind = nodeKind(node);\n const price = nodePrice(node);\n const reward = nodeReward(node);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(price, balances);\n const store_ = needsStore(node);\n const done = status === \"Completed\";\n const available = status === \"Available\" || status === \"InProgress\";\n\n const execute = async () => {\n setBusy(true);\n const res = await client.dealOffer.executeNode(slot.SlotID!, node.NodeID!);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"purchase\" ? \"purchase\" : \"claim\");\n kit.fx.burst(ref.current);\n const granted = grantedBy(res.data);\n const lines = granted.length > 0 ? granted : reward;\n if (res.data.OfferExhausted) {\n // The finished offer leaves the active list — its bar's reward is taken now or never.\n for (const id of milestonesReachedBy(slot, node)) {\n const bonus = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (bonus.ok) lines.push(...grantedBy(bonus.data));\n }\n close();\n }\n celebrate(lines);\n await store.load(client);\n };\n\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 12,\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n opacity: status === \"Locked\" ? 0.55 : 1,\n outline: available ? `3px solid ${v.gold}` : undefined,\n ...stagger(index),\n }}\n >\n {shape === \"chain\" ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"step\")} {index + 1}\n </div>\n ) : null}\n {node.Identity?.DisplayName ? (\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(node.Identity.DisplayName)}\n </div>\n ) : null}\n <ResourceList\n lines={reward}\n size={shape === \"single\" ? 36 : 26}\n gap={12}\n />\n {done ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={22} /> {t(\"done\")}\n </span>\n ) : status === \"Locked\" ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <Icon glyph=\"lock\" size={20} /> {t(\"locked\")}\n </span>\n ) : kind === \"video\" ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"adsUnavailable\")}\n </Button>\n ) : kind === \"info\" ? (\n <Button\n tone=\"blue\"\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n >\n {t(\"gotIt\")}\n </Button>\n ) : store_ ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"storeOnly\")}\n </Button>\n ) : (\n <Button\n tone={kind === \"free\" ? \"green\" : \"gold\"}\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`deal:${node.NodeID}`}\n >\n {kind === \"free\" ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={18} dimIf={() => !affordable} />\n )}\n </Button>\n )}\n </div>\n );\n}\n\nfunction Milestones({\n slot,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const rows = dealMilestones(slot);\n if (rows.length === 0) return null;\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const max = rows[rows.length - 1]?.required ?? 1;\n const claim = async (id: string) => {\n const res = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await store.load(client);\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"milestones\")}\n </div>\n <ProgressBar value={Math.min(earned, max)} max={max} tone=\"gold\" />\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rows.map((m) => (\n <Button\n key={m.id}\n size=\"sm\"\n tone={m.state === \"ready\" ? \"gold\" : \"grey\"}\n attract={m.state === \"ready\"}\n disabled={m.state !== \"ready\"}\n onClick={() => void claim(m.id)}\n >\n {m.state === \"claimed\" ? <Icon glyph=\"check\" size={16} /> : null}{\" \"}\n {m.required} <ResourceList lines={m.reward} size={16} />\n </Button>\n ))}\n </div>\n </div>\n );\n}\n"
46
+ "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type {\n ActiveDealSlotInfo,\n DealNodeDefinition,\n IDosGamesClient,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n canAfford,\n Card,\n Center,\n EmptyState,\n errorText,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n payWithCredits,\n} from \"@idosgames/react/ui\";\nimport {\n dealMilestones,\n dealShape,\n dealsBadge,\n milestonesReachedBy,\n needsStore,\n nodeKind,\n nodePrice,\n nodeReward,\n nodeStatus,\n offerRewards,\n orderedNodes,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Special offers (Unity Alikhan/DealOffer): a deal pops up by itself when a new one starts (the\n// impression is recorded), and the \"Offers\" screen lists every active one. A deal opens as a popup\n// shaped by its graph — one big offer, a chain of steps (each unlocks the next), or cards to choose\n// one from — with its milestone bar under it.\n\n/** Active deals, shared by the screen and the auto-popup (one request for both). */\nexport function createDealsStore(features: FeatureRegistry) {\n let slots: ActiveDealSlotInfo[] | null = null;\n const listeners = new Set<() => void>();\n const set = (next: ActiveDealSlotInfo[]) => {\n slots = [...next].sort(\n (a, b) => Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0),\n );\n features.setBadge(\"deals\", dealsBadge(slots));\n for (const l of listeners) l();\n };\n // One read at a time; a load asked for while one is in flight runs again right after it. The\n // screen, the auto-popup and the reload after a purchase used to overlap — the transport skipped\n // the second as a duplicate, which emptied the list and lost the fresh read after the action.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.dealOffer.getActiveDeals();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) set(res.data.Slots ?? []);\n else if (slots === null) set([]);\n };\n return {\n get: () => slots,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n };\n}\nexport type DealsStore = ReturnType<typeof createDealsStore>;\n\nexport function makeDealsScreen(store: DealsStore) {\n return function DealsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const [open, setOpen] = useState<string | null>(null);\n const stagger = useStagger();\n const catalog = useCatalog();\n const now = useNow();\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!slots)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (slots.length === 0)\n return <EmptyState glyph=\"offer\" text={t(\"noDeals\")} />;\n const current = slots.find((s) => s.SlotID === open);\n return (\n <Columns gap={12}>\n {slots.map((s, i) => (\n <Card\n key={s.SlotID!}\n onClick={() => setOpen(s.SlotID!)}\n style={{\n display: \"grid\",\n gap: 8,\n ...toneSurface(\"blue\"),\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={s.OfferDef?.Identity?.AssetPaths?.icon ?? \"offer\"}\n size={44}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(\n s.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n </div>\n <Timer\n at={s.ComputedExpiresAtUtc}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n <ResourceList lines={offerRewards(s)} size={22} />\n </Card>\n ))}\n {current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </Columns>\n );\n };\n}\n\n/** The deal that starts pops up by itself, once per session per slot; the impression is recorded. */\nexport function makeDealsWatcher(store: DealsStore) {\n return function DealsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const shown = useRef(new Set<string>());\n const [popup, setPopup] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => {\n if (popup || !slots) return;\n const next = slots.find(\n (s) => s.IsNewActivation && s.SlotID && !shown.current.has(s.SlotID),\n );\n if (!next?.SlotID) return;\n shown.current.add(next.SlotID);\n void client.dealOffer.recordShow(next.SlotID);\n setPopup(next.SlotID);\n }, [slots, popup, client]);\n const current = slots?.find((s) => s.SlotID === popup);\n return current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setPopup(null)}\n queued\n />\n ) : null;\n };\n}\n\n/** `queued` — popped up by itself (the watcher): waits for other auto-popups. */\nfunction DealPopup({\n slot,\n store,\n onClose,\n queued,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n onClose: () => void;\n queued?: boolean;\n}): ReactNode {\n const catalog = useCatalog();\n const now = useNow();\n const shape = dealShape(slot);\n const nodes = orderedNodes(slot).filter(\n (n) =>\n !(n.HideWhenCompleted && nodeStatus(slot, n) === \"Completed\") &&\n nodeStatus(slot, n) !== \"Hidden\",\n );\n return (\n <Popup\n title={catalog.localize(\n slot.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n onClose={onClose}\n width={shape === \"choice\" ? 600 : 460}\n variant=\"center\"\n queued={queued}\n >\n {slot.OfferDef?.Identity?.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 14,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(slot.OfferDef.Identity.Description)}\n </div>\n ) : null}\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <Timer at={slot.ComputedExpiresAtUtc} now={now} label={t(\"endsIn\")} />\n </div>\n {shape === \"choice\" ? (\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"choose\")}\n </div>\n ) : null}\n <div\n style={\n shape === \"choice\"\n ? {\n display: \"grid\",\n gridTemplateColumns: `repeat(${Math.min(3, nodes.length)}, minmax(0, 1fr))`,\n gap: 10,\n }\n : { display: \"grid\", gap: 10 }\n }\n >\n {nodes.map((node, i) => (\n <NodeCard\n key={node.NodeID ?? i}\n slot={slot}\n node={node}\n index={i}\n shape={shape}\n store={store}\n />\n ))}\n </div>\n <Milestones slot={slot} store={store} />\n </Popup>\n );\n}\n\nfunction NodeCard({\n slot,\n node,\n index,\n shape,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n node: DealNodeDefinition;\n index: number;\n shape: string;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const stagger = useStagger();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const status = nodeStatus(slot, node);\n const kind = nodeKind(node);\n const price = nodePrice(node);\n const reward = nodeReward(node);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(price, balances);\n const store_ = needsStore(node);\n const done = status === \"Completed\";\n const available = status === \"Available\" || status === \"InProgress\";\n\n const execute = async () => {\n setBusy(true);\n // A price in Aspis: the first call asks the player to confirm on idosgames.com (the kit shows it).\n const res = await payWithCredits(client, (creditsConfirmationID) =>\n client.dealOffer.executeNode(slot.SlotID!, node.NodeID!, undefined, {\n creditsConfirmationID,\n }),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"purchase\" ? \"purchase\" : \"claim\");\n kit.fx.burst(ref.current);\n const granted = grantedBy(res.data);\n const lines = granted.length > 0 ? granted : reward;\n if (res.data.OfferExhausted) {\n // The finished offer leaves the active list — its bar's reward is taken now or never.\n for (const id of milestonesReachedBy(slot, node)) {\n const bonus = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (bonus.ok) lines.push(...grantedBy(bonus.data));\n }\n close();\n }\n celebrate(lines);\n await store.load(client);\n };\n\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 12,\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n opacity: status === \"Locked\" ? 0.55 : 1,\n outline: available ? `3px solid ${v.gold}` : undefined,\n ...stagger(index),\n }}\n >\n {shape === \"chain\" ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"step\")} {index + 1}\n </div>\n ) : null}\n {node.Identity?.DisplayName ? (\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(node.Identity.DisplayName)}\n </div>\n ) : null}\n <ResourceList\n lines={reward}\n size={shape === \"single\" ? 36 : 26}\n gap={12}\n />\n {done ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={22} /> {t(\"done\")}\n </span>\n ) : status === \"Locked\" ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <Icon glyph=\"lock\" size={20} /> {t(\"locked\")}\n </span>\n ) : kind === \"video\" ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"adsUnavailable\")}\n </Button>\n ) : kind === \"info\" ? (\n <Button\n tone=\"blue\"\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n >\n {t(\"gotIt\")}\n </Button>\n ) : store_ ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"storeOnly\")}\n </Button>\n ) : (\n <Button\n tone={kind === \"free\" ? \"green\" : \"gold\"}\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`deal:${node.NodeID}`}\n >\n {kind === \"free\" ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={18} dimIf={() => !affordable} />\n )}\n </Button>\n )}\n </div>\n );\n}\n\nfunction Milestones({\n slot,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const rows = dealMilestones(slot);\n if (rows.length === 0) return null;\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const max = rows[rows.length - 1]?.required ?? 1;\n const claim = async (id: string) => {\n const res = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await store.load(client);\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"milestones\")}\n </div>\n <ProgressBar value={Math.min(earned, max)} max={max} tone=\"gold\" />\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rows.map((m) => (\n <Button\n key={m.id}\n size=\"sm\"\n tone={m.state === \"ready\" ? \"gold\" : \"grey\"}\n attract={m.state === \"ready\"}\n disabled={m.state !== \"ready\"}\n onClick={() => void claim(m.id)}\n >\n {m.state === \"claimed\" ? <Icon glyph=\"check\" size={16} /> : null}{\" \"}\n {m.required} <ResourceList lines={m.reward} size={16} />\n </Button>\n ))}\n </div>\n </div>\n );\n}\n"
47
47
  },
48
48
  {
49
49
  "path": "i18n.ts",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  {
57
57
  "path": "model.ts",
58
- "content": "// Pure helpers of the deal offers (Unity Alikhan/DealOffer): what shape an offer's node graph has,\n// in which order to draw its steps, each step's state, price and reward, and the milestone bar.\n// The server is the authority on node status — `ActivationState` is read, never recomputed.\n// Covered by model.test.ts.\n\nimport type { ActiveDealSlotInfo, DealNodeDefinition } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport type DealShape = \"single\" | \"chain\" | \"choice\" | \"graph\";\n\nexport function nodesOf(slot: ActiveDealSlotInfo): DealNodeDefinition[] {\n return slot.OfferDef?.Graph?.Nodes ?? [];\n}\n\n/**\n * The shape is derived from the graph (there is no declared mode): one node → single; the roots\n * share a ChoiceGroupID → choice; every node leads to at most one next → chain; else a graph.\n */\nexport function dealShape(slot: ActiveDealSlotInfo): DealShape {\n const nodes = nodesOf(slot);\n if (nodes.length <= 1) return \"single\";\n const roots = slot.OfferDef?.Graph?.RootNodeIDs ?? [];\n const rootNodes = nodes.filter((n) => roots.includes(n.NodeID ?? \"\"));\n const group = rootNodes[0]?.ChoiceGroupID;\n if (\n rootNodes.length > 1 &&\n group &&\n rootNodes.every((n) => n.ChoiceGroupID === group)\n )\n return \"choice\";\n if (\n roots.length === 1 &&\n nodes.every((n) => (n.NextNodeIDs?.length ?? 0) <= 1)\n )\n return \"chain\";\n return \"graph\";\n}\n\n/** Steps in play order: from the roots along NextNodeIDs (breadth-first), then anything left. */\nexport function orderedNodes(slot: ActiveDealSlotInfo): DealNodeDefinition[] {\n const nodes = nodesOf(slot);\n const byID = new Map(nodes.map((n) => [n.NodeID ?? \"\", n]));\n const out: DealNodeDefinition[] = [];\n const seen = new Set<string>();\n const queue = [...(slot.OfferDef?.Graph?.RootNodeIDs ?? [])];\n while (queue.length > 0) {\n const id = queue.shift()!;\n if (seen.has(id) || !byID.has(id)) continue;\n seen.add(id);\n const node = byID.get(id)!;\n out.push(node);\n queue.push(...(node.NextNodeIDs ?? []));\n }\n for (const n of nodes) if (!seen.has(n.NodeID ?? \"\")) out.push(n);\n return out;\n}\n\nexport type NodeStatus =\n \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\";\n\n/** The server's status of a step; a root with no state yet is available, anything else locked. */\nexport function nodeStatus(\n slot: ActiveDealSlotInfo,\n node: DealNodeDefinition,\n): NodeStatus {\n const id = node.NodeID ?? \"\";\n const s = slot.ActivationState?.Nodes?.[id]?.Status as NodeStatus | undefined;\n if (s) return s;\n return (slot.OfferDef?.Graph?.RootNodeIDs ?? []).includes(id)\n ? \"Available\"\n : \"Locked\";\n}\n\nexport function nodeKind(\n node: DealNodeDefinition,\n): \"purchase\" | \"free\" | \"video\" | \"info\" {\n const type = String(\n node.Type ??\n (node.Action as { Type?: unknown } | null | undefined)?.Type ??\n \"\",\n ).toLowerCase();\n if (type.includes(\"video\")) return \"video\";\n if (type.includes(\"info\")) return \"info\";\n if (type.includes(\"free\")) return \"free\";\n return nodePrice(node).length === 0 ? \"free\" : \"purchase\";\n}\n\n/** The first way to pay (currencies / items). An entry paid in a store is not a resource cost. */\nexport function nodePrice(node: DealNodeDefinition): ResourceLine[] {\n const first = Object.values(node.Pricing?.Options ?? {}).find((o) => o);\n return first\n ? costLines(first.Cost as never).filter((l) => l.kind !== \"other\")\n : [];\n}\n\n/** Whether the first way to pay needs a store receipt (real money). */\nexport function needsStore(node: DealNodeDefinition): boolean {\n const first = Object.values(node.Pricing?.Options ?? {}).find((o) => o);\n const entries =\n (\n first?.Cost as\n | {\n Standard?: {\n Entries?: Array<{ Type?: string | null } | null> | null;\n } | null;\n }\n | null\n | undefined\n )?.Standard?.Entries ?? [];\n return entries.some((e) => e?.Type === \"Purchase\");\n}\n\nexport function nodeReward(node: DealNodeDefinition): ResourceLine[] {\n return grantLines(node.Reward?.Grants);\n}\n\n/** Everything the offer can give across its steps (the card preview). */\nexport function offerRewards(slot: ActiveDealSlotInfo): ResourceLine[] {\n const merged = new Map<string, ResourceLine>();\n for (const n of nodesOf(slot))\n for (const l of nodeReward(n)) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n return [...merged.values()];\n}\n\nexport interface DealMilestoneRow {\n id: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function dealMilestones(slot: ActiveDealSlotInfo): DealMilestoneRow[] {\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const claimed = slot.Milestone?.ClaimedIDs ?? [];\n const ready = slot.Milestone?.ReachedUnclaimedIDs ?? [];\n return Object.entries(slot.OfferDef?.Milestones?.Milestones ?? {})\n .map(([id, m]) => ({\n id: m?.MilestoneID ?? id,\n required: Number(m?.RequiredProgress ?? 0),\n reward: grantLines(m?.Rewards),\n }))\n .sort((a, b) => a.required - b.required)\n .map((m) => ({\n ...m,\n state: claimed.includes(m.id)\n ? \"claimed\"\n : ready.includes(m.id) || earned >= m.required\n ? \"ready\"\n : \"locked\",\n }));\n}\n\n/**\n * Milestones the step brings within reach (its MilestonePoints on top of what the bar holds) that\n * are not claimed yet. The step that exhausts the offer takes the slot out of GetActiveDeals, so\n * the screen claims these right away — otherwise the bar's last reward has nowhere to be taken.\n */\nexport function milestonesReachedBy(\n slot: ActiveDealSlotInfo,\n node: DealNodeDefinition,\n): string[] {\n const earned =\n Number(slot.Milestone?.TotalEarned ?? 0) +\n Number(node.Reward?.MilestonePoints ?? 0);\n return dealMilestones(slot)\n .filter((m) => m.state !== \"claimed\" && m.required <= earned)\n .map((m) => m.id);\n}\n\n/** Badge: steps the player can take for free right now (a pick-one group counts once) plus\n * milestone rewards waiting. */\nexport function dealsBadge(slots: ActiveDealSlotInfo[]): number {\n let n = 0;\n for (const s of slots) {\n const groups = new Set<string>();\n for (const node of nodesOf(s)) {\n if (nodeStatus(s, node) !== \"Available\" || nodeKind(node) !== \"free\")\n continue;\n if (node.ChoiceGroupID) groups.add(node.ChoiceGroupID);\n else n++;\n }\n n += groups.size + (s.Milestone?.ReachedUnclaimedIDs?.length ?? 0);\n }\n return n;\n}\n"
58
+ "content": "// Pure helpers of the deal offers (Unity Alikhan/DealOffer): what shape an offer's node graph has,\n// in which order to draw its steps, each step's state, price and reward, and the milestone bar.\n// The server is the authority on node status — `ActivationState` is read, never recomputed.\n// Covered by model.test.ts.\n\nimport type { ActiveDealSlotInfo, DealNodeDefinition } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport type DealShape = \"single\" | \"chain\" | \"choice\" | \"graph\";\n\nexport function nodesOf(slot: ActiveDealSlotInfo): DealNodeDefinition[] {\n return slot.OfferDef?.Graph?.Nodes ?? [];\n}\n\n/**\n * The shape is derived from the graph (there is no declared mode): one node → single; the roots\n * share a ChoiceGroupID → choice; every node leads to at most one next → chain; else a graph.\n */\nexport function dealShape(slot: ActiveDealSlotInfo): DealShape {\n const nodes = nodesOf(slot);\n if (nodes.length <= 1) return \"single\";\n const roots = slot.OfferDef?.Graph?.RootNodeIDs ?? [];\n const rootNodes = nodes.filter((n) => roots.includes(n.NodeID ?? \"\"));\n const group = rootNodes[0]?.ChoiceGroupID;\n if (\n rootNodes.length > 1 &&\n group &&\n rootNodes.every((n) => n.ChoiceGroupID === group)\n )\n return \"choice\";\n if (\n roots.length === 1 &&\n nodes.every((n) => (n.NextNodeIDs?.length ?? 0) <= 1)\n )\n return \"chain\";\n return \"graph\";\n}\n\n/** Steps in play order: from the roots along NextNodeIDs (breadth-first), then anything left. */\nexport function orderedNodes(slot: ActiveDealSlotInfo): DealNodeDefinition[] {\n const nodes = nodesOf(slot);\n const byID = new Map(nodes.map((n) => [n.NodeID ?? \"\", n]));\n const out: DealNodeDefinition[] = [];\n const seen = new Set<string>();\n const queue = [...(slot.OfferDef?.Graph?.RootNodeIDs ?? [])];\n while (queue.length > 0) {\n const id = queue.shift()!;\n if (seen.has(id) || !byID.has(id)) continue;\n seen.add(id);\n const node = byID.get(id)!;\n out.push(node);\n queue.push(...(node.NextNodeIDs ?? []));\n }\n for (const n of nodes) if (!seen.has(n.NodeID ?? \"\")) out.push(n);\n return out;\n}\n\nexport type NodeStatus =\n \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\";\n\n/** The server's status of a step; a root with no state yet is available, anything else locked. */\nexport function nodeStatus(\n slot: ActiveDealSlotInfo,\n node: DealNodeDefinition,\n): NodeStatus {\n const id = node.NodeID ?? \"\";\n const s = slot.ActivationState?.Nodes?.[id]?.Status as NodeStatus | undefined;\n if (s) return s;\n return (slot.OfferDef?.Graph?.RootNodeIDs ?? []).includes(id)\n ? \"Available\"\n : \"Locked\";\n}\n\nexport function nodeKind(\n node: DealNodeDefinition,\n): \"purchase\" | \"free\" | \"video\" | \"info\" {\n const type = String(\n node.Type ??\n (node.Action as { Type?: unknown } | null | undefined)?.Type ??\n \"\",\n ).toLowerCase();\n if (type.includes(\"video\")) return \"video\";\n if (type.includes(\"info\")) return \"info\";\n if (type.includes(\"free\")) return \"free\";\n return nodePrice(node).length === 0 ? \"free\" : \"purchase\";\n}\n\n/** The first way to pay (currencies / items / Aspis). An entry paid in a store is not a resource cost. */\nexport function nodePrice(node: DealNodeDefinition): ResourceLine[] {\n const first = Object.values(node.Pricing?.Options ?? {}).find((o) => o);\n return first\n ? costLines(first.Cost as never).filter((l) => l.kind !== \"other\")\n : [];\n}\n\n/** Whether the first way to pay needs a store receipt (real money). */\nexport function needsStore(node: DealNodeDefinition): boolean {\n const first = Object.values(node.Pricing?.Options ?? {}).find((o) => o);\n const entries =\n (\n first?.Cost as\n | {\n Standard?: {\n Entries?: Array<{ Type?: string | null } | null> | null;\n } | null;\n }\n | null\n | undefined\n )?.Standard?.Entries ?? [];\n return entries.some((e) => e?.Type === \"Purchase\");\n}\n\nexport function nodeReward(node: DealNodeDefinition): ResourceLine[] {\n return grantLines(node.Reward?.Grants);\n}\n\n/** Everything the offer can give across its steps (the card preview). */\nexport function offerRewards(slot: ActiveDealSlotInfo): ResourceLine[] {\n const merged = new Map<string, ResourceLine>();\n for (const n of nodesOf(slot))\n for (const l of nodeReward(n)) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n return [...merged.values()];\n}\n\nexport interface DealMilestoneRow {\n id: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function dealMilestones(slot: ActiveDealSlotInfo): DealMilestoneRow[] {\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const claimed = slot.Milestone?.ClaimedIDs ?? [];\n const ready = slot.Milestone?.ReachedUnclaimedIDs ?? [];\n return Object.entries(slot.OfferDef?.Milestones?.Milestones ?? {})\n .map(([id, m]) => ({\n id: m?.MilestoneID ?? id,\n required: Number(m?.RequiredProgress ?? 0),\n reward: grantLines(m?.Rewards),\n }))\n .sort((a, b) => a.required - b.required)\n .map((m) => ({\n ...m,\n state: claimed.includes(m.id)\n ? \"claimed\"\n : ready.includes(m.id) || earned >= m.required\n ? \"ready\"\n : \"locked\",\n }));\n}\n\n/**\n * Milestones the step brings within reach (its MilestonePoints on top of what the bar holds) that\n * are not claimed yet. The step that exhausts the offer takes the slot out of GetActiveDeals, so\n * the screen claims these right away — otherwise the bar's last reward has nowhere to be taken.\n */\nexport function milestonesReachedBy(\n slot: ActiveDealSlotInfo,\n node: DealNodeDefinition,\n): string[] {\n const earned =\n Number(slot.Milestone?.TotalEarned ?? 0) +\n Number(node.Reward?.MilestonePoints ?? 0);\n return dealMilestones(slot)\n .filter((m) => m.state !== \"claimed\" && m.required <= earned)\n .map((m) => m.id);\n}\n\n/** Badge: steps the player can take for free right now (a pick-one group counts once) plus\n * milestone rewards waiting. */\nexport function dealsBadge(slots: ActiveDealSlotInfo[]): number {\n let n = 0;\n for (const s of slots) {\n const groups = new Set<string>();\n for (const node of nodesOf(s)) {\n if (nodeStatus(s, node) !== \"Available\" || nodeKind(node) !== \"free\")\n continue;\n if (node.ChoiceGroupID) groups.add(node.ChoiceGroupID);\n else n++;\n }\n n += groups.size + (s.Milestone?.ReachedUnclaimedIDs?.length ?? 0);\n }\n return n;\n}\n"
59
59
  },
60
60
  {
61
61
  "path": "module.ts",
@@ -37,9 +37,9 @@
37
37
  "version": "0.1.5"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.25.0",
41
- "@idosgames/module-sdk": "0.13.2",
42
- "@idosgames/react": "0.6.3",
40
+ "@idosgames/core": "0.26.0",
41
+ "@idosgames/module-sdk": "0.13.3",
42
+ "@idosgames/react": "0.7.0",
43
43
  "react": "19.2.7"
44
44
  },
45
45
  "contentHash": "97ba661e41bf3aa20fa109b3a102907fb94cac6bb66a4e101e7a0d539c78e4e9",
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.3"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.25.0",
39
- "@idosgames/module-sdk": "0.13.2",
40
- "@idosgames/react": "0.6.3",
38
+ "@idosgames/core": "0.26.0",
39
+ "@idosgames/module-sdk": "0.13.3",
40
+ "@idosgames/react": "0.7.0",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "cb4b66936fa31747a116946730b9fc2576262e68220570b149de0dbf3bb9a943",
@@ -69,13 +69,13 @@
69
69
  }
70
70
  },
71
71
  "dependencies": {
72
- "@idosgames/core": "0.25.0",
73
- "@idosgames/module-sdk": "0.13.2",
74
- "@idosgames/react": "0.6.3",
72
+ "@idosgames/core": "0.26.0",
73
+ "@idosgames/module-sdk": "0.13.3",
74
+ "@idosgames/react": "0.7.0",
75
75
  "phaser": "4.2.1",
76
76
  "react": "19.2.7"
77
77
  },
78
- "contentHash": "0a95a09bfcc557220462eecf8ee89848e06c845df9fe631dea52dbeb46df2d1d",
78
+ "contentHash": "4f092b6d504b4ddfe84081e28ba71f7c09e8e33cedc511e764e85b213b8edfe3",
79
79
  "files": [
80
80
  {
81
81
  "path": "character/art/svg.ts",
@@ -383,7 +383,7 @@
383
383
  },
384
384
  {
385
385
  "path": "game/session.ts",
386
- "content": "// The game's state for the whole session — created once in setup(), outliving the Phaser scene\n// (the Mode Router destroys a hidden mode; the progress and a run in progress must not reset).\n//\n// It owns: which hero fights (the Heroes screen tells us — `character:hero-selected@1`), that hero's\n// numbers (heroStats.ts) and gear types (heroGear.ts), the campaign progress (private custom data),\n// the run — a Battle of one level, idle on the map until started — and what the run costs and pays\n// through the platform (economy.ts: energy → ticket → chest). The hero's numbers are not bought here:\n// they grow from its gear and rank (the Heroes screen) — between runs the build is the gear, in a run\n// the cards.\n\nimport {\n CurrencyType,\n type IDosGamesClient,\n type LootboxOpenResponse,\n} from \"@idosgames/core\";\nimport {\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport {\n Battle,\n type BattleEvent,\n type CampChoice,\n type RunResult,\n type UltId,\n} from \"./battle\";\nimport { levelOf, scout, type LevelDef } from \"./campaign\";\nimport {\n CAMP_FEAST_GEMS,\n ENERGY_ID,\n GEMS_ID,\n POTION_ID,\n POTION_SLOTS,\n TICKET_ID,\n XP_ID,\n chestFor,\n chestTier,\n pickEconomy,\n playerLevel,\n type ChestTier,\n type CurrencySection,\n type LootboxSection,\n type RunEconomy,\n} from \"./economy\";\nimport { heroGear, type GearItemView, type HeroGear } from \"./heroGear\";\nimport {\n fighterStats,\n heroRank,\n resolveStats,\n type CharacterDefView,\n type CharacterSection,\n type FighterStats,\n type GearDefView,\n type HeroInput,\n type HeroModelView,\n type StatInfo,\n} from \"./heroStats\";\nimport {\n METRIC_LEVEL_CLEARED,\n METRIC_RUN_FINISHED,\n questMetric,\n type QuestSection,\n} from \"./metrics\";\nimport {\n PROGRESS_KEY,\n parseProgress,\n serializeProgress,\n type Progress,\n} from \"./progress\";\n\n/** How often the HUD redraws while the fight runs. */\nconst HUD_HZ = 8;\nconst TURN_EVENTS = new Set<BattleEvent[\"type\"]>([\n \"levelUp\",\n \"card\",\n \"reached\",\n \"bossStart\",\n \"end\",\n \"heroDown\",\n \"ult\",\n \"potion\",\n]);\n\nexport interface HeroView {\n id: string;\n name: string;\n classID: string;\n rarity: string;\n rank: number;\n power: number;\n def: CharacterDefView;\n input: HeroInput;\n stats: StatInfo[];\n fighter: FighterStats;\n gear: HeroGear;\n}\n\nexport type Notice =\n { kind: \"error\"; text: string } | { kind: \"notEnough\"; currencyID: string };\n\n/** A run that ended: its result and the chest it earned (opened by `claim`). */\nexport interface Ending {\n result: RunResult;\n tier: ChestTier;\n /** The chest opened — its rewards; null until claimed (or when the title has no chests). */\n opened: LootboxOpenResponse | null;\n claiming: boolean;\n}\n\nexport interface SessionCallbacks {\n /** A level was cleared — the module turns it into its event for other modules. */\n onLevelCleared?: (island: number, level: number) => void;\n /** A hero's rank went up (anywhere — the Heroes screen too). */\n onRankUp?: (characterId: string, level: number) => void;\n}\n\ntype Listener = () => void;\n\nexport class RunSession {\n /** The run: idle on the selected level until started; ended until the chest is claimed. */\n battle: Battle;\n /** Is the run going (started and not ended). */\n running = false;\n ending: Ending | null = null;\n /** The level the map has picked (the next open one by default). */\n selected = 1;\n progress: Progress = parseProgress(null);\n /** The selected hero (from the Heroes screen), or null until it says (then the fallback fights). */\n selectedID: string | null = null;\n hero: HeroView | null = null;\n economy: RunEconomy | null = null;\n notice: (Notice & { at: number }) | null = null;\n loaded = false;\n busy = false;\n\n private version = 0;\n private listeners = new Set<Listener>();\n private hudClock = 0;\n /** The last run cleared a new level: the map moves on to the next one. */\n private progressJustMoved = false;\n private metricQueue = new Map<string, { pending: number; busy: boolean }>();\n private offClient: Array<() => void> = [];\n\n constructor(\n private readonly client: IDosGamesClient,\n private readonly callbacks: SessionCallbacks = {},\n ) {\n this.battle = new Battle({ level: levelOf(1) });\n this.offClient.push(\n client.on(\"user:anyUpdated\", () => this.refreshHero()),\n client.on(\"character:levelUpgraded\", (data) =>\n callbacks.onRankUp?.(data.CharacterID, data.NewLevel),\n ),\n );\n this.refreshHero();\n void this.load();\n }\n\n // ── React bridge ───────────────────────────────────────────────────────────────────────────\n\n subscribe = (listener: Listener): (() => void) => {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n };\n\n getVersion = (): number => this.version;\n\n private changed(): void {\n this.version++;\n for (const l of [...this.listeners]) l();\n }\n\n // ── The hero ───────────────────────────────────────────────────────────────────────────────\n\n selectHero(id: string): void {\n if (id === this.selectedID) return;\n this.selectedID = id;\n this.refreshHero();\n }\n\n /** Re-reads the hero from the cache: an upgrade, new gear or another hero changes the fighter. */\n refreshHero(): void {\n const section = configSection<CharacterSection>(this.client, \"Character\");\n const owned = (this.client.data.user.state?.Character?.Characters ??\n {}) as Record<string, HeroModelView | undefined>;\n this.economy = pickEconomy(\n configSection<CurrencySection>(this.client, \"Currency\"),\n configSection<LootboxSection>(this.client, \"Lootbox\"),\n );\n const id = pickHero(section, owned, this.selectedID);\n if (!id) {\n this.hero = null;\n return this.changed();\n }\n const def = section?.Definitions?.[id] ?? {};\n const items = itemDefinitions(\n configSection<ItemSection>(this.client, \"Item\"),\n ) as unknown as Map<string, GearDefView & GearItemView>;\n const instances = (\n this.client.data.user.state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, { Level?: number | null } | null>;\n }\n | undefined\n )?.UnstackableItems;\n const instanceLevels: Record<string, number> = {};\n for (const [k, inst] of Object.entries(instances ?? {}))\n if (inst?.Level) instanceLevels[k] = inst.Level;\n const input: HeroInput = {\n section,\n def,\n model: owned[id],\n items,\n instanceLevels,\n };\n const fighter = fighterStats(input);\n const equipment: Record<string, string> = {};\n for (const [slot, worn] of Object.entries(owned[id]?.Equipment ?? {}))\n if (worn?.ItemID) equipment[slot] = worn.ItemID;\n const classID = def.Classification?.ClassID ?? \"\";\n this.hero = {\n id,\n name: def.Identity?.DisplayName ?? id,\n classID,\n rarity: def.Classification?.RarityID ?? \"\",\n rank: heroRank(input),\n power: Number(owned[id]?.Power ?? 0),\n def,\n input,\n stats: resolveStats(section, def),\n fighter,\n gear: heroGear({ id, classId: classID, equipment }, items),\n };\n this.battle.setStats(fighter);\n this.changed();\n }\n\n balance(currencyID: string): number {\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number } | undefined> | undefined;\n return Number(vc?.[currencyID]?.Amount ?? 0);\n }\n\n /** The energy's recharge state from the server (the countdown to the next batch). */\n rechargeState(): { LastRechargeAt?: string; PendingSeconds?: number } | null {\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\n | Record<\n string,\n | {\n Recharge?: {\n LastRechargeAt?: string;\n PendingSeconds?: number;\n } | null;\n }\n | undefined\n >\n | undefined;\n return vc?.[ENERGY_ID]?.Recharge ?? null;\n }\n\n playerLevel(): ReturnType<typeof playerLevel> {\n return playerLevel(this.balance(XP_ID));\n }\n\n // ── The map ────────────────────────────────────────────────────────────────────────────────\n\n /** The highest level the player may play (the one after the last cleared). */\n openLevel(): number {\n return this.progress.cleared + 1;\n }\n\n level(): LevelDef {\n return this.battle.level;\n }\n\n /** Pick a level on the map (an open one); the road shows it, idle. */\n select(index: number): void {\n if (this.running || this.ending) return;\n const n = Math.max(1, Math.min(this.openLevel(), Math.floor(index)));\n this.selected = n;\n // the road of that level, idle: the hero stands on it as it will run it (its shield's bar too)\n this.battle = new Battle({\n level: levelOf(n),\n stats: this.hero?.fighter,\n shield: this.hero?.gear.shield,\n });\n this.changed();\n }\n\n setUlt(ult: UltId): void {\n if (this.progress.ult === ult) return;\n this.progress = { ...this.progress, ult };\n this.save();\n this.changed();\n }\n\n /** The types the selected level brings that the player has not had explained yet. */\n newTips(): string[] {\n const island = this.level().island;\n return island.introduces\n .map((t) => `${t.kind}:${t.type}`)\n .filter((key) => !this.progress.tips.includes(key));\n }\n\n markTipsSeen(keys: readonly string[]): void {\n const tips = [...new Set([...this.progress.tips, ...keys])];\n if (tips.length === this.progress.tips.length) return;\n this.progress = { ...this.progress, tips };\n this.save();\n this.changed();\n }\n\n scouting(): ReturnType<typeof scout> {\n return scout(this.level());\n }\n\n /** Energy a run costs now (0 — a ticket is waiting, or the title has free runs). */\n runCost(): number {\n if (!this.economy) return 0;\n return this.balance(TICKET_ID) >= 1 ? 0 : this.economy.energyPerTicket;\n }\n\n // ── The run ────────────────────────────────────────────────────────────────────────────────\n\n /** Start the selected level: a ticket (from energy if none is waiting), then the road. */\n async start(): Promise<boolean> {\n if (this.running || this.ending || this.busy || !this.hero) return false;\n const economy = this.economy;\n if (economy && this.balance(TICKET_ID) < 1) {\n if (this.balance(ENERGY_ID) < economy.energyPerTicket) {\n this.notify({ kind: \"notEnough\", currencyID: ENERGY_ID });\n return false;\n }\n this.busy = true;\n this.changed();\n const res = await this.client.currency.convert(\n CurrencyType.Virtual,\n ENERGY_ID,\n CurrencyType.Virtual,\n TICKET_ID,\n economy.energyPerTicket,\n );\n this.busy = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n return false;\n }\n }\n const hero = this.hero;\n this.battle = new Battle({\n level: levelOf(this.selected),\n stats: hero.fighter,\n heroAttack: hero.gear.attack,\n heroArmor: hero.gear.armor,\n shield: hero.gear.shield,\n ult: this.progress.ult,\n // a title without the run economy plays free — with full potion slots\n potions: this.economy\n ? Math.min(POTION_SLOTS, this.balance(POTION_ID))\n : POTION_SLOTS,\n seed: Date.now() ^ (this.selected * 2654435761),\n });\n this.running = true;\n this.changed();\n return true;\n }\n\n takeCard(id: string): boolean {\n const ok = this.battle.takeCard(id);\n if (ok) this.changed();\n return ok;\n }\n\n /** \"Refresh\": three other cards for the same choice (once a run). */\n reroll(): boolean {\n const ok = this.battle.reroll();\n if (ok) this.changed();\n return ok;\n }\n\n /** The camp's choice; \"both\" asks for the gems first (converted into XP at the server). */\n async chooseCamp(choice: CampChoice): Promise<boolean> {\n if (this.battle.pending?.kind !== \"camp\" || this.busy) return false;\n if (choice === \"both\") {\n if (!this.economy?.gemsToXp) return false;\n if (this.balance(GEMS_ID) < CAMP_FEAST_GEMS) {\n this.notify({ kind: \"notEnough\", currencyID: GEMS_ID });\n return false;\n }\n this.busy = true;\n this.changed();\n const res = await this.client.currency.convert(\n CurrencyType.Virtual,\n GEMS_ID,\n CurrencyType.Virtual,\n XP_ID,\n CAMP_FEAST_GEMS,\n );\n this.busy = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n this.changed();\n return false;\n }\n }\n const ok = this.battle.chooseCamp(choice);\n this.changed();\n return ok;\n }\n\n /** Leave the run — it ends as lost (the small chest is still earned). */\n abandon(): void {\n if (!this.running) return;\n this.battle.abandon();\n this.ended(this.battle.result!);\n }\n\n /** Open the run's chest with its ticket, then back to the map. */\n async claim(): Promise<LootboxOpenResponse | null> {\n const ending = this.ending;\n if (!ending || ending.claiming) return null;\n const economy = this.economy;\n const chest = economy\n ? chestFor(economy, ending.tier, this.progress.chests)\n : null;\n if (!economy || !chest || this.balance(TICKET_ID) < 1) {\n this.backToMap();\n return null;\n }\n ending.claiming = true;\n this.changed();\n const res = await this.client.lootbox.open(chest, 1);\n ending.claiming = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n this.changed();\n return null;\n }\n ending.opened = res.data;\n this.progress = { ...this.progress, chests: this.progress.chests + 1 };\n this.save();\n this.backToMap();\n return res.data;\n }\n\n private backToMap(): void {\n this.ending = null;\n this.running = false;\n this.select(\n Math.min(\n this.openLevel(),\n this.selected + (this.progressJustMoved ? 1 : 0),\n ),\n );\n this.progressJustMoved = false;\n }\n\n private ended(result: RunResult): void {\n this.running = false;\n this.ending = {\n result,\n tier: chestTier(result),\n opened: null,\n claiming: false,\n };\n const level = levelOf(result.level);\n if (result.cleared) {\n this.callbacks.onLevelCleared?.(level.island.index, level.level);\n this.report(METRIC_LEVEL_CLEARED, 1);\n if (result.level > this.progress.cleared) {\n this.progress = { ...this.progress, cleared: result.level };\n this.progressJustMoved = true;\n this.save();\n }\n }\n this.report(METRIC_RUN_FINISHED, 1);\n // the potions drunk leave the wallet at the server (into XP)\n if (result.potionsUsed > 0 && this.economy?.potionToXp)\n void this.client.currency.convert(\n CurrencyType.Virtual,\n POTION_ID,\n CurrencyType.Virtual,\n XP_ID,\n result.potionsUsed,\n );\n this.changed();\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** One frame of the fight (called by the scene). Returns what happened, for the drawing. */\n tick(dt: number): BattleEvent[] {\n if (!this.running) return [];\n const events = this.battle.tick(dt);\n for (const e of events) if (e.type === \"end\") this.ended(e.result);\n this.hudClock += dt;\n const turn = events.some((e) => TURN_EVENTS.has(e.type));\n if (turn || this.hudClock >= 1 / HUD_HZ) {\n this.hudClock = 0;\n this.changed();\n }\n return events;\n }\n\n activate(): void {}\n\n suspend(): void {}\n\n destroy(): void {\n for (const off of this.offClient) off();\n this.offClient = [];\n this.listeners.clear();\n }\n\n private notify(notice: Notice): void {\n this.notice = { ...notice, at: Date.now() };\n this.changed();\n }\n\n // ── Progress ───────────────────────────────────────────────────────────────────────────────\n\n private async load(): Promise<void> {\n let raw = this.readProgress();\n if (raw === undefined) {\n const res = await this.client.userCustomData.getMyUserCustomData();\n raw = res.ok ? (res.data.Private?.[PROGRESS_KEY]?.Value ?? null) : null;\n }\n this.progress = parseProgress(raw ?? null);\n this.loaded = true;\n this.select(this.openLevel());\n }\n\n /** `undefined` — the login state did not bring the custom data at all (ask once). */\n private readProgress(): string | null | undefined {\n const cd = this.client.data.user.state?.CustomData as\n | { Private?: Record<string, { Value?: string | null } | undefined> }\n | null\n | undefined;\n if (!cd?.Private) return undefined;\n return cd.Private[PROGRESS_KEY]?.Value ?? null;\n }\n\n private save(): void {\n if (!this.loaded) return;\n const value = serializeProgress(this.progress);\n if (value === this.readProgress()) return;\n void this.client.userCustomData.setPrivateData(PROGRESS_KEY, value);\n }\n\n // ── Quest metrics ──────────────────────────────────────────────────────────────────────────\n\n /** Report a metric to the quests that count it — one call at a time, never above their cap. */\n private report(metricID: string, amount: number): void {\n if (!questMetric(this.questSection(), metricID)) return;\n const q = this.metricQueue.get(metricID) ?? { pending: 0, busy: false };\n q.pending += amount;\n this.metricQueue.set(metricID, q);\n void this.pumpMetric(metricID);\n }\n\n private async pumpMetric(metricID: string): Promise<void> {\n const q = this.metricQueue.get(metricID);\n const metric = questMetric(this.questSection(), metricID);\n if (!q || q.busy || q.pending <= 0 || !metric) return;\n const chunk =\n metric.maxPerCall > 0\n ? Math.min(q.pending, metric.maxPerCall)\n : q.pending;\n q.busy = true;\n q.pending -= chunk;\n await this.client.quest.addQuestProgress(metricID, chunk);\n q.busy = false;\n void this.pumpMetric(metricID);\n }\n\n private questSection(): QuestSection | undefined {\n return configSection<QuestSection>(this.client, \"Quest\");\n }\n}\n\n// ── Pure helpers ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * The fighter: the selected hero while it is playable; else the strongest owned hero; else the\n * first hero playable by default (roster order).\n */\nexport function pickHero(\n section: CharacterSection | undefined,\n owned: Record<string, HeroModelView | undefined>,\n selectedID: string | null,\n): string | null {\n const defs = section?.Definitions ?? {};\n const playable = (id: string) =>\n Number(owned[id]?.Level ?? 0) > 0 || !!defs[id]?.Unlock?.UnlockedByDefault;\n if (selectedID && defs[selectedID] && playable(selectedID)) return selectedID;\n const strongest = Object.entries(owned)\n .filter(([id, m]) => defs[id] && Number(m?.Level ?? 0) > 0)\n .sort(([, a], [, b]) => Number(b?.Power ?? 0) - Number(a?.Power ?? 0))[0];\n if (strongest) return strongest[0];\n const byOrder = Object.entries(defs)\n .filter(([id, d]) => d && playable(id))\n .sort(\n ([a, x], [b, y]) =>\n Number(x?.Identity?.SortOrder ?? 0) -\n Number(y?.Identity?.SortOrder ?? 0) || a.localeCompare(b),\n )[0];\n return byOrder?.[0] ?? null;\n}\n"
386
+ "content": "// The game's state for the whole session — created once in setup(), outliving the Phaser scene\n// (the Mode Router destroys a hidden mode; the progress and a run in progress must not reset).\n//\n// It owns: which hero fights (the Heroes screen tells us — `character:hero-selected@1`), that hero's\n// numbers (heroStats.ts) and gear types (heroGear.ts), the campaign progress (private custom data),\n// the run — a Battle of one level, idle on the map until started — and what the run costs and pays\n// through the platform (economy.ts: energy → ticket → chest). The hero's numbers are not bought here:\n// they grow from its gear and rank (the Heroes screen) — between runs the build is the gear, in a run\n// the cards.\n\nimport {\n CurrencyType,\n type IDosGamesClient,\n type LootboxOpenResponse,\n} from \"@idosgames/core\";\nimport {\n configSection,\n itemDefinitions,\n payWithCredits,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport {\n Battle,\n type BattleEvent,\n type CampChoice,\n type RunResult,\n type UltId,\n} from \"./battle\";\nimport { levelOf, scout, type LevelDef } from \"./campaign\";\nimport {\n CAMP_FEAST_GEMS,\n ENERGY_ID,\n GEMS_ID,\n POTION_ID,\n POTION_SLOTS,\n TICKET_ID,\n XP_ID,\n chestFor,\n chestTier,\n pickEconomy,\n playerLevel,\n type ChestTier,\n type CurrencySection,\n type LootboxSection,\n type RunEconomy,\n} from \"./economy\";\nimport { heroGear, type GearItemView, type HeroGear } from \"./heroGear\";\nimport {\n fighterStats,\n heroRank,\n resolveStats,\n type CharacterDefView,\n type CharacterSection,\n type FighterStats,\n type GearDefView,\n type HeroInput,\n type HeroModelView,\n type StatInfo,\n} from \"./heroStats\";\nimport {\n METRIC_LEVEL_CLEARED,\n METRIC_RUN_FINISHED,\n questMetric,\n type QuestSection,\n} from \"./metrics\";\nimport {\n PROGRESS_KEY,\n parseProgress,\n serializeProgress,\n type Progress,\n} from \"./progress\";\n\n/** How often the HUD redraws while the fight runs. */\nconst HUD_HZ = 8;\nconst TURN_EVENTS = new Set<BattleEvent[\"type\"]>([\n \"levelUp\",\n \"card\",\n \"reached\",\n \"bossStart\",\n \"end\",\n \"heroDown\",\n \"ult\",\n \"potion\",\n]);\n\nexport interface HeroView {\n id: string;\n name: string;\n classID: string;\n rarity: string;\n rank: number;\n power: number;\n def: CharacterDefView;\n input: HeroInput;\n stats: StatInfo[];\n fighter: FighterStats;\n gear: HeroGear;\n}\n\nexport type Notice =\n { kind: \"error\"; text: string } | { kind: \"notEnough\"; currencyID: string };\n\n/** A run that ended: its result and the chest it earned (opened by `claim`). */\nexport interface Ending {\n result: RunResult;\n tier: ChestTier;\n /** The chest opened — its rewards; null until claimed (or when the title has no chests). */\n opened: LootboxOpenResponse | null;\n claiming: boolean;\n}\n\nexport interface SessionCallbacks {\n /** A level was cleared — the module turns it into its event for other modules. */\n onLevelCleared?: (island: number, level: number) => void;\n /** A hero's rank went up (anywhere — the Heroes screen too). */\n onRankUp?: (characterId: string, level: number) => void;\n}\n\ntype Listener = () => void;\n\nexport class RunSession {\n /** The run: idle on the selected level until started; ended until the chest is claimed. */\n battle: Battle;\n /** Is the run going (started and not ended). */\n running = false;\n ending: Ending | null = null;\n /** The level the map has picked (the next open one by default). */\n selected = 1;\n progress: Progress = parseProgress(null);\n /** The selected hero (from the Heroes screen), or null until it says (then the fallback fights). */\n selectedID: string | null = null;\n hero: HeroView | null = null;\n economy: RunEconomy | null = null;\n notice: (Notice & { at: number }) | null = null;\n loaded = false;\n busy = false;\n\n private version = 0;\n private listeners = new Set<Listener>();\n private hudClock = 0;\n /** The last run cleared a new level: the map moves on to the next one. */\n private progressJustMoved = false;\n private metricQueue = new Map<string, { pending: number; busy: boolean }>();\n private offClient: Array<() => void> = [];\n\n constructor(\n private readonly client: IDosGamesClient,\n private readonly callbacks: SessionCallbacks = {},\n ) {\n this.battle = new Battle({ level: levelOf(1) });\n this.offClient.push(\n client.on(\"user:anyUpdated\", () => this.refreshHero()),\n client.on(\"character:levelUpgraded\", (data) =>\n callbacks.onRankUp?.(data.CharacterID, data.NewLevel),\n ),\n );\n this.refreshHero();\n void this.load();\n }\n\n // ── React bridge ───────────────────────────────────────────────────────────────────────────\n\n subscribe = (listener: Listener): (() => void) => {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n };\n\n getVersion = (): number => this.version;\n\n private changed(): void {\n this.version++;\n for (const l of [...this.listeners]) l();\n }\n\n // ── The hero ───────────────────────────────────────────────────────────────────────────────\n\n selectHero(id: string): void {\n if (id === this.selectedID) return;\n this.selectedID = id;\n this.refreshHero();\n }\n\n /** Re-reads the hero from the cache: an upgrade, new gear or another hero changes the fighter. */\n refreshHero(): void {\n const section = configSection<CharacterSection>(this.client, \"Character\");\n const owned = (this.client.data.user.state?.Character?.Characters ??\n {}) as Record<string, HeroModelView | undefined>;\n this.economy = pickEconomy(\n configSection<CurrencySection>(this.client, \"Currency\"),\n configSection<LootboxSection>(this.client, \"Lootbox\"),\n );\n const id = pickHero(section, owned, this.selectedID);\n if (!id) {\n this.hero = null;\n return this.changed();\n }\n const def = section?.Definitions?.[id] ?? {};\n const items = itemDefinitions(\n configSection<ItemSection>(this.client, \"Item\"),\n ) as unknown as Map<string, GearDefView & GearItemView>;\n const instances = (\n this.client.data.user.state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, { Level?: number | null } | null>;\n }\n | undefined\n )?.UnstackableItems;\n const instanceLevels: Record<string, number> = {};\n for (const [k, inst] of Object.entries(instances ?? {}))\n if (inst?.Level) instanceLevels[k] = inst.Level;\n const input: HeroInput = {\n section,\n def,\n model: owned[id],\n items,\n instanceLevels,\n };\n const fighter = fighterStats(input);\n const equipment: Record<string, string> = {};\n for (const [slot, worn] of Object.entries(owned[id]?.Equipment ?? {}))\n if (worn?.ItemID) equipment[slot] = worn.ItemID;\n const classID = def.Classification?.ClassID ?? \"\";\n this.hero = {\n id,\n name: def.Identity?.DisplayName ?? id,\n classID,\n rarity: def.Classification?.RarityID ?? \"\",\n rank: heroRank(input),\n power: Number(owned[id]?.Power ?? 0),\n def,\n input,\n stats: resolveStats(section, def),\n fighter,\n gear: heroGear({ id, classId: classID, equipment }, items),\n };\n this.battle.setStats(fighter);\n this.changed();\n }\n\n balance(currencyID: string): number {\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number } | undefined> | undefined;\n return Number(vc?.[currencyID]?.Amount ?? 0);\n }\n\n /** The energy's recharge state from the server (the countdown to the next batch). */\n rechargeState(): { LastRechargeAt?: string; PendingSeconds?: number } | null {\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\n | Record<\n string,\n | {\n Recharge?: {\n LastRechargeAt?: string;\n PendingSeconds?: number;\n } | null;\n }\n | undefined\n >\n | undefined;\n return vc?.[ENERGY_ID]?.Recharge ?? null;\n }\n\n playerLevel(): ReturnType<typeof playerLevel> {\n return playerLevel(this.balance(XP_ID));\n }\n\n // ── The map ────────────────────────────────────────────────────────────────────────────────\n\n /** The highest level the player may play (the one after the last cleared). */\n openLevel(): number {\n return this.progress.cleared + 1;\n }\n\n level(): LevelDef {\n return this.battle.level;\n }\n\n /** Pick a level on the map (an open one); the road shows it, idle. */\n select(index: number): void {\n if (this.running || this.ending) return;\n const n = Math.max(1, Math.min(this.openLevel(), Math.floor(index)));\n this.selected = n;\n // the road of that level, idle: the hero stands on it as it will run it (its shield's bar too)\n this.battle = new Battle({\n level: levelOf(n),\n stats: this.hero?.fighter,\n shield: this.hero?.gear.shield,\n });\n this.changed();\n }\n\n setUlt(ult: UltId): void {\n if (this.progress.ult === ult) return;\n this.progress = { ...this.progress, ult };\n this.save();\n this.changed();\n }\n\n /** The types the selected level brings that the player has not had explained yet. */\n newTips(): string[] {\n const island = this.level().island;\n return island.introduces\n .map((t) => `${t.kind}:${t.type}`)\n .filter((key) => !this.progress.tips.includes(key));\n }\n\n markTipsSeen(keys: readonly string[]): void {\n const tips = [...new Set([...this.progress.tips, ...keys])];\n if (tips.length === this.progress.tips.length) return;\n this.progress = { ...this.progress, tips };\n this.save();\n this.changed();\n }\n\n scouting(): ReturnType<typeof scout> {\n return scout(this.level());\n }\n\n /** Energy a run costs now (0 — a ticket is waiting, or the title has free runs). */\n runCost(): number {\n if (!this.economy) return 0;\n return this.balance(TICKET_ID) >= 1 ? 0 : this.economy.energyPerTicket;\n }\n\n // ── The run ────────────────────────────────────────────────────────────────────────────────\n\n /** Start the selected level: a ticket (from energy if none is waiting), then the road. */\n async start(): Promise<boolean> {\n if (this.running || this.ending || this.busy || !this.hero) return false;\n const economy = this.economy;\n if (economy && this.balance(TICKET_ID) < 1) {\n if (this.balance(ENERGY_ID) < economy.energyPerTicket) {\n this.notify({ kind: \"notEnough\", currencyID: ENERGY_ID });\n return false;\n }\n this.busy = true;\n this.changed();\n const res = await this.client.currency.convert(\n CurrencyType.Virtual,\n ENERGY_ID,\n CurrencyType.Virtual,\n TICKET_ID,\n economy.energyPerTicket,\n );\n this.busy = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n return false;\n }\n }\n const hero = this.hero;\n this.battle = new Battle({\n level: levelOf(this.selected),\n stats: hero.fighter,\n heroAttack: hero.gear.attack,\n heroArmor: hero.gear.armor,\n shield: hero.gear.shield,\n ult: this.progress.ult,\n // a title without the run economy plays free — with full potion slots\n potions: this.economy\n ? Math.min(POTION_SLOTS, this.balance(POTION_ID))\n : POTION_SLOTS,\n seed: Date.now() ^ (this.selected * 2654435761),\n });\n this.running = true;\n this.changed();\n return true;\n }\n\n takeCard(id: string): boolean {\n const ok = this.battle.takeCard(id);\n if (ok) this.changed();\n return ok;\n }\n\n /** \"Refresh\": three other cards for the same choice (once a run). */\n reroll(): boolean {\n const ok = this.battle.reroll();\n if (ok) this.changed();\n return ok;\n }\n\n /** The camp's choice; \"both\" asks for the gems first (converted into XP at the server). */\n async chooseCamp(choice: CampChoice): Promise<boolean> {\n if (this.battle.pending?.kind !== \"camp\" || this.busy) return false;\n if (choice === \"both\") {\n if (!this.economy?.gemsToXp) return false;\n if (this.balance(GEMS_ID) < CAMP_FEAST_GEMS) {\n this.notify({ kind: \"notEnough\", currencyID: GEMS_ID });\n return false;\n }\n this.busy = true;\n this.changed();\n const res = await this.client.currency.convert(\n CurrencyType.Virtual,\n GEMS_ID,\n CurrencyType.Virtual,\n XP_ID,\n CAMP_FEAST_GEMS,\n );\n this.busy = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n this.changed();\n return false;\n }\n }\n const ok = this.battle.chooseCamp(choice);\n this.changed();\n return ok;\n }\n\n /** Leave the run — it ends as lost (the small chest is still earned). */\n abandon(): void {\n if (!this.running) return;\n this.battle.abandon();\n this.ended(this.battle.result!);\n }\n\n /** Open the run's chest with its ticket, then back to the map. */\n async claim(): Promise<LootboxOpenResponse | null> {\n const ending = this.ending;\n if (!ending || ending.claiming) return null;\n const economy = this.economy;\n const chest = economy\n ? chestFor(economy, ending.tier, this.progress.chests)\n : null;\n if (!economy || !chest || this.balance(TICKET_ID) < 1) {\n this.backToMap();\n return null;\n }\n ending.claiming = true;\n this.changed();\n // The chest's price is the publisher's: in Aspis the player confirms it on idosgames.com (the kit shows it).\n const res = await payWithCredits(this.client, (creditsConfirmationID) =>\n this.client.lootbox.open(chest, 1, undefined, undefined, creditsConfirmationID),\n );\n ending.claiming = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n this.changed();\n return null;\n }\n ending.opened = res.data;\n this.progress = { ...this.progress, chests: this.progress.chests + 1 };\n this.save();\n this.backToMap();\n return res.data;\n }\n\n private backToMap(): void {\n this.ending = null;\n this.running = false;\n this.select(\n Math.min(\n this.openLevel(),\n this.selected + (this.progressJustMoved ? 1 : 0),\n ),\n );\n this.progressJustMoved = false;\n }\n\n private ended(result: RunResult): void {\n this.running = false;\n this.ending = {\n result,\n tier: chestTier(result),\n opened: null,\n claiming: false,\n };\n const level = levelOf(result.level);\n if (result.cleared) {\n this.callbacks.onLevelCleared?.(level.island.index, level.level);\n this.report(METRIC_LEVEL_CLEARED, 1);\n if (result.level > this.progress.cleared) {\n this.progress = { ...this.progress, cleared: result.level };\n this.progressJustMoved = true;\n this.save();\n }\n }\n this.report(METRIC_RUN_FINISHED, 1);\n // the potions drunk leave the wallet at the server (into XP)\n if (result.potionsUsed > 0 && this.economy?.potionToXp)\n void this.client.currency.convert(\n CurrencyType.Virtual,\n POTION_ID,\n CurrencyType.Virtual,\n XP_ID,\n result.potionsUsed,\n );\n this.changed();\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** One frame of the fight (called by the scene). Returns what happened, for the drawing. */\n tick(dt: number): BattleEvent[] {\n if (!this.running) return [];\n const events = this.battle.tick(dt);\n for (const e of events) if (e.type === \"end\") this.ended(e.result);\n this.hudClock += dt;\n const turn = events.some((e) => TURN_EVENTS.has(e.type));\n if (turn || this.hudClock >= 1 / HUD_HZ) {\n this.hudClock = 0;\n this.changed();\n }\n return events;\n }\n\n activate(): void {}\n\n suspend(): void {}\n\n destroy(): void {\n for (const off of this.offClient) off();\n this.offClient = [];\n this.listeners.clear();\n }\n\n private notify(notice: Notice): void {\n this.notice = { ...notice, at: Date.now() };\n this.changed();\n }\n\n // ── Progress ───────────────────────────────────────────────────────────────────────────────\n\n private async load(): Promise<void> {\n let raw = this.readProgress();\n if (raw === undefined) {\n const res = await this.client.userCustomData.getMyUserCustomData();\n raw = res.ok ? (res.data.Private?.[PROGRESS_KEY]?.Value ?? null) : null;\n }\n this.progress = parseProgress(raw ?? null);\n this.loaded = true;\n this.select(this.openLevel());\n }\n\n /** `undefined` — the login state did not bring the custom data at all (ask once). */\n private readProgress(): string | null | undefined {\n const cd = this.client.data.user.state?.CustomData as\n | { Private?: Record<string, { Value?: string | null } | undefined> }\n | null\n | undefined;\n if (!cd?.Private) return undefined;\n return cd.Private[PROGRESS_KEY]?.Value ?? null;\n }\n\n private save(): void {\n if (!this.loaded) return;\n const value = serializeProgress(this.progress);\n if (value === this.readProgress()) return;\n void this.client.userCustomData.setPrivateData(PROGRESS_KEY, value);\n }\n\n // ── Quest metrics ──────────────────────────────────────────────────────────────────────────\n\n /** Report a metric to the quests that count it — one call at a time, never above their cap. */\n private report(metricID: string, amount: number): void {\n if (!questMetric(this.questSection(), metricID)) return;\n const q = this.metricQueue.get(metricID) ?? { pending: 0, busy: false };\n q.pending += amount;\n this.metricQueue.set(metricID, q);\n void this.pumpMetric(metricID);\n }\n\n private async pumpMetric(metricID: string): Promise<void> {\n const q = this.metricQueue.get(metricID);\n const metric = questMetric(this.questSection(), metricID);\n if (!q || q.busy || q.pending <= 0 || !metric) return;\n const chunk =\n metric.maxPerCall > 0\n ? Math.min(q.pending, metric.maxPerCall)\n : q.pending;\n q.busy = true;\n q.pending -= chunk;\n await this.client.quest.addQuestProgress(metricID, chunk);\n q.busy = false;\n void this.pumpMetric(metricID);\n }\n\n private questSection(): QuestSection | undefined {\n return configSection<QuestSection>(this.client, \"Quest\");\n }\n}\n\n// ── Pure helpers ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * The fighter: the selected hero while it is playable; else the strongest owned hero; else the\n * first hero playable by default (roster order).\n */\nexport function pickHero(\n section: CharacterSection | undefined,\n owned: Record<string, HeroModelView | undefined>,\n selectedID: string | null,\n): string | null {\n const defs = section?.Definitions ?? {};\n const playable = (id: string) =>\n Number(owned[id]?.Level ?? 0) > 0 || !!defs[id]?.Unlock?.UnlockedByDefault;\n if (selectedID && defs[selectedID] && playable(selectedID)) return selectedID;\n const strongest = Object.entries(owned)\n .filter(([id, m]) => defs[id] && Number(m?.Level ?? 0) > 0)\n .sort(([, a], [, b]) => Number(b?.Power ?? 0) - Number(a?.Power ?? 0))[0];\n if (strongest) return strongest[0];\n const byOrder = Object.entries(defs)\n .filter(([id, d]) => d && playable(id))\n .sort(\n ([a, x], [b, y]) =>\n Number(x?.Identity?.SortOrder ?? 0) -\n Number(y?.Identity?.SortOrder ?? 0) || a.localeCompare(b),\n )[0];\n return byOrder?.[0] ?? null;\n}\n"
387
387
  },
388
388
  {
389
389
  "path": "game/sim.ts",