@idosgames/mcp 0.1.36 → 0.1.38

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 (44) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +2204 -27
  3. package/package.json +4 -1
  4. package/registry/host.json +7 -7
  5. package/registry/index.json +310 -156
  6. package/registry/modules/ai.json +7 -7
  7. package/registry/modules/board-game.json +14 -14
  8. package/registry/modules/boosts.json +3 -3
  9. package/registry/modules/character.json +32 -15
  10. package/registry/modules/chat.json +3 -3
  11. package/registry/modules/collections.json +7 -7
  12. package/registry/modules/coop-events.json +7 -7
  13. package/registry/modules/craft.json +3 -3
  14. package/registry/modules/deals.json +7 -7
  15. package/registry/modules/events.json +8 -8
  16. package/registry/modules/friends.json +9 -9
  17. package/registry/modules/hero-run.json +498 -0
  18. package/registry/modules/idle-rpg.json +69 -17
  19. package/registry/modules/inventory.json +3 -3
  20. package/registry/modules/invite.json +7 -7
  21. package/registry/modules/leaderboard.json +10 -10
  22. package/registry/modules/lootboxes.json +7 -7
  23. package/registry/modules/mailbox.json +8 -8
  24. package/registry/modules/marketplace.json +8 -8
  25. package/registry/modules/mini-game.json +8 -8
  26. package/registry/modules/multiplayer.json +7 -7
  27. package/registry/modules/premium.json +7 -7
  28. package/registry/modules/profile.json +7 -7
  29. package/registry/modules/pvp.json +7 -7
  30. package/registry/modules/quests.json +8 -8
  31. package/registry/modules/rewards.json +8 -8
  32. package/registry/modules/season.json +7 -7
  33. package/registry/modules/store.json +7 -7
  34. package/registry/modules/tutorials.json +7 -7
  35. package/registry/modules/video-studio.json +228 -0
  36. package/registry/modules/voxelcraft.json +42 -125
  37. package/registry/modules/workshop.json +3 -3
  38. package/registry/skills/authentication.json +1 -1
  39. package/registry/skills/hero-run.json +6 -0
  40. package/registry/skills/idosgames-module-contract.json +2 -2
  41. package/registry/skills/idosgames-project-structure.json +1 -1
  42. package/registry/skills/idosgames-ui-kit.json +1 -1
  43. package/registry/skills/video-studio.json +6 -0
  44. package/registry/video.json +9800 -0
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.22.0",
39
- "@idosgames/module-sdk": "0.12.0",
40
- "@idosgames/react": "0.5.2",
38
+ "@idosgames/core": "0.23.0",
39
+ "@idosgames/module-sdk": "0.13.0",
40
+ "@idosgames/react": "0.6.0",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "8ee2540d83607f36c770ed998f2a3806edac1c0373b89a3425549f3f80f0e9ff",
@@ -31,15 +31,15 @@
31
31
  "name": "iDos Games",
32
32
  "url": "https://idosgames.com"
33
33
  },
