@idosgames/mcp 0.1.44 → 0.1.46

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 (43) hide show
  1. package/dist/cli.js +1597 -1542
  2. package/package.json +1 -1
  3. package/registry/host.json +3 -3
  4. package/registry/index.json +172 -125
  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 +15 -234
  17. package/registry/modules/idle-rpg.json +8 -8
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/kit-game.json +171 -0
  21. package/registry/modules/leaderboard.json +3 -3
  22. package/registry/modules/lootboxes.json +5 -5
  23. package/registry/modules/mailbox.json +3 -3
  24. package/registry/modules/marketplace.json +3 -3
  25. package/registry/modules/mini-game.json +3 -3
  26. package/registry/modules/multiplayer.json +3 -3
  27. package/registry/modules/premium.json +5 -5
  28. package/registry/modules/profile.json +3 -3
  29. package/registry/modules/pvp.json +3 -3
  30. package/registry/modules/quests.json +3 -3
  31. package/registry/modules/rewards.json +3 -3
  32. package/registry/modules/season.json +3 -3
  33. package/registry/modules/store.json +6 -6
  34. package/registry/modules/tutorials.json +3 -3
  35. package/registry/modules/video-studio.json +9 -9
  36. package/registry/modules/voxelcraft.json +6 -6
  37. package/registry/modules/workshop.json +6 -6
  38. package/registry/skills/currency-system.json +1 -1
  39. package/registry/skills/idosgames-compose-modules.json +1 -1
  40. package/registry/skills/idosgames-getting-started.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +1 -1
  42. package/registry/skills/store-system.json +1 -1
  43. package/registry/video.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.1",
36
+ "@idosgames/module-sdk": "0.13.3",
37
+ "@idosgames/react": "0.7.1",
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.1",
39
+ "@idosgames/module-sdk": "0.13.3",
40
+ "@idosgames/react": "0.7.1",
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.1",
38
+ "@idosgames/module-sdk": "0.13.3",
39
+ "@idosgames/react": "0.7.1",
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.1",
41
+ "@idosgames/module-sdk": "0.13.3",
42
+ "@idosgames/react": "0.7.1",
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.1",
39
+ "@idosgames/module-sdk": "0.13.3",
40
+ "@idosgames/react": "0.7.1",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "cb4b66936fa31747a116946730b9fc2576262e68220570b149de0dbf3bb9a943",