@idosgames/mcp 0.1.17 → 0.1.18

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 (32) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +14 -6
  4. package/registry/index.json +731 -33
  5. package/registry/modules/board-game.json +4 -4
  6. package/registry/modules/character-ui.json +78 -0
  7. package/registry/modules/chat-ui.json +74 -0
  8. package/registry/modules/craft-ui.json +74 -0
  9. package/registry/modules/deal-offer-ui.json +73 -0
  10. package/registry/modules/events-ui.json +80 -0
  11. package/registry/modules/friends-ui.json +73 -0
  12. package/registry/modules/game-hud.json +15 -10
  13. package/registry/modules/idle-rpg.json +4 -4
  14. package/registry/modules/inventory-ui.json +73 -0
  15. package/registry/modules/leaderboard-ui.json +75 -0
  16. package/registry/modules/marketplace-ui.json +75 -0
  17. package/registry/modules/profile-ui.json +74 -0
  18. package/registry/modules/pvp-ui.json +72 -0
  19. package/registry/modules/referral-ui.json +73 -0
  20. package/registry/modules/rewards-ui.json +75 -0
  21. package/registry/modules/season-ui.json +79 -0
  22. package/registry/modules/showcase.json +92 -0
  23. package/registry/modules/store-ui.json +75 -0
  24. package/registry/modules/tutorial-ui.json +71 -0
  25. package/registry/modules/voxelcraft.json +75 -30
  26. package/registry/modules/workshop.json +3 -3
  27. package/registry/skills/idosgames-module-contract.json +1 -1
  28. package/registry/skills/idosgames-ui-kit.json +6 -0
  29. package/registry/skills/leaderboard-system.json +1 -1
  30. package/registry/skills/multiplayer-realtime.json +3 -3
  31. package/registry/skills/referral-system.json +2 -2
  32. package/registry/skills/timed-event-system.json +1 -1