34
- "version": "0.1.2"
34
+ "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.22.0",
38
- "@idosgames/module-sdk": "0.12.0",
39
- "@idosgames/react": "0.5.2",
37
+ "@idosgames/core": "0.23.0",
38
+ "@idosgames/module-sdk": "0.13.0",
39
+ "@idosgames/react": "0.6.0",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "bb88c07207fcc242fa2328e5aece9f7afca487cf8a6e0947de8576f387377a34",
42
+ "contentHash": "090986cb22bc6fe03d78b9227d8720477737f35757a0495d6399b43bc3577fd7",
43
43
  "files": [
44
44
  {
45
45
  "path": "CollectionsScreen.tsx",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  {
49
49
  "path": "components/Album.tsx",
50
- "content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Icon,\n ProgressBar,\n ResourceList,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { grandPrizeState, type SetRow } from \"../model\";\nimport { t } from \"../i18n\";\nimport { CardTile } from \"./CardTile\";\n\nexport function Album({\n collectionID,\n name,\n grandPrize,\n prize,\n sets,\n onChanged,\n}: {\n collectionID: string;\n name: string;\n grandPrize: ReturnType<typeof grantLines>;\n prize: ReturnType<typeof grandPrizeState>;\n sets: SetRow[];\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const owned = sets.reduce((s, x) => s + x.owned, 0);\n const total = sets.reduce((s, x) => s + x.total, 0);\n\n const claimSet = async (setID: string) => {\n setBusy(setID);\n const res = await client.collection.claimSetReward(collectionID, setID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onChanged();\n };\n const claimPrize = async () => {\n setBusy(\"grand\");\n const res = await client.collection.claimGrandPrize(collectionID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n await onChanged();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(name)}\n </div>\n <ProgressBar value={owned} max={Math.max(1, total)} tone=\"gold\" />\n {grandPrize.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList lines={grandPrize} size={20} />\n {prize === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claimPrize()}\n >\n {t(\"claim\")}\n </Button>\n ) : prize === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n ) : null}\n </div>\n {sets.map((s, i) => (\n <Card key={s.setID} highlight={s.state === \"ready\"} style={stagger(i)}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(s.name)}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {s.owned} / {s.total}\n </span>\n </div>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {s.cards.map((c) => (\n <CardTile key={c.id} card={c} />\n ))}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <ResourceList lines={s.reward} size={18} />\n {s.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === s.setID}\n onClick={() => void claimSet(s.setID)}\n data-tutorial-anchor={`collection:set:${s.setID}`}\n >\n {t(\"claim\")}\n </Button>\n ) : s.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={20} />\n ) : null}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n"
50
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n Card,\n Icon,\n ProgressBar,\n ResourceList,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { grandPrizeState, type SetRow } from \"../model\";\nimport { t } from \"../i18n\";\nimport { CardTile } from \"./CardTile\";\n\nexport function Album({\n collectionID,\n name,\n grandPrize,\n prize,\n sets,\n onChanged,\n}: {\n collectionID: string;\n name: string;\n grandPrize: ReturnType<typeof grantLines>;\n prize: ReturnType<typeof grandPrizeState>;\n sets: SetRow[];\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const owned = sets.reduce((s, x) => s + x.owned, 0);\n const total = sets.reduce((s, x) => s + x.total, 0);\n\n const claimSet = async (setID: string) => {\n setBusy(setID);\n const res = await client.collection.claimSetReward(collectionID, setID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onChanged();\n };\n const claimPrize = async () => {\n setBusy(\"grand\");\n const res = await client.collection.claimGrandPrize(collectionID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n await onChanged();\n };\n\n return (\n <Columns gap={12} min={360}>\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"grid\",\n gap: 8,\n gridColumn: \"1 / -1\",\n }}\n >\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(name)}\n </div>\n <ProgressBar value={owned} max={Math.max(1, total)} tone=\"gold\" />\n {grandPrize.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList lines={grandPrize} size={20} />\n {prize === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claimPrize()}\n >\n {t(\"claim\")}\n </Button>\n ) : prize === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n ) : null}\n </div>\n {sets.map((s, i) => (\n <Card key={s.setID} highlight={s.state === \"ready\"} style={stagger(i)}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(s.name)}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {s.owned} / {s.total}\n </span>\n </div>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {s.cards.map((c) => (\n <CardTile key={c.id} card={c} />\n ))}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <ResourceList lines={s.reward} size={18} />\n {s.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === s.setID}\n onClick={() => void claimSet(s.setID)}\n data-tutorial-anchor={`collection:set:${s.setID}`}\n >\n {t(\"claim\")}\n </Button>\n ) : s.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={20} />\n ) : null}\n </div>\n </div>\n </Card>\n ))}\n </Columns>\n );\n}\n"
51
51
  },
52
52
  {
53
53
  "path": "components/CardTile.tsx",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  {
81
81
  "path": "module.meta.json",
82
- "content": "{\n \"id\": \"collections\",\n \"type\": \"feature\",\n \"summary\": \"Collections for any game: an album of card sets with set rewards and a grand prize, packs and chests, trading spare cards with friends.\",\n \"description\": \"A ready collections screen (engine Collection) for ANY game, built on the @idosgames/react/ui kit and client.collection. Album: the player's collection with every set in order — the cards held (with how many) and the gaps, a reward to claim per completed set and a grand prize for the whole album. Packs: every pack type with every way to pay, opened once or ten at a time (the server rolls them — new cards and duplicates are shown), and collection chests bought with the points duplicates turn into. Trades: offers from other players (accept / decline), your own offers (cancel), and giving a spare card to a friend. Registers the 'collections' feature; its badge counts set rewards and the grand prize waiting. Ships title-seed.json: a small album with sets, a pack and a chest.\",\n \"provides\": [\n \"collection album with sets\",\n \"set rewards and grand prize\",\n \"card packs, once or ten at a time\",\n \"collection chests for points\",\n \"trading cards with friends\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\n \"collection\",\n \"album\",\n \"cards\",\n \"stickers\",\n \"trading\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
82
+ "content": "{\n \"id\": \"collections\",\n \"type\": \"feature\",\n \"summary\": \"Collections for any game: an album of card sets with set rewards and a grand prize, packs and chests, trading spare cards with friends.\",\n \"description\": \"A ready collections screen (engine Collection) for ANY game, built on the @idosgames/react/ui kit and client.collection. Album: the player's collection with every set in order — the cards held (with how many) and the gaps, a reward to claim per completed set and a grand prize for the whole album. Packs: every pack type with every way to pay, opened once or ten at a time (the server rolls them — new cards and duplicates are shown), and collection chests bought with the points duplicates turn into. Trades: offers from other players (accept / decline), your own offers (cancel), and giving a spare card to a friend. Registers the 'collections' feature; its badge counts set rewards and the grand prize waiting. Ships title-seed.json: a small album with sets, a pack and a chest.\",\n \"provides\": [\n \"collection album with sets\",\n \"set rewards and grand prize\",\n \"card packs, once or ten at a time\",\n \"collection chests for points\",\n \"trading cards with friends\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\n \"collection\",\n \"album\",\n \"cards\",\n \"stickers\",\n \"trading\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
83
83
  }
84
84
  ]
85
85
  }
@@ -29,19 +29,19 @@
29
29
  "name": "iDos Games",
30
30
  "url": "https://idosgames.com"
31
31
  },
32
- "version": "0.1.2"
32
+ "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.22.0",
36
- "@idosgames/module-sdk": "0.12.0",
37
- "@idosgames/react": "0.5.2",
35
+ "@idosgames/core": "0.23.0",
36
+ "@idosgames/module-sdk": "0.13.0",
37
+ "@idosgames/react": "0.6.0",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "ed7bff8cefd18dfb597b5a09e58c2ee5fc8500dc41e37091fa1e798540307db6",
40
+ "contentHash": "a2fa5522c2b27ba330c9edf5610df2029a7f6a50843bbdbc955dc73b60f5eb07",
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 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 <div style={{ display: \"grid\", gap: 12 }}>\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 </div>\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} 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"
45
45
  },
46
46
  {
47
47
  "path": "i18n.ts",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
64
- "content": "{\n \"id\": \"coop-events\",\n \"type\": \"feature\",\n \"summary\": \"Team events for any game: join a group, each member spins the wheel to build their own object, the team shares a grand prize.\",\n \"description\": \"A ready team event screen (engine CoopEvent) for ANY game, built on the @idosgames/react/ui kit and client.coopEvent. Not in a group: the running team events of the title with their timers and team prize, 'Join a team' (the server matches partners, helpers fill the gaps). In a group: every member with their object and its progress, 'Spin' (once or five at a time, free or for currency) to build your own object, the object's reward when it is done, the team prize when every object is done, and leaving the team. The group lives in one shared document on the server — the screen re-reads it after every action. Registers the 'coop-events' feature; its badge counts the object reward and the team prize waiting. Ships title-seed.json: a weekly team build event.\",\n \"provides\": [\n \"team event screen\",\n \"join a team (matchmaking by the server)\",\n \"spin to build your object\",\n \"object reward and team prize\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\"coop\", \"team\", \"group\", \"events\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
64
+ "content": "{\n \"id\": \"coop-events\",\n \"type\": \"feature\",\n \"summary\": \"Team events for any game: join a group, each member spins the wheel to build their own object, the team shares a grand prize.\",\n \"description\": \"A ready team event screen (engine CoopEvent) for ANY game, built on the @idosgames/react/ui kit and client.coopEvent. Not in a group: the running team events of the title with their timers and team prize, 'Join a team' (the server matches partners, helpers fill the gaps). In a group: every member with their object and its progress, 'Spin' (once or five at a time, free or for currency) to build your own object, the object's reward when it is done, the team prize when every object is done, and leaving the team. The group lives in one shared document on the server — the screen re-reads it after every action. Registers the 'coop-events' feature; its badge counts the object reward and the team prize waiting. Ships title-seed.json: a weekly team build event.\",\n \"provides\": [\n \"team event screen\",\n \"join a team (matchmaking by the server)\",\n \"spin to build your object\",\n \"object reward and team prize\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\"coop\", \"team\", \"group\", \"events\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
65
65
  }
66
66
  ]
67
67
  }
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.22.0",
39
- "@idosgames/module-sdk": "0.12.0",
40
- "@idosgames/react": "0.5.2",
38
+ "@idosgames/core": "0.23.0",
39
+ "@idosgames/module-sdk": "0.13.0",
40
+ "@idosgames/react": "0.6.0",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "f4d962e257dc5647a013afc7899b43134964f6505431f9ed1147611c67c4af90",
@@ -31,19 +31,19 @@
31
31
  "name": "iDos Games",
32
32
  "url": "https://idosgames.com"
33
33
  },
34
- "version": "0.1.2"
34
+ "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.22.0",
38
- "@idosgames/module-sdk": "0.12.0",
39
- "@idosgames/react": "0.5.2",
37
+ "@idosgames/core": "0.23.0",
38
+ "@idosgames/module-sdk": "0.13.0",
39
+ "@idosgames/react": "0.6.0",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "4f1608aa1fd90cf3561d8da43160187b46cc15b0f2f22fd0d980142ef90f0ef6",
42
+ "contentHash": "20e587ae8a91d2d1fff38e7b2c1d66a320b2a995d797629b890c6ca9b8e9bb5e",
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 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 <div style={{ display: \"grid\", 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 </div>\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\n at={slot.ComputedExpiresAtUtc}\n now={now}\n label={`⏱ ${t(\"endsIn\")}`}\n />\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 border: available ? `2px solid ${v.gold}` : panel.border,\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} 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"
47
47
  },
48
48
  {
49
49
  "path": "i18n.ts",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",
66
- "content": "{\n \"id\": \"deals\",\n \"type\": \"feature\",\n \"summary\": \"Special offers for any game: a new deal pops up by itself; single offers, step chains and pick-one gifts with a milestone bar.\",\n \"description\": \"Special offers (Unity Alikhan/DealOffer) for ANY game, built on the @idosgames/react/ui kit and client.dealOffer. An always-on watcher pops a newly started deal up by itself once (and records the impression with recordShow); the 'Offers' screen lists every active deal with its timer and everything it can give. A deal opens as a popup shaped by its node graph: one big offer, a chain of steps (each unlocks the next, the server decides status), or cards to pick one from; free steps, currency prices, store-only steps and the milestone bar with its rewards. Registers the 'deals' feature; its badge counts free steps and milestone rewards waiting. Ships title-seed.json: a three-step starter pack with a bonus and a free pick-one welcome gift.\",\n \"provides\": [\n \"special offers screen\",\n \"auto-popup of a new deal\",\n \"single, chain and pick-one offers\",\n \"offer milestones\",\n \"deals badge\",\n \"demo offers\"\n ],\n \"tags\": [\n \"offers\",\n \"deals\",\n \"starter-pack\",\n \"monetization\",\n \"popup\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
66
+ "content": "{\n \"id\": \"deals\",\n \"type\": \"feature\",\n \"summary\": \"Special offers for any game: a new deal pops up by itself; single offers, step chains and pick-one gifts with a milestone bar.\",\n \"description\": \"Special offers (Unity Alikhan/DealOffer) for ANY game, built on the @idosgames/react/ui kit and client.dealOffer. An always-on watcher pops a newly started deal up by itself once (and records the impression with recordShow); the 'Offers' screen lists every active deal with its timer and everything it can give. A deal opens as a popup shaped by its node graph: one big offer, a chain of steps (each unlocks the next, the server decides status), or cards to pick one from; free steps, currency prices, store-only steps and the milestone bar with its rewards. Registers the 'deals' feature; its badge counts free steps and milestone rewards waiting. Ships title-seed.json: a three-step starter pack with a bonus and a free pick-one welcome gift.\",\n \"provides\": [\n \"special offers screen\",\n \"auto-popup of a new deal\",\n \"single, chain and pick-one offers\",\n \"offer milestones\",\n \"deals badge\",\n \"demo offers\"\n ],\n \"tags\": [\n \"offers\",\n \"deals\",\n \"starter-pack\",\n \"monetization\",\n \"popup\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
67
67
  }
68
68
  ]
69
69
  }
@@ -34,19 +34,19 @@
34
34
  "name": "iDos Games",
35
35
  "url": "https://idosgames.com"
36
36
  },
37
- "version": "0.1.3"
37
+ "version": "0.1.4"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.22.0",
41
- "@idosgames/module-sdk": "0.12.0",
42
- "@idosgames/react": "0.5.2",
40
+ "@idosgames/core": "0.23.0",
41
+ "@idosgames/module-sdk": "0.13.0",
42
+ "@idosgames/react": "0.6.0",
43
43
  "react": "19.2.7"
44
44
  },