@@ -0,0 +1,73 @@
1
+ {
2
+ "id": "deal-offer-ui",
3
+ "meta": {
4
+ "name": "Offers",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "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.",
11
+ "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.",
12
+ "provides": [
13
+ "special offers screen",
14
+ "auto-popup of a new deal",
15
+ "single, chain and pick-one offers",
16
+ "offer milestones",
17
+ "deals badge",
18
+ "demo offers"
19
+ ],
20
+ "tags": [
21
+ "offers",
22
+ "deals",
23
+ "starter-pack",
24
+ "monetization",
25
+ "popup",
26
+ "ui",
27
+ "screen",
28
+ "economy"
29
+ ],
30
+ "author": {
31
+ "name": "iDos Games",
32
+ "url": "https://idosgames.com"
33
+ },
34
+ "version": "0.1.0"
35
+ },
36
+ "dependencies": {
37
+ "@idosgames/core": "0.19.0",
38
+ "@idosgames/module-sdk": "0.5.0",
39
+ "@idosgames/react": "0.3.0",
40
+ "react": "19.2.7"
41
+ },
42
+ "contentHash": "08d3eb3ffb85c9e17738e943e5d82329189e9783c950237dd074874bcc4d6ed3",
43
+ "files": [
44
+ {
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 Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Timer,\n canAfford,\n errorText,\n grantedBy,\n outlined,\n panel,\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 return {\n get: () => slots,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n const res = await client.dealOffer.getActiveDeals();\n set(res.ok ? (res.data.Slots ?? []) : []);\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 background: \"linear-gradient(180deg, #7b4be0 0%, #3f2699 100%)\",\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"
47
+ },
48
+ {
49
+ "path": "i18n.ts",
50
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Offers\",\n specialOffer: \"Special offer\",\n endsIn: \"Ends in\",\n take: \"Take\",\n free: \"Free\",\n claim: \"Claim\",\n done: \"Done\",\n locked: \"Locked\",\n step: \"Step\",\n choose: \"Choose one\",\n watchAd: \"Watch a video\",\n adsUnavailable: \"Videos are not available here\",\n storeOnly: \"Available in the app store\",\n milestones: \"Bonus bar\",\n later: \"Later\",\n noDeals: \"No offers right now — check back later.\",\n gotIt: \"OK\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Акции\",\n specialOffer: \"Специальное предложение\",\n endsIn: \"До конца\",\n take: \"Взять\",\n free: \"Бесплатно\",\n claim: \"Забрать\",\n done: \"Получено\",\n locked: \"Закрыто\",\n step: \"Шаг\",\n choose: \"Выберите одно\",\n watchAd: \"Смотреть видео\",\n adsUnavailable: \"Видео здесь недоступно\",\n storeOnly: \"Доступно в магазине приложений\",\n milestones: \"Бонусная шкала\",\n later: \"Позже\",\n noDeals: \"Сейчас предложений нет — загляните позже.\",\n gotIt: \"Понятно\",\n});\n"
51
+ },
52
+ {
53
+ "path": "index.ts",
54
+ "content": "// @idosgames/mod-deal-offer-ui — Special offers for any game: timed deals that pop up by themselves, single offers, step chains and choice cards, with a milestone bar.\n\nexport { dealOfferUiModule } from \"./module\";\n"
55
+ },
56
+ {
57
+ "path": "model.test.ts",
58
+ "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n dealMilestones,\n dealShape,\n dealsBadge,\n milestonesReachedBy,\n needsStore,\n nodeKind,\n nodePrice,\n nodeStatus,\n offerRewards,\n orderedNodes,\n} from \"./model\";\n\n// The server resolves active slots (definition + activation state); here a slot is assembled from\n// the seeded offer the way GetActiveDeals returns it.\ntype Offer = {\n Graph: { RootNodeIDs: string[]; Nodes: Array<{ NodeID: string }> };\n};\nconst offers = (\n seed as unknown as { DealOffer: { Offers: Record<string, Offer> } }\n).DealOffer.Offers;\nconst slot = (offerID: string, extra: Record<string, unknown> = {}) =>\n ({ SlotID: offerID, OfferDef: offers[offerID], ...extra }) as never;\n\ndescribe(\"deal offers\", () => {\n it(\"derives the shape from the graph: the starter pack is a chain, the gift a choice\", () => {\n expect(dealShape(slot(\"starter_pack\"))).toBe(\"chain\");\n expect(dealShape(slot(\"welcome_gift\"))).toBe(\"choice\");\n expect(\n dealShape({ OfferDef: { Graph: { Nodes: [{ NodeID: \"x\" }] } } } as never),\n ).toBe(\"single\");\n });\n\n it(\"orders steps along the chain and reads each step's price and kind\", () => {\n const nodes = orderedNodes(slot(\"starter_pack\"));\n expect(nodes.map((n) => n.NodeID)).toEqual([\n \"step_free\",\n \"step_coins\",\n \"step_gems\",\n ]);\n expect(nodes.map(nodeKind)).toEqual([\"free\", \"purchase\", \"purchase\"]);\n expect(nodePrice(nodes[1]!)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n ]);\n expect(needsStore(nodes[1]!)).toBe(false);\n });\n\n it(\"the server's status wins; without state only roots are open\", () => {\n const s = slot(\"starter_pack\", {\n ActivationState: {\n Nodes: {\n step_free: { Status: \"Completed\" },\n step_coins: { Status: \"Available\" },\n },\n },\n });\n const [a, b, c] = orderedNodes(s);\n expect([nodeStatus(s, a!), nodeStatus(s, b!), nodeStatus(s, c!)]).toEqual([\n \"Completed\",\n \"Available\",\n \"Locked\",\n ]);\n });\n\n it(\"previews everything the offer can give, summed by resource\", () => {\n const lines = offerRewards(slot(\"welcome_gift\"));\n expect(lines).toEqual(\n expect.arrayContaining([\n { kind: \"currency\", id: \"CO\", amount: 200 },\n { kind: \"currency\", id: \"GE\", amount: 10 },\n ]),\n );\n });\n\n it(\"milestones: reached, claimed, locked\", () => {\n expect(\n dealMilestones(\n slot(\"starter_pack\", { Milestone: { TotalEarned: 1 } }),\n )[0]!.state,\n ).toBe(\"locked\");\n expect(\n dealMilestones(\n slot(\"starter_pack\", { Milestone: { TotalEarned: 3 } }),\n )[0]!.state,\n ).toBe(\"ready\");\n expect(\n dealMilestones(\n slot(\"starter_pack\", {\n Milestone: { TotalEarned: 3, ClaimedIDs: [\"all_steps\"] },\n }),\n )[0]!.state,\n ).toBe(\"claimed\");\n });\n\n it(\"the last step brings the bonus within reach; a claimed one is not taken twice\", () => {\n const last = orderedNodes(slot(\"starter_pack\"))[2]!;\n expect(\n milestonesReachedBy(\n slot(\"starter_pack\", { Milestone: { TotalEarned: 2 } }),\n last,\n ),\n ).toEqual([\"all_steps\"]);\n expect(\n milestonesReachedBy(\n slot(\"starter_pack\", { Milestone: { TotalEarned: 1 } }),\n last,\n ),\n ).toEqual([]);\n expect(\n milestonesReachedBy(\n slot(\"starter_pack\", {\n Milestone: { TotalEarned: 2, ClaimedIDs: [\"all_steps\"] },\n }),\n last,\n ),\n ).toEqual([]);\n });\n\n it(\"the badge counts free steps open now plus milestone rewards waiting\", () => {\n // step_free + one pick-one gift (two cards, one choice).\n expect(dealsBadge([slot(\"starter_pack\"), slot(\"welcome_gift\")])).toBe(2);\n const later = slot(\"starter_pack\", {\n ActivationState: { Nodes: { step_free: { Status: \"Completed\" } } },\n Milestone: { ReachedUnclaimedIDs: [\"all_steps\"] },\n });\n expect(dealsBadge([later])).toBe(1);\n });\n});\n"
59
+ },
60
+ {
61
+ "path": "model.ts",
62
+ "content": "// Pure helpers of the deal offers (Unity Alikhan/DealOffer): what shape an offer's node graph has,\n// in which order to draw its steps, each step's state, price and reward, and the milestone bar.\n// The server is the authority on node status — `ActivationState` is read, never recomputed.\n// Covered by model.test.ts.\n\nimport type { ActiveDealSlotInfo, DealNodeDefinition } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport type DealShape = \"single\" | \"chain\" | \"choice\" | \"graph\";\n\nexport function nodesOf(slot: ActiveDealSlotInfo): DealNodeDefinition[] {\n return slot.OfferDef?.Graph?.Nodes ?? [];\n}\n\n/**\n * The shape is derived from the graph (there is no declared mode): one node → single; the roots\n * share a ChoiceGroupID → choice; every node leads to at most one next → chain; else a graph.\n */\nexport function dealShape(slot: ActiveDealSlotInfo): DealShape {\n const nodes = nodesOf(slot);\n if (nodes.length <= 1) return \"single\";\n const roots = slot.OfferDef?.Graph?.RootNodeIDs ?? [];\n const rootNodes = nodes.filter((n) => roots.includes(n.NodeID ?? \"\"));\n const group = rootNodes[0]?.ChoiceGroupID;\n if (\n rootNodes.length > 1 &&\n group &&\n rootNodes.every((n) => n.ChoiceGroupID === group)\n )\n return \"choice\";\n if (\n roots.length === 1 &&\n nodes.every((n) => (n.NextNodeIDs?.length ?? 0) <= 1)\n )\n return \"chain\";\n return \"graph\";\n}\n\n/** Steps in play order: from the roots along NextNodeIDs (breadth-first), then anything left. */\nexport function orderedNodes(slot: ActiveDealSlotInfo): DealNodeDefinition[] {\n const nodes = nodesOf(slot);\n const byID = new Map(nodes.map((n) => [n.NodeID ?? \"\", n]));\n const out: DealNodeDefinition[] = [];\n const seen = new Set<string>();\n const queue = [...(slot.OfferDef?.Graph?.RootNodeIDs ?? [])];\n while (queue.length > 0) {\n const id = queue.shift()!;\n if (seen.has(id) || !byID.has(id)) continue;\n seen.add(id);\n const node = byID.get(id)!;\n out.push(node);\n queue.push(...(node.NextNodeIDs ?? []));\n }\n for (const n of nodes) if (!seen.has(n.NodeID ?? \"\")) out.push(n);\n return out;\n}\n\nexport type NodeStatus =\n \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\";\n\n/** The server's status of a step; a root with no state yet is available, anything else locked. */\nexport function nodeStatus(\n slot: ActiveDealSlotInfo,\n node: DealNodeDefinition,\n): NodeStatus {\n const id = node.NodeID ?? \"\";\n const s = slot.ActivationState?.Nodes?.[id]?.Status as NodeStatus | undefined;\n if (s) return s;\n return (slot.OfferDef?.Graph?.RootNodeIDs ?? []).includes(id)\n ? \"Available\"\n : \"Locked\";\n}\n\nexport function nodeKind(\n node: DealNodeDefinition,\n): \"purchase\" | \"free\" | \"video\" | \"info\" {\n const type = String(\n node.Type ??\n (node.Action as { Type?: unknown } | null | undefined)?.Type ??\n \"\",\n ).toLowerCase();\n if (type.includes(\"video\")) return \"video\";\n if (type.includes(\"info\")) return \"info\";\n if (type.includes(\"free\")) return \"free\";\n return nodePrice(node).length === 0 ? \"free\" : \"purchase\";\n}\n\n/** The first way to pay (currencies / items). An entry paid in a store is not a resource cost. */\nexport function nodePrice(node: DealNodeDefinition): ResourceLine[] {\n const first = Object.values(node.Pricing?.Options ?? {}).find((o) => o);\n return first\n ? costLines(first.Cost as never).filter((l) => l.kind !== \"other\")\n : [];\n}\n\n/** Whether the first way to pay needs a store receipt (real money). */\nexport function needsStore(node: DealNodeDefinition): boolean {\n const first = Object.values(node.Pricing?.Options ?? {}).find((o) => o);\n const entries =\n (\n first?.Cost as\n | {\n Standard?: {\n Entries?: Array<{ Type?: string | null } | null> | null;\n } | null;\n }\n | null\n | undefined\n )?.Standard?.Entries ?? [];\n return entries.some((e) => e?.Type === \"Purchase\");\n}\n\nexport function nodeReward(node: DealNodeDefinition): ResourceLine[] {\n return grantLines(node.Reward?.Grants);\n}\n\n/** Everything the offer can give across its steps (the card preview). */\nexport function offerRewards(slot: ActiveDealSlotInfo): ResourceLine[] {\n const merged = new Map<string, ResourceLine>();\n for (const n of nodesOf(slot))\n for (const l of nodeReward(n)) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n return [...merged.values()];\n}\n\nexport interface DealMilestoneRow {\n id: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function dealMilestones(slot: ActiveDealSlotInfo): DealMilestoneRow[] {\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const claimed = slot.Milestone?.ClaimedIDs ?? [];\n const ready = slot.Milestone?.ReachedUnclaimedIDs ?? [];\n return Object.entries(slot.OfferDef?.Milestones?.Milestones ?? {})\n .map(([id, m]) => ({\n id: m?.MilestoneID ?? id,\n required: Number(m?.RequiredProgress ?? 0),\n reward: grantLines(m?.Rewards),\n }))\n .sort((a, b) => a.required - b.required)\n .map((m) => ({\n ...m,\n state: claimed.includes(m.id)\n ? \"claimed\"\n : ready.includes(m.id) || earned >= m.required\n ? \"ready\"\n : \"locked\",\n }));\n}\n\n/**\n * Milestones the step brings within reach (its MilestonePoints on top of what the bar holds) that\n * are not claimed yet. The step that exhausts the offer takes the slot out of GetActiveDeals, so\n * the screen claims these right away — otherwise the bar's last reward has nowhere to be taken.\n */\nexport function milestonesReachedBy(\n slot: ActiveDealSlotInfo,\n node: DealNodeDefinition,\n): string[] {\n const earned =\n Number(slot.Milestone?.TotalEarned ?? 0) +\n Number(node.Reward?.MilestonePoints ?? 0);\n return dealMilestones(slot)\n .filter((m) => m.state !== \"claimed\" && m.required <= earned)\n .map((m) => m.id);\n}\n\n/** Badge: steps the player can take for free right now (a pick-one group counts once) plus\n * milestone rewards waiting. */\nexport function dealsBadge(slots: ActiveDealSlotInfo[]): number {\n let n = 0;\n for (const s of slots) {\n const groups = new Set<string>();\n for (const node of nodesOf(s)) {\n if (nodeStatus(s, node) !== \"Available\" || nodeKind(node) !== \"free\")\n continue;\n if (node.ChoiceGroupID) groups.add(node.ChoiceGroupID);\n else n++;\n }\n n += groups.size + (s.Milestone?.ReachedUnclaimedIDs?.length ?? 0);\n }\n return n;\n}\n"
63
+ },
64
+ {
65
+ "path": "module.ts",
66
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createDealsStore,\n makeDealsScreen,\n makeDealsWatcher,\n} from \"./DealsScreen\";\nimport { t } from \"./i18n\";\n\n// Special offers as a feature of the game. The always-on watcher pops a newly started deal up by\n// itself (and records the impression); the screen lists every active deal. Badge: free steps and\n// milestone rewards waiting.\nexport const dealOfferUiModule: Module = defineModule({\n id: \"deal-offer-ui\",\n meta: { name: \"Offers\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createDealsStore(ctx.features);\n ctx.features.register({\n id: \"deals\",\n label: t(\"title\"),\n icon: \"offer\",\n group: \"economy\",\n order: 15,\n Screen: makeDealsScreen(store),\n });\n ctx.registerPanel({\n id: \"auto\",\n slot: \"modal\",\n activeOnly: false,\n component: makeDealsWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"deals\",\n active: store.get()?.map((s) => s.SlotID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
67
+ },
68
+ {
69
+ "path": "module.meta.json",
70
+ "content": "{\n \"id\": \"deal-offer-ui\",\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.0\"\n}\n"
71
+ }
72
+ ]
73
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "id": "events-ui",
3
+ "meta": {
4
+ "name": "Events",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "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.",
11
+ "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) and the VIP pass (its benefits and prices in currencies) are popups. 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 showcase mini-game 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.",
12
+ "provides": [
13
+ "events screen with a tab per active event",
14
+ "event timer and token progress bar",
15
+ "milestone track with rewards",
16
+ "VIP second prize and top-up after purchase",
17
+ "claim all milestones",
18
+ "how-to-earn popup with daily caps",
19
+ "VIP pass purchase popup",
20
+ "events badge",
21
+ "demo weekly event"
22
+ ],
23
+ "tags": [
24
+ "events",
25
+ "live-ops",
26
+ "limited-time",
27
+ "milestones",
28
+ "battle-pass",
29
+ "vip",
30
+ "ui",
31
+ "screen"
32
+ ],
33
+ "author": {
34
+ "name": "iDos Games",
35
+ "url": "https://idosgames.com"
36
+ },
37
+ "version": "0.1.0"
38
+ },
39
+ "dependencies": {
40
+ "@idosgames/core": "0.19.0",
41
+ "@idosgames/module-sdk": "0.5.0",
42
+ "@idosgames/react": "0.3.0",
43
+ "react": "19.2.7"
44
+ },
45
+ "contentHash": "516eb5f792f385475c2ccb9cb831ea93e9e3958fc6baf0e44275a5fa7fb2bec1",
46
+ "files": [
47
+ {
48
+ "path": "EventsScreen.tsx",
49
+ "content": "import {\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 Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\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 { VipPopup } from \"./VipPopup\";\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 and the VIP pass are popups.\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 return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const res = await client.timedEvent.getActiveEvents();\n events = res.ok ? (res.data.ActiveEvents ?? []) : [];\n badge();\n for (const l of listeners) l();\n },\n refreshBadge: badge,\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 <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/** Keeps the badge current: loads the events once, then every few minutes and on premium changes. */\nexport function makeEventsWatcher(store: EventsStore) {\n return function EventsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\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(() => store.refreshBadge(), [state?.Premium]);\n return null;\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 [vip, setVip] = useState<string | null>(null);\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 token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\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\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\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n background: \"linear-gradient(180deg, #e0672b 0%, #8a2c1a 100%)\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"flame\"}\n size={46}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(\n event.Content?.DisplayName ?? event.TimedEventID,\n )}\n </div>\n <Timer at={event.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n <Button size=\"sm\" tone=\"blue\" onClick={() => setSources(true)}>\n {t(\"howToEarn\")}\n </Button>\n </div>\n {event.Content?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: \"rgba(255,255,255,.85)\",\n }}\n >\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}\n >\n <Icon glyph=\"star\" size={16} /> {formatAmount(goal.earned)}{\" \"}\n {token}\n </span>\n <span>\n {goal.earned >= goal.goal\n ? t(\"allDone\")\n : `${t(\"nextPrize\")} ${formatAmount(goal.goal)}`}\n </span>\n </div>\n <ProgressBar\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => setVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\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 </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <MilestoneCard\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n {vip ? <VipPopup premiumID={vip} onClose={() => setVip(null)} /> : null}\n </div>\n );\n}\n\nfunction MilestoneCard({\n event,\n row,\n index,\n earned,\n store,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\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 const claimable = claimableNow(row);\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 return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border: claimable\n ? `2px solid ${v.gold}`\n : row.featured\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" && row.vipState !== \"ready\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 48 }}>\n <Icon glyph={reached ? \"star\" : \"lock\"} size={24} />\n <span style={{ ...outlined, fontSize: 14 }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(row.name)}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipState === \"locked\" ? 0.5 : 1,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n {row.vipState === \"granted\" ? (\n <Icon glyph=\"check\" size={16} />\n ) : null}\n </div>\n ) : null}\n </div>\n <div data-index={index}>\n {claimable ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\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 <Popup 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 </Popup>\n );\n}\n"
50
+ },
51
+ {
52
+ "path": "i18n.ts",
53
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Events\",\n noEvents: \"No events right now — check back soon.\",\n endsIn: \"Ends in\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n locked: \"Locked\",\n vip: \"VIP\",\n vipPrize: \"VIP prize\",\n vipTopUp: \"Claim VIP\",\n getVip: \"Get VIP\",\n vipActive: \"VIP active\",\n vipUntil: \"until\",\n vipBenefit:\n \"A second prize on every milestone — also for the ones you already claimed.\",\n buy: \"Buy\",\n howToEarn: \"How to earn\",\n playRound: \"Play a round\",\n perDay: \"per day\",\n shopping: \"Buy in the shop\",\n quests: \"Finish a quest\",\n login: \"Log in\",\n other: \"Other activity\",\n nextPrize: \"Next prize at\",\n allDone: \"All prizes collected!\",\n days: \"days\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Ивенты\",\n noEvents: \"Сейчас ивентов нет — загляните позже.\",\n endsIn: \"До конца\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n locked: \"Закрыто\",\n vip: \"VIP\",\n vipPrize: \"Приз VIP\",\n vipTopUp: \"Забрать VIP\",\n getVip: \"Купить VIP\",\n vipActive: \"VIP активен\",\n vipUntil: \"до\",\n vipBenefit: \"Второй приз на каждой вехе — и на тех, что вы уже забрали.\",\n buy: \"Купить\",\n howToEarn: \"Как получить\",\n playRound: \"Сыграйте раунд\",\n perDay: \"в день\",\n shopping: \"Покупка в магазине\",\n quests: \"Выполненное задание\",\n login: \"Вход в игру\",\n other: \"Другие действия\",\n nextPrize: \"Следующий приз на\",\n allDone: \"Все призы собраны!\",\n days: \"дн.\",\n});\n"
54
+ },
55
+ {
56
+ "path": "index.ts",
57
+ "content": "// @idosgames/mod-events-ui — Limited-time events for any game: event tabs, a milestone track with rewards, claim all and a VIP second prize.\n\nexport { eventsUiModule } from \"./module\";\n"
58
+ },
59
+ {
60
+ "path": "model.test.ts",
61
+ "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n claimedLines,\n claimableNow,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumKey,\n tokenSources,\n} from \"./model\";\n\n// The server resolves active events (definition content + the player's token progress); here one is\n// assembled from the seeded definition the way GetActiveEvents returns it.\ntype Def = { TimedEventID: string; Content: unknown };\nconst def = Object.values(\n (seed as unknown as { TimedEvent: { Definitions: Record<string, Def> } })\n .TimedEvent.Definitions,\n)[0]!;\nconst NOW = Date.parse(\"2026-09-28T12:00:00Z\");\nconst vip = {\n Subscriptions: { vip: { ExpirationDate: \"2026-10-28T00:00:00Z\" } },\n MaxActiveTier: 1,\n};\nconst event = (\n earned: number,\n claims: Record<string, { ClaimedAt?: string; PremiumTiers?: string[] }> = {},\n) =>\n ({\n Type: \"Cyclic\",\n TimedEventID: def.TimedEventID,\n Content: def.Content,\n Progress: {\n Balance: { TotalEarned: earned },\n Milestone: { Claims: claims },\n },\n }) as never;\n\ndescribe(\"events\", () => {\n it(\"reads the seeded festival: five milestones in order, each with a VIP prize\", () => {\n const rows = milestoneRows(event(0), null, NOW);\n expect(rows.map((r) => r.required)).toEqual([10, 50, 120, 250, 400]);\n expect(\n rows.every((r) => r.vip.length > 0 && r.vipPremiumID === \"vip\"),\n ).toBe(true);\n // The reward also moves the season: the status is a line of it like the coins.\n expect(rows[0]!.reward).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n { kind: \"token\", id: \"Season:main\", amount: 10 },\n ]);\n expect(rows[4]!.featured).toBe(true);\n // Each milestone moves the season: 10 status for the first, 50 for the crown.\n expect(\n rows.map((r) => r.reward.find((l) => l.kind === \"token\")?.amount),\n ).toEqual([10, 20, 30, 40, 50]);\n });\n\n it(\"a reached milestone is claimable; without a pass its VIP prize stays locked\", () => {\n const [first, second] = milestoneRows(event(12), null, NOW);\n expect(first).toMatchObject({ state: \"ready\", vipState: \"locked\" });\n expect(second).toMatchObject({ state: \"locked\", vipState: \"locked\" });\n expect(claimableNow(first!)).toBe(true);\n });\n\n it(\"a pass bought after the claim offers the VIP top-up, once\", () => {\n const claimed = { stars_10: { ClaimedAt: \"2026-09-27T10:00:00Z\" } };\n const row = milestoneRows(event(12, claimed), vip, NOW)[0]!;\n expect(row).toMatchObject({ state: \"claimed\", vipState: \"ready\" });\n expect(claimableNow(row)).toBe(true);\n const topped = {\n stars_10: { ClaimedAt: \"2026-09-27T10:00:00Z\", PremiumTiers: [\"vip#1\"] },\n };\n expect(milestoneRows(event(12, topped), vip, NOW)[0]).toMatchObject({\n state: \"claimed\",\n vipState: \"granted\",\n });\n });\n\n it(\"an expired pass is not a pass\", () => {\n expect(hasPremium(vip, \"vip\", 1, NOW)).toBe(true);\n expect(hasPremium(vip, \"vip\", 1, Date.parse(\"2026-11-01T00:00:00Z\"))).toBe(\n false,\n );\n expect(hasPremium({ MaxActiveTier: 2 }, null, 1, NOW)).toBe(true);\n expect(premiumKey({ RequiredPremiumID: \"vip\", MinPremiumTier: 1 })).toBe(\n \"vip#1\",\n );\n expect(premiumKey({ MinPremiumTier: 2 })).toBe(\"*#2\");\n });\n\n it(\"the bar counts from the last reached milestone to the next\", () => {\n const e = event(60);\n expect(nextGoal(e, milestoneRows(e, null, NOW))).toEqual({\n earned: 60,\n from: 50,\n goal: 120,\n });\n });\n\n it(\"the badge counts milestones and VIP top-ups waiting\", () => {\n expect(eventsBadge([event(55)], null, NOW)).toBe(2);\n expect(\n eventsBadge([event(55, { stars_10: { ClaimedAt: \"x\" } })], vip, NOW),\n ).toBe(2);\n expect(eventsBadge([event(5)], vip, NOW)).toBe(0);\n });\n\n it(\"stars come from rounds (capped per day), shop purchases and claimed quests\", () => {\n expect(tokenSources(event(0))).toEqual([\n { kind: \"CustomAction\", action: \"play_round\", amount: 5, dailyCap: 20 },\n { kind: \"StorePurchase\", action: null, amount: 2, dailyCap: 0 },\n { kind: \"QuestComplete\", action: null, amount: 10, dailyCap: 0 },\n ]);\n });\n});\n\ndescribe(\"claimed lines\", () => {\n it(\"sums the standard part and the VIP bundles of the applied operation\", () => {\n const coins = (n: number) => ({\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: n }],\n });\n const data = {\n Resources: {\n Grant: {\n Standard: coins(50),\n PremiumTiers: [{ RequiredPremiumID: \"vip\", Resources: coins(50) }],\n },\n },\n };\n expect(claimedLines(data)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n ]);\n expect(\n claimedLines({ Rewards: { Grant: { Standard: coins(10) } } }),\n ).toEqual([{ kind: \"currency\", id: \"CO\", amount: 10 }]);\n expect(claimedLines({})).toBeNull();\n });\n});\n"
62
+ },
63
+ {
64
+ "path": "model.ts",
65
+ "content": "// Pure helpers of the events screen (Unity Core/Window LimitedTimeEvent): the milestone track of an\n// active event, its VIP second prize, and what the player can claim. The server resolves which events\n// are active and is the authority on every claim. Covered by model.test.ts.\n\nimport type { ActiveEventInfo, MilestoneDefinition } from \"@idosgames/core\";\nimport {\n bundleLines,\n grantLines,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n/** The player's premium passes, as the client caches them (`state.Premium`). */\nexport interface PremiumView {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null\n > | null;\n MaxActiveTier?: number | null;\n}\n\ntype PremiumTier = {\n RequiredPremiumID?: string | null;\n MinPremiumTier?: number | null;\n Resources?: unknown;\n};\n\n/** Does the player hold the pass a premium bundle asks for (a named pass, or any pass of a tier)? */\nexport function hasPremium(\n premium: PremiumView | null | undefined,\n premiumID: string | null | undefined,\n minTier: number | null | undefined,\n nowMs: number,\n): boolean {\n if (premiumID) {\n const until = premium?.Subscriptions?.[premiumID]?.ExpirationDate;\n return !!until && new Date(until).getTime() > nowMs;\n }\n return (\n Number(premium?.MaxActiveTier ?? 0) >= Math.max(1, Number(minTier ?? 1))\n );\n}\n\n/** The server's key of a granted premium bundle: `{RequiredPremiumID|*}#{MinPremiumTier}`. */\nexport function premiumKey(tier: PremiumTier): string {\n return `${tier.RequiredPremiumID || \"*\"}#${Number(tier.MinPremiumTier ?? 0)}`;\n}\n\n/** Premium passes the rewards ask for (so the screen knows which pass to offer). */\nexport function premiumIDsOf(\n grants: Array<{ PremiumTiers?: PremiumTier[] | null } | null | undefined>,\n): string[] {\n const ids = new Set<string>();\n for (const g of grants)\n for (const t of g?.PremiumTiers ?? [])\n if (t?.RequiredPremiumID) ids.add(t.RequiredPremiumID);\n return [...ids];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string | null;\n required: number;\n featured: boolean;\n reward: ResourceLine[];\n /** The VIP second prize (empty when the milestone has none). */\n vip: ResourceLine[];\n /** Pass the VIP prize asks for (the first one). */\n vipPremiumID: string | null;\n state: \"claimed\" | \"ready\" | \"locked\";\n /**\n * none — no VIP prize; locked — the player has no pass; ready — the pass is held and the prize\n * can be taken (with the base, or topped up after it); granted — already taken.\n */\n vipState: \"none\" | \"locked\" | \"ready\" | \"granted\";\n}\n\nexport function eventEarned(e: ActiveEventInfo): number {\n return Number(e.Progress?.Balance?.TotalEarned ?? 0);\n}\n\n/** The event's milestones in threshold order, with what the player can do about each. */\nexport function milestoneRows(\n e: ActiveEventInfo,\n premium: PremiumView | null | undefined,\n nowMs: number,\n): MilestoneRow[] {\n const earned = eventEarned(e);\n const claims = e.Progress?.Milestone?.Claims ?? {};\n return Object.entries(e.Content?.Milestones ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const def = m as MilestoneDefinition;\n const id = def.MilestoneID || key;\n const claim = claims[id];\n const claimed = !!claim?.ClaimedAt;\n const reached = earned >= Number(def.RequiredProgress ?? 0);\n const tiers = (def.Rewards?.PremiumTiers ?? []) as PremiumTier[];\n const vip = tiers.flatMap((t) => bundleLines(t.Resources as never));\n const held = tiers.filter((t) =>\n hasPremium(premium, t.RequiredPremiumID, t.MinPremiumTier, nowMs),\n );\n const granted = claim?.PremiumTiers ?? [];\n const missing = held.filter((t) => !granted.includes(premiumKey(t)));\n let vipState: MilestoneRow[\"vipState\"] = \"locked\";\n if (tiers.length === 0) vipState = \"none\";\n else if (tiers.every((t) => granted.includes(premiumKey(t))))\n vipState = \"granted\";\n else if (missing.length > 0 && reached) vipState = \"ready\";\n return {\n id,\n name: def.DisplayName ?? null,\n required: Number(def.RequiredProgress ?? 0),\n featured: def.IsFeatured === true,\n reward: grantLines(def.Rewards),\n vip,\n vipPremiumID:\n tiers.find((t) => t.RequiredPremiumID)?.RequiredPremiumID ?? null,\n state: claimed ? \"claimed\" : reached ? \"ready\" : \"locked\",\n vipState,\n } satisfies MilestoneRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** What a tap on the row claims now: the base (with any held VIP part), or only the VIP top-up. */\nexport function claimableNow(row: MilestoneRow): boolean {\n return (\n row.state === \"ready\" ||\n (row.state === \"claimed\" && row.vipState === \"ready\")\n );\n}\n\n/** Progress toward the next unclaimed-or-unreached milestone, for the header bar. */\nexport function nextGoal(\n e: ActiveEventInfo,\n rows: MilestoneRow[],\n): { earned: number; goal: number; from: number } {\n const earned = eventEarned(e);\n const next = rows.find((r) => r.required > earned);\n const prev = [...rows].reverse().find((r) => r.required <= earned);\n return {\n earned,\n goal: next?.required ?? rows[rows.length - 1]?.required ?? earned,\n from: prev?.required ?? 0,\n };\n}\n\n/** Badge: milestones (or VIP top-ups) waiting across the active events. */\nexport function eventsBadge(\n events: ActiveEventInfo[],\n premium: PremiumView | null | undefined,\n nowMs: number,\n): number {\n return events.reduce(\n (n, e) => n + milestoneRows(e, premium, nowMs).filter(claimableNow).length,\n 0,\n );\n}\n\nexport interface TokenSourceView {\n kind: string;\n action: string | null;\n amount: number;\n dailyCap: number;\n}\n\n/** How the event's tokens are earned, for the \"how to get\" popup. */\nexport function tokenSources(e: ActiveEventInfo): TokenSourceView[] {\n return (e.Content?.TokenSources ?? []).map((s) => {\n const src = s as {\n SourceType?: string | null;\n BaseWeight?: number | null;\n Params?: Record<string, string> | null;\n Limits?: { DailyCap?: number | null } | null;\n };\n return {\n kind: String(src.SourceType ?? \"\"),\n action: src.Params?.ActionName ?? null,\n amount: Number(src.BaseWeight ?? 1),\n dailyCap: Number(src.Limits?.DailyCap ?? 0),\n };\n });\n}\n\n/**\n * Everything a claim paid, for the reward popup: the standard part plus every premium (VIP) bundle\n * of the applied operation, summed by resource. The generic reader takes only the standard part,\n * so a VIP prize paid with the base would not show. `null` = the response has no operation.\n */\nexport function claimedLines(data: unknown): ResourceLine[] | null {\n const d = data as\n { Resources?: OpView | null; Rewards?: OpView | null } | null | undefined;\n const op = d?.Resources ?? d?.Rewards;\n if (!op?.Grant) return null;\n const merged = new Map<string, ResourceLine>();\n const add = (lines: ResourceLine[]) => {\n for (const l of lines) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n };\n add(bundleLines(op.Grant.Standard as never));\n for (const t of op.Grant.PremiumTiers ?? [])\n add(bundleLines(t?.Resources as never));\n return [...merged.values()];\n}\ntype OpView = {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n};\n"
66
+ },
67
+ {
68
+ "path": "module.ts",
69
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createEventsStore,\n makeEventsScreen,\n makeEventsWatcher,\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 eventsUiModule: Module = defineModule({\n id: \"events-ui\",\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 Screen: makeEventsScreen(store),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeEventsWatcher(store),\n });\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"
70
+ },
71
+ {
72
+ "path": "VipPopup.tsx",
73
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n canAfford,\n costLines,\n errorText,\n outlined,\n panel,\n usePopupClose,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The VIP pass (Unity PremiumPopUp): what it gives and a price option to buy it with currencies.\n// A pass paid in a store (real money) is not sold here — that is the platform's checkout.\n\n/** The part of a premium definition (config section Premium) the popup reads. */\ntype PremiumDefinition = {\n PremiumID?: string | null;\n DisplayName?: string | null;\n DurationDays?: number | null;\n Benefits?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n};\n\nexport function VipPopup({\n premiumID,\n onClose,\n}: {\n premiumID: string;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [def, setDef] = useState<PremiumDefinition | null | undefined>(\n undefined,\n );\n useEffect(() => {\n void client.premium\n .getDefinitions()\n .then((res) =>\n setDef(\n res.ok\n ? ((res.data.Premium?.Definitions?.[premiumID] as\n PremiumDefinition | undefined) ?? null)\n : null,\n ),\n );\n }, [client, premiumID]);\n return (\n <Popup\n title={def?.DisplayName ?? t(\"vip\")}\n onClose={onClose}\n width={420}\n variant=\"center\"\n >\n {def === undefined ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : def === null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n —\n </div>\n ) : (\n <VipOffer def={def} />\n )}\n </Popup>\n );\n}\n\nfunction VipOffer({ def }: { def: PremiumDefinition }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const options = Object.entries(def.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([id, o]) => ({\n id: o?.OptionID || id,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0);\n\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n def.PremiumID!,\n crypto.randomUUID(),\n optionID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n toast(t(\"vipActive\"), \"success\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={64} />\n </div>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"vipBenefit\")}\n </div>\n {Object.values(def.Benefits ?? {}).length > 0 ? (\n <div style={{ display: \"grid\", gap: 4, width: \"100%\" }}>\n {Object.values(def.Benefits ?? {}).map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {b}\n </div>\n ))}\n </div>\n ) : null}\n {Number(def.DurationDays ?? 0) > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {def.DurationDays} {t(\"days\")}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 8, width: \"100%\" }}>\n {options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor=\"vip:buy\"\n >\n {t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </div>\n </div>\n );\n}\n"
74
+ },
75
+ {
76
+ "path": "module.meta.json",
77
+ "content": "{\n \"id\": \"events-ui\",\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) and the VIP pass (its benefits and prices in currencies) are popups. 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 showcase mini-game 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 \"VIP pass purchase popup\",\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.0\"\n}\n"
78
+ }
79
+ ]
80
+ }
@@ -0,0 +1,73 @@
1
+ {
2
+ "id": "friends-ui",
3
+ "meta": {
4
+ "name": "Friends",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "Friends for any game: the friends list, requests, suggestions and the notification inbox, with a player card and add-by-ID in popups.",
11
+ "description": "A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat-ui is installed; remove friend; block) and the confirmations. When the title takes friends from idosgames.com only, requests, suggestions and 'Add' disappear and platform friends cannot be removed in the game. Registers the 'friends' feature; its badge counts requests waiting and unread news. No title settings needed.",
12
+ "provides": [
13
+ "friends list",
14
+ "incoming and sent friend requests",
15
+ "friend suggestions",
16
+ "notification inbox",
17
+ "add a friend by ID",
18
+ "player card with message, remove and block",
19
+ "friends badge"
20
+ ],
21
+ "tags": [
22
+ "friends",
23
+ "social",
24
+ "requests",
25
+ "inbox",
26
+ "notifications",
27
+ "ui",
28
+ "screen"
29
+ ],
30
+ "author": {
31
+ "name": "iDos Games",
32
+ "url": "https://idosgames.com"
33
+ },
34
+ "version": "0.1.0"
35
+ },
36
+ "dependencies": {
37
+ "@idosgames/core": "0.19.0",
38
+ "@idosgames/module-sdk": "0.5.0",
39
+ "@idosgames/react": "0.3.0",
40
+ "react": "19.2.7"
41
+ },
42
+ "contentHash": "eaaf0ec5662490736c610ad463449c4696a00ffa2d980aae568188b74c6c600c",
43
+ "files": [
44
+ {
45
+ "path": "FriendsScreen.tsx",
46
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [unread, setUnread] = useState(0);\n useEffect(() => {\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n load();\n const id = setInterval(load, 5 * 60_000);\n const off = client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(\n () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n state?.Social as { IncomingCount?: number } | undefined,\n unread,\n ),\n ),\n [state?.Social, unread],\n );\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: \"2px solid rgba(255,255,255,.6)\",\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
47
+ },
48
+ {
49
+ "path": "i18n.ts",
50
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Friends\",\n friends: \"Friends\",\n requests: \"Requests\",\n suggested: \"Suggested\",\n inbox: \"News\",\n noFriends: \"No friends yet — add someone by ID or pick from the suggestions.\",\n noRequests: \"No requests right now.\",\n noSuggestions: \"No suggestions right now — come back later.\",\n noInbox: \"Nothing new yet.\",\n incoming: \"Want to be friends\",\n outgoing: \"Waiting for an answer\",\n accept: \"Accept\",\n decline: \"Decline\",\n add: \"Add\",\n addFriend: \"Add a friend\",\n sent: \"Request sent\",\n playerID: \"Player ID\",\n yourID: \"Your ID\",\n copied: \"Copied\",\n send: \"Send request\",\n badID: \"Enter another player's ID\",\n message: \"Message\",\n remove: \"Remove friend\",\n removeAsk: \"Remove this player from your friends?\",\n block: \"Block\",\n blockAsk:\n \"Block this player? You stop seeing each other, and the friendship ends.\",\n blocked: \"Blocked\",\n platformFriend: \"Friend on idosgames.com\",\n level: \"Level\",\n power: \"Power\",\n someone: \"Someone\",\n and: \"and\",\n more: \"more\",\n vAttack: \"attacked you\",\n vRaid: \"raided you\",\n vFriend: \"is now your friend\",\n vSale: \"bought your creation\",\n vFollow: \"followed you\",\n vLike: \"liked your creation\",\n vComment: \"commented\",\n vReply: \"replied to you\",\n vOther: \"did something\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Друзья\",\n friends: \"Друзья\",\n requests: \"Заявки\",\n suggested: \"Рекомендуем\",\n inbox: \"Новости\",\n noFriends:\n \"Друзей пока нет — добавьте игрока по ID или выберите из рекомендаций.\",\n noRequests: \"Заявок нет.\",\n noSuggestions: \"Рекомендаций пока нет — загляните позже.\",\n noInbox: \"Пока ничего нового.\",\n incoming: \"Хотят дружить\",\n outgoing: \"Ждут ответа\",\n accept: \"Принять\",\n decline: \"Отклонить\",\n add: \"Добавить\",\n addFriend: \"Добавить друга\",\n sent: \"Заявка отправлена\",\n playerID: \"ID игрока\",\n yourID: \"Ваш ID\",\n copied: \"Скопировано\",\n send: \"Отправить заявку\",\n badID: \"Введите ID другого игрока\",\n message: \"Написать\",\n remove: \"Удалить из друзей\",\n removeAsk: \"Удалить игрока из друзей?\",\n block: \"Заблокировать\",\n blockAsk:\n \"Заблокировать игрока? Вы перестанете видеть друг друга, дружба закончится.\",\n blocked: \"Заблокирован\",\n platformFriend: \"Друг на idosgames.com\",\n level: \"Уровень\",\n power: \"Сила\",\n someone: \"Кто-то\",\n and: \"и\",\n more: \"ещё\",\n vAttack: \"напал на вас\",\n vRaid: \"совершил набег\",\n vFriend: \"теперь ваш друг\",\n vSale: \"купил ваше творение\",\n vFollow: \"подписался на вас\",\n vLike: \"оценил ваше творение\",\n vComment: \"оставил комментарий\",\n vReply: \"ответил вам\",\n vOther: \"что-то сделал\",\n});\n"
51
+ },
52
+ {
53
+ "path": "index.ts",
54
+ "content": "// @idosgames/mod-friends-ui — Friends for any game: friends, requests, suggestions and the notification inbox, with a player card in a popup.\n\nexport { friendsUiModule } from \"./module\";\n"
55
+ },
56
+ {
57
+ "path": "model.test.ts",
58
+ "content": "import { describe, expect, it } from \"vitest\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n} from \"./model\";\n\ndescribe(\"friends\", () => {\n it(\"names a player by username, else by a short id\", () => {\n expect(\n displayName({ UserID: \"U1\", PublicData: { Username: \" Ann \" } }),\n ).toBe(\"Ann\");\n expect(displayName({ UserID: \"ABCDEFGHIJKLMNOP\" })).toBe(\"ABCDE…MNOP\");\n expect(initial({ UserID: \"x\", PublicData: { Username: \"bob\" } })).toBe(\"B\");\n expect(\n sortByName([\n { UserID: \"2\", PublicData: { Username: \"Zed\" } },\n { UserID: \"1\", PublicData: { Username: \"Amy\" } },\n ]).map((p) => p.UserID),\n ).toEqual([\"1\", \"2\"]);\n });\n\n it(\"a platform friend is removed on the site, not in the game\", () => {\n expect(canRemove({ UserID: \"a\", Source: \"Title\" })).toBe(true);\n expect(canRemove({ UserID: \"a\", Source: \"Platform\" })).toBe(false);\n });\n\n it(\"an id typed by hand is one token and not my own\", () => {\n expect(cleanUserID(\" ABC123 \", \"ME\")).toBe(\"ABC123\");\n expect(cleanUserID(\"ME\", \"ME\")).toBeNull();\n expect(cleanUserID(\"A B\", \"ME\")).toBeNull();\n expect(cleanUserID(\" \", \"ME\")).toBeNull();\n });\n\n it(\"the badge is requests waiting plus unread news\", () => {\n expect(friendsBadge({ IncomingCount: 2 }, 3)).toBe(5);\n expect(friendsBadge(null, 0)).toBe(0);\n });\n\n it(\"reads the open-ended verbs and aggregates actors\", () => {\n expect(inboxKind(\"FriendAdded\")).toBe(\"friend\");\n expect(inboxKind(\"SomethingNew\")).toBe(\"other\");\n expect(actorsText([{ DisplayName: \"Ann\" }], 1, \"?\", \"and\", \"more\")).toBe(\n \"Ann\",\n );\n expect(\n actorsText(\n [{ DisplayName: \"Ann\" }, { DisplayName: \"Bob\" }],\n 2,\n \"?\",\n \"and\",\n \"more\",\n ),\n ).toBe(\"Ann and Bob\");\n expect(\n actorsText(\n [{ DisplayName: \"Ann\" }, { DisplayName: \"\" }],\n 42,\n \"Someone\",\n \"and\",\n \"more\",\n ),\n ).toBe(\"Ann and 41 more\");\n expect(actorsText([], 0, \"Someone\", \"and\", \"more\")).toBe(\"Someone\");\n });\n});\n"
59
+ },
60
+ {
61
+ "path": "model.ts",
62
+ "content": "// Pure helpers of the friends screen (Unity Runtime/UI/Friends): names, sorting, what can be done\n// with a friend, the inbox lines and the badge. The server owns the graph — every rule (limits,\n// platform friends, blocks) is enforced there. Covered by model.test.ts.\n\nexport interface ProfileView {\n UserID: string;\n PublicData?: {\n Username?: string | null;\n AvatarUrl?: string | null;\n Level?: number | null;\n Power?: number | null;\n Country?: string | null;\n } | null;\n Source?: \"Title\" | \"Platform\" | null;\n}\n\n/** A player's name: the chosen username, else a short form of the id. */\nexport function displayName(\n p:\n | {\n UserID?: string | null;\n PublicData?: { Username?: string | null } | null;\n }\n | null\n | undefined,\n): string {\n const name = p?.PublicData?.Username?.trim();\n if (name) return name;\n const id = p?.UserID ?? \"\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id || \"?\";\n}\n\n/** The letter for an avatar without a picture. */\nexport function initial(p: ProfileView | null | undefined): string {\n return (displayName(p).trim()[0] ?? \"?\").toUpperCase();\n}\n\nexport function sortByName<T extends ProfileView>(list: readonly T[]): T[] {\n return [...list].sort((a, b) => displayName(a).localeCompare(displayName(b)));\n}\n\n/** A platform friend is a mutual follow on idosgames.com — it is removed there, not in the game. */\nexport function canRemove(friend: ProfileView): boolean {\n return friend.Source !== \"Platform\";\n}\n\n/** A player id typed by hand: trimmed, one token, not my own. */\nexport function cleanUserID(\n input: string,\n myID: string | null | undefined,\n): string | null {\n const id = input.trim();\n if (!id || /\\s/.test(id) || id === myID) return null;\n return id;\n}\n\n/** Badge: requests waiting for my answer + unread notifications. */\nexport function friendsBadge(\n social: { IncomingCount?: number | null } | null | undefined,\n inboxUnread: number,\n): number {\n return (\n Math.max(0, Number(social?.IncomingCount ?? 0)) + Math.max(0, inboxUnread)\n );\n}\n\nexport type InboxKind =\n | \"attack\"\n | \"raid\"\n | \"friend\"\n | \"sale\"\n | \"follow\"\n | \"like\"\n | \"comment\"\n | \"reply\"\n | \"other\";\n\n/** The server's verbs are open-ended strings; anything new falls into \"other\". */\nexport function inboxKind(verb: string | null | undefined): InboxKind {\n switch (verb) {\n case \"Attack\":\n return \"attack\";\n case \"Raid\":\n return \"raid\";\n case \"FriendAdded\":\n return \"friend\";\n case \"WorkshopSale\":\n return \"sale\";\n case \"Followed\":\n return \"follow\";\n case \"Liked\":\n return \"like\";\n case \"Commented\":\n return \"comment\";\n case \"Replied\":\n return \"reply\";\n default:\n return \"other\";\n }\n}\n\n/** \"Ann\", \"Ann and Bob\", \"Ann and 41 more\" — the actors of an aggregated notification. */\nexport function actorsText(\n actors: Array<{ DisplayName?: string | null } | null> | null | undefined,\n count: number | null | undefined,\n someone: string,\n and: string,\n more: string,\n): string {\n const names = (actors ?? []).map((a) => a?.DisplayName?.trim() || someone);\n const total = Math.max(Number(count ?? 0), names.length);\n if (names.length === 0) return someone;\n if (total <= 1) return names[0]!;\n if (total === 2 && names.length >= 2) return `${names[0]} ${and} ${names[1]}`;\n return `${names[0]} ${and} ${total - 1} ${more}`;\n}\n"
63
+ },
64
+ {
65
+ "path": "module.ts",
66
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeFriendsScreen, makeFriendsWatcher } from \"./FriendsScreen\";\nimport { t } from \"./i18n\";\n\n// Friends as a feature of the game. The always-on watcher keeps the badge: requests waiting for an\n// answer (a counter that comes with the player state) and unread notifications.\nexport const friendsUiModule: Module = defineModule({\n id: \"friends-ui\",\n meta: { name: \"Friends\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"friends\",\n label: t(\"title\"),\n icon: \"friends\",\n group: \"social\",\n order: 80,\n Screen: makeFriendsScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeFriendsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"friends\" }),\n describeActions: {},\n });\n },\n});\n"
67
+ },
68
+ {
69
+ "path": "module.meta.json",
70
+ "content": "{\n \"id\": \"friends-ui\",\n \"type\": \"feature\",\n \"summary\": \"Friends for any game: the friends list, requests, suggestions and the notification inbox, with a player card and add-by-ID in popups.\",\n \"description\": \"A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat-ui is installed; remove friend; block) and the confirmations. When the title takes friends from idosgames.com only, requests, suggestions and 'Add' disappear and platform friends cannot be removed in the game. Registers the 'friends' feature; its badge counts requests waiting and unread news. No title settings needed.\",\n \"provides\": [\n \"friends list\",\n \"incoming and sent friend requests\",\n \"friend suggestions\",\n \"notification inbox\",\n \"add a friend by ID\",\n \"player card with message, remove and block\",\n \"friends badge\"\n ],\n \"tags\": [\n \"friends\",\n \"social\",\n \"requests\",\n \"inbox\",\n \"notifications\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
71
+ }
72
+ ]
73
+ }
@@ -10,7 +10,8 @@
10
10
  "currency-bar",
11
11
  "wallet",
12
12
  "status",
13
- "account"
13
+ "account",
14
+ "menu"
14
15
  ]