45
- "contentHash": "5cdfd90f4bb56df44d95dea46bb8bd49e682577c73b18c739ff9b5d0f7c758dc",
45
+ "contentHash": "70e2cc84a42ee818deaccf7c39ec87e826d7f22e71fc05346f40be96a6f29e6b",
46
46
  "files": [
47
47
  {
48
48
  "path": "EventsScreen.tsx",
49
- "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Center,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Spinner,\n U,\n UButton,\n UItemFrame,\n UPopup,\n URays,\n UScreen,\n UTabs,\n UTimer,\n U_PATTERN,\n tokenGlyph,\n uText,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\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 watcher and the reload after a claim used to overlap — the transport skipped the\n // second as a duplicate, which wiped the list and lost the fresh read after the claim.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.timedEvent.getActiveEvents();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) events = res.data.ActiveEvents ?? [];\n else events ??= [];\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\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 refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <UScreen\n background=\"#00adff\"\n footer={\n events.length > 1 ? (\n <UTabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(\n e.Content?.DisplayName ?? e.TimedEventID,\n ),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n ) : undefined\n }\n >\n <EventView key={current.TimedEventID} event={current} store={store} />\n </UScreen>\n );\n };\n}\n\n/**\n * Keeps the badge current: loads the events once, then every few minutes; re-reads soon after the\n * player earns event tokens (a milestone may have been reached) and re-counts on premium changes.\n */\nexport function watchEvents(ctx: ModuleContext, store: EventsStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no event running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n });\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n const hasVip = rows.some((r) => r.vip.length > 0);\n const level = rows.filter((r) => goal.earned >= r.required).length;\n const left = countdown(event.ComputedEndUtc, now);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\" }}>\n {/* ── TopBg: the pass hero — curtains, gold piles, the chest over its light ── */}\n <div\n style={{\n position: \"relative\",\n height: 178,\n overflow: \"hidden\",\n background:\n \"radial-gradient(ellipse at 50% 30%, #6a5cff 0%, #1a9eff 75%)\",\n }}\n >\n <EventHeroArt />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: 8,\n marginLeft: -80,\n width: 160,\n height: 160,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -20 }}>\n <URays size={200} color=\"rgba(255,255,255,.35)\" />\n </span>\n <span\n style={{\n position: \"relative\",\n animation: \"idos-u-float 2.6s ease-in-out infinite\",\n filter: \"drop-shadow(0 6px 0 rgba(0,0,0,.3))\",\n }}\n >\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"chest\"}\n size={112}\n fallback=\"chest\"\n />\n </span>\n </div>\n </div>\n\n {/* ── PanelFrame_TopBar03: the title banner, timer, level slider, Activate ── */}\n <div\n style={{\n position: \"relative\",\n background: \"linear-gradient(180deg, #3d3d58 0%, #35354f 100%)\",\n borderTop: `6px solid ${U.gold}`,\n borderBottom: `3px solid ${U.ink}`,\n padding: \"30px 12px 12px\",\n display: \"grid\",\n gap: 10,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -26,\n transform: \"translateX(-50%)\",\n maxWidth: \"86%\",\n padding: \"6px 28px 8px\",\n background: `linear-gradient(180deg, #ffe066 0%, ${U.gold} 100%)`,\n border: `3px solid ${U.ink}`,\n borderRadius: \"14px 14px 10px 10px\",\n boxShadow:\n \"inset 0 3px 0 rgba(255,255,255,.5), 0 4px 0 rgba(0,0,0,.3)\",\n whiteSpace: \"nowrap\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n ...uText(20),\n }}\n >\n {catalog.localize(event.Content?.DisplayName ?? event.TimedEventID)}\n </div>\n {left ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UTimer>{left}</UTimer>\n </div>\n ) : null}\n <LevelSlider\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n level={level}\n glyph={tokenGlyph({ kind: \"token\", id: \"TimedEvent\", amount: 0 })}\n />\n {vipID && !vipHeld && store.vipOnSale() ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UButton tone=\"yellow\" attract onClick={() => store.openVip(vipID)}>\n <Icon glyph=\"lock\" size={18} /> {t(\"activate\")}\n </UButton>\n </div>\n ) : null}\n <span style={{ position: \"absolute\", right: 10, top: 12 }}>\n <UButton\n tone=\"sky\"\n size=\"s\"\n onClick={() => setSources(true)}\n aria-label={t(\"howToEarn\")}\n >\n ?\n </UButton>\n </span>\n {event.Content?.Description ? (\n <div style={{ ...uText(13, \"#d6dcff\"), textAlign: \"center\" }}>\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <UButton\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </UButton>\n ) : null}\n </div>\n\n {/* ── Bottom: Free (blue) | the track | Premium (yellow) ── */}\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n gridTemplateColumns: hasVip ? \"1fr 44px 1fr\" : \"1fr 44px\",\n background: hasVip\n ? `${U_PATTERN}, linear-gradient(90deg, #0aabf5 50%, #fad100 50%)`\n : `${U_PATTERN}, #0aabf5`,\n paddingBottom: 14,\n }}\n >\n <ColumnHead tone=\"free\" />\n <span />\n {hasVip ? <ColumnHead tone=\"premium\" held={vipHeld} /> : null}\n {/* the vertical stage track down the middle */}\n <div\n style={{\n position: \"absolute\",\n left: hasVip ? \"50%\" : \"calc(100% - 22px)\",\n top: 62,\n bottom: 24,\n width: 14,\n marginLeft: -7,\n borderRadius: 7,\n background: \"#232639\",\n border: `2px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n height: `${rows.length > 0 ? (level / rows.length) * 100 : 0}%`,\n background: \"linear-gradient(90deg, #ffd06b, #ff9a1a)\",\n transition: \"height .5s ease\",\n }}\n />\n </div>\n {rows.map((row, i) => (\n <MilestoneRowView\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n hasVip={hasVip}\n vipID={vipID}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nconst COIN_SPOTS: Array<[number, number]> = [\n [60, 158],\n [86, 150],\n [112, 156],\n [134, 162],\n [268, 156],\n [294, 148],\n [320, 152],\n [346, 160],\n];\n\n/** Background_Pass curtains + gold piles behind the chest, drawn in SVG. */\nfunction EventHeroArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 400 178\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke={U.ink} strokeWidth=\"3\" strokeLinejoin=\"round\">\n <path d=\"M0 0 L120 0 C100 40 70 60 52 178 L0 178 Z\" fill=\"#c2185b\" />\n <path d=\"M0 0 L84 0 C70 50 40 80 26 178 L0 178 Z\" fill=\"#e91e63\" />\n <path\n d=\"M400 0 L280 0 C300 40 330 60 348 178 L400 178 Z\"\n fill=\"#c2185b\"\n />\n <path\n d=\"M400 0 L316 0 C330 50 360 80 374 178 L400 178 Z\"\n fill=\"#e91e63\"\n />\n <path\n d=\"M-10 0 L410 0 L410 16 C340 30 270 22 200 30 C130 22 60 30 -10 16 Z\"\n fill=\"#ffce0d\"\n />\n <ellipse cx=\"96\" cy=\"170\" rx=\"70\" ry=\"22\" fill=\"#f5a90c\" />\n <ellipse cx=\"304\" cy=\"168\" rx=\"80\" ry=\"24\" fill=\"#f5a90c\" />\n </g>\n <g fill=\"#ffe066\" stroke=\"#a8650a\" strokeWidth=\"2\">\n {COIN_SPOTS.map(([x, y], i) => (\n <ellipse key={i} cx={x} cy={y} rx=\"14\" ry=\"6\" />\n ))}\n </g>\n </svg>\n );\n}\n\n/** Slider_Icon02: the token icon at the left end, the orange fill, the level badge at the right. */\nfunction LevelSlider({\n value,\n max,\n level,\n glyph,\n}: {\n value: number;\n max: number;\n level: number;\n glyph: string;\n}): ReactNode {\n const pct = Math.max(0, Math.min(1, value / max));\n return (\n <div\n style={{\n position: \"relative\",\n height: 52,\n display: \"flex\",\n alignItems: \"center\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 26,\n right: 26,\n height: 28,\n borderRadius: 14,\n background: \"#1f1f29\",\n border: `3px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${pct * 100}%`,\n height: \"100%\",\n background: \"linear-gradient(180deg, #ffd06b 0%, #ff9a1a 100%)\",\n boxShadow: \"inset 0 3px 0 rgba(255,255,255,.35)\",\n transition: \"width .4s ease\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(14),\n }}\n >\n {formatAmount(value)} / {formatAmount(max)}\n </div>\n </div>\n <span\n style={{\n position: \"absolute\",\n left: 0,\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph={glyph} size={50} fallback=\"star\" />\n </span>\n <span\n style={{\n position: \"absolute\",\n right: 0,\n width: 48,\n height: 48,\n borderRadius: \"50%\",\n background:\n \"radial-gradient(circle at 50% 35%, #5fe4ff 0%, #00a6f0 70%)\",\n border: `3px solid ${U.ink}`,\n boxShadow: \"inset 0 0 0 4px #33d1ff\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(20),\n }}\n >\n {level}\n </span>\n </div>\n );\n}\n\n/** Icon_Ticket_Silver / _Gold with FREE / PREMIUM over each column. */\nfunction ColumnHead({\n tone,\n held,\n}: {\n tone: \"free\" | \"premium\";\n held?: boolean;\n}): ReactNode {\n const free = tone === \"free\";\n return (\n <div\n style={{\n display: \"grid\",\n placeItems: \"center\",\n padding: \"12px 6px 10px\",\n }}\n >\n <span\n style={{\n padding: \"6px 22px\",\n borderRadius: 10,\n border: `3px solid ${U.ink}`,\n background: free\n ? \"linear-gradient(180deg, #f2f6fb 0%, #a9b6cc 100%)\"\n : \"linear-gradient(180deg, #ffe066 0%, #f5a90c 100%)\",\n boxShadow:\n \"inset 0 3px 0 rgba(255,255,255,.5), 0 3px 0 rgba(0,0,0,.3)\",\n ...uText(18),\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n }}\n >\n {free ? null : <Icon glyph={held ? \"crown\" : \"lock\"} size={18} />}\n {free ? t(\"free\") : t(\"premium\")}\n </span>\n </div>\n );\n}\n\nconst BUBBLE = {\n free: {\n shadow: \"#6370c4\",\n bg: \"#ccdbf5\",\n border: \"#8fa1e8\",\n inner: \"#adc2ff\",\n },\n premium: {\n shadow: \"#d46300\",\n bg: \"#fff542\",\n border: \"#fca621\",\n inner: \"#ffd612\",\n },\n} as const;\n\n/** BubbleFrame05 (FrameFree / FramePremium): a speech bubble pointing at the track, the reward in it. */\nfunction Bubble({\n tone,\n lines,\n dim,\n done,\n locked,\n action,\n onLock,\n}: {\n tone: \"free\" | \"premium\";\n lines: ResourceLine[];\n dim: boolean;\n done: boolean;\n locked?: boolean;\n action?: ReactNode;\n onLock?: () => void;\n}): ReactNode {\n const c = BUBBLE[tone];\n const toRight = tone === \"free\";\n if (lines.length === 0) return <div />;\n return (\n <div\n style={{\n position: \"relative\",\n padding: toRight ? \"6px 12px 24px 8px\" : \"6px 8px 24px 12px\",\n }}\n >\n <div\n style={{\n position: \"relative\",\n borderRadius: 16,\n background: c.bg,\n border: `3px solid ${U.ink}`,\n boxShadow: `0 6px 0 ${c.shadow}`,\n padding: 7,\n minHeight: 88,\n }}\n >\n {/* the pointer toward the track */}\n <span\n style={{\n position: \"absolute\",\n top: 30,\n ...(toRight ? { right: -14 } : { left: -14 }),\n width: 14,\n height: 22,\n background: U.ink,\n clipPath: toRight\n ? \"polygon(0 0, 100% 50%, 0 100%)\"\n : \"polygon(100% 0, 0 50%, 100% 100%)\",\n }}\n />\n <div\n style={{\n borderRadius: 11,\n background: c.inner,\n border: `3px solid ${c.border}`,\n minHeight: 68,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n flexWrap: \"wrap\",\n padding: 4,\n }}\n >\n {lines.slice(0, 2).map((l, i) => (\n <UItemFrame key={i} line={l} size={lines.length > 1 ? 48 : 58} />\n ))}\n </div>\n {dim ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 13,\n background: \"rgba(20,18,26,.45)\",\n }}\n />\n ) : null}\n {done ? (\n <span style={{ position: \"absolute\", right: -8, top: -10 }}>\n <Icon glyph=\"check\" size={34} />\n </span>\n ) : null}\n {locked ? (\n <button\n type=\"button\"\n onClick={onLock}\n aria-label={t(\"getVip\")}\n style={{\n position: \"absolute\",\n right: -10,\n top: -14,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: onLock ? \"pointer\" : \"default\",\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph=\"lock\" size={36} />\n </button>\n ) : null}\n </div>\n {action ? (\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n {action}\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneRowView({\n event,\n row,\n index,\n earned,\n store,\n hasVip,\n vipID,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n hasVip: boolean;\n vipID: string | null;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n const claimBtn = (label: string) => (\n <UButton\n tone=\"green\"\n size=\"s\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {label}\n </UButton>\n );\n return (\n <>\n <div style={style} data-index={index}>\n <Bubble\n tone=\"free\"\n lines={row.reward}\n dim={!reached}\n done={row.state === \"claimed\"}\n action={row.state === \"ready\" ? claimBtn(t(\"claim\")) : undefined}\n />\n </div>\n {/* the stage badge on the track */}\n <div\n ref={ref}\n style={{\n position: \"relative\",\n zIndex: 1,\n display: \"grid\",\n justifyItems: \"center\",\n paddingTop: 34,\n }}\n >\n <span\n style={{\n minWidth: 38,\n height: 38,\n padding: \"0 4px\",\n borderRadius: 19,\n border: `3px solid ${U.ink}`,\n background: reached\n ? \"linear-gradient(180deg, #ffe066, #f5a90c)\"\n : \"linear-gradient(180deg, #8d97b3, #5d6a92)\",\n display: \"grid\",\n placeItems: \"center\",\n boxShadow: row.featured ? \"0 0 0 3px #ff243d\" : undefined,\n ...uText(row.required >= 1000 ? 10 : 13),\n }}\n >\n {formatAmount(row.required)}\n </span>\n </div>\n {hasVip ? (\n <div style={style}>\n <Bubble\n tone=\"premium\"\n lines={row.vip}\n dim={!reached}\n done={row.vipState === \"granted\"}\n locked={row.vip.length > 0 && row.vipState === \"locked\"}\n onLock={\n vipID && store.vipOnSale()\n ? () => store.openVip(vipID)\n : undefined\n }\n action={\n row.vipState === \"ready\"\n ? claimBtn(row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\"))\n : undefined\n }\n />\n </div>\n ) : null}\n </>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <UPopup tone=\"blue\" title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </UPopup>\n );\n}\n"
49
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Center,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Spinner,\n U,\n UButton,\n UItemFrame,\n UPopup,\n URays,\n UScreen,\n UTabs,\n UTimer,\n U_PATTERN,\n tokenGlyph,\n uText,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\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 watcher and the reload after a claim used to overlap — the transport skipped the\n // second as a duplicate, which wiped the list and lost the fresh read after the claim.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.timedEvent.getActiveEvents();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) events = res.data.ActiveEvents ?? [];\n else events ??= [];\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\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 refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <UScreen\n background={v.blue}\n footer={\n events.length > 1 ? (\n <UTabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(\n e.Content?.DisplayName ?? e.TimedEventID,\n ),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n ) : undefined\n }\n >\n <EventView key={current.TimedEventID} event={current} store={store} />\n </UScreen>\n );\n };\n}\n\n/**\n * Keeps the badge current: loads the events once, then every few minutes; re-reads soon after the\n * player earns event tokens (a milestone may have been reached) and re-counts on premium changes.\n */\nexport function watchEvents(ctx: ModuleContext, store: EventsStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no event running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n });\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n const hasVip = rows.some((r) => r.vip.length > 0);\n const level = rows.filter((r) => goal.earned >= r.required).length;\n const left = countdown(event.ComputedEndUtc, now);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\" }}>\n {/* ── TopBg: the pass hero — curtains, gold piles, the chest over its light ── */}\n <div\n style={{\n position: \"relative\",\n height: 178,\n overflow: \"hidden\",\n background: `radial-gradient(ellipse at 50% 30%, ${v.bgTop} 0%, ${v.blue} 75%)`,\n }}\n >\n <EventHeroArt />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: 8,\n marginLeft: -80,\n width: 160,\n height: 160,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -20 }}>\n <URays size={200} color=\"rgba(255,255,255,.35)\" />\n </span>\n <span\n style={{\n position: \"relative\",\n animation: \"idos-u-float 2.6s ease-in-out infinite\",\n filter: \"drop-shadow(0 6px 0 rgba(0,0,0,.3))\",\n }}\n >\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"chest\"}\n size={112}\n fallback=\"chest\"\n />\n </span>\n </div>\n </div>\n\n {/* ── PanelFrame_TopBar03: the title banner, timer, level slider, Activate ── */}\n <div\n style={{\n position: \"relative\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 100%)`,\n borderTop: `6px solid ${U.gold}`,\n borderBottom: `3px solid ${U.ink}`,\n padding: \"30px 12px 12px\",\n display: \"grid\",\n gap: 10,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -26,\n transform: \"translateX(-50%)\",\n maxWidth: \"86%\",\n padding: \"6px 28px 8px\",\n background: `linear-gradient(180deg, #ffe066 0%, ${U.gold} 100%)`,\n border: `3px solid ${U.ink}`,\n borderRadius: \"14px 14px 10px 10px\",\n boxShadow:\n \"inset 0 3px 0 rgba(255,255,255,.5), 0 4px 0 rgba(0,0,0,.3)\",\n whiteSpace: \"nowrap\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n ...uText(20),\n }}\n >\n {catalog.localize(event.Content?.DisplayName ?? event.TimedEventID)}\n </div>\n {left ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UTimer>{left}</UTimer>\n </div>\n ) : null}\n <LevelSlider\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n level={level}\n glyph={tokenGlyph({ kind: \"token\", id: \"TimedEvent\", amount: 0 })}\n />\n {vipID && !vipHeld && store.vipOnSale() ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UButton tone=\"yellow\" attract onClick={() => store.openVip(vipID)}>\n <Icon glyph=\"lock\" size={18} /> {t(\"activate\")}\n </UButton>\n </div>\n ) : null}\n <span style={{ position: \"absolute\", right: 10, top: 12 }}>\n <UButton\n tone=\"sky\"\n size=\"s\"\n onClick={() => setSources(true)}\n aria-label={t(\"howToEarn\")}\n >\n ?\n </UButton>\n </span>\n {event.Content?.Description ? (\n <div style={{ ...uText(13, \"#d6dcff\"), textAlign: \"center\" }}>\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <UButton\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </UButton>\n ) : null}\n </div>\n\n {/* ── Bottom: Free (blue) | the track | Premium (yellow) ── */}\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n gridTemplateColumns: hasVip ? \"1fr 44px 1fr\" : \"1fr 44px\",\n background: hasVip\n ? `${U_PATTERN}, linear-gradient(90deg, #0aabf5 50%, #fad100 50%)`\n : `${U_PATTERN}, #0aabf5`,\n paddingBottom: 14,\n }}\n >\n <ColumnHead tone=\"free\" />\n <span />\n {hasVip ? <ColumnHead tone=\"premium\" held={vipHeld} /> : null}\n {/* the vertical stage track down the middle */}\n <div\n style={{\n position: \"absolute\",\n left: hasVip ? \"50%\" : \"calc(100% - 22px)\",\n top: 62,\n bottom: 24,\n width: 14,\n marginLeft: -7,\n borderRadius: 7,\n background: v.well,\n border: `2px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n height: `${rows.length > 0 ? (level / rows.length) * 100 : 0}%`,\n background: \"linear-gradient(90deg, #ffd06b, #ff9a1a)\",\n transition: \"height .5s ease\",\n }}\n />\n </div>\n {rows.map((row, i) => (\n <MilestoneRowView\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n hasVip={hasVip}\n vipID={vipID}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nconst COIN_SPOTS: Array<[number, number]> = [\n [60, 158],\n [86, 150],\n [112, 156],\n [134, 162],\n [268, 156],\n [294, 148],\n [320, 152],\n [346, 160],\n];\n\n/** Background_Pass curtains + gold piles behind the chest, drawn in SVG. */\nfunction EventHeroArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 400 178\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke={U.ink} strokeWidth=\"3\" strokeLinejoin=\"round\">\n <path d=\"M0 0 L120 0 C100 40 70 60 52 178 L0 178 Z\" fill=\"#c2185b\" />\n <path d=\"M0 0 L84 0 C70 50 40 80 26 178 L0 178 Z\" fill=\"#e91e63\" />\n <path\n d=\"M400 0 L280 0 C300 40 330 60 348 178 L400 178 Z\"\n fill=\"#c2185b\"\n />\n <path\n d=\"M400 0 L316 0 C330 50 360 80 374 178 L400 178 Z\"\n fill=\"#e91e63\"\n />\n <path\n d=\"M-10 0 L410 0 L410 16 C340 30 270 22 200 30 C130 22 60 30 -10 16 Z\"\n fill=\"#ffce0d\"\n />\n <ellipse cx=\"96\" cy=\"170\" rx=\"70\" ry=\"22\" fill=\"#f5a90c\" />\n <ellipse cx=\"304\" cy=\"168\" rx=\"80\" ry=\"24\" fill=\"#f5a90c\" />\n </g>\n <g fill=\"#ffe066\" stroke=\"#a8650a\" strokeWidth=\"2\">\n {COIN_SPOTS.map(([x, y], i) => (\n <ellipse key={i} cx={x} cy={y} rx=\"14\" ry=\"6\" />\n ))}\n </g>\n </svg>\n );\n}\n\n/** Slider_Icon02: the token icon at the left end, the orange fill, the level badge at the right. */\nfunction LevelSlider({\n value,\n max,\n level,\n glyph,\n}: {\n value: number;\n max: number;\n level: number;\n glyph: string;\n}): ReactNode {\n const pct = Math.max(0, Math.min(1, value / max));\n return (\n <div\n style={{\n position: \"relative\",\n height: 52,\n display: \"flex\",\n alignItems: \"center\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 26,\n right: 26,\n height: 28,\n borderRadius: 14,\n background: v.well,\n border: `3px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${pct * 100}%`,\n height: \"100%\",\n background: \"linear-gradient(180deg, #ffd06b 0%, #ff9a1a 100%)\",\n boxShadow: \"inset 0 3px 0 rgba(255,255,255,.35)\",\n transition: \"width .4s ease\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(14),\n }}\n >\n {formatAmount(value)} / {formatAmount(max)}\n </div>\n </div>\n <span\n style={{\n position: \"absolute\",\n left: 0,\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph={glyph} size={50} fallback=\"star\" />\n </span>\n <span\n style={{\n position: \"absolute\",\n right: 0,\n width: 48,\n height: 48,\n borderRadius: \"50%\",\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.blue} 55%, #fff) 0%, ${v.blue} 70%)`,\n border: `3px solid ${U.ink}`,\n boxShadow: \"inset 0 0 0 4px #33d1ff\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(20),\n }}\n >\n {level}\n </span>\n </div>\n );\n}\n\n/** Icon_Ticket_Silver / _Gold with FREE / PREMIUM over each column. */\nfunction ColumnHead({\n tone,\n held,\n}: {\n tone: \"free\" | \"premium\";\n held?: boolean;\n}): ReactNode {\n const free = tone === \"free\";\n return (\n <div\n style={{\n display: \"grid\",\n placeItems: \"center\",\n padding: \"12px 6px 10px\",\n }}\n >\n <span\n style={{\n padding: \"6px 22px\",\n borderRadius: 10,\n border: `3px solid ${U.ink}`,\n background: free\n ? \"linear-gradient(180deg, #f2f6fb 0%, #a9b6cc 100%)\"\n : \"linear-gradient(180deg, #ffe066 0%, #f5a90c 100%)\",\n boxShadow:\n \"inset 0 3px 0 rgba(255,255,255,.5), 0 3px 0 rgba(0,0,0,.3)\",\n ...uText(18),\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n }}\n >\n {free ? null : <Icon glyph={held ? \"crown\" : \"lock\"} size={18} />}\n {free ? t(\"free\") : t(\"premium\")}\n </span>\n </div>\n );\n}\n\nconst BUBBLE = {\n free: {\n shadow: \"#6370c4\",\n bg: \"#ccdbf5\",\n border: \"#8fa1e8\",\n inner: \"#adc2ff\",\n },\n premium: {\n shadow: \"#d46300\",\n bg: \"#fff542\",\n border: \"#fca621\",\n inner: \"#ffd612\",\n },\n} as const;\n\n/** BubbleFrame05 (FrameFree / FramePremium): a speech bubble pointing at the track, the reward in it. */\nfunction Bubble({\n tone,\n lines,\n dim,\n done,\n locked,\n action,\n onLock,\n}: {\n tone: \"free\" | \"premium\";\n lines: ResourceLine[];\n dim: boolean;\n done: boolean;\n locked?: boolean;\n action?: ReactNode;\n onLock?: () => void;\n}): ReactNode {\n const c = BUBBLE[tone];\n const toRight = tone === \"free\";\n if (lines.length === 0) return <div />;\n return (\n <div\n style={{\n position: \"relative\",\n padding: toRight ? \"6px 12px 24px 8px\" : \"6px 8px 24px 12px\",\n }}\n >\n <div\n style={{\n position: \"relative\",\n borderRadius: 16,\n background: c.bg,\n border: `3px solid ${U.ink}`,\n boxShadow: `0 6px 0 ${c.shadow}`,\n padding: 7,\n minHeight: 88,\n }}\n >\n {/* the pointer toward the track */}\n <span\n style={{\n position: \"absolute\",\n top: 30,\n ...(toRight ? { right: -14 } : { left: -14 }),\n width: 14,\n height: 22,\n background: U.ink,\n clipPath: toRight\n ? \"polygon(0 0, 100% 50%, 0 100%)\"\n : \"polygon(100% 0, 0 50%, 100% 100%)\",\n }}\n />\n <div\n style={{\n borderRadius: 11,\n background: c.inner,\n border: `3px solid ${c.border}`,\n minHeight: 68,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n flexWrap: \"wrap\",\n padding: 4,\n }}\n >\n {lines.slice(0, 2).map((l, i) => (\n <UItemFrame key={i} line={l} size={lines.length > 1 ? 48 : 58} />\n ))}\n </div>\n {dim ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 13,\n background: \"rgba(20,18,26,.45)\",\n }}\n />\n ) : null}\n {done ? (\n <span style={{ position: \"absolute\", right: -8, top: -10 }}>\n <Icon glyph=\"check\" size={34} />\n </span>\n ) : null}\n {locked ? (\n <button\n type=\"button\"\n onClick={onLock}\n aria-label={t(\"getVip\")}\n style={{\n position: \"absolute\",\n right: -10,\n top: -14,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: onLock ? \"pointer\" : \"default\",\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph=\"lock\" size={36} />\n </button>\n ) : null}\n </div>\n {action ? (\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n {action}\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneRowView({\n event,\n row,\n index,\n earned,\n store,\n hasVip,\n vipID,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n hasVip: boolean;\n vipID: string | null;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n const claimBtn = (label: string) => (\n <UButton\n tone=\"green\"\n size=\"s\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {label}\n </UButton>\n );\n return (\n <>\n <div style={style} data-index={index}>\n <Bubble\n tone=\"free\"\n lines={row.reward}\n dim={!reached}\n done={row.state === \"claimed\"}\n action={row.state === \"ready\" ? claimBtn(t(\"claim\")) : undefined}\n />\n </div>\n {/* the stage badge on the track */}\n <div\n ref={ref}\n style={{\n position: \"relative\",\n zIndex: 1,\n display: \"grid\",\n justifyItems: \"center\",\n paddingTop: 34,\n }}\n >\n <span\n style={{\n minWidth: 38,\n height: 38,\n padding: \"0 4px\",\n borderRadius: 19,\n border: `3px solid ${U.ink}`,\n background: reached\n ? \"linear-gradient(180deg, #ffe066, #f5a90c)\"\n : \"linear-gradient(180deg, #8d97b3, #5d6a92)\",\n display: \"grid\",\n placeItems: \"center\",\n boxShadow: row.featured ? \"0 0 0 3px #ff243d\" : undefined,\n ...uText(row.required >= 1000 ? 10 : 13),\n }}\n >\n {formatAmount(row.required)}\n </span>\n </div>\n {hasVip ? (\n <div style={style}>\n <Bubble\n tone=\"premium\"\n lines={row.vip}\n dim={!reached}\n done={row.vipState === \"granted\"}\n locked={row.vip.length > 0 && row.vipState === \"locked\"}\n onLock={\n vipID && store.vipOnSale()\n ? () => store.openVip(vipID)\n : undefined\n }\n action={\n row.vipState === \"ready\"\n ? claimBtn(row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\"))\n : undefined\n }\n />\n </div>\n ) : null}\n </>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <UPopup tone=\"blue\" title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </UPopup>\n );\n}\n"
50
50
  },
51
51
  {
52
52
  "path": "i18n.ts",
@@ -62,11 +62,11 @@
62
62
  },
63
63
  {
64
64
  "path": "module.ts",
65
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createEventsStore,\n makeEventsScreen,\n watchEvents,\n} from \"./EventsScreen\";\nimport { t } from \"./i18n\";\n\n// Limited-time events as a feature of the game. The always-on watcher keeps the badge current\n// (milestones and VIP top-ups waiting); the screen shows every active event as a tab.\nexport const eventsModule: Module = defineModule({\n id: \"events\",\n meta: { name: \"Events\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createEventsStore(ctx.features);\n ctx.features.register({\n id: \"events\",\n label: () => t(\"title\"),\n icon: \"flame\",\n group: \"events\",\n order: 25,\n available: false,\n Screen: makeEventsScreen(store),\n });\n // The tab shows while an event is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"events\",\n (store.get()?.length ?? 0) > 0,\n \"no active event right now (TimedEvent)\",\n ),\n );\n watchEvents(ctx, store);\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"events\",\n active: store.get()?.map((e) => e.TimedEventID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
65
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createEventsStore,\n makeEventsScreen,\n watchEvents,\n} from \"./EventsScreen\";\nimport { t } from \"./i18n\";\n\n// Limited-time events as a feature of the game. The always-on watcher keeps the badge current\n// (milestones and VIP top-ups waiting); the screen shows every active event as a tab.\nexport const eventsModule: Module = defineModule({\n id: \"events\",\n meta: { name: \"Events\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createEventsStore(ctx.features);\n ctx.features.register({\n id: \"events\",\n label: () => t(\"title\"),\n icon: \"flame\",\n group: \"events\",\n order: 25,\n available: false,\n // a UScreen page: edge to edge, the whole height (the lobby gives it all of its area)\n fullBleed: true,\n Screen: makeEventsScreen(store),\n });\n // The tab shows while an event is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"events\",\n (store.get()?.length ?? 0) > 0,\n \"no active event right now (TimedEvent)\",\n ),\n );\n watchEvents(ctx, store);\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"events\",\n active: store.get()?.map((e) => e.TimedEventID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
66
66
  },
67
67
  {
68
68
  "path": "module.meta.json",
69
- "content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
69
+ "content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
70
70
  }
71
71
  ]
72
72
  }