15
16
  },
16
17
  "catalog": {
@@ -48,28 +49,32 @@
48
49
  }
49
50
  },
50
51
  "dependencies": {
51
- "@idosgames/core": "0.18.0",
52
- "@idosgames/module-sdk": "0.4.1",
53
- "@idosgames/react": "0.2.11",
54
- "@idosgames/wallet": "0.5.0",
52
+ "@idosgames/core": "0.19.0",
53
+ "@idosgames/module-sdk": "0.5.0",
54
+ "@idosgames/react": "0.3.0",
55
+ "@idosgames/wallet": "0.5.1",
55
56
  "@tanstack/react-query": "5.101.2",
56
57
  "react": "19.2.7",
57
58
  "viem": "2.55.2",
58
59
  "wagmi": "3.7.2"
59
60
  },
60
- "contentHash": "176e3d1a194c398afafe43b05df5283fa0a45acf6a95442b7222bc1ccf52c61a",
61
+ "contentHash": "08e902f30f360b5638c6432dd938b2b1cc120cc2bc9fbab9dde4ca926b914a5c",
61
62
  "files": [
62
63
  {
63
64
  "path": "AccountChip.tsx",
64
- "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The player's ID (click to copy) and \"Log out\". This HUD takes the \"account\" role, so the host does\n// not draw its own copy in the bottom-left corner. Logging out goes through the SDK: it emits\n// \"auth:loggedOut\", and the host returns to the login screen and tears the modules down.\n\nexport function AccountChip(): ReactNode {\n const client = useIDosGamesClient();\n const userID = client.auth.context?.userID ?? null;\n const [copied, setCopied] = useState(false);\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n // Clipboard API needs a secure context; the drag-select fallback still works.\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n\n // Shown truncated, but the click always copies the COMPLETE id; hovering shows it in the tooltip.\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n\n return (\n <>\n {userID && (\n <button\n type=\"button\"\n style={idStyle}\n onClick={copy}\n title={`Copy your player ID: ${userID}`}\n >\n {copied ? \"✓ Copied\" : `ID: ${shortID} ⧉`}\n </button>\n )}\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => void client.auth.logout()}\n title=\"Log out\"\n >\n 🚪 Log out\n </button>\n </>\n );\n}\n\nconst buttonStyle: CSSProperties = {\n border: \"none\",\n borderRadius: 12,\n padding: \"8px 14px\",\n fontWeight: 700,\n fontSize: 13,\n color: \"#cfe0ff\",\n background: \"rgba(4,32,90,0.72)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n};\nconst idStyle: CSSProperties = {\n ...buttonStyle,\n fontWeight: 600,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n};\n"
65
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\n\n// The player's ID (click to copy) and \"Log out\". This HUD takes the \"account\" role, so the host does\n// not draw its own copy in the bottom-left corner. Logging out goes through the SDK: it emits\n// \"auth:loggedOut\", and the host returns to the login screen and tears the modules down.\n//\n// `compact` (a phone): one profile button with the ID and \"Log out\" in a dropdown under it — side by\n// side with the wallet they took the whole HUD row and pushed the balances into a column.\n\nexport function AccountChip({\n compact = false,\n}: {\n compact?: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const userID = client.auth.context?.userID ?? null;\n const [copied, setCopied] = useState(false);\n const [open, setOpen] = useState(false);\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n // Clipboard API needs a secure context; the drag-select fallback still works.\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n\n // Shown truncated, but the click always copies the COMPLETE id; hovering shows it in the tooltip.\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n\n const idButton = userID && (\n <button\n type=\"button\"\n style={idStyle}\n onClick={copy}\n title={`Copy your player ID: ${userID}`}\n >\n {copied ? \"✓ Copied\" : `ID: ${shortID} ⧉`}\n </button>\n );\n const logoutButton = (\n <button\n type=\"button\"\n style={buttonStyle}\n onClick={() => void client.auth.logout()}\n title=\"Log out\"\n >\n 🚪 Log out\n </button>\n );\n\n if (!compact)\n return (\n <>\n {idButton}\n {logoutButton}\n </>\n );\n\n // The dropdown is absolutely positioned, so opening it does not change the HUD's height — the host\n // measures that height to place every mode below it.\n return (\n <div style={anchorStyle}>\n <button\n type=\"button\"\n style={profileStyle}\n onClick={() => setOpen((value) => !value)}\n aria-expanded={open}\n title=\"Account\"\n >\n 👤\n </button>\n {open && (\n <div style={dropdownStyle}>\n {idButton}\n {logoutButton}\n </div>\n )}\n </div>\n );\n}\n\nconst buttonStyle: CSSProperties = {\n border: \"none\",\n borderRadius: 12,\n padding: \"8px 14px\",\n fontWeight: 700,\n fontSize: 13,\n color: \"#cfe0ff\",\n background: \"rgba(4,32,90,0.72)\",\n backdropFilter: \"blur(6px)\",\n cursor: \"pointer\",\n pointerEvents: \"auto\",\n whiteSpace: \"nowrap\",\n};\nconst idStyle: CSSProperties = {\n ...buttonStyle,\n fontWeight: 600,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n};\nconst profileStyle: CSSProperties = {\n ...buttonStyle,\n padding: \"6px 10px\",\n fontSize: 16,\n lineHeight: 1,\n};\nconst anchorStyle: CSSProperties = {\n position: \"relative\",\n pointerEvents: \"auto\",\n};\nconst dropdownStyle: CSSProperties = {\n position: \"absolute\",\n top: \"calc(100% + 8px)\",\n right: 0,\n zIndex: 5,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"stretch\",\n gap: 6,\n padding: 6,\n borderRadius: 14,\n background: \"rgba(4,32,90,0.92)\",\n boxShadow: \"0 8px 24px rgba(0,0,0,.35)\",\n};\n"
65
66
  },
66
67
  {
67
68
  "path": "CurrencyChips.tsx",
68
- "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport type { CurrencyDefinitions } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\n\n// The player's virtual-currency balances, one chip each. Labels come from the title's currency\n// configuration (`Currency.VirtualCurrencies[id].DisplayName`), translated through the title's\n// localization when that name is a localization key; a currency without a definition shows its id.\n\nexport function CurrencyChips(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n const balances = state?.InventoryV2?.VirtualCurrencies ?? {};\n const definitions =\n client.data.config.getSection<CurrencyDefinitions>(\"Currency\")\n ?.VirtualCurrencies ?? {};\n\n const label = (id: string): string => {\n const name = definitions[id]?.DisplayName;\n if (!name) return id;\n // DisplayName is either plain text or a localization key — translate only when it IS a key, so\n // a title with MissingKeyMode \"ReturnMarkedKey\" does not show ⟦Gold⟧.\n return client.localization.has(name) ? client.localization.t(name) : name;\n };\n\n const entries = Object.entries(balances);\n if (entries.length === 0) return null;\n\n return (\n <>\n {entries.map(([id, currency]) => (\n <span key={id} style={chipStyle} title={id}>\n {label(id)}: {currency.Amount ?? 0}\n </span>\n ))}\n </>\n );\n}\n\nconst chipStyle: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n whiteSpace: \"nowrap\",\n pointerEvents: \"auto\",\n};\n"
69
+ "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { useCounterValue } from \"@idosgames/react/ui\";\n\n// The player's virtual-currency balances, one chip each. Labels come from the title's currency\n// configuration (`Currency.VirtualCurrencies[id].DisplayName`), translated through the title's\n// localization when that name is a localization key; a currency without a definition shows its id.\n\nexport function CurrencyChips(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n const balances = state?.InventoryV2?.VirtualCurrencies ?? {};\n // `currencyDefinitions`, not `getSection(\"Currency\")`: the latter holds only sections fetched on\n // their own (GetDefinitions), so on a fresh sign-in it was empty and the chips showed raw ids.\n const definitions =\n client.data.config.currencyDefinitions?.VirtualCurrencies ?? {};\n\n const label = (id: string): string => {\n const name = definitions[id]?.DisplayName;\n if (!name) return id;\n // DisplayName is either plain text or a localization key — translate only when it IS a key, so\n // a title with MissingKeyMode \"ReturnMarkedKey\" does not show ⟦Gold⟧.\n return client.localization.has(name) ? client.localization.t(name) : name;\n };\n\n const entries = Object.entries(balances);\n if (entries.length === 0) return null;\n\n return (\n <>\n {entries.map(([id, currency]) => (\n <Chip\n key={id}\n id={id}\n label={label(id)}\n amount={Number(currency.Amount ?? 0)}\n />\n ))}\n </>\n );\n}\n\n// `data-resource-counter` makes the chip a target of the kit's reward animation: icons of a claimed\n// reward fly here, and the number holds until they land, then ticks up (useCounterValue).\nfunction Chip({\n id,\n label,\n amount,\n}: {\n id: string;\n label: string;\n amount: number;\n}): ReactNode {\n const shown = useCounterValue(id, amount);\n return (\n <span data-resource-counter={id} style={chipStyle} title={id}>\n {label}: {shown}\n </span>\n );\n}\n\nconst chipStyle: CSSProperties = {\n background: \"#052e7ecc\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"4px 12px\",\n fontSize: 13,\n fontWeight: 600,\n backdropFilter: \"blur(2px)\",\n whiteSpace: \"nowrap\",\n pointerEvents: \"auto\",\n};\n"
70
+ },
71
+ {
72
+ "path": "FeatureMenu.tsx",
73
+ "content": "import {\n useEffect,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n FeatureGrid,\n IconButton,\n Popup,\n Window,\n uiT,\n} from \"@idosgames/react/ui\";\n\n// ☰ in the HUD: this module took the \"menu\" shared-UI role, so the game's features (ctx.features —\n// the shop, the marketplace, friends…) are listed here in every mode, and a picked feature's screen\n// opens in a full-screen window. It is also the presenter of `ctx.features.open(id)`, so \"not enough\n// coins → open the shop\" works from any module.\n\nexport function FeatureMenu({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n const list = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const [menuOpen, setMenuOpen] = useState(false);\n const [screen, setScreen] = useState<{\n id: string;\n args?: Record<string, unknown>;\n } | null>(null);\n\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setMenuOpen(false);\n setScreen({ id, args });\n return true;\n }),\n [features],\n );\n\n if (list.length === 0) return null;\n const badges = list.reduce((sum, f) => sum + (f.badge > 0 ? 1 : 0), 0);\n const current = screen ? features.get(screen.id) : undefined;\n const Screen = current?.Screen;\n\n return (\n <>\n <span style={{ pointerEvents: \"auto\" }}>\n <IconButton\n glyph=\"menu\"\n label={uiT(\"menu\")}\n onClick={() => setMenuOpen(true)}\n size={38}\n tone=\"blue\"\n badge={badges}\n />\n </span>\n {menuOpen ? (\n <Popup\n title={uiT(\"menu\")}\n onClose={() => setMenuOpen(false)}\n width={520}\n >\n <FeatureGrid\n items={list}\n onOpen={(id) => {\n setMenuOpen(false);\n setScreen({ id });\n }}\n />\n </Popup>\n ) : null}\n {current && Screen ? (\n <Window\n key={current.id}\n title={current.label}\n icon={current.icon}\n onClose={() => setScreen(null)}\n >\n <Screen args={screen?.args} close={() => setScreen(null)} />\n </Window>\n ) : null}\n </>\n );\n}\n"
69
74
  },
70
75
  {
71
76
  "path": "GameHud.tsx",
72
- "content": "import {\n useEffect,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { useStatus } from \"@idosgames/react\";\nimport type { ModeStore } from \"./modeStore\";\nimport { CurrencyChips } from \"./CurrencyChips\";\nimport { WalletButton } from \"./WalletButton\";\nimport { StatusToast } from \"./StatusToast\";\nimport { AccountChip } from \"./AccountChip\";\n\n// The HUD row: balances on the left, wallet + account on the right, the status toast floating under\n// the middle. It renders in the host's \"hud\" slot, which is a flex row across the top of the screen;\n// the host measures that row and moves every mode's overlay below it, so templates never slide under\n// the HUD.\n//\n// Pointer events: the row itself lets clicks through to the game (voxelcraft needs the canvas), only\n// the actual controls take them.\n\n/** Which parts this HUD draws — the shared-UI roles it won (see module.ts). */\nexport interface HudParts {\n currencies: boolean;\n wallet: boolean;\n status: boolean;\n account: boolean;\n}\n\nexport function makeGameHudPanel(\n mode: ModeStore,\n parts: HudParts,\n): ComponentType {\n return function GameHudPanel(): ReactNode {\n return <GameHud mode={mode} parts={parts} />;\n };\n}\n\nexport function GameHud({\n mode,\n parts,\n}: {\n mode: ModeStore;\n parts: HudParts;\n}): ReactNode {\n const current = useSyncExternalStore(mode.subscribe, mode.get);\n const { clear } = useStatus();\n\n // A status message belongs to the mode that wrote it: the board's \"Rolling…\" must not linger over\n // Idle RPG. (Hide parts per mode here too if a mode does not need them — `current` is its id.)\n useEffect(() => {\n clear();\n }, [current, clear]);\n\n return (\n <div style={barStyle} data-mode={current ?? undefined}>\n <div style={sideStyle}>{parts.currencies && <CurrencyChips />}</div>\n <div style={sideStyle}>\n {parts.wallet && <WalletButton />}\n {parts.account && <AccountChip />}\n </div>\n {parts.status && <StatusToast />}\n </div>\n );\n}\n\nconst barStyle: CSSProperties = {\n flex: 1,\n minWidth: 0,\n position: \"relative\",\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"flex-start\",\n gap: 12,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n pointerEvents: \"none\",\n};\nconst sideStyle: CSSProperties = {\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 8,\n minWidth: 0,\n};\n"
77
+ "content": "import {\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { useStatus } from \"@idosgames/react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport type { ModeStore } from \"./modeStore\";\nimport { FeatureMenu } from \"./FeatureMenu\";\nimport { CurrencyChips } from \"./CurrencyChips\";\nimport { WalletButton } from \"./WalletButton\";\nimport { StatusToast } from \"./StatusToast\";\nimport { AccountChip } from \"./AccountChip\";\n\n// The HUD row: balances on the left, wallet + account on the right, the status toast floating under\n// the middle. It renders in the host's \"hud\" slot, which is a flex row across the top of the screen;\n// the host measures that row and moves every mode's overlay below it, so templates never slide under\n// the HUD.\n//\n// Pointer events: the row itself lets clicks through to the game (voxelcraft needs the canvas), only\n// the actual controls take them.\n\n/** Which parts this HUD draws — the shared-UI roles it won (see module.ts). */\nexport interface HudParts {\n currencies: boolean;\n wallet: boolean;\n status: boolean;\n account: boolean;\n /** ☰ — the menu of the game's features (ctx.features). */\n menu: boolean;\n}\n\nexport function makeGameHudPanel(\n mode: ModeStore,\n parts: HudParts,\n features: FeatureRegistry,\n): ComponentType {\n return function GameHudPanel(): ReactNode {\n return <GameHud mode={mode} parts={parts} features={features} />;\n };\n}\n\nexport function GameHud({\n mode,\n parts,\n features,\n}: {\n mode: ModeStore;\n parts: HudParts;\n features: FeatureRegistry;\n}): ReactNode {\n const current = useSyncExternalStore(mode.subscribe, mode.get);\n const { clear } = useStatus();\n\n // A status message belongs to the mode that wrote it: the board's \"Rolling…\" must not linger over\n // Idle RPG. (Hide parts per mode here too if a mode does not need them — `current` is its id.)\n useEffect(() => {\n clear();\n }, [current, clear]);\n\n // On a phone the ID and \"Log out\" fold into one profile button: side by side with the wallet\n // they took the whole row and pushed the balances into a column.\n const barRef = useRef<HTMLDivElement>(null);\n const compact = useNarrow(barRef, COMPACT_BELOW_PX);\n\n return (\n <div ref={barRef} style={barStyle} data-mode={current ?? undefined}>\n <div style={balancesSideStyle}>\n {parts.currencies && <CurrencyChips />}\n </div>\n <div style={controlsSideStyle}>\n {parts.menu && <FeatureMenu features={features} />}\n {parts.wallet && <WalletButton />}\n {parts.account && <AccountChip compact={compact} />}\n </div>\n {parts.status && <StatusToast />}\n </div>\n );\n}\n\nconst barStyle: CSSProperties = {\n flex: 1,\n minWidth: 0,\n position: \"relative\",\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"flex-start\",\n gap: 12,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n pointerEvents: \"none\",\n};\n/** Narrower than this, the account row folds into a profile button. */\nconst COMPACT_BELOW_PX = 600;\n\n/** True while the element is narrower than `px` (its own width, not the window's). */\nfunction useNarrow(ref: { current: HTMLElement | null }, px: number): boolean {\n const [narrow, setNarrow] = useState(false);\n useLayoutEffect(() => {\n const el = ref.current;\n if (!el) return;\n const measure = (): void =>\n setNarrow(el.getBoundingClientRect().width < px);\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [ref, px]);\n return narrow;\n}\n\nconst sideStyle: CSSProperties = {\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 8,\n minWidth: 0,\n};\nconst balancesSideStyle: CSSProperties = { ...sideStyle, flex: \"1 1 auto\" };\nconst controlsSideStyle: CSSProperties = {\n ...sideStyle,\n flex: \"0 1 auto\",\n justifyContent: \"flex-end\",\n};\n"
73
78
  },
74
79
  {
75
80
  "path": "index.ts",
@@ -81,7 +86,7 @@
81
86
  },
82
87
  {
83
88
  "path": "module.ts",
84
- "content": "import { defineModule, hostTopics, type Module } from \"@idosgames/module-sdk\";\nimport { createModeStore } from \"./modeStore\";\nimport { makeGameHudPanel, type HudParts } from \"./GameHud\";\n\n// The Game HUD feature module: no scene, no route — one HUD panel that stays on screen in every mode\n// and draws the chrome every mode shares. It declares every shared-UI role, so templates mixed into\n// the same game ask `ctx.sharedUi.shouldDraw(role)`, get `false` and hide their own copies.\nexport const gameHudModule: Module = defineModule({\n id: \"game-hud\",\n meta: {\n name: \"Game HUD\",\n type: \"app\",\n engine: \"dom\",\n },\n sharedUi: { provides: [\"currency-bar\", \"wallet\", \"status\", \"account\"] },\n setup(ctx) {\n // Draw only the roles this module actually won: if another provider comes first in\n // src/modules.ts and takes, say, \"wallet\", this HUD hides its wallet button like any template.\n const parts: HudParts = {\n currencies: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n account: ctx.sharedUi.shouldDraw(\"account\"),\n };\n\n // Subscribed here, not in the panel: setup() runs before the shell mounts, so even the first\n // activation is seen. The panel reads the current mode from the store.\n const mode = createModeStore();\n ctx.events.on(hostTopics.modeChanged, ({ to }) => mode.set(to));\n\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: makeGameHudPanel(mode, parts),\n });\n\n ctx.exposeToAgent({\n state: () => ({ mode: mode.get(), draws: parts }),\n describeActions: {},\n });\n },\n});\n"
89
+ "content": "import { defineModule, hostTopics, type Module } from \"@idosgames/module-sdk\";\nimport { createModeStore } from \"./modeStore\";\nimport { makeGameHudPanel, type HudParts } from \"./GameHud\";\n\n// The Game HUD feature module: no scene, no route — one HUD panel that stays on screen in every mode\n// and draws the chrome every mode shares. It declares every shared-UI role, so templates mixed into\n// the same game ask `ctx.sharedUi.shouldDraw(role)`, get `false` and hide their own copies.\nexport const gameHudModule: Module = defineModule({\n id: \"game-hud\",\n meta: {\n name: \"Game HUD\",\n type: \"app\",\n engine: \"dom\",\n },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"status\", \"account\", \"menu\"],\n },\n setup(ctx) {\n // Draw only the roles this module actually won: if another provider comes first in\n // src/modules.ts and takes, say, \"wallet\", this HUD hides its wallet button like any template.\n const parts: HudParts = {\n currencies: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n account: ctx.sharedUi.shouldDraw(\"account\"),\n menu: ctx.sharedUi.shouldDraw(\"menu\"),\n };\n\n // Subscribed here, not in the panel: setup() runs before the shell mounts, so even the first\n // activation is seen. The panel reads the current mode from the store.\n const mode = createModeStore();\n ctx.events.on(hostTopics.modeChanged, ({ to }) => mode.set(to));\n\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: makeGameHudPanel(mode, parts, ctx.features),\n });\n\n ctx.exposeToAgent({\n state: () => ({ mode: mode.get(), draws: parts }),\n describeActions: {},\n });\n },\n});\n"
85
90
  },
86
91
  {
87
92
  "path": "StatusToast.tsx",
@@ -49,10 +49,10 @@
49
49
  }
50
50
  },
51
51
  "dependencies": {
52
- "@idosgames/core": "0.18.0",
53
- "@idosgames/module-sdk": "0.4.1",
54
- "@idosgames/react": "0.2.11",
55
- "@idosgames/wallet": "0.5.0",
52
+ "@idosgames/core": "0.19.0",
53
+ "@idosgames/module-sdk": "0.5.0",
54
+ "@idosgames/react": "0.3.0",
55
+ "@idosgames/wallet": "0.5.1",
56
56
  "@tanstack/react-query": "5.101.2",
57
57
  "phaser": "4.2.1",
58
58
  "react": "19.2.7",